Merge branch 'master' into release
This commit is contained in:
		
						commit
						bc2913a5cb
					
				| 
						 | 
				
			
			@ -199,3 +199,4 @@ M Nafis Al Mukhdi (mnafisalmukhdi1) :: Indonesian
 | 
			
		|||
sulfo :: Danish
 | 
			
		||||
Raukze :: German
 | 
			
		||||
zygimantus :: Lithuanian
 | 
			
		||||
marinkaberg :: Russian
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,6 +4,7 @@ namespace BookStack\Actions;
 | 
			
		|||
 | 
			
		||||
use BookStack\Auth\Permissions\PermissionService;
 | 
			
		||||
use BookStack\Auth\User;
 | 
			
		||||
use BookStack\Entities\Models\Book;
 | 
			
		||||
use BookStack\Entities\Models\Chapter;
 | 
			
		||||
use BookStack\Entities\Models\Entity;
 | 
			
		||||
use BookStack\Entities\Models\Page;
 | 
			
		||||
| 
						 | 
				
			
			@ -100,14 +101,14 @@ class ActivityService
 | 
			
		|||
     */
 | 
			
		||||
    public function entityActivity(Entity $entity, int $count = 20, int $page = 1): array
 | 
			
		||||
    {
 | 
			
		||||
        /** @var [string => int[]] $queryIds */
 | 
			
		||||
        /** @var array<string, int[]> $queryIds */
 | 
			
		||||
        $queryIds = [$entity->getMorphClass() => [$entity->id]];
 | 
			
		||||
 | 
			
		||||
        if ($entity->isA('book')) {
 | 
			
		||||
            $queryIds[(new Chapter())->getMorphClass()] = $entity->chapters()->visible()->pluck('id');
 | 
			
		||||
        if ($entity instanceof Book) {
 | 
			
		||||
            $queryIds[(new Chapter())->getMorphClass()] = $entity->chapters()->scopes('visible')->pluck('id');
 | 
			
		||||
        }
 | 
			
		||||
        if ($entity->isA('book') || $entity->isA('chapter')) {
 | 
			
		||||
            $queryIds[(new Page())->getMorphClass()] = $entity->pages()->visible()->pluck('id');
 | 
			
		||||
        if ($entity instanceof Book || $entity instanceof Chapter) {
 | 
			
		||||
            $queryIds[(new Page())->getMorphClass()] = $entity->pages()->scopes('visible')->pluck('id');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $query = $this->activity->newQuery();
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,6 +4,7 @@ namespace BookStack\Auth\Access;
 | 
			
		|||
 | 
			
		||||
use Illuminate\Contracts\Auth\Authenticatable;
 | 
			
		||||
use Illuminate\Contracts\Auth\UserProvider;
 | 
			
		||||
use Illuminate\Database\Eloquent\Model;
 | 
			
		||||
 | 
			
		||||
class ExternalBaseUserProvider implements UserProvider
 | 
			
		||||
{
 | 
			
		||||
| 
						 | 
				
			
			@ -16,8 +17,6 @@ class ExternalBaseUserProvider implements UserProvider
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * LdapUserProvider constructor.
 | 
			
		||||
     *
 | 
			
		||||
     * @param $model
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(string $model)
 | 
			
		||||
    {
 | 
			
		||||
| 
						 | 
				
			
			@ -27,7 +26,7 @@ class ExternalBaseUserProvider implements UserProvider
 | 
			
		|||
    /**
 | 
			
		||||
     * Create a new instance of the model.
 | 
			
		||||
     *
 | 
			
		||||
     * @return \Illuminate\Database\Eloquent\Model
 | 
			
		||||
     * @return Model
 | 
			
		||||
     */
 | 
			
		||||
    public function createModel()
 | 
			
		||||
    {
 | 
			
		||||
| 
						 | 
				
			
			@ -41,7 +40,7 @@ class ExternalBaseUserProvider implements UserProvider
 | 
			
		|||
     *
 | 
			
		||||
     * @param mixed $identifier
 | 
			
		||||
     *
 | 
			
		||||
     * @return \Illuminate\Contracts\Auth\Authenticatable|null
 | 
			
		||||
     * @return Authenticatable|null
 | 
			
		||||
     */
 | 
			
		||||
    public function retrieveById($identifier)
 | 
			
		||||
    {
 | 
			
		||||
| 
						 | 
				
			
			@ -54,7 +53,7 @@ class ExternalBaseUserProvider implements UserProvider
 | 
			
		|||
     * @param mixed  $identifier
 | 
			
		||||
     * @param string $token
 | 
			
		||||
     *
 | 
			
		||||
     * @return \Illuminate\Contracts\Auth\Authenticatable|null
 | 
			
		||||
     * @return Authenticatable|null
 | 
			
		||||
     */
 | 
			
		||||
    public function retrieveByToken($identifier, $token)
 | 
			
		||||
    {
 | 
			
		||||
| 
						 | 
				
			
			@ -64,8 +63,8 @@ class ExternalBaseUserProvider implements UserProvider
 | 
			
		|||
    /**
 | 
			
		||||
     * Update the "remember me" token for the given user in storage.
 | 
			
		||||
     *
 | 
			
		||||
     * @param \Illuminate\Contracts\Auth\Authenticatable $user
 | 
			
		||||
     * @param string                                     $token
 | 
			
		||||
     * @param Authenticatable $user
 | 
			
		||||
     * @param string          $token
 | 
			
		||||
     *
 | 
			
		||||
     * @return void
 | 
			
		||||
     */
 | 
			
		||||
| 
						 | 
				
			
			@ -79,7 +78,7 @@ class ExternalBaseUserProvider implements UserProvider
 | 
			
		|||
     *
 | 
			
		||||
     * @param array $credentials
 | 
			
		||||
     *
 | 
			
		||||
     * @return \Illuminate\Contracts\Auth\Authenticatable|null
 | 
			
		||||
     * @return Authenticatable|null
 | 
			
		||||
     */
 | 
			
		||||
    public function retrieveByCredentials(array $credentials)
 | 
			
		||||
    {
 | 
			
		||||
| 
						 | 
				
			
			@ -94,8 +93,8 @@ class ExternalBaseUserProvider implements UserProvider
 | 
			
		|||
    /**
 | 
			
		||||
     * Validate a user against the given credentials.
 | 
			
		||||
     *
 | 
			
		||||
     * @param \Illuminate\Contracts\Auth\Authenticatable $user
 | 
			
		||||
     * @param array                                      $credentials
 | 
			
		||||
     * @param Authenticatable $user
 | 
			
		||||
     * @param array           $credentials
 | 
			
		||||
     *
 | 
			
		||||
     * @return bool
 | 
			
		||||
     */
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -165,7 +165,7 @@ class LdapService
 | 
			
		|||
     * Bind the system user to the LDAP connection using the given credentials
 | 
			
		||||
     * otherwise anonymous access is attempted.
 | 
			
		||||
     *
 | 
			
		||||
     * @param $connection
 | 
			
		||||
     * @param resource $connection
 | 
			
		||||
     *
 | 
			
		||||
     * @throws LdapException
 | 
			
		||||
     */
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -41,16 +41,18 @@ class OidcJwtSigningKey
 | 
			
		|||
    protected function loadFromPath(string $path)
 | 
			
		||||
    {
 | 
			
		||||
        try {
 | 
			
		||||
            $this->key = PublicKeyLoader::load(
 | 
			
		||||
            $key = PublicKeyLoader::load(
 | 
			
		||||
                file_get_contents($path)
 | 
			
		||||
            )->withPadding(RSA::SIGNATURE_PKCS1);
 | 
			
		||||
            );
 | 
			
		||||
        } catch (\Exception $exception) {
 | 
			
		||||
            throw new OidcInvalidKeyException("Failed to load key from file path with error: {$exception->getMessage()}");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!($this->key instanceof RSA)) {
 | 
			
		||||
        if (!$key instanceof RSA) {
 | 
			
		||||
            throw new OidcInvalidKeyException('Key loaded from file path is not an RSA key as expected');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $this->key = $key->withPadding(RSA::SIGNATURE_PKCS1);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
| 
						 | 
				
			
			@ -81,14 +83,19 @@ class OidcJwtSigningKey
 | 
			
		|||
        $n = strtr($jwk['n'] ?? '', '-_', '+/');
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            /** @var RSA $key */
 | 
			
		||||
            $this->key = PublicKeyLoader::load([
 | 
			
		||||
            $key = PublicKeyLoader::load([
 | 
			
		||||
                'e' => new BigInteger(base64_decode($jwk['e']), 256),
 | 
			
		||||
                'n' => new BigInteger(base64_decode($n), 256),
 | 
			
		||||
            ])->withPadding(RSA::SIGNATURE_PKCS1);
 | 
			
		||||
            ]);
 | 
			
		||||
        } catch (\Exception $exception) {
 | 
			
		||||
            throw new OidcInvalidKeyException("Failed to load key from JWK parameters with error: {$exception->getMessage()}");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!$key instanceof RSA) {
 | 
			
		||||
            throw new OidcInvalidKeyException('Key loaded from file path is not an RSA key as expected');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $this->key = $key->withPadding(RSA::SIGNATURE_PKCS1);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -12,6 +12,7 @@ use Illuminate\Support\Str;
 | 
			
		|||
use Laravel\Socialite\Contracts\Factory as Socialite;
 | 
			
		||||
use Laravel\Socialite\Contracts\Provider;
 | 
			
		||||
use Laravel\Socialite\Contracts\User as SocialUser;
 | 
			
		||||
use Laravel\Socialite\Two\GoogleProvider;
 | 
			
		||||
use SocialiteProviders\Manager\SocialiteWasCalled;
 | 
			
		||||
use Symfony\Component\HttpFoundation\RedirectResponse;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -278,7 +279,7 @@ class SocialAuthService
 | 
			
		|||
    {
 | 
			
		||||
        $driver = $this->socialite->driver($driverName);
 | 
			
		||||
 | 
			
		||||
        if ($driverName === 'google' && config('services.google.select_account')) {
 | 
			
		||||
        if ($driver instanceof GoogleProvider && config('services.google.select_account')) {
 | 
			
		||||
            $driver->with(['prompt' => 'select_account']);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,6 +4,7 @@ namespace BookStack\Auth\Permissions;
 | 
			
		|||
 | 
			
		||||
use BookStack\Auth\Role;
 | 
			
		||||
use BookStack\Model;
 | 
			
		||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @property int $id
 | 
			
		||||
| 
						 | 
				
			
			@ -13,19 +14,15 @@ class RolePermission extends Model
 | 
			
		|||
    /**
 | 
			
		||||
     * The roles that belong to the permission.
 | 
			
		||||
     */
 | 
			
		||||
    public function roles()
 | 
			
		||||
    public function roles(): BelongsToMany
 | 
			
		||||
    {
 | 
			
		||||
        return $this->belongsToMany(Role::class, 'permission_role', 'permission_id', 'role_id');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the permission object by name.
 | 
			
		||||
     *
 | 
			
		||||
     * @param $name
 | 
			
		||||
     *
 | 
			
		||||
     * @return mixed
 | 
			
		||||
     */
 | 
			
		||||
    public static function getByName($name)
 | 
			
		||||
    public static function getByName(string $name): ?RolePermission
 | 
			
		||||
    {
 | 
			
		||||
        return static::where('name', '=', $name)->first();
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -49,7 +49,7 @@ class RegenerateSearch extends Command
 | 
			
		|||
            DB::setDefaultConnection($this->option('database'));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $this->searchIndex->indexAllEntities(function (Entity $model, int $processed, int $total) {
 | 
			
		||||
        $this->searchIndex->indexAllEntities(function (Entity $model, int $processed, int $total): void {
 | 
			
		||||
            $this->info('Indexed ' . class_basename($model) . ' entries (' . $processed . '/' . $total . ')');
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -79,53 +79,43 @@ class Book extends Entity implements HasCoverImage
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get all pages within this book.
 | 
			
		||||
     *
 | 
			
		||||
     * @return HasMany
 | 
			
		||||
     */
 | 
			
		||||
    public function pages()
 | 
			
		||||
    public function pages(): HasMany
 | 
			
		||||
    {
 | 
			
		||||
        return $this->hasMany(Page::class);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the direct child pages of this book.
 | 
			
		||||
     *
 | 
			
		||||
     * @return HasMany
 | 
			
		||||
     */
 | 
			
		||||
    public function directPages()
 | 
			
		||||
    public function directPages(): HasMany
 | 
			
		||||
    {
 | 
			
		||||
        return $this->pages()->where('chapter_id', '=', '0');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get all chapters within this book.
 | 
			
		||||
     *
 | 
			
		||||
     * @return HasMany
 | 
			
		||||
     */
 | 
			
		||||
    public function chapters()
 | 
			
		||||
    public function chapters(): HasMany
 | 
			
		||||
    {
 | 
			
		||||
        return $this->hasMany(Chapter::class);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the shelves this book is contained within.
 | 
			
		||||
     *
 | 
			
		||||
     * @return BelongsToMany
 | 
			
		||||
     */
 | 
			
		||||
    public function shelves()
 | 
			
		||||
    public function shelves(): BelongsToMany
 | 
			
		||||
    {
 | 
			
		||||
        return $this->belongsToMany(Bookshelf::class, 'bookshelves_books', 'book_id', 'bookshelf_id');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the direct child items within this book.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Collection
 | 
			
		||||
     */
 | 
			
		||||
    public function getDirectChildren(): Collection
 | 
			
		||||
    {
 | 
			
		||||
        $pages = $this->directPages()->visible()->get();
 | 
			
		||||
        $chapters = $this->chapters()->visible()->get();
 | 
			
		||||
        $pages = $this->directPages()->scopes('visible')->get();
 | 
			
		||||
        $chapters = $this->chapters()->scopes('visible')->get();
 | 
			
		||||
 | 
			
		||||
        return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft');
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -37,7 +37,7 @@ class Bookshelf extends Entity implements HasCoverImage
 | 
			
		|||
     */
 | 
			
		||||
    public function visibleBooks(): BelongsToMany
 | 
			
		||||
    {
 | 
			
		||||
        return $this->books()->visible();
 | 
			
		||||
        return $this->books()->scopes('visible');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -23,6 +23,8 @@ class Chapter extends BookChild
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the pages that this chapter contains.
 | 
			
		||||
     *
 | 
			
		||||
     * @return HasMany<Page>
 | 
			
		||||
     */
 | 
			
		||||
    public function pages(string $dir = 'ASC'): HasMany
 | 
			
		||||
    {
 | 
			
		||||
| 
						 | 
				
			
			@ -50,7 +52,8 @@ class Chapter extends BookChild
 | 
			
		|||
     */
 | 
			
		||||
    public function getVisiblePages(): Collection
 | 
			
		||||
    {
 | 
			
		||||
        return $this->pages()->visible()
 | 
			
		||||
        return $this->pages()
 | 
			
		||||
        ->scopes('visible')
 | 
			
		||||
        ->orderBy('draft', 'desc')
 | 
			
		||||
        ->orderBy('priority', 'asc')
 | 
			
		||||
        ->get();
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,13 +3,14 @@
 | 
			
		|||
namespace BookStack\Entities\Models;
 | 
			
		||||
 | 
			
		||||
use BookStack\Auth\User;
 | 
			
		||||
use BookStack\Interfaces\Deletable;
 | 
			
		||||
use BookStack\Interfaces\Loggable;
 | 
			
		||||
use Illuminate\Database\Eloquent\Model;
 | 
			
		||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
 | 
			
		||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @property Model $deletable
 | 
			
		||||
 * @property Deletable $deletable
 | 
			
		||||
 */
 | 
			
		||||
class Deletion extends Model implements Loggable
 | 
			
		||||
{
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -12,6 +12,7 @@ use BookStack\Auth\Permissions\JointPermission;
 | 
			
		|||
use BookStack\Entities\Tools\SearchIndex;
 | 
			
		||||
use BookStack\Entities\Tools\SlugGenerator;
 | 
			
		||||
use BookStack\Facades\Permissions;
 | 
			
		||||
use BookStack\Interfaces\Deletable;
 | 
			
		||||
use BookStack\Interfaces\Favouritable;
 | 
			
		||||
use BookStack\Interfaces\Sluggable;
 | 
			
		||||
use BookStack\Interfaces\Viewable;
 | 
			
		||||
| 
						 | 
				
			
			@ -44,7 +45,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
 | 
			
		|||
 * @method static Builder withLastView()
 | 
			
		||||
 * @method static Builder withViewCount()
 | 
			
		||||
 */
 | 
			
		||||
abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
 | 
			
		||||
abstract class Entity extends Model implements Sluggable, Favouritable, Viewable, Deletable
 | 
			
		||||
{
 | 
			
		||||
    use SoftDeletes;
 | 
			
		||||
    use HasCreatorAndUpdater;
 | 
			
		||||
| 
						 | 
				
			
			@ -120,11 +121,11 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
 | 
			
		|||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (($entity->isA('chapter') || $entity->isA('page')) && $this->isA('book')) {
 | 
			
		||||
        if (($entity instanceof BookChild) && $this instanceof Book) {
 | 
			
		||||
            return $entity->book_id === $this->id;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if ($entity->isA('page') && $this->isA('chapter')) {
 | 
			
		||||
        if ($entity instanceof Page && $this instanceof Chapter) {
 | 
			
		||||
            return $entity->chapter_id === $this->id;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -210,6 +211,8 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
 | 
			
		|||
    /**
 | 
			
		||||
     * Check if this instance or class is a certain type of entity.
 | 
			
		||||
     * Examples of $type are 'page', 'book', 'chapter'.
 | 
			
		||||
     *
 | 
			
		||||
     * @deprecated Use instanceof instead.
 | 
			
		||||
     */
 | 
			
		||||
    public static function isA(string $type): bool
 | 
			
		||||
    {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -63,10 +63,8 @@ class PageRevision extends Model
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the previous revision for the same page if existing.
 | 
			
		||||
     *
 | 
			
		||||
     * @return \BookStack\Entities\PageRevision|null
 | 
			
		||||
     */
 | 
			
		||||
    public function getPrevious()
 | 
			
		||||
    public function getPrevious(): ?PageRevision
 | 
			
		||||
    {
 | 
			
		||||
        $id = static::newQuery()->where('page_id', '=', $this->page_id)
 | 
			
		||||
            ->where('id', '<', $this->id)
 | 
			
		||||
| 
						 | 
				
			
			@ -84,11 +82,9 @@ class PageRevision extends Model
 | 
			
		|||
     * Included here to align with entities in similar use cases.
 | 
			
		||||
     * (Yup, Bit of an awkward hack).
 | 
			
		||||
     *
 | 
			
		||||
     * @param $type
 | 
			
		||||
     *
 | 
			
		||||
     * @return bool
 | 
			
		||||
     * @deprecated Use instanceof instead.
 | 
			
		||||
     */
 | 
			
		||||
    public static function isA($type)
 | 
			
		||||
    public static function isA(string $type): bool
 | 
			
		||||
    {
 | 
			
		||||
        return $type === 'revision';
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -67,10 +67,12 @@ class BaseRepo
 | 
			
		|||
    /**
 | 
			
		||||
     * Update the given items' cover image, or clear it.
 | 
			
		||||
     *
 | 
			
		||||
     * @param Entity&HasCoverImage $entity
 | 
			
		||||
     *
 | 
			
		||||
     * @throws ImageUploadException
 | 
			
		||||
     * @throws \Exception
 | 
			
		||||
     */
 | 
			
		||||
    public function updateCoverImage(HasCoverImage $entity, ?UploadedFile $coverImage, bool $removeImage = false)
 | 
			
		||||
    public function updateCoverImage($entity, ?UploadedFile $coverImage, bool $removeImage = false)
 | 
			
		||||
    {
 | 
			
		||||
        if ($coverImage) {
 | 
			
		||||
            $this->imageRepo->destroyImage($entity->cover);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -69,9 +69,10 @@ class PageRepo
 | 
			
		|||
     */
 | 
			
		||||
    public function getByOldSlug(string $bookSlug, string $pageSlug): ?Page
 | 
			
		||||
    {
 | 
			
		||||
        /** @var ?PageRevision $revision */
 | 
			
		||||
        $revision = PageRevision::query()
 | 
			
		||||
            ->whereHas('page', function (Builder $query) {
 | 
			
		||||
                $query->visible();
 | 
			
		||||
                $query->scopes('visible');
 | 
			
		||||
            })
 | 
			
		||||
            ->where('slug', '=', $pageSlug)
 | 
			
		||||
            ->where('type', '=', 'version')
 | 
			
		||||
| 
						 | 
				
			
			@ -80,7 +81,7 @@ class PageRepo
 | 
			
		|||
            ->with('page')
 | 
			
		||||
            ->first();
 | 
			
		||||
 | 
			
		||||
        return $revision ? $revision->page : null;
 | 
			
		||||
        return $revision->page ?? null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
| 
						 | 
				
			
			@ -290,6 +291,8 @@ class PageRepo
 | 
			
		|||
    public function restoreRevision(Page $page, int $revisionId): Page
 | 
			
		||||
    {
 | 
			
		||||
        $page->revision_count++;
 | 
			
		||||
 | 
			
		||||
        /** @var PageRevision $revision */
 | 
			
		||||
        $revision = $page->revisions()->where('id', '=', $revisionId)->first();
 | 
			
		||||
 | 
			
		||||
        $page->fill($revision->toArray());
 | 
			
		||||
| 
						 | 
				
			
			@ -334,7 +337,8 @@ class PageRepo
 | 
			
		|||
        }
 | 
			
		||||
 | 
			
		||||
        $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
 | 
			
		||||
        $page->changeBook($parent instanceof Book ? $parent->id : $parent->book->id);
 | 
			
		||||
        $newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id;
 | 
			
		||||
        $page->changeBook($newBookId);
 | 
			
		||||
        $page->rebuildPermissions();
 | 
			
		||||
 | 
			
		||||
        Activity::addForEntity($page, ActivityType::PAGE_MOVE);
 | 
			
		||||
| 
						 | 
				
			
			@ -406,7 +410,7 @@ class PageRepo
 | 
			
		|||
     */
 | 
			
		||||
    protected function changeParent(Page $page, Entity $parent)
 | 
			
		||||
    {
 | 
			
		||||
        $book = ($parent instanceof Book) ? $parent : $parent->book;
 | 
			
		||||
        $book = ($parent instanceof Chapter) ? $parent->book : $parent;
 | 
			
		||||
        $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : 0;
 | 
			
		||||
        $page->save();
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -467,6 +471,7 @@ class PageRepo
 | 
			
		|||
    {
 | 
			
		||||
        $parent = $page->getParent();
 | 
			
		||||
        if ($parent instanceof Chapter) {
 | 
			
		||||
            /** @var ?Page $lastPage */
 | 
			
		||||
            $lastPage = $parent->pages('desc')->first();
 | 
			
		||||
 | 
			
		||||
            return $lastPage ? $lastPage->priority + 1 : 0;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -67,7 +67,7 @@ class BookContents
 | 
			
		|||
        $all->each(function (Entity $entity) use ($renderPages) {
 | 
			
		||||
            $entity->setRelation('book', $this->book);
 | 
			
		||||
 | 
			
		||||
            if ($renderPages && $entity->isA('page')) {
 | 
			
		||||
            if ($renderPages && $entity instanceof Page) {
 | 
			
		||||
                $entity->html = (new PageContent($entity))->render();
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
| 
						 | 
				
			
			@ -151,7 +151,7 @@ class BookContents
 | 
			
		|||
 | 
			
		||||
        $priorityChanged = intval($model->priority) !== intval($sortMapItem->sort);
 | 
			
		||||
        $bookChanged = intval($model->book_id) !== intval($sortMapItem->book);
 | 
			
		||||
        $chapterChanged = ($sortMapItem->type === 'page') && intval($model->chapter_id) !== $sortMapItem->parentChapter;
 | 
			
		||||
        $chapterChanged = ($model instanceof Page) && intval($model->chapter_id) !== $sortMapItem->parentChapter;
 | 
			
		||||
 | 
			
		||||
        if ($bookChanged) {
 | 
			
		||||
            $model->changeBook($sortMapItem->book);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -64,7 +64,7 @@ class NextPreviousContentLocator
 | 
			
		|||
        /** @var Entity $item */
 | 
			
		||||
        foreach ($bookTree->all() as $item) {
 | 
			
		||||
            $flatOrdered->push($item);
 | 
			
		||||
            $childPages = $item->visible_pages ?? [];
 | 
			
		||||
            $childPages = $item->getAttribute('visible_pages') ?? [];
 | 
			
		||||
            $flatOrdered = $flatOrdered->concat($childPages);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -12,6 +12,8 @@ use BookStack\Uploads\ImageRepo;
 | 
			
		|||
use BookStack\Uploads\ImageService;
 | 
			
		||||
use BookStack\Util\HtmlContentFilter;
 | 
			
		||||
use DOMDocument;
 | 
			
		||||
use DOMElement;
 | 
			
		||||
use DOMNode;
 | 
			
		||||
use DOMNodeList;
 | 
			
		||||
use DOMXPath;
 | 
			
		||||
use Illuminate\Support\Str;
 | 
			
		||||
| 
						 | 
				
			
			@ -156,7 +158,7 @@ class PageContent
 | 
			
		|||
    /**
 | 
			
		||||
     * Parse a base64 image URI into the data and extension.
 | 
			
		||||
     *
 | 
			
		||||
     * @return array{extension: array, data: string}
 | 
			
		||||
     * @return array{extension: string, data: string}
 | 
			
		||||
     */
 | 
			
		||||
    protected function parseBase64ImageUri(string $uri): array
 | 
			
		||||
    {
 | 
			
		||||
| 
						 | 
				
			
			@ -193,6 +195,15 @@ class PageContent
 | 
			
		|||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Set ids on nested header nodes
 | 
			
		||||
        $nestedHeaders = $xPath->query('//body//*//h1|//body//*//h2|//body//*//h3|//body//*//h4|//body//*//h5|//body//*//h6');
 | 
			
		||||
        foreach ($nestedHeaders as $nestedHeader) {
 | 
			
		||||
            [$oldId, $newId] = $this->setUniqueId($nestedHeader, $idMap);
 | 
			
		||||
            if ($newId && $newId !== $oldId) {
 | 
			
		||||
                $this->updateLinks($xPath, '#' . $oldId, '#' . $newId);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Ensure no duplicate ids within child items
 | 
			
		||||
        $idElems = $xPath->query('//body//*//*[@id]');
 | 
			
		||||
        foreach ($idElems as $domElem) {
 | 
			
		||||
| 
						 | 
				
			
			@ -228,9 +239,9 @@ class PageContent
 | 
			
		|||
     * A map for existing ID's should be passed in to check for current existence.
 | 
			
		||||
     * Returns a pair of strings in the format [old_id, new_id].
 | 
			
		||||
     */
 | 
			
		||||
    protected function setUniqueId(\DOMNode $element, array &$idMap): array
 | 
			
		||||
    protected function setUniqueId(DOMNode $element, array &$idMap): array
 | 
			
		||||
    {
 | 
			
		||||
        if (get_class($element) !== 'DOMElement') {
 | 
			
		||||
        if (!$element instanceof DOMElement) {
 | 
			
		||||
            return ['', ''];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -242,7 +253,7 @@ class PageContent
 | 
			
		|||
            return [$existingId, $existingId];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Create an unique id for the element
 | 
			
		||||
        // Create a unique id for the element
 | 
			
		||||
        // Uses the content as a basis to ensure output is the same every time
 | 
			
		||||
        // the same content is passed through.
 | 
			
		||||
        $contentId = 'bkmrk-' . mb_substr(strtolower(preg_replace('/\s+/', '-', trim($element->nodeValue))), 0, 20);
 | 
			
		||||
| 
						 | 
				
			
			@ -312,7 +323,7 @@ class PageContent
 | 
			
		|||
     */
 | 
			
		||||
    protected function headerNodesToLevelList(DOMNodeList $nodeList): array
 | 
			
		||||
    {
 | 
			
		||||
        $tree = collect($nodeList)->map(function ($header) {
 | 
			
		||||
        $tree = collect($nodeList)->map(function (DOMElement $header) {
 | 
			
		||||
            $text = trim(str_replace("\xc2\xa0", '', $header->nodeValue));
 | 
			
		||||
            $text = mb_substr($text, 0, 100);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -9,6 +9,7 @@ use BookStack\Entities\Models\Page;
 | 
			
		|||
use BookStack\Entities\Models\SearchTerm;
 | 
			
		||||
use DOMDocument;
 | 
			
		||||
use DOMNode;
 | 
			
		||||
use Illuminate\Database\Eloquent\Builder;
 | 
			
		||||
use Illuminate\Support\Collection;
 | 
			
		||||
 | 
			
		||||
class SearchIndex
 | 
			
		||||
| 
						 | 
				
			
			@ -67,7 +68,7 @@ class SearchIndex
 | 
			
		|||
     * - The number that have been processed so far.
 | 
			
		||||
     * - The total number of that model to be processed.
 | 
			
		||||
     *
 | 
			
		||||
     * @param callable(Entity, int, int)|null $progressCallback
 | 
			
		||||
     * @param callable(Entity, int, int):void|null $progressCallback
 | 
			
		||||
     */
 | 
			
		||||
    public function indexAllEntities(?callable $progressCallback = null)
 | 
			
		||||
    {
 | 
			
		||||
| 
						 | 
				
			
			@ -76,7 +77,9 @@ class SearchIndex
 | 
			
		|||
        foreach ($this->entityProvider->all() as $entityModel) {
 | 
			
		||||
            $indexContentField = $entityModel instanceof Page ? 'html' : 'description';
 | 
			
		||||
            $selectFields = ['id', 'name', $indexContentField];
 | 
			
		||||
            $total = $entityModel->newQuery()->withTrashed()->count();
 | 
			
		||||
            /** @var Builder<Entity> $query */
 | 
			
		||||
            $query = $entityModel->newQuery();
 | 
			
		||||
            $total = $query->withTrashed()->count();
 | 
			
		||||
            $chunkSize = 250;
 | 
			
		||||
            $processed = 0;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -223,7 +226,7 @@ class SearchIndex
 | 
			
		|||
        if ($entity instanceof Page) {
 | 
			
		||||
            $bodyTermsMap = $this->generateTermScoreMapFromHtml($entity->html);
 | 
			
		||||
        } else {
 | 
			
		||||
            $bodyTermsMap = $this->generateTermScoreMapFromText($entity->description ?? '', $entity->searchFactor);
 | 
			
		||||
            $bodyTermsMap = $this->generateTermScoreMapFromText($entity->getAttribute('description') ?? '', $entity->searchFactor);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $mergedScoreMap = $this->mergeTermScoreMaps($nameTermsMap, $bodyTermsMap, $tagTermsMap);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -9,6 +9,7 @@ use BookStack\Entities\Models\BookChild;
 | 
			
		|||
use BookStack\Entities\Models\Entity;
 | 
			
		||||
use BookStack\Entities\Models\Page;
 | 
			
		||||
use BookStack\Entities\Models\SearchTerm;
 | 
			
		||||
use Illuminate\Database\Connection;
 | 
			
		||||
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
 | 
			
		||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
 | 
			
		||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
 | 
			
		||||
| 
						 | 
				
			
			@ -144,13 +145,13 @@ class SearchRunner
 | 
			
		|||
 | 
			
		||||
        if ($entityModelInstance instanceof BookChild) {
 | 
			
		||||
            $relations['book'] = function (BelongsTo $query) {
 | 
			
		||||
                $query->visible();
 | 
			
		||||
                $query->scopes('visible');
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if ($entityModelInstance instanceof Page) {
 | 
			
		||||
            $relations['chapter'] = function (BelongsTo $query) {
 | 
			
		||||
                $query->visible();
 | 
			
		||||
                $query->scopes('visible');
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -356,7 +357,9 @@ class SearchRunner
 | 
			
		|||
                    // We have to do a raw sql query for this since otherwise PDO will quote the value and MySQL will
 | 
			
		||||
                    // search the value as a string which prevents being able to do number-based operations
 | 
			
		||||
                    // on the tag values. We ensure it has a numeric value and then cast it just to be sure.
 | 
			
		||||
                    $tagValue = (float) trim($query->getConnection()->getPdo()->quote($tagValue), "'");
 | 
			
		||||
                    /** @var Connection $connection */
 | 
			
		||||
                    $connection = $query->getConnection();
 | 
			
		||||
                    $tagValue = (float) trim($connection->getPdo()->quote($tagValue), "'");
 | 
			
		||||
                    $query->whereRaw("value ${tagOperator} ${tagValue}");
 | 
			
		||||
                } else {
 | 
			
		||||
                    $query->where('value', $tagOperator, $tagValue);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,6 +5,7 @@ namespace BookStack\Entities\Tools;
 | 
			
		|||
use BookStack\Entities\EntityProvider;
 | 
			
		||||
use BookStack\Entities\Models\Book;
 | 
			
		||||
use BookStack\Entities\Models\Bookshelf;
 | 
			
		||||
use BookStack\Entities\Models\Chapter;
 | 
			
		||||
use BookStack\Entities\Models\Page;
 | 
			
		||||
use Illuminate\Support\Collection;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -24,7 +25,7 @@ class SiblingFetcher
 | 
			
		|||
        }
 | 
			
		||||
 | 
			
		||||
        // Page in book or chapter
 | 
			
		||||
        if (($entity instanceof Page && !$entity->chapter) || $entity->isA('chapter')) {
 | 
			
		||||
        if (($entity instanceof Page && !$entity->chapter) || $entity instanceof Chapter) {
 | 
			
		||||
            $entities = $entity->book->getDirectChildren();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -15,6 +15,7 @@ use BookStack\Facades\Activity;
 | 
			
		|||
use BookStack\Uploads\AttachmentService;
 | 
			
		||||
use BookStack\Uploads\ImageService;
 | 
			
		||||
use Exception;
 | 
			
		||||
use Illuminate\Database\Eloquent\Builder;
 | 
			
		||||
use Illuminate\Support\Carbon;
 | 
			
		||||
 | 
			
		||||
class TrashCan
 | 
			
		||||
| 
						 | 
				
			
			@ -141,11 +142,9 @@ class TrashCan
 | 
			
		|||
    {
 | 
			
		||||
        $count = 0;
 | 
			
		||||
        $pages = $chapter->pages()->withTrashed()->get();
 | 
			
		||||
        if (count($pages)) {
 | 
			
		||||
            foreach ($pages as $page) {
 | 
			
		||||
                $this->destroyPage($page);
 | 
			
		||||
                $count++;
 | 
			
		||||
            }
 | 
			
		||||
        foreach ($pages as $page) {
 | 
			
		||||
            $this->destroyPage($page);
 | 
			
		||||
            $count++;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $this->destroyCommonRelations($chapter);
 | 
			
		||||
| 
						 | 
				
			
			@ -183,9 +182,10 @@ class TrashCan
 | 
			
		|||
    {
 | 
			
		||||
        $counts = [];
 | 
			
		||||
 | 
			
		||||
        /** @var Entity $instance */
 | 
			
		||||
        foreach ((new EntityProvider())->all() as $key => $instance) {
 | 
			
		||||
            $counts[$key] = $instance->newQuery()->onlyTrashed()->count();
 | 
			
		||||
            /** @var Builder<Entity> $query */
 | 
			
		||||
            $query = $instance->newQuery();
 | 
			
		||||
            $counts[$key] = $query->onlyTrashed()->count();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $counts;
 | 
			
		||||
| 
						 | 
				
			
			@ -235,13 +235,15 @@ class TrashCan
 | 
			
		|||
    {
 | 
			
		||||
        $shouldRestore = true;
 | 
			
		||||
        $restoreCount = 0;
 | 
			
		||||
        $parent = $deletion->deletable->getParent();
 | 
			
		||||
 | 
			
		||||
        if ($parent && $parent->trashed()) {
 | 
			
		||||
            $shouldRestore = false;
 | 
			
		||||
        if ($deletion->deletable instanceof Entity) {
 | 
			
		||||
            $parent = $deletion->deletable->getParent();
 | 
			
		||||
            if ($parent && $parent->trashed()) {
 | 
			
		||||
                $shouldRestore = false;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if ($shouldRestore) {
 | 
			
		||||
        if ($deletion->deletable instanceof Entity && $shouldRestore) {
 | 
			
		||||
            $restoreCount = $this->restoreEntity($deletion->deletable);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -342,9 +344,9 @@ class TrashCan
 | 
			
		|||
        $entity->deletions()->delete();
 | 
			
		||||
        $entity->favourites()->delete();
 | 
			
		||||
 | 
			
		||||
        if ($entity instanceof HasCoverImage && $entity->cover) {
 | 
			
		||||
        if ($entity instanceof HasCoverImage && $entity->cover()->exists()) {
 | 
			
		||||
            $imageService = app()->make(ImageService::class);
 | 
			
		||||
            $imageService->destroy($entity->cover);
 | 
			
		||||
            $imageService->destroy($entity->cover()->first());
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -75,7 +75,7 @@ class BookshelfApiController extends ApiController
 | 
			
		|||
        $shelf = Bookshelf::visible()->with([
 | 
			
		||||
            'tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy',
 | 
			
		||||
            'books' => function (BelongsToMany $query) {
 | 
			
		||||
                $query->visible()->get(['id', 'name', 'slug']);
 | 
			
		||||
                $query->scopes('visible')->get(['id', 'name', 'slug']);
 | 
			
		||||
            },
 | 
			
		||||
        ])->findOrFail($id);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -70,7 +70,7 @@ class ChapterApiController extends ApiController
 | 
			
		|||
    public function read(string $id)
 | 
			
		||||
    {
 | 
			
		||||
        $chapter = Chapter::visible()->with(['tags', 'createdBy', 'updatedBy', 'ownedBy', 'pages' => function (HasMany $query) {
 | 
			
		||||
            $query->visible()->get(['id', 'name', 'slug']);
 | 
			
		||||
            $query->scopes('visible')->get(['id', 'name', 'slug']);
 | 
			
		||||
        }])->findOrFail($id);
 | 
			
		||||
 | 
			
		||||
        return response()->json($chapter);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -114,7 +114,7 @@ class BookController extends Controller
 | 
			
		|||
    {
 | 
			
		||||
        $book = $this->bookRepo->getBySlug($slug);
 | 
			
		||||
        $bookChildren = (new BookContents($book))->getTree(true);
 | 
			
		||||
        $bookParentShelves = $book->shelves()->visible()->get();
 | 
			
		||||
        $bookParentShelves = $book->shelves()->scopes('visible')->get();
 | 
			
		||||
 | 
			
		||||
        View::incrementFor($book);
 | 
			
		||||
        if ($request->has('shelf')) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -39,7 +39,7 @@ class HomeController extends Controller
 | 
			
		|||
        $recentlyUpdatedPages = Page::visible()->with('book')
 | 
			
		||||
            ->where('draft', false)
 | 
			
		||||
            ->orderBy('updated_at', 'desc')
 | 
			
		||||
            ->take($favourites->count() > 0 ? 6 : 12)
 | 
			
		||||
            ->take($favourites->count() > 0 ? 5 : 10)
 | 
			
		||||
            ->select(Page::$listAttributes)
 | 
			
		||||
            ->get();
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -58,6 +58,7 @@ class RecycleBinController extends Controller
 | 
			
		|||
                $searching = false;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        /** @var ?Deletion $parentDeletion */
 | 
			
		||||
        $parentDeletion = ($currentDeletable === $deletion->deletable) ? null : $currentDeletable->deletions()->first();
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,14 @@
 | 
			
		|||
<?php
 | 
			
		||||
 | 
			
		||||
namespace BookStack\Interfaces;
 | 
			
		||||
 | 
			
		||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * A model that can be deleted in a manner that deletions
 | 
			
		||||
 * are tracked to be part of the recycle bin system.
 | 
			
		||||
 */
 | 
			
		||||
interface Deletable
 | 
			
		||||
{
 | 
			
		||||
    public function deletions(): MorphMany;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -3,6 +3,9 @@
 | 
			
		|||
namespace BookStack\Theming;
 | 
			
		||||
 | 
			
		||||
use BookStack\Auth\Access\SocialAuthService;
 | 
			
		||||
use Illuminate\Console\Application;
 | 
			
		||||
use Illuminate\Console\Application as Artisan;
 | 
			
		||||
use Symfony\Component\Console\Command\Command;
 | 
			
		||||
 | 
			
		||||
class ThemeService
 | 
			
		||||
{
 | 
			
		||||
| 
						 | 
				
			
			@ -43,6 +46,16 @@ class ThemeService
 | 
			
		|||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Register a new custom artisan command to be available.
 | 
			
		||||
     */
 | 
			
		||||
    public function registerCommand(Command $command)
 | 
			
		||||
    {
 | 
			
		||||
        Artisan::starting(function (Application $application) use ($command) {
 | 
			
		||||
            $application->addCommands([$command]);
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Read any actions from the set theme path if the 'functions.php' file exists.
 | 
			
		||||
     */
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -103,7 +103,10 @@ class ImageRepo
 | 
			
		|||
                if ($filterType === 'page') {
 | 
			
		||||
                    $query->where('uploaded_to', '=', $contextPage->id);
 | 
			
		||||
                } elseif ($filterType === 'book') {
 | 
			
		||||
                    $validPageIds = $contextPage->book->pages()->visible()->pluck('id')->toArray();
 | 
			
		||||
                    $validPageIds = $contextPage->book->pages()
 | 
			
		||||
                        ->scopes('visible')
 | 
			
		||||
                        ->pluck('id')
 | 
			
		||||
                        ->toArray();
 | 
			
		||||
                    $query->whereIn('uploaded_to', $validPageIds);
 | 
			
		||||
                }
 | 
			
		||||
            };
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -77,6 +77,32 @@ Theme::listen(ThemeEvents::APP_BOOT, function($app) {
 | 
			
		|||
});
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Custom Commands
 | 
			
		||||
 | 
			
		||||
The logical theme system supports adding custom [artisan commands](https://laravel.com/docs/8.x/artisan) to BookStack. These can be registered in your `functions.php` file by calling `Theme::registerCommand($command)`, where `$command` is an instance of `\Symfony\Component\Console\Command\Command`. 
 | 
			
		||||
 | 
			
		||||
Below is an example of registering a command that could then be ran using `php artisan bookstack:meow` on the command line.
 | 
			
		||||
 | 
			
		||||
```php
 | 
			
		||||
<?php
 | 
			
		||||
 | 
			
		||||
use BookStack\Facades\Theme;
 | 
			
		||||
use Illuminate\Console\Command;
 | 
			
		||||
 | 
			
		||||
class MeowCommand extends Command
 | 
			
		||||
{
 | 
			
		||||
    protected $signature = 'bookstack:meow';
 | 
			
		||||
    protected $description = 'Say meow on the command line';
 | 
			
		||||
 | 
			
		||||
    public function handle()
 | 
			
		||||
    {
 | 
			
		||||
        $this->line('Meow there!');
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
Theme::registerCommand(new MeowCommand);
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Custom Socialite Service Example
 | 
			
		||||
 | 
			
		||||
The below shows an example of adding a custom reddit socialite service to BookStack. 
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,6 +6,8 @@ This theme system itself is maintained and supported but usages of this system,
 | 
			
		|||
 | 
			
		||||
## Getting Started
 | 
			
		||||
 | 
			
		||||
*[Video Guide](https://www.youtube.com/watch?v=gLy_2GBse48)*
 | 
			
		||||
 | 
			
		||||
This makes use of the theme system. Create a folder for your theme within your BookStack `themes` directory. As an example we'll use `my_theme`, so we'd create a `themes/my_theme` folder.
 | 
			
		||||
You'll need to tell BookStack to use your theme via the `APP_THEME` option in your `.env` file. For example: `APP_THEME=my_theme`.
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -28,4 +30,4 @@ As an example, Say I wanted to change 'Search' to 'Find'; Within a `themes/<them
 | 
			
		|||
return [
 | 
			
		||||
    'search' => 'find',
 | 
			
		||||
];
 | 
			
		||||
```
 | 
			
		||||
```
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -54,7 +54,7 @@ return [
 | 
			
		|||
    'email_confirm_text' => 'Palun kinnita oma e-posti aadress, klõpsates alloleval nupul:',
 | 
			
		||||
    'email_confirm_action' => 'Kinnita e-posti aadress',
 | 
			
		||||
    'email_confirm_send_error' => 'E-posti aadressi kinnitamine on vajalik, aga e-kirja saatmine ebaõnnestus. Võta ühendust administraatoriga.',
 | 
			
		||||
    'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
 | 
			
		||||
    'email_confirm_success' => 'E-posti aadress on kinnitatud! Nüüd saad selle aadressiga sisse logida.',
 | 
			
		||||
    'email_confirm_resent' => 'Kinnituskiri on saadetud, vaata oma postkasti.',
 | 
			
		||||
 | 
			
		||||
    'email_not_confirmed' => 'E-posti aadress ei ole kinnitatud',
 | 
			
		||||
| 
						 | 
				
			
			@ -71,7 +71,7 @@ return [
 | 
			
		|||
    'user_invite_page_welcome' => 'Tere tulemast rakendusse :appName!',
 | 
			
		||||
    'user_invite_page_text' => 'Registreerumise lõpetamiseks ja ligipääsu saamiseks pead seadma parooli, millega edaspidi rakendusse sisse logid.',
 | 
			
		||||
    'user_invite_page_confirm_button' => 'Kinnita parool',
 | 
			
		||||
    'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
 | 
			
		||||
    'user_invite_success_login' => 'Parool seatud, nüüd on sul selle parooli abil ligipääs rakendusele :appName!',
 | 
			
		||||
 | 
			
		||||
    // Multi-factor Authentication
 | 
			
		||||
    'mfa_setup' => 'Seadista mitmeastmeline autentimine',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -54,7 +54,7 @@ return [
 | 
			
		|||
    'email_confirm_text' => 'Conferma il tuo indirizzo email cliccando il pulsante sotto:',
 | 
			
		||||
    'email_confirm_action' => 'Conferma Email',
 | 
			
		||||
    'email_confirm_send_error' => 'La conferma della mail è richiesta ma non è stato possibile mandare la mail. Contatta l\'amministratore.',
 | 
			
		||||
    'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
 | 
			
		||||
    'email_confirm_success' => 'La tua email è stata confermata! Ora dovresti essere in grado di effettuare il login utilizzando questo indirizzo email.',
 | 
			
		||||
    'email_confirm_resent' => 'Mail di conferma reinviata, controlla la tua posta.',
 | 
			
		||||
 | 
			
		||||
    'email_not_confirmed' => 'Indirizzo Email Non Confermato',
 | 
			
		||||
| 
						 | 
				
			
			@ -71,7 +71,7 @@ return [
 | 
			
		|||
    'user_invite_page_welcome' => 'Benvenuto in :appName!',
 | 
			
		||||
    'user_invite_page_text' => 'Per completare il tuo account e ottenere l\'accesso devi impostare una password che verrà utilizzata per accedere a :appName in futuro.',
 | 
			
		||||
    'user_invite_page_confirm_button' => 'Conferma Password',
 | 
			
		||||
    'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
 | 
			
		||||
    'user_invite_success_login' => 'Password impostata, ora dovresti essere in grado di effettuare il login utilizzando la password impostata per accedere a :appName!',
 | 
			
		||||
 | 
			
		||||
    // Multi-factor Authentication
 | 
			
		||||
    'mfa_setup' => 'Imposta Autenticazione Multi-Fattore',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -45,8 +45,8 @@ return [
 | 
			
		|||
    'unfavourite' => 'Rimuovi dai preferiti',
 | 
			
		||||
    'next' => 'Successivo',
 | 
			
		||||
    'previous' => 'Precedente',
 | 
			
		||||
    'filter_active' => 'Active Filter:',
 | 
			
		||||
    'filter_clear' => 'Clear Filter',
 | 
			
		||||
    'filter_active' => 'Filtro attivo:',
 | 
			
		||||
    'filter_clear' => 'Pulisci filtro',
 | 
			
		||||
 | 
			
		||||
    // Sort Options
 | 
			
		||||
    'sort_options' => 'Opzioni Ordinamento',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -258,16 +258,16 @@ return [
 | 
			
		|||
    'tags_explain' => "Aggiungi tag per categorizzare meglio il contenuto. \n Puoi assegnare un valore ai tag per una migliore organizzazione.",
 | 
			
		||||
    'tags_add' => 'Aggiungi un altro tag',
 | 
			
		||||
    'tags_remove' => 'Rimuovi questo tag',
 | 
			
		||||
    'tags_usages' => 'Total tag usages',
 | 
			
		||||
    'tags_assigned_pages' => 'Assigned to Pages',
 | 
			
		||||
    'tags_assigned_chapters' => 'Assigned to Chapters',
 | 
			
		||||
    'tags_assigned_books' => 'Assigned to Books',
 | 
			
		||||
    'tags_assigned_shelves' => 'Assigned to Shelves',
 | 
			
		||||
    'tags_usages' => 'Utilizzo totale dei tag',
 | 
			
		||||
    'tags_assigned_pages' => 'Assegnato alle Pagine',
 | 
			
		||||
    'tags_assigned_chapters' => 'Assegnato ai capitoli',
 | 
			
		||||
    'tags_assigned_books' => 'Assegnato a Libri',
 | 
			
		||||
    'tags_assigned_shelves' => 'Assegnato alle Librerie',
 | 
			
		||||
    'tags_x_unique_values' => ':count unique values',
 | 
			
		||||
    'tags_all_values' => 'All values',
 | 
			
		||||
    'tags_view_tags' => 'View Tags',
 | 
			
		||||
    'tags_view_existing_tags' => 'View existing tags',
 | 
			
		||||
    'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.',
 | 
			
		||||
    'tags_all_values' => 'Tutti i valori',
 | 
			
		||||
    'tags_view_tags' => 'Visualizza tag',
 | 
			
		||||
    'tags_view_existing_tags' => 'Usa i tag esistenti',
 | 
			
		||||
    'tags_list_empty_hint' => 'I tag possono essere assegnati tramite la barra laterale dell\'editor di pagina o durante la modifica dei dettagli di un libro, capitolo o libreria.',
 | 
			
		||||
    'attachments' => 'Allegati',
 | 
			
		||||
    'attachments_explain' => 'Carica alcuni file o allega link per visualizzarli nella pagina. Questi sono visibili nella sidebar della pagina.',
 | 
			
		||||
    'attachments_explain_instant_save' => 'I cambiamenti qui sono salvati istantaneamente.',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -54,7 +54,7 @@ return [
 | 
			
		|||
    'email_confirm_text' => 'Пожалуйста, подтвердите свой адрес электронной почты нажав на кнопку ниже:',
 | 
			
		||||
    'email_confirm_action' => 'Подтвердить адрес электронной почты',
 | 
			
		||||
    'email_confirm_send_error' => 'Требуется подтверждение электронной почты, но система не может отправить письмо. Свяжитесь с администратором, чтобы убедиться, что адрес электронной почты настроен правильно.',
 | 
			
		||||
    'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
 | 
			
		||||
    'email_confirm_success' => 'Ваш адрес электронной почты был подтвержден! Теперь вы можете войти в систему, используя этот адрес электронной почты.',
 | 
			
		||||
    'email_confirm_resent' => 'Письмо с подтверждение выслано снова. Пожалуйста, проверьте ваш почтовый ящик.',
 | 
			
		||||
 | 
			
		||||
    'email_not_confirmed' => 'Адрес электронной почты не подтвержден',
 | 
			
		||||
| 
						 | 
				
			
			@ -71,7 +71,7 @@ return [
 | 
			
		|||
    'user_invite_page_welcome' => 'Добро пожаловать в :appName!',
 | 
			
		||||
    'user_invite_page_text' => 'Завершите настройку аккаунта, установите пароль для дальнейшего входа в :appName.',
 | 
			
		||||
    'user_invite_page_confirm_button' => 'Подтвердите пароль',
 | 
			
		||||
    'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
 | 
			
		||||
    'user_invite_success_login' => 'Пароль установлен, теперь вы можете войти в систему, используя установленный пароль для доступа к :appName!',
 | 
			
		||||
 | 
			
		||||
    // Multi-factor Authentication
 | 
			
		||||
    'mfa_setup' => 'Двухфакторная аутентификация',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -39,14 +39,14 @@ return [
 | 
			
		|||
    'reset' => 'Сбросить',
 | 
			
		||||
    'remove' => 'Удалить',
 | 
			
		||||
    'add' => 'Добавить',
 | 
			
		||||
    'configure' => 'Configure',
 | 
			
		||||
    'configure' => 'Настройка',
 | 
			
		||||
    'fullscreen' => 'На весь экран',
 | 
			
		||||
    'favourite' => 'Избранное',
 | 
			
		||||
    'unfavourite' => 'Убрать из избранного',
 | 
			
		||||
    'next' => 'Следующая',
 | 
			
		||||
    'previous' => 'Предыдущая',
 | 
			
		||||
    'filter_active' => 'Active Filter:',
 | 
			
		||||
    'filter_clear' => 'Clear Filter',
 | 
			
		||||
    'filter_active' => 'Активный фильтр:',
 | 
			
		||||
    'filter_clear' => 'Сбросить фильтр',
 | 
			
		||||
 | 
			
		||||
    // Sort Options
 | 
			
		||||
    'sort_options' => 'Параметры сортировки',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -36,7 +36,7 @@ return [
 | 
			
		|||
    'export_html' => 'Веб файл',
 | 
			
		||||
    'export_pdf' => 'PDF файл',
 | 
			
		||||
    'export_text' => 'Текстовый файл',
 | 
			
		||||
    'export_md' => 'Markdown File',
 | 
			
		||||
    'export_md' => 'Файл Markdown',
 | 
			
		||||
 | 
			
		||||
    // Permissions and restrictions
 | 
			
		||||
    'permissions' => 'Разрешения',
 | 
			
		||||
| 
						 | 
				
			
			@ -99,7 +99,7 @@ return [
 | 
			
		|||
    'shelves_permissions' => 'Доступы к книжной полке',
 | 
			
		||||
    'shelves_permissions_updated' => 'Доступы к книжной полке обновлены',
 | 
			
		||||
    'shelves_permissions_active' => 'Действующие разрешения книжной полки',
 | 
			
		||||
    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
 | 
			
		||||
    'shelves_permissions_cascade_warning' => 'Разрешения на полки не наследуются автоматически содержащимся в них книгам. Это происходит потому, что книга может находиться на нескольких полках. Однако разрешения могут быть установлены для книг полки с помощью опции, приведенной ниже.',
 | 
			
		||||
    'shelves_copy_permissions_to_books' => 'Наследовать доступы книгам',
 | 
			
		||||
    'shelves_copy_permissions' => 'Копировать доступы',
 | 
			
		||||
    'shelves_copy_permissions_explain' => 'Это применит текущие настройки доступов этой книжной полки ко всем книгам, содержащимся внутри. Перед активацией убедитесь, что все изменения в доступах этой книжной полки сохранены.',
 | 
			
		||||
| 
						 | 
				
			
			@ -234,7 +234,7 @@ return [
 | 
			
		|||
    'pages_initial_name' => 'Новая страница',
 | 
			
		||||
    'pages_editing_draft_notification' => 'В настоящее время вы редактируете черновик, который был сохранён :timeDiff.',
 | 
			
		||||
    'pages_draft_edited_notification' => 'Эта страница была обновлена до этого момента. Рекомендуется отменить этот черновик.',
 | 
			
		||||
    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
 | 
			
		||||
    'pages_draft_page_changed_since_creation' => 'Эта страница была обновлена с момента создания данного черновика. Рекомендуется выбросить этот черновик или следить за тем, чтобы не перезаписать все изменения на странице.',
 | 
			
		||||
    'pages_draft_edit_active' => [
 | 
			
		||||
        'start_a' => ':count пользователей начали редактирование этой страницы',
 | 
			
		||||
        'start_b' => ':userName начал редактирование этой страницы',
 | 
			
		||||
| 
						 | 
				
			
			@ -258,16 +258,16 @@ return [
 | 
			
		|||
    'tags_explain' => "Добавьте теги, чтобы лучше классифицировать ваш контент. \\n Вы можете присвоить значение тегу для более глубокой организации.",
 | 
			
		||||
    'tags_add' => 'Добавить тег',
 | 
			
		||||
    'tags_remove' => 'Удалить этот тег',
 | 
			
		||||
    'tags_usages' => 'Total tag usages',
 | 
			
		||||
    'tags_assigned_pages' => 'Assigned to Pages',
 | 
			
		||||
    'tags_assigned_chapters' => 'Assigned to Chapters',
 | 
			
		||||
    'tags_assigned_books' => 'Assigned to Books',
 | 
			
		||||
    'tags_assigned_shelves' => 'Assigned to Shelves',
 | 
			
		||||
    'tags_x_unique_values' => ':count unique values',
 | 
			
		||||
    'tags_all_values' => 'All values',
 | 
			
		||||
    'tags_view_tags' => 'View Tags',
 | 
			
		||||
    'tags_view_existing_tags' => 'View existing tags',
 | 
			
		||||
    'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.',
 | 
			
		||||
    'tags_usages' => 'Всего использовано тегов',
 | 
			
		||||
    'tags_assigned_pages' => 'Назначено на страницы',
 | 
			
		||||
    'tags_assigned_chapters' => 'Назначено на главы',
 | 
			
		||||
    'tags_assigned_books' => 'Назначено на книги',
 | 
			
		||||
    'tags_assigned_shelves' => 'Назначено на полки',
 | 
			
		||||
    'tags_x_unique_values' => 'Уникальные значения: :count',
 | 
			
		||||
    'tags_all_values' => 'Все значения',
 | 
			
		||||
    'tags_view_tags' => 'Посмотреть теги',
 | 
			
		||||
    'tags_view_existing_tags' => 'Просмотр имеющихся тегов',
 | 
			
		||||
    'tags_list_empty_hint' => 'Теги можно присваивать через боковую панель редактора страниц или при редактировании сведений о книге, главе или полке.',
 | 
			
		||||
    'attachments' => 'Вложения',
 | 
			
		||||
    'attachments_explain' => 'Загрузите несколько файлов или добавьте ссылку для отображения на своей странице. Они видны на боковой панели страницы.',
 | 
			
		||||
    'attachments_explain_instant_save' => 'Изменения здесь сохраняются мгновенно.',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -23,10 +23,10 @@ return [
 | 
			
		|||
    'saml_no_email_address' => 'Не удалось найти email для этого пользователя в данных, предоставленных внешней системой аутентификации',
 | 
			
		||||
    'saml_invalid_response_id' => 'Запрос от внешней системы аутентификации не распознается процессом, запущенным этим приложением. Переход назад после входа в систему может вызвать эту проблему.',
 | 
			
		||||
    'saml_fail_authed' => 'Вход с помощью :system не удался, система не предоставила успешную авторизацию',
 | 
			
		||||
    'oidc_already_logged_in' => 'Already logged in',
 | 
			
		||||
    'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
 | 
			
		||||
    'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system',
 | 
			
		||||
    'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization',
 | 
			
		||||
    'oidc_already_logged_in' => 'Вход в систему уже произведен',
 | 
			
		||||
    'oidc_user_not_registered' => 'Пользователь :name не зарегистрирован и автоматическая регистрация отключена',
 | 
			
		||||
    'oidc_no_email_address' => 'Не удалось найти email этого пользователя в данных, предоставленных внешней системой аутентификации',
 | 
			
		||||
    'oidc_fail_authed' => 'Вход в систему с помощью :system не удался, система не обеспечила успешную авторизацию',
 | 
			
		||||
    'social_no_action_defined' => 'Действие не определено',
 | 
			
		||||
    'social_login_bad_response' => "При попытке входа с :socialAccount произошла ошибка: \\n:error",
 | 
			
		||||
    'social_account_in_use' => 'Этот :socialAccount аккаунт уже используется, попробуйте войти с параметрами :socialAccount.',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -92,7 +92,7 @@ return [
 | 
			
		|||
    'recycle_bin' => 'Корзина',
 | 
			
		||||
    'recycle_bin_desc' => 'Здесь вы можете восстановить удаленные элементы или навсегда удалить их из системы. Этот список не отфильтрован в отличие от аналогичных списков действий в системе, где применяются фильтры.',
 | 
			
		||||
    'recycle_bin_deleted_item' => 'Удаленный элемент',
 | 
			
		||||
    'recycle_bin_deleted_parent' => 'Parent',
 | 
			
		||||
    'recycle_bin_deleted_parent' => 'Родительский объект',
 | 
			
		||||
    'recycle_bin_deleted_by' => 'Удалён',
 | 
			
		||||
    'recycle_bin_deleted_at' => 'Время удаления',
 | 
			
		||||
    'recycle_bin_permanently_delete' => 'Удалить навсегда',
 | 
			
		||||
| 
						 | 
				
			
			@ -105,7 +105,7 @@ return [
 | 
			
		|||
    'recycle_bin_restore_list' => 'Элементы для восстановления',
 | 
			
		||||
    'recycle_bin_restore_confirm' => 'Это действие восстановит удаленный элемент, включая дочерние, в исходное место. Если исходное место было удалено и теперь находится в корзине, родительский элемент также необходимо будет восстановить.',
 | 
			
		||||
    'recycle_bin_restore_deleted_parent' => 'Родитель этого элемента также был удален. Элементы будут удалены до тех пор, пока этот родитель не будет восстановлен.',
 | 
			
		||||
    'recycle_bin_restore_parent' => 'Restore Parent',
 | 
			
		||||
    'recycle_bin_restore_parent' => 'Восстановить родительский объект',
 | 
			
		||||
    'recycle_bin_destroy_notification' => 'Удалено :count элементов из корзины.',
 | 
			
		||||
    'recycle_bin_restore_notification' => 'Восстановлено :count элементов из корзины',
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -139,7 +139,7 @@ return [
 | 
			
		|||
    'role_details' => 'Детали роли',
 | 
			
		||||
    'role_name' => 'Название роли',
 | 
			
		||||
    'role_desc' => 'Краткое описание роли',
 | 
			
		||||
    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
 | 
			
		||||
    'role_mfa_enforced' => 'Требует многофакторной аутентификации',
 | 
			
		||||
    'role_external_auth_id' => 'Внешние ID авторизации',
 | 
			
		||||
    'role_system' => 'Системные разрешения',
 | 
			
		||||
    'role_manage_users' => 'Управление пользователями',
 | 
			
		||||
| 
						 | 
				
			
			@ -149,7 +149,7 @@ return [
 | 
			
		|||
    'role_manage_page_templates' => 'Управление шаблонами страниц',
 | 
			
		||||
    'role_access_api' => 'Доступ к системному API',
 | 
			
		||||
    'role_manage_settings' => 'Управление настройками приложения',
 | 
			
		||||
    'role_export_content' => 'Export content',
 | 
			
		||||
    'role_export_content' => 'Экспорт контента',
 | 
			
		||||
    'role_asset' => 'Права доступа к материалам',
 | 
			
		||||
    'roles_system_warning' => 'Имейте в виду, что доступ к любому из указанных выше трех разрешений может позволить пользователю изменить свои собственные привилегии или привилегии других пользователей системы. Назначать роли с этими правами можно только доверенным пользователям.',
 | 
			
		||||
    'role_asset_desc' => 'Эти разрешения контролируют доступ по умолчанию к параметрам внутри системы. Разрешения на книги, главы и страницы перезапишут эти разрешения.',
 | 
			
		||||
| 
						 | 
				
			
			@ -209,7 +209,7 @@ return [
 | 
			
		|||
    'users_api_tokens_docs' => 'Документация',
 | 
			
		||||
    'users_mfa' => 'Двухфакторная аутентификация',
 | 
			
		||||
    'users_mfa_desc' => 'Двухфакторная аутентификация повышает степень безопасности вашей учетной записи.',
 | 
			
		||||
    'users_mfa_x_methods' => ':count method configured|:count methods configured',
 | 
			
		||||
    'users_mfa_x_methods' => 'методов настроено :count|методов сконфигурировано :count',
 | 
			
		||||
    'users_mfa_configure' => 'Настройка методов',
 | 
			
		||||
 | 
			
		||||
    // API Tokens
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -44,8 +44,8 @@ return [
 | 
			
		|||
    'bookshelf_delete_notification'    => '书架已成功删除',
 | 
			
		||||
 | 
			
		||||
    // Favourites
 | 
			
		||||
    'favourite_add_notification' => '":name" 已添加到你的收藏',
 | 
			
		||||
    'favourite_remove_notification' => '":name" 已从你的收藏中删除',
 | 
			
		||||
    'favourite_add_notification' => '":name" 已添加到您的收藏',
 | 
			
		||||
    'favourite_remove_notification' => '":name" 已从您的收藏中删除',
 | 
			
		||||
 | 
			
		||||
    // MFA
 | 
			
		||||
    'mfa_setup_method_notification' => '多重身份认证设置成功',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -54,7 +54,7 @@ return [
 | 
			
		|||
    'email_confirm_text' => '请点击下面的按钮确认您的Email地址:',
 | 
			
		||||
    'email_confirm_action' => '确认Email',
 | 
			
		||||
    'email_confirm_send_error' => '需要Email验证,但系统无法发送电子邮件,请联系网站管理员。',
 | 
			
		||||
    'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
 | 
			
		||||
    'email_confirm_success' => '您已成功验证电子邮件地址!您现在可以使用此电子邮件地址登录。',
 | 
			
		||||
    'email_confirm_resent' => '验证邮件已重新发送,请检查收件箱。',
 | 
			
		||||
 | 
			
		||||
    'email_not_confirmed' => 'Email地址未验证',
 | 
			
		||||
| 
						 | 
				
			
			@ -71,7 +71,7 @@ return [
 | 
			
		|||
    'user_invite_page_welcome' => '欢迎来到 :appName!',
 | 
			
		||||
    'user_invite_page_text' => '要完成您的帐户并获得访问权限,您需要设置一个密码,该密码将在以后访问时用于登录 :appName。',
 | 
			
		||||
    'user_invite_page_confirm_button' => '确认密码',
 | 
			
		||||
    'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
 | 
			
		||||
    'user_invite_success_login' => '密码已设置,您现在可以使用您设置的密码登录 :appName!',
 | 
			
		||||
 | 
			
		||||
    // Multi-factor Authentication
 | 
			
		||||
    'mfa_setup' => '设置多重身份认证',
 | 
			
		||||
| 
						 | 
				
			
			@ -92,7 +92,7 @@ return [
 | 
			
		|||
    'mfa_gen_backup_codes_usage_warning' => '每个认证码只能使用一次',
 | 
			
		||||
    'mfa_gen_totp_title' => '移动设备 App',
 | 
			
		||||
    'mfa_gen_totp_desc' => '要使用多重身份认证功能,您需要一个支持 TOTP(基于时间的一次性密码算法) 的移动设备 App,如谷歌身份验证器(Google Authenticator)、Authy 或微软身份验证器(Microsoft Authenticator)。',
 | 
			
		||||
    'mfa_gen_totp_scan' => '要开始操作,请使用你的身份验证 App 扫描下面的二维码。',
 | 
			
		||||
    'mfa_gen_totp_scan' => '要开始操作,请使用您的身份验证 App 扫描下面的二维码。',
 | 
			
		||||
    'mfa_gen_totp_verify_setup' => '验证设置',
 | 
			
		||||
    'mfa_gen_totp_verify_setup_desc' => '请在下面的框中输入您在身份验证 App 中生成的认证码来验证一切是否正常:',
 | 
			
		||||
    'mfa_gen_totp_provide_code_here' => '在此输入您的 App 生成的认证码',
 | 
			
		||||
| 
						 | 
				
			
			@ -103,7 +103,7 @@ return [
 | 
			
		|||
    'mfa_verify_use_totp' => '使用移动设备 App 进行认证',
 | 
			
		||||
    'mfa_verify_use_backup_codes' => '使用备用认证码进行认证',
 | 
			
		||||
    'mfa_verify_backup_code' => '备用认证码',
 | 
			
		||||
    'mfa_verify_backup_code_desc' => '在下面输入你的其中一个备用认证码:',
 | 
			
		||||
    'mfa_verify_backup_code_desc' => '在下面输入您的其中一个备用认证码:',
 | 
			
		||||
    'mfa_verify_backup_code_enter_here' => '在这里输入备用认证码',
 | 
			
		||||
    'mfa_verify_totp_desc' => '在下面输入您的移动 App 生成的认证码:',
 | 
			
		||||
    'mfa_setup_login_notification' => '多重身份认证已设置,请使用新配置的方法重新登录。',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -19,7 +19,7 @@ return [
 | 
			
		|||
    'description' => '概要',
 | 
			
		||||
    'role' => '角色',
 | 
			
		||||
    'cover_image' => '封面图片',
 | 
			
		||||
    'cover_image_description' => '该图像大小需要为440x250px。',
 | 
			
		||||
    'cover_image_description' => '此图像大小应约为 440x250 像素。',
 | 
			
		||||
 | 
			
		||||
    // Actions
 | 
			
		||||
    'actions' => '操作',
 | 
			
		||||
| 
						 | 
				
			
			@ -45,8 +45,8 @@ return [
 | 
			
		|||
    'unfavourite' => '取消收藏',
 | 
			
		||||
    'next' => '下一页',
 | 
			
		||||
    'previous' => '上一页',
 | 
			
		||||
    'filter_active' => 'Active Filter:',
 | 
			
		||||
    'filter_clear' => 'Clear Filter',
 | 
			
		||||
    'filter_active' => '标签过滤器:',
 | 
			
		||||
    'filter_clear' => '清除过滤器',
 | 
			
		||||
 | 
			
		||||
    // Sort Options
 | 
			
		||||
    'sort_options' => '排序选项',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -254,20 +254,20 @@ return [
 | 
			
		|||
    'tag' => '标签',
 | 
			
		||||
    'tags' =>  '标签',
 | 
			
		||||
    'tag_name' =>  '标签名称',
 | 
			
		||||
    'tag_value' => '标签值 (Optional)',
 | 
			
		||||
    'tags_explain' => "添加一些标签以更好地对您的内容进行分类。\n您可以为标签分配一个值,以进行更深入的组织。",
 | 
			
		||||
    'tag_value' => '标签值 (可选)',
 | 
			
		||||
    'tags_explain' => "添加一些标签以更好地对您的内容进行分类。\n您可以为标签分配一个值,以进行更好的进行管理。",
 | 
			
		||||
    'tags_add' => '添加另一个标签',
 | 
			
		||||
    'tags_remove' => '删除此标签',
 | 
			
		||||
    'tags_usages' => 'Total tag usages',
 | 
			
		||||
    'tags_assigned_pages' => 'Assigned to Pages',
 | 
			
		||||
    'tags_assigned_chapters' => 'Assigned to Chapters',
 | 
			
		||||
    'tags_assigned_books' => 'Assigned to Books',
 | 
			
		||||
    'tags_assigned_shelves' => 'Assigned to Shelves',
 | 
			
		||||
    'tags_x_unique_values' => ':count unique values',
 | 
			
		||||
    'tags_all_values' => 'All values',
 | 
			
		||||
    'tags_view_tags' => 'View Tags',
 | 
			
		||||
    'tags_view_existing_tags' => 'View existing tags',
 | 
			
		||||
    'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.',
 | 
			
		||||
    'tags_usages' => '标签总使用量',
 | 
			
		||||
    'tags_assigned_pages' => '有这个标签的页面',
 | 
			
		||||
    'tags_assigned_chapters' => '有这个标签的章节',
 | 
			
		||||
    'tags_assigned_books' => '有这个标签的图书',
 | 
			
		||||
    'tags_assigned_shelves' => '有这个标签的书架',
 | 
			
		||||
    'tags_x_unique_values' => '个不重复项目',
 | 
			
		||||
    'tags_all_values' => '所有值',
 | 
			
		||||
    'tags_view_tags' => '查看标签',
 | 
			
		||||
    'tags_view_existing_tags' => '查看已有的标签',
 | 
			
		||||
    'tags_list_empty_hint' => '您可以在页面编辑器的侧边栏添加标签,或者在编辑图书、章节、书架时添加。',
 | 
			
		||||
    'attachments' => '附件',
 | 
			
		||||
    'attachments_explain' => '上传一些文件或附加一些链接显示在您的网页上。这些在页面的侧边栏中可见。',
 | 
			
		||||
    'attachments_explain_instant_save' => '这里的更改将立即保存。',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -31,7 +31,7 @@ return [
 | 
			
		|||
    'app_custom_html_desc' => '此处添加的任何内容都将插入到每个页面的<head>部分的底部,这对于覆盖样式或添加分析代码很方便。',
 | 
			
		||||
    'app_custom_html_disabled_notice' => '在此设置页面上禁用了自定义HTML标题内容,以确保可以恢复所有重大更改。',
 | 
			
		||||
    'app_logo' => '站点Logo',
 | 
			
		||||
    'app_logo_desc' => '这个图片的高度应该为43px。<br>大图片将会被缩小。',
 | 
			
		||||
    'app_logo_desc' => '这个图片的高度应为 43 像素。<br>大图片将会被缩小。',
 | 
			
		||||
    'app_primary_color' => '站点主色',
 | 
			
		||||
    'app_primary_color_desc' => '这应该是一个十六进制值。<br>保留为空以重置为默认颜色。',
 | 
			
		||||
    'app_homepage' => '站点主页',
 | 
			
		||||
| 
						 | 
				
			
			@ -193,7 +193,7 @@ return [
 | 
			
		|||
    'users_edit_profile' => '编辑资料',
 | 
			
		||||
    'users_edit_success' => '用户更新成功',
 | 
			
		||||
    'users_avatar' => '用户头像',
 | 
			
		||||
    'users_avatar_desc' => '当前图片应该为约256px的正方形。',
 | 
			
		||||
    'users_avatar_desc' => '选择一张头像。 这张图片应该是约 256 像素的正方形。',
 | 
			
		||||
    'users_preferred_language' => '语言',
 | 
			
		||||
    'users_preferred_language_desc' => '此选项将更改用于应用程序用户界面的语言。 这不会影响任何用户创建的内容。',
 | 
			
		||||
    'users_social_accounts' => '社交账户',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -87,6 +87,20 @@
 | 
			
		|||
.card-title a {
 | 
			
		||||
  line-height: 1;
 | 
			
		||||
}
 | 
			
		||||
.card-footer-link {
 | 
			
		||||
  display: block;
 | 
			
		||||
  padding: $-s $-m;
 | 
			
		||||
  line-height: 1;
 | 
			
		||||
  border-top: 1px solid;
 | 
			
		||||
  @include lightDark(border-color, #DDD, #555);
 | 
			
		||||
  border-radius: 0 0 3px 3px;
 | 
			
		||||
  font-size: 0.9em;
 | 
			
		||||
  margin-top: $-xs;
 | 
			
		||||
  &:hover {
 | 
			
		||||
    text-decoration: none;
 | 
			
		||||
    @include lightDark(background-color, #f2f2f2, #2d2d2d);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.card.border-card {
 | 
			
		||||
  border: 1px solid #DDD;
 | 
			
		||||
| 
						 | 
				
			
			@ -229,6 +243,9 @@
 | 
			
		|||
  &:hover, &:focus-within {
 | 
			
		||||
    opacity: 1;
 | 
			
		||||
  }
 | 
			
		||||
  @media (prefers-contrast: more) {
 | 
			
		||||
    opacity: 1;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -262,6 +262,9 @@ header .search-box {
 | 
			
		|||
  &:hover, &:focus-within {
 | 
			
		||||
    opacity: 1;
 | 
			
		||||
  }
 | 
			
		||||
  @media (prefers-contrast: more) {
 | 
			
		||||
    opacity: 1;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@include smaller-than($l) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -369,6 +369,9 @@ body.flexbox {
 | 
			
		|||
    &:focus-within {
 | 
			
		||||
      opacity: 1;
 | 
			
		||||
    }
 | 
			
		||||
    @media (prefers-contrast: more) {
 | 
			
		||||
      opacity: 1;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -412,4 +412,7 @@ body.mce-fullscreen, body.markdown-fullscreen {
 | 
			
		|||
    text-decoration: none;
 | 
			
		||||
    opacity: 1;
 | 
			
		||||
  }
 | 
			
		||||
  @media (prefers-contrast: more) {
 | 
			
		||||
    opacity: 1;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -44,27 +44,27 @@
 | 
			
		|||
            <div>
 | 
			
		||||
                @if(count($favourites) > 0)
 | 
			
		||||
                    <div id="top-favourites" class="card mb-xl">
 | 
			
		||||
                        <h3 class="card-title">
 | 
			
		||||
                            <a href="{{ url('/favourites') }}" class="no-color">{{ trans('entities.my_most_viewed_favourites') }}</a>
 | 
			
		||||
                        </h3>
 | 
			
		||||
                        <h3 class="card-title">{{ trans('entities.my_most_viewed_favourites') }}</h3>
 | 
			
		||||
                        <div class="px-m">
 | 
			
		||||
                            @include('entities.list', [
 | 
			
		||||
                            'entities' => $favourites,
 | 
			
		||||
                            'style' => 'compact',
 | 
			
		||||
                            ])
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <a href="{{ url('/favourites')  }}" class="card-footer-link">{{ trans('common.view_all') }}</a>
 | 
			
		||||
                    </div>
 | 
			
		||||
                @endif
 | 
			
		||||
 | 
			
		||||
                <div id="recent-pages" class="card mb-xl">
 | 
			
		||||
                    <h3 class="card-title"><a class="no-color" href="{{ url("/pages/recently-updated") }}">{{ trans('entities.recently_updated_pages') }}</a></h3>
 | 
			
		||||
                    <h3 class="card-title">{{ trans('entities.recently_updated_pages') }}</h3>
 | 
			
		||||
                    <div id="recently-updated-pages" class="px-m">
 | 
			
		||||
                        @include('entities.list', [
 | 
			
		||||
                        'entities' => $recentlyUpdatedPages,
 | 
			
		||||
                        'style' => 'compact',
 | 
			
		||||
                        'emptyText' => trans('entities.no_pages_recently_updated')
 | 
			
		||||
                        'emptyText' => trans('entities.no_pages_recently_updated'),
 | 
			
		||||
                        ])
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <a href="{{ url("/pages/recently-updated") }}" class="card-footer-link">{{ trans('common.view_all') }}</a>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,13 +7,12 @@
 | 
			
		|||
 | 
			
		||||
@if(count($favourites) > 0)
 | 
			
		||||
    <div id="top-favourites" class="mb-xl">
 | 
			
		||||
        <h5>
 | 
			
		||||
            <a href="{{ url('/favourites') }}" class="no-color">{{ trans('entities.my_most_viewed_favourites') }}</a>
 | 
			
		||||
        </h5>
 | 
			
		||||
        <h5>{{ trans('entities.my_most_viewed_favourites') }}</h5>
 | 
			
		||||
        @include('entities.list', [
 | 
			
		||||
            'entities' => $favourites,
 | 
			
		||||
            'style' => 'compact',
 | 
			
		||||
        ])
 | 
			
		||||
        <a href="{{ url('/favourites')  }}" class="text-muted block py-xs">{{ trans('common.view_all') }}</a>
 | 
			
		||||
    </div>
 | 
			
		||||
@endif
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -27,7 +26,7 @@
 | 
			
		|||
</div>
 | 
			
		||||
 | 
			
		||||
<div class="mb-xl">
 | 
			
		||||
    <h5><a class="no-color" href="{{ url("/pages/recently-updated") }}">{{ trans('entities.recently_updated_pages') }}</a></h5>
 | 
			
		||||
    <h5>{{ trans('entities.recently_updated_pages') }}</h5>
 | 
			
		||||
    <div id="recently-updated-pages">
 | 
			
		||||
        @include('entities.list', [
 | 
			
		||||
        'entities' => $recentlyUpdatedPages,
 | 
			
		||||
| 
						 | 
				
			
			@ -35,6 +34,7 @@
 | 
			
		|||
        'emptyText' => trans('entities.no_pages_recently_updated')
 | 
			
		||||
        ])
 | 
			
		||||
    </div>
 | 
			
		||||
    <a href="{{ url('/pages/recently-updated')  }}" class="text-muted block py-xs">{{ trans('common.view_all') }}</a>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<div id="recent-activity" class="mb-xl">
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,6 +6,7 @@
 | 
			
		|||
    {{ csrf_field() }}
 | 
			
		||||
    <input type="text"
 | 
			
		||||
           name="code"
 | 
			
		||||
           autofocus
 | 
			
		||||
           placeholder="{{ trans('auth.mfa_gen_totp_provide_code_here') }}"
 | 
			
		||||
           class="input-fill-width {{ $errors->has('code') ? 'neg' : '' }}">
 | 
			
		||||
    @if($errors->has('code'))
 | 
			
		||||
| 
						 | 
				
			
			@ -14,4 +15,4 @@
 | 
			
		|||
    <div class="mt-s text-right">
 | 
			
		||||
        <button class="button">{{ trans('common.confirm') }}</button>
 | 
			
		||||
    </div>
 | 
			
		||||
</form>
 | 
			
		||||
</form>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -23,7 +23,7 @@ class MfaVerificationTest extends TestCase
 | 
			
		|||
        $resp = $this->get('/mfa/verify');
 | 
			
		||||
        $resp->assertSee('Verify Access');
 | 
			
		||||
        $resp->assertSee('Enter the code, generated using your mobile app, below:');
 | 
			
		||||
        $resp->assertElementExists('form[action$="/mfa/totp/verify"] input[name="code"]');
 | 
			
		||||
        $resp->assertElementExists('form[action$="/mfa/totp/verify"] input[name="code"][autofocus]');
 | 
			
		||||
 | 
			
		||||
        $google2fa = new Google2FA();
 | 
			
		||||
        $resp = $this->post('/mfa/totp/verify', [
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -670,4 +670,24 @@ class PageContentTest extends TestCase
 | 
			
		|||
        $page->refresh();
 | 
			
		||||
        $this->assertStringContainsString('<img src=""', $page->html);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function test_nested_headers_gets_assigned_an_id()
 | 
			
		||||
    {
 | 
			
		||||
        $this->asEditor();
 | 
			
		||||
        $page = Page::query()->first();
 | 
			
		||||
 | 
			
		||||
        $content = '<table><tbody><tr><td><h5>Simple Test</h5></td></tr></tbody></table>';
 | 
			
		||||
        $this->put($page->getUrl(), [
 | 
			
		||||
            'name'    => $page->name,
 | 
			
		||||
            'html'    => $content,
 | 
			
		||||
            'summary' => '',
 | 
			
		||||
        ]);
 | 
			
		||||
 | 
			
		||||
        $updatedPage = Page::query()->where('id', '=', $page->id)->first();
 | 
			
		||||
 | 
			
		||||
        // The top level <table> node will get assign the bkmrk-simple-test id because the system will
 | 
			
		||||
        // take the node value of h5
 | 
			
		||||
        // So the h5 should get the bkmrk-simple-test-1 id
 | 
			
		||||
        $this->assertStringContainsString('<h5 id="bkmrk-simple-test-1">Simple Test</h5>', $updatedPage->html);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,8 +7,10 @@ use BookStack\Entities\Models\Page;
 | 
			
		|||
use BookStack\Entities\Tools\PageContent;
 | 
			
		||||
use BookStack\Facades\Theme;
 | 
			
		||||
use BookStack\Theming\ThemeEvents;
 | 
			
		||||
use Illuminate\Console\Command;
 | 
			
		||||
use Illuminate\Http\Request;
 | 
			
		||||
use Illuminate\Http\Response;
 | 
			
		||||
use Illuminate\Support\Facades\Artisan;
 | 
			
		||||
use Illuminate\Support\Facades\File;
 | 
			
		||||
use League\CommonMark\ConfigurableEnvironmentInterface;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -206,6 +208,16 @@ class ThemeTest extends TestCase
 | 
			
		|||
        $this->assertStringContainsString('donkey=donut', $redirect);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function test_register_command_allows_provided_command_to_be_usable_via_artisan()
 | 
			
		||||
    {
 | 
			
		||||
        Theme::registerCommand(new MyCustomCommand());
 | 
			
		||||
 | 
			
		||||
        Artisan::call('bookstack:test-custom-command', []);
 | 
			
		||||
        $output = Artisan::output();
 | 
			
		||||
 | 
			
		||||
        $this->assertStringContainsString('Command ran!', $output);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    protected function usingThemeFolder(callable $callback)
 | 
			
		||||
    {
 | 
			
		||||
        // Create a folder and configure a theme
 | 
			
		||||
| 
						 | 
				
			
			@ -220,3 +232,13 @@ class ThemeTest extends TestCase
 | 
			
		|||
        File::deleteDirectory($themeFolderPath);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class MyCustomCommand extends Command
 | 
			
		||||
{
 | 
			
		||||
    protected $signature = 'bookstack:test-custom-command';
 | 
			
		||||
 | 
			
		||||
    public function handle()
 | 
			
		||||
    {
 | 
			
		||||
        $this->line('Command ran!');
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,31 @@
 | 
			
		|||
<?php
 | 
			
		||||
 | 
			
		||||
namespace Tests\Unit;
 | 
			
		||||
 | 
			
		||||
use BadMethodCallException;
 | 
			
		||||
use BookStack\Entities\Models\Page;
 | 
			
		||||
use Tests\TestCase;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * This class tests assumptions we're relying upon in the framework.
 | 
			
		||||
 * This is primarily to keep track of certain bits of functionality that
 | 
			
		||||
 * may be used in important areas such as to enforce permissions.
 | 
			
		||||
 */
 | 
			
		||||
class FrameworkAssumptionTest extends TestCase
 | 
			
		||||
{
 | 
			
		||||
    public function test_scopes_error_if_not_existing()
 | 
			
		||||
    {
 | 
			
		||||
        $this->expectException(BadMethodCallException::class);
 | 
			
		||||
        $this->expectExceptionMessage('Call to undefined method BookStack\Entities\Models\Page::scopeNotfoundscope()');
 | 
			
		||||
        Page::query()->scopes('notfoundscope');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function test_scopes_applies_upon_existing()
 | 
			
		||||
    {
 | 
			
		||||
        // Page has SoftDeletes trait by default, so we apply our custom scope and ensure
 | 
			
		||||
        // it stacks on the global scope to filter out deleted items.
 | 
			
		||||
        $query = Page::query()->scopes('visible')->toSql();
 | 
			
		||||
        $this->assertStringContainsString('joint_permissions', $query);
 | 
			
		||||
        $this->assertStringContainsString('`deleted_at` is null', $query);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
		Reference in New Issue