diff --git a/.github/translators.txt b/.github/translators.txt index 57a8714f4..8d9601e72 100644 --- a/.github/translators.txt +++ b/.github/translators.txt @@ -199,3 +199,4 @@ M Nafis Al Mukhdi (mnafisalmukhdi1) :: Indonesian sulfo :: Danish Raukze :: German zygimantus :: Lithuanian +marinkaberg :: Russian diff --git a/app/Actions/ActivityService.php b/app/Actions/ActivityService.php index bc7a6b6b7..983c1a603 100644 --- a/app/Actions/ActivityService.php +++ b/app/Actions/ActivityService.php @@ -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 $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(); diff --git a/app/Auth/Access/ExternalBaseUserProvider.php b/app/Auth/Access/ExternalBaseUserProvider.php index fde610c3e..122425c11 100644 --- a/app/Auth/Access/ExternalBaseUserProvider.php +++ b/app/Auth/Access/ExternalBaseUserProvider.php @@ -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 */ diff --git a/app/Auth/Access/LdapService.php b/app/Auth/Access/LdapService.php index e3a38537a..e529b80fd 100644 --- a/app/Auth/Access/LdapService.php +++ b/app/Auth/Access/LdapService.php @@ -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 */ diff --git a/app/Auth/Access/Oidc/OidcJwtSigningKey.php b/app/Auth/Access/Oidc/OidcJwtSigningKey.php index 9a5b3833a..a70f3b3c7 100644 --- a/app/Auth/Access/Oidc/OidcJwtSigningKey.php +++ b/app/Auth/Access/Oidc/OidcJwtSigningKey.php @@ -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); } /** diff --git a/app/Auth/Access/SocialAuthService.php b/app/Auth/Access/SocialAuthService.php index 23e95970c..0df5ceb5e 100644 --- a/app/Auth/Access/SocialAuthService.php +++ b/app/Auth/Access/SocialAuthService.php @@ -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']); } diff --git a/app/Auth/Permissions/RolePermission.php b/app/Auth/Permissions/RolePermission.php index 0a0e6ff17..f34de917c 100644 --- a/app/Auth/Permissions/RolePermission.php +++ b/app/Auth/Permissions/RolePermission.php @@ -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(); } diff --git a/app/Console/Commands/RegenerateSearch.php b/app/Console/Commands/RegenerateSearch.php index 62ee88fc0..20e3fc798 100644 --- a/app/Console/Commands/RegenerateSearch.php +++ b/app/Console/Commands/RegenerateSearch.php @@ -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 . ')'); }); diff --git a/app/Entities/Models/Book.php b/app/Entities/Models/Book.php index 735d25a99..8217d2cab 100644 --- a/app/Entities/Models/Book.php +++ b/app/Entities/Models/Book.php @@ -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'); } diff --git a/app/Entities/Models/Bookshelf.php b/app/Entities/Models/Bookshelf.php index e4d9775b7..b9ebab92e 100644 --- a/app/Entities/Models/Bookshelf.php +++ b/app/Entities/Models/Bookshelf.php @@ -37,7 +37,7 @@ class Bookshelf extends Entity implements HasCoverImage */ public function visibleBooks(): BelongsToMany { - return $this->books()->visible(); + return $this->books()->scopes('visible'); } /** diff --git a/app/Entities/Models/Chapter.php b/app/Entities/Models/Chapter.php index 224ded935..08d6608a9 100644 --- a/app/Entities/Models/Chapter.php +++ b/app/Entities/Models/Chapter.php @@ -23,6 +23,8 @@ class Chapter extends BookChild /** * Get the pages that this chapter contains. + * + * @return HasMany */ 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(); diff --git a/app/Entities/Models/Deletion.php b/app/Entities/Models/Deletion.php index 3face841b..97abb87ff 100644 --- a/app/Entities/Models/Deletion.php +++ b/app/Entities/Models/Deletion.php @@ -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 { diff --git a/app/Entities/Models/Entity.php b/app/Entities/Models/Entity.php index 4c4e55bb8..0eb402284 100644 --- a/app/Entities/Models/Entity.php +++ b/app/Entities/Models/Entity.php @@ -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 { diff --git a/app/Entities/Models/PageRevision.php b/app/Entities/Models/PageRevision.php index 6856c23e1..2bfa169f4 100644 --- a/app/Entities/Models/PageRevision.php +++ b/app/Entities/Models/PageRevision.php @@ -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'; } diff --git a/app/Entities/Repos/BaseRepo.php b/app/Entities/Repos/BaseRepo.php index 569918503..6b29dad7b 100644 --- a/app/Entities/Repos/BaseRepo.php +++ b/app/Entities/Repos/BaseRepo.php @@ -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); diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index f66f2beb8..24fc1e7dd 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -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; diff --git a/app/Entities/Tools/BookContents.php b/app/Entities/Tools/BookContents.php index 8622d5e12..9b2190ca2 100644 --- a/app/Entities/Tools/BookContents.php +++ b/app/Entities/Tools/BookContents.php @@ -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); diff --git a/app/Entities/Tools/NextPreviousContentLocator.php b/app/Entities/Tools/NextPreviousContentLocator.php index f70abd9b6..54b575935 100644 --- a/app/Entities/Tools/NextPreviousContentLocator.php +++ b/app/Entities/Tools/NextPreviousContentLocator.php @@ -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); } diff --git a/app/Entities/Tools/PageContent.php b/app/Entities/Tools/PageContent.php index 45bfe8fa1..b95131fce 100644 --- a/app/Entities/Tools/PageContent.php +++ b/app/Entities/Tools/PageContent.php @@ -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); diff --git a/app/Entities/Tools/SearchIndex.php b/app/Entities/Tools/SearchIndex.php index d748c1695..d43d98207 100644 --- a/app/Entities/Tools/SearchIndex.php +++ b/app/Entities/Tools/SearchIndex.php @@ -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 $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); diff --git a/app/Entities/Tools/SearchRunner.php b/app/Entities/Tools/SearchRunner.php index 04f4c5768..a0a44f3a5 100644 --- a/app/Entities/Tools/SearchRunner.php +++ b/app/Entities/Tools/SearchRunner.php @@ -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); diff --git a/app/Entities/Tools/SiblingFetcher.php b/app/Entities/Tools/SiblingFetcher.php index 249e0038e..617ef4a62 100644 --- a/app/Entities/Tools/SiblingFetcher.php +++ b/app/Entities/Tools/SiblingFetcher.php @@ -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(); } diff --git a/app/Entities/Tools/TrashCan.php b/app/Entities/Tools/TrashCan.php index 8327c9489..ab62165af 100644 --- a/app/Entities/Tools/TrashCan.php +++ b/app/Entities/Tools/TrashCan.php @@ -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 $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()); } } } diff --git a/app/Http/Controllers/Api/BookshelfApiController.php b/app/Http/Controllers/Api/BookshelfApiController.php index de7284e61..bd4f23a10 100644 --- a/app/Http/Controllers/Api/BookshelfApiController.php +++ b/app/Http/Controllers/Api/BookshelfApiController.php @@ -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); diff --git a/app/Http/Controllers/Api/ChapterApiController.php b/app/Http/Controllers/Api/ChapterApiController.php index 6b226b5f0..8459b8449 100644 --- a/app/Http/Controllers/Api/ChapterApiController.php +++ b/app/Http/Controllers/Api/ChapterApiController.php @@ -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); diff --git a/app/Http/Controllers/BookController.php b/app/Http/Controllers/BookController.php index af44a6689..51cba642c 100644 --- a/app/Http/Controllers/BookController.php +++ b/app/Http/Controllers/BookController.php @@ -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')) { diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 5451c0abf..df810a3cf 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -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(); diff --git a/app/Http/Controllers/RecycleBinController.php b/app/Http/Controllers/RecycleBinController.php index 1736023a5..1cffb161c 100644 --- a/app/Http/Controllers/RecycleBinController.php +++ b/app/Http/Controllers/RecycleBinController.php @@ -58,6 +58,7 @@ class RecycleBinController extends Controller $searching = false; } } + /** @var ?Deletion $parentDeletion */ $parentDeletion = ($currentDeletable === $deletion->deletable) ? null : $currentDeletable->deletions()->first(); diff --git a/app/Interfaces/Deletable.php b/app/Interfaces/Deletable.php new file mode 100644 index 000000000..be9b4ac41 --- /dev/null +++ b/app/Interfaces/Deletable.php @@ -0,0 +1,14 @@ +addCommands([$command]); + }); + } + /** * Read any actions from the set theme path if the 'functions.php' file exists. */ diff --git a/app/Uploads/ImageRepo.php b/app/Uploads/ImageRepo.php index 494ff3ac0..bfe4b5977 100644 --- a/app/Uploads/ImageRepo.php +++ b/app/Uploads/ImageRepo.php @@ -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); } }; diff --git a/dev/docs/logical-theme-system.md b/dev/docs/logical-theme-system.md index b950d7df9..4d6ed719b 100644 --- a/dev/docs/logical-theme-system.md +++ b/dev/docs/logical-theme-system.md @@ -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 +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. diff --git a/dev/docs/visual-theme-system.md b/dev/docs/visual-theme-system.md index 058bd2823..8f3129b22 100644 --- a/dev/docs/visual-theme-system.md +++ b/dev/docs/visual-theme-system.md @@ -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/ 'find', ]; -``` \ No newline at end of file +``` diff --git a/resources/lang/et/auth.php b/resources/lang/et/auth.php index b23e316c8..934d1847d 100644 --- a/resources/lang/et/auth.php +++ b/resources/lang/et/auth.php @@ -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', diff --git a/resources/lang/it/auth.php b/resources/lang/it/auth.php index 40d604752..346a12611 100755 --- a/resources/lang/it/auth.php +++ b/resources/lang/it/auth.php @@ -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', diff --git a/resources/lang/it/common.php b/resources/lang/it/common.php index e8ef850c6..07d0b4574 100755 --- a/resources/lang/it/common.php +++ b/resources/lang/it/common.php @@ -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', diff --git a/resources/lang/it/entities.php b/resources/lang/it/entities.php index 273551d96..97a6a88a5 100755 --- a/resources/lang/it/entities.php +++ b/resources/lang/it/entities.php @@ -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.', diff --git a/resources/lang/ru/auth.php b/resources/lang/ru/auth.php index 97402eb69..bb05c70e1 100644 --- a/resources/lang/ru/auth.php +++ b/resources/lang/ru/auth.php @@ -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' => 'Двухфакторная аутентификация', diff --git a/resources/lang/ru/common.php b/resources/lang/ru/common.php index aac4c84d0..377281957 100644 --- a/resources/lang/ru/common.php +++ b/resources/lang/ru/common.php @@ -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' => 'Параметры сортировки', diff --git a/resources/lang/ru/entities.php b/resources/lang/ru/entities.php index 39ba10c6b..fedeebc91 100644 --- a/resources/lang/ru/entities.php +++ b/resources/lang/ru/entities.php @@ -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' => 'Изменения здесь сохраняются мгновенно.', diff --git a/resources/lang/ru/errors.php b/resources/lang/ru/errors.php index 1edef426c..7ea17aa72 100644 --- a/resources/lang/ru/errors.php +++ b/resources/lang/ru/errors.php @@ -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.', diff --git a/resources/lang/ru/settings.php b/resources/lang/ru/settings.php index af18213ce..9b6bb326b 100755 --- a/resources/lang/ru/settings.php +++ b/resources/lang/ru/settings.php @@ -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 diff --git a/resources/lang/zh_CN/activities.php b/resources/lang/zh_CN/activities.php index 65e7c3583..82c6e8f26 100644 --- a/resources/lang/zh_CN/activities.php +++ b/resources/lang/zh_CN/activities.php @@ -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' => '多重身份认证设置成功', diff --git a/resources/lang/zh_CN/auth.php b/resources/lang/zh_CN/auth.php index ea97aaa07..201fe2500 100644 --- a/resources/lang/zh_CN/auth.php +++ b/resources/lang/zh_CN/auth.php @@ -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' => '多重身份认证已设置,请使用新配置的方法重新登录。', diff --git a/resources/lang/zh_CN/common.php b/resources/lang/zh_CN/common.php index 5b45ee450..1201178f0 100644 --- a/resources/lang/zh_CN/common.php +++ b/resources/lang/zh_CN/common.php @@ -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' => '排序选项', diff --git a/resources/lang/zh_CN/entities.php b/resources/lang/zh_CN/entities.php index e13a5ddf7..cce5a2ecf 100644 --- a/resources/lang/zh_CN/entities.php +++ b/resources/lang/zh_CN/entities.php @@ -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' => '这里的更改将立即保存。', diff --git a/resources/lang/zh_CN/settings.php b/resources/lang/zh_CN/settings.php index b662bb755..b3a28a73a 100755 --- a/resources/lang/zh_CN/settings.php +++ b/resources/lang/zh_CN/settings.php @@ -31,7 +31,7 @@ return [ 'app_custom_html_desc' => '此处添加的任何内容都将插入到每个页面的部分的底部,这对于覆盖样式或添加分析代码很方便。', 'app_custom_html_disabled_notice' => '在此设置页面上禁用了自定义HTML标题内容,以确保可以恢复所有重大更改。', 'app_logo' => '站点Logo', - 'app_logo_desc' => '这个图片的高度应该为43px。
大图片将会被缩小。', + 'app_logo_desc' => '这个图片的高度应为 43 像素。
大图片将会被缩小。', 'app_primary_color' => '站点主色', 'app_primary_color_desc' => '这应该是一个十六进制值。
保留为空以重置为默认颜色。', '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' => '社交账户', diff --git a/resources/sass/_blocks.scss b/resources/sass/_blocks.scss index ae3e7a441..0a7a689f7 100644 --- a/resources/sass/_blocks.scss +++ b/resources/sass/_blocks.scss @@ -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; + } } /** diff --git a/resources/sass/_header.scss b/resources/sass/_header.scss index 1a7015078..f070f5a18 100644 --- a/resources/sass/_header.scss +++ b/resources/sass/_header.scss @@ -262,6 +262,9 @@ header .search-box { &:hover, &:focus-within { opacity: 1; } + @media (prefers-contrast: more) { + opacity: 1; + } } @include smaller-than($l) { diff --git a/resources/sass/_layout.scss b/resources/sass/_layout.scss index 362bab7d3..783ccc8f9 100644 --- a/resources/sass/_layout.scss +++ b/resources/sass/_layout.scss @@ -369,6 +369,9 @@ body.flexbox { &:focus-within { opacity: 1; } + @media (prefers-contrast: more) { + opacity: 1; + } } } diff --git a/resources/sass/_pages.scss b/resources/sass/_pages.scss index 4f249244b..14c253679 100755 --- a/resources/sass/_pages.scss +++ b/resources/sass/_pages.scss @@ -412,4 +412,7 @@ body.mce-fullscreen, body.markdown-fullscreen { text-decoration: none; opacity: 1; } + @media (prefers-contrast: more) { + opacity: 1; + } } \ No newline at end of file diff --git a/resources/views/home/default.blade.php b/resources/views/home/default.blade.php index b8866526d..f6a337e50 100644 --- a/resources/views/home/default.blade.php +++ b/resources/views/home/default.blade.php @@ -44,27 +44,27 @@
@if(count($favourites) > 0)
-

- {{ trans('entities.my_most_viewed_favourites') }} -

+

{{ trans('entities.my_most_viewed_favourites') }}

@include('entities.list', [ 'entities' => $favourites, 'style' => 'compact', ])
+ {{ trans('common.view_all') }}
@endif
-

{{ trans('entities.recently_updated_pages') }}

+

{{ trans('entities.recently_updated_pages') }}

@include('entities.list', [ 'entities' => $recentlyUpdatedPages, 'style' => 'compact', - 'emptyText' => trans('entities.no_pages_recently_updated') + 'emptyText' => trans('entities.no_pages_recently_updated'), ])
+ {{ trans('common.view_all') }}
diff --git a/resources/views/home/parts/sidebar.blade.php b/resources/views/home/parts/sidebar.blade.php index 8dc8118f5..78f7e7a80 100644 --- a/resources/views/home/parts/sidebar.blade.php +++ b/resources/views/home/parts/sidebar.blade.php @@ -7,13 +7,12 @@ @if(count($favourites) > 0)
-
- {{ trans('entities.my_most_viewed_favourites') }} -
+
{{ trans('entities.my_most_viewed_favourites') }}
@include('entities.list', [ 'entities' => $favourites, 'style' => 'compact', ]) + {{ trans('common.view_all') }}
@endif @@ -27,7 +26,7 @@
-
{{ trans('entities.recently_updated_pages') }}
+
{{ trans('entities.recently_updated_pages') }}
@include('entities.list', [ 'entities' => $recentlyUpdatedPages, @@ -35,6 +34,7 @@ 'emptyText' => trans('entities.no_pages_recently_updated') ])
+ {{ trans('common.view_all') }}
diff --git a/resources/views/mfa/parts/verify-totp.blade.php b/resources/views/mfa/parts/verify-totp.blade.php index 9a861fc6c..a52d9b652 100644 --- a/resources/views/mfa/parts/verify-totp.blade.php +++ b/resources/views/mfa/parts/verify-totp.blade.php @@ -6,6 +6,7 @@ {{ csrf_field() }} @if($errors->has('code')) @@ -14,4 +15,4 @@
- \ No newline at end of file + diff --git a/tests/Auth/MfaVerificationTest.php b/tests/Auth/MfaVerificationTest.php index 49ca6663d..9a6106243 100644 --- a/tests/Auth/MfaVerificationTest.php +++ b/tests/Auth/MfaVerificationTest.php @@ -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', [ diff --git a/tests/Entity/PageContentTest.php b/tests/Entity/PageContentTest.php index 4dace533b..9524186c8 100644 --- a/tests/Entity/PageContentTest.php +++ b/tests/Entity/PageContentTest.php @@ -670,4 +670,24 @@ class PageContentTest extends TestCase $page->refresh(); $this->assertStringContainsString('html); } + + public function test_nested_headers_gets_assigned_an_id() + { + $this->asEditor(); + $page = Page::query()->first(); + + $content = '
Simple Test
'; + $this->put($page->getUrl(), [ + 'name' => $page->name, + 'html' => $content, + 'summary' => '', + ]); + + $updatedPage = Page::query()->where('id', '=', $page->id)->first(); + + // The top level 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('
Simple Test
', $updatedPage->html); + } } diff --git a/tests/ThemeTest.php b/tests/ThemeTest.php index 9aa7873b0..364bf6900 100644 --- a/tests/ThemeTest.php +++ b/tests/ThemeTest.php @@ -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!'); + } +} diff --git a/tests/Unit/FrameworkAssumptionTest.php b/tests/Unit/FrameworkAssumptionTest.php new file mode 100644 index 000000000..54d315de9 --- /dev/null +++ b/tests/Unit/FrameworkAssumptionTest.php @@ -0,0 +1,31 @@ +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); + } +}