diff --git a/.github/translators.txt b/.github/translators.txt index a6e01d0ed8f..d58538d6c64 100644 --- a/.github/translators.txt +++ b/.github/translators.txt @@ -552,3 +552,5 @@ Tim (timakai) :: Dutch; German Informal; French; Romanian; Catalan; Czech; Danis dadda123 :: Swedish Julien Muggli (JulienMuggli) :: French nomoreshow :: Turkish +Qasem Talaee (qasem_talaee) :: Persian +ddeicide :: Bulgarian diff --git a/app/Access/ExternalBaseUserProvider.php b/app/Access/ExternalBaseUserProvider.php index ef001289d11..8d82d28c8eb 100644 --- a/app/Access/ExternalBaseUserProvider.php +++ b/app/Access/ExternalBaseUserProvider.php @@ -48,7 +48,7 @@ public function updateRememberToken(Authenticatable $user, $token) /** * Retrieve a user by the given credentials. */ - public function retrieveByCredentials(array $credentials): ?Authenticatable + public function retrieveByCredentials(array $credentials): ?User { return $this->userRepo->getByExternalAuthId($credentials['external_auth_id']); } diff --git a/app/Access/Guards/ExternalBaseSessionGuard.php b/app/Access/Guards/ExternalBaseSessionGuard.php index b389031824b..4d263e581f8 100644 --- a/app/Access/Guards/ExternalBaseSessionGuard.php +++ b/app/Access/Guards/ExternalBaseSessionGuard.php @@ -50,8 +50,12 @@ class ExternalBaseSessionGuard implements StatefulGuard /** * Create a new authentication guard. */ - public function __construct(string $name, UserProvider $provider, Session $session, RegistrationService $registrationService) - { + public function __construct( + string $name, + UserProvider $provider, + Session $session, + RegistrationService $registrationService + ) { $this->name = $name; $this->session = $session; $this->provider = $provider; diff --git a/app/Access/Guards/LdapSessionGuard.php b/app/Access/Guards/LdapSessionGuard.php index 9455d530dfe..f3628c897d8 100644 --- a/app/Access/Guards/LdapSessionGuard.php +++ b/app/Access/Guards/LdapSessionGuard.php @@ -83,6 +83,10 @@ public function attempt(array $credentials = [], $remember = false): bool } } + if (!($user instanceof User)) { + throw new LoginAttemptException('Could not find or create a user for LDAP login.'); + } + // Sync LDAP groups if required if ($this->ldapService->shouldSyncGroups()) { $this->ldapService->syncGroups($user, $username); diff --git a/app/Access/Oidc/OidcJwtWithClaims.php b/app/Access/Oidc/OidcJwtWithClaims.php index 9d7eeead1a9..9763ab15864 100644 --- a/app/Access/Oidc/OidcJwtWithClaims.php +++ b/app/Access/Oidc/OidcJwtWithClaims.php @@ -131,8 +131,6 @@ protected function validateTokenSignature(): void } }, $this->keys); - $parsedKeys = array_filter($parsedKeys); - $contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1]; /** @var OidcJwtSigningKey $parsedKey */ foreach ($parsedKeys as $parsedKey) { diff --git a/app/Access/Oidc/OidcService.php b/app/Access/Oidc/OidcService.php index a84bd320513..19120e2d3c3 100644 --- a/app/Access/Oidc/OidcService.php +++ b/app/Access/Oidc/OidcService.php @@ -80,6 +80,7 @@ public function processAuthorizeResponse(?string $authorizationCode): User $provider->setPkceCode($pkceCode); // Try to exchange authorization code for access token + /** @var OidcAccessToken $accessToken */ $accessToken = $provider->getAccessToken('authorization_code', [ 'code' => $authorizationCode, ]); diff --git a/app/Activity/ActivityQueries.php b/app/Activity/ActivityQueries.php index d5b047937fb..a3383e80933 100644 --- a/app/Activity/ActivityQueries.php +++ b/app/Activity/ActivityQueries.php @@ -11,6 +11,7 @@ use BookStack\Permissions\PermissionApplicator; use BookStack\Users\Models\User; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\Relation; @@ -27,6 +28,7 @@ public function __construct( */ public function latest(int $count = 20, int $page = 0): array { + /** @var Collection $activityList */ $activityList = $this->permissions ->restrictEntityRelationQuery(Activity::query(), 'activities', 'loggable_id', 'loggable_type') ->orderBy('created_at', 'desc') @@ -83,6 +85,7 @@ public function entityActivity(Entity $entity, int $count = 20, int $page = 1): */ public function userActivity(User $user, int $count = 20, int $page = 0): array { + /** @var Collection $activityList */ $activityList = $this->permissions ->restrictEntityRelationQuery(Activity::query(), 'activities', 'loggable_id', 'loggable_type') ->orderBy('created_at', 'desc') diff --git a/app/Activity/Models/Comment.php b/app/Activity/Models/Comment.php index 3faa76657b6..b49eb456488 100644 --- a/app/Activity/Models/Comment.php +++ b/app/Activity/Models/Comment.php @@ -3,6 +3,7 @@ namespace BookStack\Activity\Models; use BookStack\App\Model; +use BookStack\Entities\Models\Page; use BookStack\Permissions\Models\JointPermission; use BookStack\Permissions\PermissionApplicator; use BookStack\Users\Models\HasCreatorAndUpdater; @@ -40,6 +41,9 @@ class Comment extends Model implements Loggable, OwnableInterface /** * Get the entity that this comment belongs to. + * It's only pages right now hence the typing below. + * Would need a deeper audit if that changes as many areas assume this is always a page. + * @return MorphTo */ public function entity(): MorphTo { @@ -55,7 +59,9 @@ public function entity(): MorphTo // Ultimately, we could just align the method name to 'commentable' but that would be a potential // breaking change and not really worthwhile in a patch due to the risk of creating extra problems. - return $this->morphTo(null, 'commentable_type', 'commentable_id'); + /** @var MorphTo $relation */ + $relation = $this->morphTo(null, 'commentable_type', 'commentable_id'); + return $relation; } /** diff --git a/app/Activity/Tools/WebhookFormatter.php b/app/Activity/Tools/WebhookFormatter.php index cb4e9cb0a9c..111efd5d498 100644 --- a/app/Activity/Tools/WebhookFormatter.php +++ b/app/Activity/Tools/WebhookFormatter.php @@ -73,13 +73,17 @@ public function addDefaultModelFormatters(): void // Load entity owner, creator, updater details $this->addModelFormatter( fn ($event, $model) => ($model instanceof Entity), - fn ($model) => $model->load(['ownedBy', 'createdBy', 'updatedBy']) + function ($model) { + $model->load(['ownedBy', 'createdBy', 'updatedBy']); + } ); // Load revision detail for page update and create events $this->addModelFormatter( fn ($event, $model) => ($model instanceof Page && ($event === ActivityType::PAGE_CREATE || $event === ActivityType::PAGE_UPDATE)), - fn ($model) => $model->load('currentRevision') + function ($model) { + $model->load('currentRevision'); + } ); } diff --git a/app/Api/ApiDocsGenerator.php b/app/Api/ApiDocsGenerator.php index 53cb2890a7e..8d1be79c88f 100644 --- a/app/Api/ApiDocsGenerator.php +++ b/app/Api/ApiDocsGenerator.php @@ -138,8 +138,8 @@ protected function getValidationAsString($validation): string return $validation; } - if (is_object($validation) && method_exists($validation, '__toString')) { - return strval($validation); + if (is_object($validation) && $validation instanceof \Stringable) { + return $validation->__toString(); } if ($validation instanceof Password) { diff --git a/app/App/HomeController.php b/app/App/HomeController.php index 00e2db3df43..d8193e234f9 100644 --- a/app/App/HomeController.php +++ b/app/App/HomeController.php @@ -2,11 +2,8 @@ namespace BookStack\App; -use BookStack\Activity\ActivityQueries; use BookStack\Entities\Models\Page; use BookStack\Entities\Queries\EntityQueries; -use BookStack\Entities\Queries\QueryRecentlyViewed; -use BookStack\Entities\Queries\QueryTopFavourites; use BookStack\Entities\Tools\PageContent; use BookStack\Http\Controller; use BookStack\Util\SimpleListOptions; @@ -24,49 +21,19 @@ public function __construct( */ public function index( Request $request, - ActivityQueries $activities, - QueryRecentlyViewed $recentlyViewed, - QueryTopFavourites $topFavourites, ) { - $activity = $activities->latest(10); - $draftPages = []; - - if ($this->isSignedIn()) { - $draftPages = $this->queries->pages->currentUserDraftsForList() - ->orderBy('updated_at', 'desc') - ->with('book') - ->take(6) - ->get(); - } - - $recentFactor = count($draftPages) > 0 ? 0.5 : 1; - $recents = $this->isSignedIn() ? - $recentlyViewed->run(12 * $recentFactor, 1) - : $this->queries->books->visibleForList()->orderBy('created_at', 'desc')->take(12 * $recentFactor)->get(); - $favourites = $topFavourites->run(6); - $recentlyUpdatedPages = $this->queries->pages->visibleForList() - ->where('draft', false) - ->orderBy('updated_at', 'desc') - ->take($favourites->count() > 0 ? 5 : 10) - ->get(); - - $homepageOptions = ['default', 'books', 'bookshelves', 'page']; - $homepageOption = setting('app-homepage-type', 'default'); - if (!in_array($homepageOption, $homepageOptions)) { - $homepageOption = 'default'; + $homepageType = setting('app-homepage-type'); + if (!in_array($homepageType, ['default', 'books', 'bookshelves', 'page'])) { + $homepageType = 'default'; } $commonData = [ - 'activity' => $activity, - 'recents' => $recents, - 'recentlyUpdatedPages' => $recentlyUpdatedPages, - 'draftPages' => $draftPages, - 'favourites' => $favourites, + 'homeView' => $homepageType, ]; // Add required list ordering & sorting for books & shelves views. - if ($homepageOption === 'bookshelves' || $homepageOption === 'books') { - $key = $homepageOption; + if ($homepageType === 'bookshelves' || $homepageType === 'books') { + $key = $homepageType; $view = setting()->getForCurrentUser($key . '_view_type'); $listOptions = SimpleListOptions::fromRequest($request, $key)->withSortOptions([ 'name' => trans('common.sort_name'), @@ -80,7 +47,7 @@ public function index( ]); } - if ($homepageOption === 'bookshelves') { + if ($homepageType === 'bookshelves') { $shelves = $this->queries->shelves->visibleForListWithCover() ->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder()) ->paginate(setting()->getInteger('lists-page-count-shelves', 18, 1, 1000)); @@ -89,7 +56,7 @@ public function index( return view('home.shelves', $data); } - if ($homepageOption === 'books') { + if ($homepageType === 'books') { $books = $this->queries->books->visibleForListWithCover() ->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder()) ->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000)); @@ -98,7 +65,7 @@ public function index( return view('home.books', $data); } - if ($homepageOption === 'page') { + if ($homepageType === 'page') { $homepageSetting = setting('app-homepage', '0:'); $id = intval(explode(':', $homepageSetting)[0]); /** @var Page $customHomepage */ diff --git a/app/App/Providers/ThemeServiceProvider.php b/app/App/Providers/ThemeServiceProvider.php index 671e5e1df74..9b0f7001e81 100644 --- a/app/App/Providers/ThemeServiceProvider.php +++ b/app/App/Providers/ThemeServiceProvider.php @@ -7,6 +7,7 @@ use BookStack\Theming\ThemeViews; use Illuminate\Support\Facades\Blade; use Illuminate\Support\ServiceProvider; +use Illuminate\View\FileViewFinder; class ThemeServiceProvider extends ServiceProvider { @@ -27,7 +28,11 @@ public function boot(): void // Boot up the theme system $themeService = $this->app->make(ThemeService::class); $viewFactory = $this->app->make('view'); - $themeViews = new ThemeViews($viewFactory->getFinder()); + $viewFinder = $viewFactory->getFinder(); + if (!($viewFinder instanceof FileViewFinder)) { + throw new \Exception('Only the file view finder is supported for the theme system'); + } + $themeViews = new ThemeViews($viewFinder); // Use a custom include so that we can insert theme views before/after includes. // This is done, even if no theme is active, so that view caching does not create problems diff --git a/app/App/Providers/ViewTweaksServiceProvider.php b/app/App/Providers/ViewTweaksServiceProvider.php index 6771e513fa6..ae41c1d953a 100644 --- a/app/App/Providers/ViewTweaksServiceProvider.php +++ b/app/App/Providers/ViewTweaksServiceProvider.php @@ -3,7 +3,11 @@ namespace BookStack\App\Providers; use BookStack\Entities\BreadcrumbsViewComposer; +use BookStack\Facades\Theme; +use BookStack\Theming\ThemeEvents; use BookStack\Util\DateFormatter; +use BookStack\View\ViewBlockManager; +use BookStack\View\ViewBlockPreferences; use Illuminate\Pagination\Paginator; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\View; @@ -18,6 +22,10 @@ public function register() $app['config']->get('app.display_timezone'), ); }); + + $this->app->singleton(ViewBlockManager::class, function ($app) { + return new ViewBlockManager(new ViewBlockPreferences()); + }); } /** @@ -32,7 +40,10 @@ public function boot(): void View::composer('entities.breadcrumbs', BreadcrumbsViewComposer::class); // View Globals + $viewBlockManager = $this->app->make(ViewBlockManager::class); View::share('dates', $this->app->make(DateFormatter::class)); + View::share('viewBlocks', $viewBlockManager); + Theme::dispatch(ThemeEvents::VIEW_BLOCKS_REGISTER, $viewBlockManager); // Custom blade view directives Blade::directive('icon', function ($expression) { diff --git a/app/App/helpers.php b/app/App/helpers.php index 8f210ecafd4..45a84da8db7 100644 --- a/app/App/helpers.php +++ b/app/App/helpers.php @@ -6,6 +6,7 @@ use BookStack\Permissions\Permission; use BookStack\Permissions\PermissionApplicator; use BookStack\Settings\SettingService; +use BookStack\Users\Models\OwnableInterface; use BookStack\Users\Models\User; /** @@ -40,7 +41,7 @@ function user(): User * Check if the current user has a permission. If an ownable element * is passed in the jointPermissions are checked against that particular item. */ -function userCan(string|Permission $permission, ?Model $ownable = null): bool +function userCan(string|Permission $permission, (Model&OwnableInterface)|null $ownable = null): bool { if (is_null($ownable)) { return user()->can($permission); diff --git a/app/Config/setting-defaults.php b/app/Config/setting-defaults.php index 2f270b283a2..59425449516 100644 --- a/app/Config/setting-defaults.php +++ b/app/Config/setting-defaults.php @@ -32,6 +32,7 @@ 'page-draft-color-dark' => '#a66ce8', 'app-custom-head' => false, 'registration-enabled' => false, + 'app-homepage-type' => 'default', // User-level default settings 'user' => [ diff --git a/app/Entities/Controllers/BookController.php b/app/Entities/Controllers/BookController.php index 98470d91ce8..aa4f99daa6d 100644 --- a/app/Entities/Controllers/BookController.php +++ b/app/Entities/Controllers/BookController.php @@ -2,10 +2,8 @@ namespace BookStack\Entities\Controllers; -use BookStack\Activity\ActivityQueries; use BookStack\Activity\ActivityType; use BookStack\Activity\Models\View; -use BookStack\Activity\Tools\UserEntityWatchOptions; use BookStack\Entities\Queries\BookQueries; use BookStack\Entities\Queries\BookshelfQueries; use BookStack\Entities\Queries\EntityQueries; @@ -19,7 +17,6 @@ use BookStack\Facades\Activity; use BookStack\Http\Controller; use BookStack\Permissions\Permission; -use BookStack\References\ReferenceFetcher; use BookStack\Util\DatabaseTransaction; use BookStack\Util\SimpleListOptions; use Illuminate\Http\Request; @@ -34,7 +31,6 @@ public function __construct( protected BookQueries $queries, protected EntityQueries $entityQueries, protected BookshelfQueries $shelfQueries, - protected ReferenceFetcher $referenceFetcher, ) { } @@ -53,9 +49,6 @@ public function index(Request $request) $books = $this->queries->visibleForListWithCover() ->orderBy($listOptions->getSort(), $listOptions->getOrder()) ->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000)); - $recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->take(4)->get() : false; - $popular = $this->queries->popularForList()->take(4)->get(); - $new = $this->queries->visibleForList()->orderBy('created_at', 'desc')->take(4)->get(); $this->shelfContext->clearShelfContext(); @@ -63,9 +56,6 @@ public function index(Request $request) return view('books.index', [ 'books' => $books, - 'recents' => $recents, - 'popular' => $popular, - 'new' => $new, 'view' => $view, 'listOptions' => $listOptions, ]); @@ -127,7 +117,7 @@ public function store(Request $request, ?string $shelfSlug = null) /** * Display the specified book. */ - public function show(Request $request, ActivityQueries $activities, string $slug) + public function show(Request $request, string $slug) { try { $book = $this->queries->findVisibleBySlugOrFail($slug); @@ -140,7 +130,6 @@ public function show(Request $request, ActivityQueries $activities, string $slug } $bookChildren = (new BookContents($book))->getTree(true); - $bookParentShelves = $book->shelves()->scopes('visible')->get(); View::incrementFor($book); if ($request->has('shelf')) { @@ -153,10 +142,6 @@ public function show(Request $request, ActivityQueries $activities, string $slug 'book' => $book, 'current' => $book, 'bookChildren' => $bookChildren, - 'bookParentShelves' => $bookParentShelves, - 'watchOptions' => new UserEntityWatchOptions(user(), $book), - 'activity' => $activities->entityActivity($book, 20, 1), - 'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($book), ]); } diff --git a/app/Entities/Controllers/BookshelfController.php b/app/Entities/Controllers/BookshelfController.php index 1e8b26b5156..a918c48f185 100644 --- a/app/Entities/Controllers/BookshelfController.php +++ b/app/Entities/Controllers/BookshelfController.php @@ -2,7 +2,6 @@ namespace BookStack\Entities\Controllers; -use BookStack\Activity\ActivityQueries; use BookStack\Activity\Models\View; use BookStack\Entities\Queries\BookQueries; use BookStack\Entities\Queries\BookshelfQueries; @@ -13,7 +12,6 @@ use BookStack\Exceptions\NotFoundException; use BookStack\Http\Controller; use BookStack\Permissions\Permission; -use BookStack\References\ReferenceFetcher; use BookStack\Util\SimpleListOptions; use Exception; use Illuminate\Http\Request; @@ -27,7 +25,6 @@ public function __construct( protected EntityQueries $entityQueries, protected BookQueries $bookQueries, protected ShelfContext $shelfContext, - protected ReferenceFetcher $referenceFetcher, ) { } @@ -46,21 +43,12 @@ public function index(Request $request) $shelves = $this->queries->visibleForListWithCover() ->orderBy($listOptions->getSort(), $listOptions->getOrder()) ->paginate(setting()->getInteger('lists-page-count-shelves', 18, 1, 1000)); - $recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->get() : false; - $popular = $this->queries->popularForList()->get(); - $new = $this->queries->visibleForList() - ->orderBy('created_at', 'desc') - ->take(4) - ->get(); $this->shelfContext->clearShelfContext(); $this->setPageTitle(trans('entities.shelves')); return view('shelves.index', [ 'shelves' => $shelves, - 'recents' => $recents, - 'popular' => $popular, - 'new' => $new, 'view' => $view, 'listOptions' => $listOptions, ]); @@ -105,7 +93,7 @@ public function store(Request $request) * * @throws NotFoundException */ - public function show(Request $request, ActivityQueries $activities, string $slug) + public function show(Request $request, string $slug) { try { $shelf = $this->queries->findVisibleBySlugOrFail($slug); @@ -144,9 +132,7 @@ public function show(Request $request, ActivityQueries $activities, string $slug 'shelf' => $shelf, 'sortedVisibleShelfBooks' => $sortedVisibleShelfBooks, 'view' => $view, - 'activity' => $activities->entityActivity($shelf, 20, 1), 'listOptions' => $listOptions, - 'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($shelf), ]); } diff --git a/app/Entities/Controllers/ChapterController.php b/app/Entities/Controllers/ChapterController.php index db2391599ab..c089d357512 100644 --- a/app/Entities/Controllers/ChapterController.php +++ b/app/Entities/Controllers/ChapterController.php @@ -3,7 +3,6 @@ namespace BookStack\Entities\Controllers; use BookStack\Activity\Models\View; -use BookStack\Activity\Tools\UserEntityWatchOptions; use BookStack\Entities\Models\Book; use BookStack\Entities\Queries\ChapterQueries; use BookStack\Entities\Queries\EntityQueries; @@ -18,7 +17,6 @@ use BookStack\Exceptions\PermissionsException; use BookStack\Http\Controller; use BookStack\Permissions\Permission; -use BookStack\References\ReferenceFetcher; use BookStack\Util\DatabaseTransaction; use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; @@ -30,7 +28,6 @@ public function __construct( protected ChapterRepo $chapterRepo, protected ChapterQueries $queries, protected EntityQueries $entityQueries, - protected ReferenceFetcher $referenceFetcher, ) { } @@ -87,10 +84,10 @@ public function show(string $bookSlug, string $chapterSlug) return redirect($chapter->getUrl()); } - $sidebarTree = (new BookContents($chapter->book))->getTree(); $pages = $this->entityQueries->pages->visibleForChapterList($chapter->id)->get(); - $nextPreviousLocator = new NextPreviousContentLocator($chapter, $sidebarTree); + $bookTree = (new BookContents($chapter->book))->getTree(); + $nextPreviousLocator = new NextPreviousContentLocator($chapter, $bookTree); View::incrementFor($chapter); $this->setPageTitle($chapter->getShortName()); @@ -99,12 +96,10 @@ public function show(string $bookSlug, string $chapterSlug) 'book' => $chapter->book, 'chapter' => $chapter, 'current' => $chapter, - 'sidebarTree' => $sidebarTree, - 'watchOptions' => new UserEntityWatchOptions(user(), $chapter), 'pages' => $pages, 'next' => $nextPreviousLocator->getNext(), 'previous' => $nextPreviousLocator->getPrevious(), - 'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($chapter), + 'bookTree' => $bookTree, ]); } diff --git a/app/Entities/Controllers/PageApiController.php b/app/Entities/Controllers/PageApiController.php index 38042e67058..ca8989f0764 100644 --- a/app/Entities/Controllers/PageApiController.php +++ b/app/Entities/Controllers/PageApiController.php @@ -1,5 +1,7 @@ queries->visibleForList() ->addSelect(['created_by', 'updated_by', 'revision_count', 'editor']); @@ -69,7 +73,7 @@ public function list() * Any images included via base64 data URIs will be extracted and saved as gallery * images against the page during upload. */ - public function create(Request $request) + public function create(Request $request): JsonResponse { $this->validate($request, $this->rules['create']); @@ -102,9 +106,9 @@ public function create(Request $request) * Comments for the page are provided in a tree-structure representing the hierarchy of top-level * comments and replies, for both archived and active comments. */ - public function read(string $id) + public function read(string $id): JsonResponse { - $page = $this->queries->findVisibleByIdOrFail($id); + $page = $this->queries->findVisibleByIdOrFail(intval($id)); $page = $page->forJsonDisplay(); $commentTree = (new CommentTree($page)); @@ -124,11 +128,11 @@ public function read(string $id) * Providing a 'book_id' or 'chapter_id' property will essentially move * the page into that parent element if you have permissions to do so. */ - public function update(Request $request, string $id) + public function update(Request $request, string $id): JsonResponse { $requestData = $this->validate($request, $this->rules['update']); - $page = $this->queries->findVisibleByIdOrFail($id); + $page = $this->queries->findVisibleByIdOrFail(intval($id)); $this->checkOwnablePermission(Permission::PageUpdate, $page); $parent = null; @@ -161,9 +165,9 @@ public function update(Request $request, string $id) * Delete a page. * This will typically send the page to the recycle bin. */ - public function delete(string $id) + public function delete(string $id): Response { - $page = $this->queries->findVisibleByIdOrFail($id); + $page = $this->queries->findVisibleByIdOrFail(intval($id)); $this->checkOwnablePermission(Permission::PageDelete, $page); $this->pageRepo->destroy($page); diff --git a/app/Entities/Controllers/PageController.php b/app/Entities/Controllers/PageController.php index 82edfbc2763..10a46d58bab 100644 --- a/app/Entities/Controllers/PageController.php +++ b/app/Entities/Controllers/PageController.php @@ -4,9 +4,9 @@ use BookStack\Activity\Models\View; use BookStack\Activity\Tools\CommentTree; -use BookStack\Activity\Tools\UserEntityWatchOptions; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Chapter; +use BookStack\Entities\Models\Page; use BookStack\Entities\Queries\EntityQueries; use BookStack\Entities\Queries\PageQueries; use BookStack\Entities\Repos\PageRepo; @@ -20,11 +20,11 @@ use BookStack\Exceptions\PermissionsException; use BookStack\Http\Controller; use BookStack\Permissions\Permission; -use BookStack\References\ReferenceFetcher; use BookStack\Util\HtmlContentFilter; use BookStack\Util\HtmlContentFilterConfig; use Exception; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; use Throwable; @@ -35,7 +35,6 @@ public function __construct( protected PageRepo $pageRepo, protected PageQueries $queries, protected EntityQueries $entityQueries, - protected ReferenceFetcher $referenceFetcher ) { } @@ -95,7 +94,7 @@ public function createAsGuest(Request $request, string $bookSlug, ?string $chapt } /** - * Show form to continue editing a draft page. + * Show a form to continue editing a draft page. * * @throws NotFoundException */ @@ -103,6 +102,7 @@ public function editDraft(Request $request, string $bookSlug, int $pageId) { $draft = $this->queries->findVisibleByIdOrFail($pageId); $this->checkOwnablePermission(Permission::PageCreate, $draft->getParent()); + $this->ensureDraftAccess($draft); $editorData = new PageEditorData($draft, $this->entityQueries, $request->query('editor', '')); $this->setPageTitle(trans('entities.pages_edit_draft')); @@ -124,6 +124,7 @@ public function store(Request $request, string $bookSlug, int $pageId) $draftPage = $this->queries->findVisibleByIdOrFail($pageId); $this->checkOwnablePermission(Permission::PageCreate, $draftPage->getParent()); + $this->ensureDraftAccess($draftPage); $page = $this->pageRepo->publishDraft($draftPage, $request->all()); @@ -151,11 +152,10 @@ public function show(string $bookSlug, string $pageSlug) $pageContent = (new PageContent($page)); $page->html = $pageContent->render(); - $pageNav = $pageContent->getNavigation($page->html); - $sidebarTree = (new BookContents($page->book))->getTree(); + $bookTree = (new BookContents($page->book))->getTree(); $commentTree = (new CommentTree($page)); - $nextPreviousLocator = new NextPreviousContentLocator($page, $sidebarTree); + $nextPreviousLocator = new NextPreviousContentLocator($page, $bookTree); View::incrementFor($page); $this->setPageTitle($page->getShortName()); @@ -164,13 +164,10 @@ public function show(string $bookSlug, string $pageSlug) 'page' => $page, 'book' => $page->book, 'current' => $page, - 'sidebarTree' => $sidebarTree, + 'bookTree' => $bookTree, 'commentTree' => $commentTree, - 'pageNav' => $pageNav, - 'watchOptions' => new UserEntityWatchOptions(user(), $page), 'next' => $nextPreviousLocator->getNext(), 'previous' => $nextPreviousLocator->getPrevious(), - 'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($page), ]); } @@ -235,6 +232,7 @@ public function update(Request $request, string $bookSlug, string $pageSlug) * Save a draft update as a revision. * * @throws NotFoundException + * @throws PermissionsException */ public function saveDraft(Request $request, int $pageId) { @@ -245,6 +243,10 @@ public function saveDraft(Request $request, int $pageId) return $this->jsonError(trans('errors.guests_cannot_save_drafts'), 500); } + if ($page->draft) { + $this->ensureDraftAccess($page); + } + $draft = $this->pageRepo->updatePageDraft($page, $request->only(['name', 'html', 'markdown'])); $warnings = (new PageEditActivity($page))->getWarningMessagesForDraft($draft); @@ -294,11 +296,14 @@ public function showDelete(string $bookSlug, string $pageSlug) * Show the deletion page for the specified page. * * @throws NotFoundException + * @throws PermissionsException */ public function showDeleteDraft(string $bookSlug, int $pageId) { $page = $this->queries->findVisibleByIdOrFail($pageId); $this->checkOwnablePermission(Permission::PageUpdate, $page); + $this->ensureDraftAccess($page); + $this->setPageTitle(trans('entities.pages_delete_draft_named', ['pageName' => $page->getShortName()])); $usedAsTemplate = $this->entityQueries->books->start()->where('default_template_id', '=', $page->id)->count() > 0 || @@ -340,7 +345,9 @@ public function destroyDraft(string $bookSlug, int $pageId) $page = $this->queries->findVisibleByIdOrFail($pageId); $book = $page->book; $chapter = $page->chapter; + $this->checkOwnablePermission(Permission::PageUpdate, $page); + $this->ensureDraftAccess($page); $this->pageRepo->destroy($page); @@ -358,8 +365,8 @@ public function destroyDraft(string $bookSlug, int $pageId) */ public function showRecentlyUpdated() { - $visibleBelongsScope = function (BelongsTo $query) { - $query->scopes('visible'); + $visibleBelongsScope = function (Relation $relation): void { + $relation->scopes('visible'); }; $pages = $this->queries->visibleForList() @@ -470,4 +477,14 @@ public function copy(Request $request, Cloner $cloner, string $bookSlug, string return redirect($pageCopy->getUrl()); } + + /** + * @throws PermissionsException + */ + protected function ensureDraftAccess(Page $draft): void + { + if (!$draft->draft || $draft->created_by !== user()->id) { + throw new PermissionsException('This page is already published or does not belong to you.'); + } + } } diff --git a/app/Entities/Controllers/PageRevisionController.php b/app/Entities/Controllers/PageRevisionController.php index cc6b79bfe45..801aeed3ed3 100644 --- a/app/Entities/Controllers/PageRevisionController.php +++ b/app/Entities/Controllers/PageRevisionController.php @@ -177,7 +177,7 @@ public function destroy(string $bookSlug, string $pageSlug, int $revId) */ public function destroyUserDraft(string $pageId) { - $page = $this->pageQueries->findVisibleByIdOrFail($pageId); + $page = $this->pageQueries->findVisibleByIdOrFail(intval($pageId)); $this->revisionRepo->deleteDraftsForCurrentUser($page); return response('', 200); diff --git a/app/Entities/Models/Book.php b/app/Entities/Models/Book.php index 10f04695a5e..abb081d712f 100644 --- a/app/Entities/Models/Book.php +++ b/app/Entities/Models/Book.php @@ -20,10 +20,10 @@ * @property ?int $image_id * @property ?int $default_template_id * @property ?int $sort_rule_id - * @property \Illuminate\Database\Eloquent\Collection $chapters - * @property \Illuminate\Database\Eloquent\Collection $pages - * @property \Illuminate\Database\Eloquent\Collection $directPages - * @property \Illuminate\Database\Eloquent\Collection $shelves + * @property \Illuminate\Database\Eloquent\Collection $chapters + * @property \Illuminate\Database\Eloquent\Collection $pages + * @property \Illuminate\Database\Eloquent\Collection $directPages + * @property \Illuminate\Database\Eloquent\Collection $shelves * @property ?SortRule $sortRule */ class Book extends Entity implements HasDescriptionInterface, HasCoverInterface, HasDefaultTemplateInterface diff --git a/app/Entities/Models/Entity.php b/app/Entities/Models/Entity.php index 27cfccaa836..296bd3a6621 100644 --- a/app/Entities/Models/Entity.php +++ b/app/Entities/Models/Entity.php @@ -46,7 +46,7 @@ * @property int|null $created_by * @property int|null $updated_by * @property int|null $owned_by - * @property Collection $tags + * @property Collection $tags * * @method static Entity|Builder visible() * @method static Builder withLastView() diff --git a/app/Entities/Models/EntityTable.php b/app/Entities/Models/EntityTable.php index 5780162d1d2..ed5cdf88970 100644 --- a/app/Entities/Models/EntityTable.php +++ b/app/Entities/Models/EntityTable.php @@ -25,6 +25,9 @@ class EntityTable extends Model /** * Get the entities that are visible to the current user. + * Note: This only applies basic permission filtering which considers the core entity table. + * This will not filter on information from other tables such as page draft status. + * That should be done after applying this scope. */ public function scopeVisible(Builder $query): Builder { diff --git a/app/Entities/Models/Page.php b/app/Entities/Models/Page.php index d3a392da6fa..c38f33af3ca 100644 --- a/app/Entities/Models/Page.php +++ b/app/Entities/Models/Page.php @@ -24,8 +24,8 @@ * @property int $revision_count * @property string $editor * @property Chapter|null $chapter - * @property Collection $attachments - * @property Collection $revisions + * @property Collection $attachments + * @property Collection $revisions * @property PageRevision $currentRevision */ class Page extends BookChild diff --git a/app/Entities/Queries/EntityQueries.php b/app/Entities/Queries/EntityQueries.php index 3ffa0adf3db..ab7f975ae4d 100644 --- a/app/Entities/Queries/EntityQueries.php +++ b/app/Entities/Queries/EntityQueries.php @@ -81,6 +81,13 @@ public function visibleForList(): Builder })->leftJoin('entity_page_data', function (JoinClause $join) { $join->on('entity_page_data.page_id', '=', 'entities.id') ->where('entities.type', '=', 'page'); + })->where(function ($query) { + $query->whereNull('entity_page_data.draft') + ->orWhere('entity_page_data.draft', '=', 0) + ->orWhere(function ($query) { + $query->where('entity_page_data.draft', '=', 1) + ->where('entities.owned_by', '=', user()->id); + }); }); } diff --git a/app/Entities/Queries/QueryPopular.php b/app/Entities/Queries/QueryPopular.php index 065ae82ef82..1782070de86 100644 --- a/app/Entities/Queries/QueryPopular.php +++ b/app/Entities/Queries/QueryPopular.php @@ -6,6 +6,7 @@ use BookStack\Entities\EntityProvider; use BookStack\Entities\Tools\MixedEntityListLoader; use BookStack\Permissions\PermissionApplicator; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -20,6 +21,7 @@ public function __construct( public function run(int $count, int $page, array $filterModels): Collection { + /** @var Builder $query */ $query = $this->permissions ->restrictEntityRelationQuery(View::query(), 'views', 'viewable_id', 'viewable_type') ->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count')) diff --git a/app/Entities/Queries/QueryRecentlyViewed.php b/app/Entities/Queries/QueryRecentlyViewed.php index f28b8f8652f..8ba1a57badd 100644 --- a/app/Entities/Queries/QueryRecentlyViewed.php +++ b/app/Entities/Queries/QueryRecentlyViewed.php @@ -5,6 +5,7 @@ use BookStack\Activity\Models\View; use BookStack\Entities\Tools\MixedEntityListLoader; use BookStack\Permissions\PermissionApplicator; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; class QueryRecentlyViewed @@ -22,6 +23,7 @@ public function run(int $count, int $page): Collection return collect(); } + /** @var Builder $query */ $query = $this->permissions->restrictEntityRelationQuery( View::query(), 'views', diff --git a/app/Entities/Queries/QueryTopFavourites.php b/app/Entities/Queries/QueryTopFavourites.php index 6340e35ef18..2719e94bc8b 100644 --- a/app/Entities/Queries/QueryTopFavourites.php +++ b/app/Entities/Queries/QueryTopFavourites.php @@ -5,6 +5,7 @@ use BookStack\Activity\Models\Favourite; use BookStack\Entities\Tools\MixedEntityListLoader; use BookStack\Permissions\PermissionApplicator; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Query\JoinClause; class QueryTopFavourites @@ -22,6 +23,7 @@ public function run(int $count, int $skip = 0) return collect(); } + /** @var Builder $query */ $query = $this->permissions ->restrictEntityRelationQuery(Favourite::query(), 'favourites', 'favouritable_id', 'favouritable_type') ->select('favourites.*') diff --git a/app/Entities/Tools/Markdown/CustomListItemRenderer.php b/app/Entities/Tools/Markdown/CustomListItemRenderer.php index 0c506d7f9b5..14cdaa2eaa4 100644 --- a/app/Entities/Tools/Markdown/CustomListItemRenderer.php +++ b/app/Entities/Tools/Markdown/CustomListItemRenderer.php @@ -25,9 +25,13 @@ public function __construct() */ public function render(Node $node, ChildNodeRendererInterface $childRenderer) { + if (!($node instanceof ListItem)) { + return null; + } + $listItem = $this->baseRenderer->render($node, $childRenderer); - if ($node instanceof ListItem && $this->startsTaskListItem($node) && $listItem instanceof HtmlElement) { + if ($this->startsTaskListItem($node) && $listItem instanceof HtmlElement) { $listItem->setAttribute('class', 'task-list-item'); } diff --git a/app/Entities/Tools/PageContent.php b/app/Entities/Tools/PageContent.php index 9fb4596f5a0..c66dcbe4151 100644 --- a/app/Entities/Tools/PageContent.php +++ b/app/Entities/Tools/PageContent.php @@ -421,28 +421,41 @@ public function getNavigation(string $htmlContent): array */ protected function headerNodesToLevelList(DOMNodeList $nodeList): array { - $tree = collect($nodeList)->map(function (DOMElement $header) { + $minLevel = 6; + + $headerDetails = array_map(function (DOMNode $header) use (&$minLevel) { + if (!$header instanceof DOMElement) { + return null; + } + $text = trim(str_replace("\xc2\xa0", ' ', $header->nodeValue)); $text = mb_substr($text, 0, 100); + if (empty($text)) { + return null; + } + + $level = intval(str_replace('h', '', $header->nodeName)); + if ($level < $minLevel) { + $minLevel = $level; + } + return [ 'nodeName' => strtolower($header->nodeName), - 'level' => intval(str_replace('h', '', $header->nodeName)), + 'level' => $level, 'link' => '#' . $header->getAttribute('id'), 'text' => $text, ]; - })->filter(function ($header) { - return mb_strlen($header['text']) > 0; - }); + }, [...$nodeList]); - // Shift headers if only smaller headers have been used - $levelChange = ($tree->pluck('level')->min() - 1); - $tree = $tree->map(function ($header) use ($levelChange) { - $header['level'] -= ($levelChange); + $filtered = array_values(array_filter($headerDetails)); - return $header; - }); + // Shift headers if only smaller headers have been used + $levelChange = ($minLevel - 1); + foreach ($filtered as $index => $header) { + $filtered[$index]['level'] -= $levelChange; + } - return $tree->toArray(); + return $filtered; } } diff --git a/app/Entities/Tools/PageIncludeParser.php b/app/Entities/Tools/PageIncludeParser.php index af7ed4fc6a1..16917f41c8e 100644 --- a/app/Entities/Tools/PageIncludeParser.php +++ b/app/Entities/Tools/PageIncludeParser.php @@ -192,10 +192,10 @@ protected function splitNodeAtChildNode(DOMElement $parentNode, DOMNode $domNode /** * Get the parent paragraph of the given node, if existing. */ - protected function getParentParagraph(DOMNode $parent): ?DOMNode + protected function getParentParagraph(DOMNode $parent): ?DOMElement { do { - if (strtolower($parent->nodeName) === 'p') { + if (strtolower($parent->nodeName) === 'p' && $parent instanceof DOMElement) { return $parent; } diff --git a/app/Entities/Tools/SlugGenerator.php b/app/Entities/Tools/SlugGenerator.php index 6eec84a91c1..ac003b97a8f 100644 --- a/app/Entities/Tools/SlugGenerator.php +++ b/app/Entities/Tools/SlugGenerator.php @@ -52,7 +52,7 @@ protected function formatNameAsSlug(string $name): string { $slug = Str::slug($name); if ($slug === '') { - $slug = substr(md5(rand(1, 500)), 0, 5); + $slug = substr(md5(strval(rand(1, 500))), 0, 5); } return $slug; diff --git a/app/Entities/Tools/TrashCan.php b/app/Entities/Tools/TrashCan.php index 96645aebfa6..26769ecbbf2 100644 --- a/app/Entities/Tools/TrashCan.php +++ b/app/Entities/Tools/TrashCan.php @@ -358,6 +358,8 @@ protected function restoreEntity(Entity $entity): int $entity->chapters()->withTrashed()->withCount('deletions')->get()->each($restoreAction); } + $entity->rebuildPermissions(); + return $count; } diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index a7f23e5f41a..eec50afb170 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -169,14 +169,4 @@ protected function unauthenticated($request, AuthenticationException $exception) return redirect()->guest('login'); } - - /** - * Convert a validation exception into a JSON response. - * - * @param Request $request - */ - protected function invalidJson($request, ValidationException $exception): JsonResponse - { - return response()->json($exception->errors(), $exception->status); - } } diff --git a/app/Exports/ImportRepo.php b/app/Exports/ImportRepo.php index 79db69fca8f..ed631409be3 100644 --- a/app/Exports/ImportRepo.php +++ b/app/Exports/ImportRepo.php @@ -3,6 +3,8 @@ namespace BookStack\Exports; use BookStack\Activity\ActivityType; +use BookStack\Entities\Models\Book; +use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Entity; use BookStack\Entities\Queries\EntityQueries; use BookStack\Exceptions\FileUploadException; @@ -119,6 +121,9 @@ public function runImport(Import $import, ?string $parent = null): Entity $parentModel = null; if ($import->type === 'page' || $import->type === 'chapter') { $parentModel = $parent ? $this->entityQueries->findVisibleByStringIdentifier($parent) : null; + if ($parentModel && !($parentModel instanceof Book || $parentModel instanceof Chapter)) { + throw new ZipImportException(['Selected parent is not a book or chapter']); + } } DB::beginTransaction(); diff --git a/app/Exports/ZipExports/Models/ZipExportBook.php b/app/Exports/ZipExports/Models/ZipExportBook.php index ab3fd90ec1c..006a6b2d2f5 100644 --- a/app/Exports/ZipExports/Models/ZipExportBook.php +++ b/app/Exports/ZipExports/Models/ZipExportBook.php @@ -87,7 +87,7 @@ public static function validate(ZipValidationHelper $context, array $data): arra 'id' => ['nullable', 'int', $context->uniqueIdRule('book')], 'name' => ['required', 'string', 'min:1'], 'description_html' => ['nullable', 'string'], - 'cover' => ['nullable', 'string', $context->fileReferenceRule()], + 'cover' => ['nullable', 'string', $context->imageFileReferenceRule()], 'tags' => ['array'], 'pages' => ['array'], 'chapters' => ['array'], diff --git a/app/Exports/ZipExports/Models/ZipExportImage.php b/app/Exports/ZipExports/Models/ZipExportImage.php index 4c71af0c3a2..1432e6cf0d8 100644 --- a/app/Exports/ZipExports/Models/ZipExportImage.php +++ b/app/Exports/ZipExports/Models/ZipExportImage.php @@ -32,11 +32,10 @@ public function metadataOnly(): void public static function validate(ZipValidationHelper $context, array $data): array { - $acceptedImageTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']; $rules = [ 'id' => ['nullable', 'int', $context->uniqueIdRule('image')], 'name' => ['required', 'string', 'min:1'], - 'file' => ['required', 'string', $context->fileReferenceRule($acceptedImageTypes)], + 'file' => ['required', 'string', $context->imageFileReferenceRule()], 'type' => ['required', 'string', Rule::in(['gallery', 'drawio'])], ]; diff --git a/app/Exports/ZipExports/ZipExportFiles.php b/app/Exports/ZipExports/ZipExportFiles.php index 8f0a6bd4031..0386d8278f9 100644 --- a/app/Exports/ZipExports/ZipExportFiles.php +++ b/app/Exports/ZipExports/ZipExportFiles.php @@ -80,7 +80,7 @@ protected function getAllFileNames(): array } /** - * Extract each of the ZIP export tracked files. + * Extract each of the ZIP export-tracked files. * Calls the given callback for each tracked file, passing a temporary * file reference of the file contents, and the zip-local tracked reference. */ diff --git a/app/Exports/ZipExports/ZipImportRunner.php b/app/Exports/ZipExports/ZipImportRunner.php index 9fa7dec3afe..41e9038adea 100644 --- a/app/Exports/ZipExports/ZipImportRunner.php +++ b/app/Exports/ZipExports/ZipImportRunner.php @@ -48,7 +48,7 @@ public function __construct( * Returns the top-level entity item which was imported. * @throws ZipImportException */ - public function run(Import $import, ?Entity $parent = null): Entity + public function run(Import $import, Book|Chapter|null $parent = null): Entity { $zipPath = $this->getZipPath($import); $reader = new ZipExportReader($zipPath); @@ -96,7 +96,7 @@ public function run(Import $import, ?Entity $parent = null): Entity /** * Revert any files which have been stored during this import process. - * Considers files only, and avoids the database under the + * Considers files only and avoids the database under the * assumption that the database may already have been * reverted as part of a transaction rollback. */ @@ -129,7 +129,7 @@ protected function importBook(ZipExportBook $exportBook, ZipExportReader $reader $book = $this->bookRepo->create([ 'name' => $exportBook->name, 'description_html' => $exportBook->description_html ?? '', - 'image' => $exportBook->cover ? $this->zipFileToUploadedFile($exportBook->cover, $reader) : null, + 'image' => $exportBook->cover ? $this->zipFileToUploadedFile($exportBook->cover, $reader, true) : null, 'tags' => $this->exportTagsToInputArray($exportBook->tags), ]); @@ -227,18 +227,11 @@ protected function importAttachment(ZipExportAttachment $exportAttachment, Page protected function importImage(ZipExportImage $exportImage, Page $page, ZipExportReader $reader): Image { - $mime = $reader->sniffFileMime($exportImage->file); - $extension = explode('/', $mime)[1]; - - $file = $this->zipFileToUploadedFile($exportImage->file, $reader); + $file = $this->zipFileToUploadedFile($exportImage->file, $reader, true); $image = $this->imageService->saveNewFromUpload( $file, $exportImage->type, $page->id, - null, - null, - true, - $exportImage->name . '.' . $extension, ); $image->name = $exportImage->name; @@ -261,7 +254,7 @@ protected function exportTagsToInputArray(array $exportTags): array return $tags; } - protected function zipFileToUploadedFile(string $fileName, ZipExportReader $reader): UploadedFile + protected function zipFileToUploadedFile(string $fileName, ZipExportReader $reader, bool $forceExtensionFromMime = false): UploadedFile { if (!$reader->fileWithinSizeLimit($fileName)) { throw new ZipImportException([ @@ -277,7 +270,16 @@ protected function zipFileToUploadedFile(string $fileName, ZipExportReader $read $this->tempFilesToCleanup[] = $tempPath; - return new UploadedFile($tempPath, $fileName); + $intendedUploadName = $fileName; + if ($forceExtensionFromMime) { + $mime = $reader->sniffFileMime($fileName); + $extension = explode('/', $mime)[1]; + if (!str_ends_with(strtolower($intendedUploadName), '.' . $extension)) { + $intendedUploadName .= '.' . $extension; + } + } + + return new UploadedFile($tempPath, $intendedUploadName); } /** diff --git a/app/Exports/ZipExports/ZipValidationHelper.php b/app/Exports/ZipExports/ZipValidationHelper.php index fd9cd784472..2bcb5fb9747 100644 --- a/app/Exports/ZipExports/ZipValidationHelper.php +++ b/app/Exports/ZipExports/ZipValidationHelper.php @@ -3,6 +3,7 @@ namespace BookStack\Exports\ZipExports; use BookStack\Exports\ZipExports\Models\ZipExportModel; +use BookStack\Uploads\ImageService; use Illuminate\Validation\Factory; class ZipValidationHelper @@ -38,6 +39,11 @@ public function fileReferenceRule(array $acceptedMimes = []): ZipFileReferenceRu return new ZipFileReferenceRule($this, $acceptedMimes); } + public function imageFileReferenceRule(): ZipFileReferenceRule + { + return new ZipFileReferenceRule($this, ImageService::getSupportedMimeTypes()); + } + public function uniqueIdRule(string $type): ZipUniqueIdRule { return new ZipUniqueIdRule($this, $type); diff --git a/app/Http/Controller.php b/app/Http/Controller.php index 796505795e5..09c38ee6123 100644 --- a/app/Http/Controller.php +++ b/app/Http/Controller.php @@ -7,6 +7,7 @@ use BookStack\Exceptions\NotifyException; use BookStack\Facades\Activity; use BookStack\Permissions\Permission; +use BookStack\Users\Models\OwnableInterface; use Illuminate\Foundation\Bus\DispatchesJobs; use Illuminate\Foundation\Validation\ValidatesRequests; use Illuminate\Http\JsonResponse; @@ -80,7 +81,7 @@ protected function preventGuestAccess(): void /** * Check the current user's permissions against an ownable item otherwise throw an exception. */ - protected function checkOwnablePermission(string|Permission $permission, Model $ownable, string $redirectLocation = '/'): void + protected function checkOwnablePermission(string|Permission $permission, Model&OwnableInterface $ownable, string $redirectLocation = '/'): void { if (!userCan($permission, $ownable)) { $this->showPermissionError($redirectLocation); diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 00bf8cbe1c5..e7497e743c4 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -36,6 +36,7 @@ class Kernel extends HttpKernel \BookStack\Http\Middleware\CheckEmailConfirmed::class, \BookStack\Http\Middleware\RunThemeActions::class, \BookStack\Http\Middleware\Localization::class, + \BookStack\Http\Middleware\ClearPerRequestCaches::class, ], 'api' => [ \BookStack\Http\Middleware\ThrottleApiRequests::class, diff --git a/app/Http/Middleware/ClearPerRequestCaches.php b/app/Http/Middleware/ClearPerRequestCaches.php new file mode 100644 index 00000000000..5210062e7ce --- /dev/null +++ b/app/Http/Middleware/ClearPerRequestCaches.php @@ -0,0 +1,33 @@ +clearCaches(); + + return $next($request); + } + + protected function clearCaches(): void + { + $this->viewBlockManager->clearLocalCache(); + } +} diff --git a/app/Permissions/JointPermissionBuilder.php b/app/Permissions/JointPermissionBuilder.php index 94f18916d4a..101e3a00f29 100644 --- a/app/Permissions/JointPermissionBuilder.php +++ b/app/Permissions/JointPermissionBuilder.php @@ -70,7 +70,11 @@ public function rebuildForEntity(Entity $entity): void } if ($entity instanceof Chapter) { - foreach ($entity->pages as $page) { + $childPages = $entity->pages() + ->withTrashed() + ->select(['id', 'owned_by', 'book_id', 'chapter_id']) + ->get(); + foreach ($childPages as $page) { $entities[] = $page; } } @@ -101,6 +105,7 @@ public function rebuildForRole(Role $role) /** * Get a query for fetching a book with its children. + * @return Builder */ protected function bookFetchQuery(): Builder { @@ -117,9 +122,11 @@ protected function bookFetchQuery(): Builder /** * Build joint permissions for the given book and role combinations. + * @param EloquentCollection $books */ protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false): void { + /** @var EloquentCollection $entities */ $entities = clone $books; /** @var Book $book */ diff --git a/app/References/Reference.php b/app/References/Reference.php index df8a3a78932..602e19eab59 100644 --- a/app/References/Reference.php +++ b/app/References/Reference.php @@ -2,8 +2,8 @@ namespace BookStack\References; +use BookStack\App\Model; use BookStack\Permissions\Models\JointPermission; -use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphTo; diff --git a/app/References/ReferenceFetcher.php b/app/References/ReferenceFetcher.php index 8588c6e2c8e..0c76b46982c 100644 --- a/app/References/ReferenceFetcher.php +++ b/app/References/ReferenceFetcher.php @@ -22,6 +22,7 @@ public function __construct( */ public function getReferencesToEntity(Entity $entity, bool $withContents = false): Collection { + /** @var Collection $references */ $references = $this->queryReferencesToEntity($entity)->get(); $this->mixedEntityListLoader->loadIntoRelations($references->all(), 'from', false, $withContents); @@ -37,6 +38,9 @@ public function getReferenceCountToEntity(Entity $entity): int return $this->queryReferencesToEntity($entity)->count(); } + /** + * @return Builder + */ protected function queryReferencesToEntity(Entity $entity): Builder { $baseQuery = Reference::query() diff --git a/app/Search/SearchRunner.php b/app/Search/SearchRunner.php index d49cb439a6b..60d111d0839 100644 --- a/app/Search/SearchRunner.php +++ b/app/Search/SearchRunner.php @@ -4,6 +4,7 @@ use BookStack\Entities\EntityProvider; use BookStack\Entities\Models\Entity; +use BookStack\Entities\Models\EntityTable; use BookStack\Entities\Queries\EntityQueries; use BookStack\Entities\Tools\EntityHydrator; use BookStack\Permissions\PermissionApplicator; @@ -90,6 +91,8 @@ public function searchChapter(int $chapterId, string $searchString): Collection /** * Get a page of result data from the given query based on the provided page parameters. + * @param EloquentBuilder $query + * @return Collection */ protected function getPageOfDataFromQuery(EloquentBuilder $query, int $page, int $count): Collection { @@ -106,6 +109,7 @@ protected function getPageOfDataFromQuery(EloquentBuilder $query, int $page, int /** * Create a search query for an entity. * @param string[] $entityTypes + * @return EloquentBuilder */ protected function buildQuery(SearchOptions $searchOpts, array $entityTypes): EloquentBuilder { diff --git a/app/Settings/SettingService.php b/app/Settings/SettingService.php index e0b13618012..e3112e9c7d9 100644 --- a/app/Settings/SettingService.php +++ b/app/Settings/SettingService.php @@ -243,9 +243,9 @@ public function putForCurrentUser(string $key, string $value): bool /** * Convert a setting key into a user-specific key. */ - protected function userKey(string $userId, string $key = ''): string + protected function userKey(int $userId, string $key = ''): string { - return 'user:' . $userId . ':' . $key; + return 'user:' . strval($userId) . ':' . $key; } /** @@ -264,10 +264,18 @@ public function remove(string $key): void } } + /** + * Remove a user-specific setting from the database, for the current access user. + */ + public function removeForCurrentUser(string $key): void + { + $this->remove($this->userKey(user()->id, $key)); + } + /** * Delete settings for a given user id. */ - public function deleteUserSettings(string $userId): void + public function deleteUserSettings(int $userId): void { Setting::query() ->where('setting_key', 'like', $this->userKey($userId) . '%') diff --git a/app/Sorting/SortRule.php b/app/Sorting/SortRule.php index bf53365a201..ffe2d411534 100644 --- a/app/Sorting/SortRule.php +++ b/app/Sorting/SortRule.php @@ -38,21 +38,35 @@ public function setOperations(array $options): void $this->sequence = implode(',', $values); } + /** + * @inheritDoc + */ public function logDescriptor(): string { return "({$this->id}) {$this->name}"; } + /** + * Get the URL to where this rule can be managed. + */ public function getUrl(): string { return url("/settings/sorting/rules/{$this->id}"); } + /** + * Get the books which are specifically set to use this sort rule. + * @return HasMany + */ public function books(): HasMany { return $this->hasMany(Book::class, 'entity_container_data.sort_rule_id', 'id'); } + /** + * Get all the available sort rules, ordered by name, with the number of books using each. + * @return Collection + */ public static function allByName(): Collection { return static::query() diff --git a/app/Theming/ThemeEvents.php b/app/Theming/ThemeEvents.php index 511a9c1de7a..46fa7c2c08f 100644 --- a/app/Theming/ThemeEvents.php +++ b/app/Theming/ThemeEvents.php @@ -180,6 +180,15 @@ class ThemeEvents */ const THEME_REGISTER_VIEWS = 'theme_register_views'; + /** + * View blocks register event. + * Runs once a ViewBlockManager instance is available so that custom blocks can be registered + * for use within user-configurable layouts in the system. + * + * @param \BookStack\View\ViewBlockManager $manager + */ + const VIEW_BLOCKS_REGISTER = 'view_blocks_register'; + /** * Web before middleware action. * Runs before the request is handled but after all other middleware apart from those diff --git a/app/Uploads/AttachmentService.php b/app/Uploads/AttachmentService.php index dabd537292f..3de84bdd0a8 100644 --- a/app/Uploads/AttachmentService.php +++ b/app/Uploads/AttachmentService.php @@ -102,7 +102,7 @@ public function saveNewFromLink(string $name, string $link, int $page_id): Attac /** * Updates the ordering for a listing of attached files. */ - public function updateFileOrderWithinPage(array $attachmentOrder, string $pageId) + public function updateFileOrderWithinPage(array $attachmentOrder, int $pageId) { foreach ($attachmentOrder as $index => $attachmentId) { Attachment::query()->where('uploaded_to', '=', $pageId) diff --git a/app/Uploads/Base64UriMimeRule.php b/app/Uploads/Base64UriMimeRule.php new file mode 100644 index 00000000000..d49e6c1e0c2 --- /dev/null +++ b/app/Uploads/Base64UriMimeRule.php @@ -0,0 +1,47 @@ +translate(['mime' => $this->requiredMime]); + return; + } + + $imageData = base64_decode($imageDataEncoded); + if (empty($imageData)) { + $fail('validation.base64_uri_mime')->translate(['mime' => $this->requiredMime]); + return; + } + + $sniffer = new WebSafeMimeSniffer(); + $mime = $sniffer->sniff($imageData); + + if ($mime !== $this->requiredMime) { + $fail('validation.base64_uri_mime')->translate(['mime' => $this->requiredMime]); + } + } +} diff --git a/app/Uploads/Controllers/AttachmentApiController.php b/app/Uploads/Controllers/AttachmentApiController.php index 2448b79b5d5..f540833f0d4 100644 --- a/app/Uploads/Controllers/AttachmentApiController.php +++ b/app/Uploads/Controllers/AttachmentApiController.php @@ -24,7 +24,7 @@ public function __construct( /** * Get a listing of attachments visible to the user. - * The external property indicates whether the attachment is simple a link. + * The external property indicates whether the attachment is simply a link. * A false value for the external property would indicate a file upload. */ public function list() @@ -39,7 +39,7 @@ public function list() * An uploaded_to value must be provided containing an ID of the page * that this upload will be related to. * - * If you're uploading a file the POST data should be provided via + * If you're uploading a file, the POST data should be provided via * a multipart/form-data type request instead of JSON. * * @throws ValidationException @@ -71,7 +71,7 @@ public function create(Request $request) } /** - * Get the details & content of a single attachment of the given ID. + * Get the details and content of a single attachment of the given ID. * The attachment link or file content is provided via a 'content' property. * For files the content will be base64 encoded. * @@ -120,7 +120,7 @@ public function read(string $id) /** * Update the details of a single attachment. - * As per the create endpoint, if a file is being provided as the attachment content + * As per the create endpoint, if a file is being provided as the attachment content, * the request should be formatted as a multipart/form-data request instead of JSON. * * @throws ValidationException @@ -134,9 +134,12 @@ public function update(Request $request, string $id) $page = $attachment->page; if ($requestData['uploaded_to'] ?? false) { - $pageId = $request->input('uploaded_to'); - $page = $this->pageQueries->findVisibleByIdOrFail($pageId); - $attachment->uploaded_to = $requestData['uploaded_to']; + $pageId = intval($requestData['uploaded_to']); + if ($pageId !== $page->id) { + $this->checkOwnablePermission(Permission::PageUpdate, $page); + $page = $this->pageQueries->findVisibleByIdOrFail($pageId); + $attachment->uploaded_to = $pageId; + } } $this->checkOwnablePermission(Permission::PageView, $page); diff --git a/app/Uploads/Controllers/DrawioImageController.php b/app/Uploads/Controllers/DrawioImageController.php index 53ae7900408..3c2d9aa589c 100644 --- a/app/Uploads/Controllers/DrawioImageController.php +++ b/app/Uploads/Controllers/DrawioImageController.php @@ -6,6 +6,7 @@ use BookStack\Exceptions\ImageUploadException; use BookStack\Http\Controller; use BookStack\Permissions\Permission; +use BookStack\Uploads\Base64UriMimeRule; use BookStack\Uploads\ImageRepo; use BookStack\Uploads\ImageResizer; use BookStack\Util\OutOfMemoryHandler; @@ -57,7 +58,7 @@ public function create(Request $request) { $this->checkPermission(Permission::ImageCreateAll); $validated = $this->validate($request, [ - 'image' => ['required', 'string'], + 'image' => ['required', 'string', new Base64UriMimeRule('image/png')], 'uploaded_to' => ['required', 'integer'], ]); diff --git a/app/Uploads/Controllers/ImageController.php b/app/Uploads/Controllers/ImageController.php index da67639c16d..164d10e6bd6 100644 --- a/app/Uploads/Controllers/ImageController.php +++ b/app/Uploads/Controllers/ImageController.php @@ -153,7 +153,7 @@ public function rebuildThumbnails(string $id) } /** - * Check related page permission and ensure type is drawio or gallery. + * Check related page permission and ensure the type is drawio or gallery. * @throws NotifyException */ protected function checkImagePermission(Image $image): void diff --git a/app/Uploads/ImageService.php b/app/Uploads/ImageService.php index ed640913ec3..1a66be5ff4a 100644 --- a/app/Uploads/ImageService.php +++ b/app/Uploads/ImageService.php @@ -4,6 +4,8 @@ use BookStack\Entities\Queries\EntityQueries; use BookStack\Exceptions\ImageUploadException; +use BookStack\Exceptions\PrettyException; +use BookStack\Http\DownloadResponseFactory; use Exception; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -34,9 +36,8 @@ public function saveNewFromUpload( ?int $resizeWidth = null, ?int $resizeHeight = null, bool $keepRatio = true, - string $imageName = '', ): Image { - $imageName = $imageName ?: $uploadedFile->getClientOriginalName(); + $imageName = $uploadedFile->getClientOriginalName(); $imageData = file_get_contents($uploadedFile->getRealPath()); if ($resizeWidth !== null || $resizeHeight !== null) { @@ -368,7 +369,18 @@ public function streamImageFromStorageResponse(string $imageType, string $path): { $disk = $this->storage->getDisk($imageType); - return $disk->response($path); + $stream = $disk->stream($path); + $fileSize = $disk->size($path); + $imageName = basename($path); + $downloadResponseFactory = new DownloadResponseFactory(request()); + $response = $downloadResponseFactory->streamedInline($stream, $imageName, $fileSize); + + $contentType = $response->headers->get('Content-Type'); + if (!str_starts_with($contentType, 'image/')) { + throw new PrettyException('Invalid non-image file type when streaming from storage', 415); + } + + return $response; } /** @@ -378,6 +390,14 @@ public function streamImageFromStorageResponse(string $imageType, string $path): */ public static function isExtensionSupported(string $extension): bool { - return in_array($extension, static::$supportedExtensions); + return in_array(strtolower($extension), static::$supportedExtensions); + } + + /** + * Get all mime-types for images formats which BookStack supports. + */ + public static function getSupportedMimeTypes(): array + { + return array_map(fn($ext) => 'image/' . $ext, static::$supportedExtensions); } } diff --git a/app/Uploads/ImageStorage.php b/app/Uploads/ImageStorage.php index abf2b429b1c..43f448ff1bb 100644 --- a/app/Uploads/ImageStorage.php +++ b/app/Uploads/ImageStorage.php @@ -2,6 +2,7 @@ namespace BookStack\Uploads; +use BookStack\Exceptions\ImageUploadException; use Illuminate\Filesystem\FilesystemManager; use Illuminate\Support\Str; @@ -45,6 +46,7 @@ public function usingSecureImages(): bool /** * Clean up an image file name to be both URL and storage safe. + * @throws ImageUploadException */ public function cleanImageFileName(string $name): string { @@ -54,6 +56,10 @@ public function cleanImageFileName(string $name): string $name = implode('-', $nameParts); $name = Str::slug($name); + if (!ImageService::isExtensionSupported($extension)) { + throw new ImageUploadException('Non supported image extension used when storing image: ' . $extension); + } + if (strlen($name) === 0) { $name = Str::random(10); } diff --git a/app/Uploads/ImageStorageDisk.php b/app/Uploads/ImageStorageDisk.php index 36d6721de6c..ad314c5c89b 100644 --- a/app/Uploads/ImageStorageDisk.php +++ b/app/Uploads/ImageStorageDisk.php @@ -68,6 +68,14 @@ public function stream(string $path): mixed return $this->filesystem->readStream($this->adjustPathForDisk($path)); } + /** + * Get the size of the file at the given path. + */ + public function size(string $path): int + { + return $this->filesystem->size($this->adjustPathForDisk($path)); + } + /** * Save the given image data at the given path. Can choose to set * the image as public which will update its visibility after saving. diff --git a/app/Users/Controllers/RoleApiController.php b/app/Users/Controllers/RoleApiController.php index 93ecc549bb4..6f0c59651a5 100644 --- a/app/Users/Controllers/RoleApiController.php +++ b/app/Users/Controllers/RoleApiController.php @@ -87,7 +87,7 @@ public function create(Request $request) */ public function read(string $id) { - $role = $this->permissionsRepo->getRoleById($id); + $role = $this->permissionsRepo->getRoleById(intval($id)); $this->singleFormatter($role); return response()->json($role); diff --git a/app/Users/Controllers/RoleController.php b/app/Users/Controllers/RoleController.php index b9f06dace84..56a6c7416ca 100644 --- a/app/Users/Controllers/RoleController.php +++ b/app/Users/Controllers/RoleController.php @@ -94,7 +94,7 @@ public function store(Request $request) public function edit(string $id) { $this->checkPermission(Permission::UserRolesManage); - $role = $this->permissionsRepo->getRoleById($id); + $role = $this->permissionsRepo->getRoleById(intval($id)); $this->setPageTitle(trans('settings.role_edit')); @@ -129,7 +129,7 @@ public function update(Request $request, string $id) public function showDelete(string $id) { $this->checkPermission(Permission::UserRolesManage); - $role = $this->permissionsRepo->getRoleById($id); + $role = $this->permissionsRepo->getRoleById(intval($id)); $roles = $this->permissionsRepo->getAllRolesExcept($role); $blankRole = $role->newInstance(['display_name' => trans('settings.role_delete_no_migration')]); $roles->prepend($blankRole); @@ -151,7 +151,7 @@ public function delete(Request $request, string $id) try { $migrateRoleId = intval($request->input('migrate_role_id') ?: "0"); - $this->permissionsRepo->deleteRole($id, $migrateRoleId); + $this->permissionsRepo->deleteRole(intval($id), $migrateRoleId); } catch (PermissionsException $e) { $this->showErrorNotification($e->getMessage()); diff --git a/app/Users/Controllers/UserAccountController.php b/app/Users/Controllers/UserAccountController.php index 21816d5b89b..71d8ca78dfc 100644 --- a/app/Users/Controllers/UserAccountController.php +++ b/app/Users/Controllers/UserAccountController.php @@ -10,6 +10,7 @@ use BookStack\Settings\UserShortcutMap; use BookStack\Uploads\ImageRepo; use BookStack\Users\UserRepo; +use BookStack\View\ViewBlockManager; use Closure; use Illuminate\Http\Request; use Illuminate\Validation\Rules\Password; @@ -159,6 +160,37 @@ public function updateNotifications(Request $request) return redirect('/my-account/notifications'); } + /** + * Show the view for the "Interface Preferences" user account area. + */ + public function showInterface(ViewBlockManager $viewBlockManager) + { + $this->setPageTitle(trans('preferences.interface')); + + return view('users.account.interface', [ + 'category' => 'interface', + 'namedLocations' => $viewBlockManager->getNamedLocations(), + ]); + } + + /** + * Handle the submission of the interface preferences form. + */ + public function updateInterface(Request $request) + { + $this->preventAccessInDemoMode(); + + $user = user(); + $validated = $this->validate($request, [ + 'language' => ['string', 'max:15', 'alpha_dash'], + 'display_mode' => ['string', 'max:15', 'alpha_dash'], + ]); + + $this->userRepo->update($user, $validated, userCan(Permission::UsersManage)); + + return redirect('/my-account/interface'); + } + /** * Show the view for the "Access & Security" account options. */ diff --git a/app/Users/Controllers/UserApiController.php b/app/Users/Controllers/UserApiController.php index ebc17e262f3..07eefadc7b1 100644 --- a/app/Users/Controllers/UserApiController.php +++ b/app/Users/Controllers/UserApiController.php @@ -110,7 +110,7 @@ public function create(Request $request) */ public function read(string $id) { - $user = $this->userRepo->getById($id); + $user = $this->userRepo->getById(intval($id)); $this->singleFormatter($user); return response()->json($user); @@ -124,8 +124,8 @@ public function read(string $id) */ public function update(Request $request, string $id) { - $data = $this->validate($request, $this->rules($id)['update']); - $user = $this->userRepo->getById($id); + $data = $this->validate($request, $this->rules(intval($id))['update']); + $user = $this->userRepo->getById(intval($id)); $this->userRepo->update($user, $data, userCan(Permission::UsersManage)); $this->singleFormatter($user); @@ -140,7 +140,7 @@ public function update(Request $request, string $id) */ public function delete(Request $request, string $id) { - $user = $this->userRepo->getById($id); + $user = $this->userRepo->getById(intval($id)); $newOwnerId = $request->input('migrate_ownership_id', null); $this->userRepo->destroy($user, $newOwnerId); diff --git a/app/Users/UserRepo.php b/app/Users/UserRepo.php index 1643756c8d1..e8b3c27e030 100644 --- a/app/Users/UserRepo.php +++ b/app/Users/UserRepo.php @@ -122,7 +122,7 @@ public function create(array $data, bool $sendInvite = false): User /** * Update the given user with the given data, but do not create an activity. * - * @param array{name: ?string, email: ?string, external_auth_id: ?string, password: ?string, roles: ?array, language: ?string} $data + * @param array{name: ?string, email: ?string, external_auth_id: ?string, password: ?string, roles: ?array, language: ?string, display_mode: ?string} $data * * @throws UserUpdateException */ @@ -153,6 +153,11 @@ public function updateWithoutActivity(User $user, array $data, bool $manageUsers setting()->putUser($user, 'language', $data['language']); } + if (!empty($data['display_mode'])) { + $value = $data['display_mode'] === 'dark' ? 'true' : 'false'; + setting()->putUser($user, 'dark-mode-enabled', $value); + } + $user->save(); return $user; diff --git a/app/Util/DatabaseTransaction.php b/app/Util/DatabaseTransaction.php index e36bd2ef310..2e57b6479a1 100644 --- a/app/Util/DatabaseTransaction.php +++ b/app/Util/DatabaseTransaction.php @@ -3,6 +3,7 @@ namespace BookStack\Util; use Closure; +use Illuminate\Database\Connection; use Illuminate\Support\Facades\DB; use Throwable; @@ -24,7 +25,7 @@ class DatabaseTransaction { /** - * @param (Closure(static): TReturn) $callback + * @param (Closure(Connection): TReturn) $callback */ public function __construct( protected Closure $callback diff --git a/app/Util/HtmlPurifier/ConfiguredHtmlPurifier.php b/app/Util/HtmlPurifier/ConfiguredHtmlPurifier.php index c4dd91778c6..77fa304e6f7 100644 --- a/app/Util/HtmlPurifier/ConfiguredHtmlPurifier.php +++ b/app/Util/HtmlPurifier/ConfiguredHtmlPurifier.php @@ -5,6 +5,7 @@ use BookStack\App\AppVersion; use BookStack\Util\HtmlPurifier\Filters\UriEnsureScheme; use BookStack\Util\HtmlPurifier\Filters\UriLimitFileProtocolToAnchors; +use BookStack\Util\HtmlPurifier\Filters\UriMakeAbsolute; use BookStack\Util\UrlFilter; use HTMLPurifier; use HTMLPurifier_Config; @@ -12,6 +13,7 @@ use HTMLPurifier_HTML5Config; use HTMLPurifier_HTMLDefinition; use HTMLPurifier_URIDefinition; +use HTMLPurifier_URISchemeRegistry; /** * Provides a configured HTML Purifier instance. @@ -40,6 +42,9 @@ public function __construct() $this->configureHtmlDefinition($htmlDef); } + $registry = HTMLPurifier_URISchemeRegistry::instance(); + $registry->register('sftp', new SftpUriScheme()); + $uriDef = $config->getDefinition('URI', true, true); if ($uriDef instanceof HTMLPurifier_URIDefinition) { $this->configureUriDefinition($uriDef); @@ -94,7 +99,7 @@ protected function setConfig(HTMLPurifier_Config $config, string $cachePath): vo $defaultScheme = str_starts_with(url('/'), 'http:') ? 'http' : 'https'; $config->set('URI.SafeIframeRegexp', '%^(http://|https://|//)%'); $config->set('URI.AllowedSchemes', $allowedSchemesSetting); - $config->set('URI.MakeAbsolute', true); + $config->set('URI.MakeAbsolute', false); // We register our own MakeAbsolute filter below $config->set('URI.DefaultScheme', $defaultScheme); $config->set('URI.Base', url('/')); @@ -170,6 +175,7 @@ protected function configureUriDefinition(HTMLPurifier_URIDefinition $definition { $definition->registerFilter(new UriLimitFileProtocolToAnchors()); $definition->registerFilter(new UriEnsureScheme()); + $definition->registerFilter(new UriMakeAbsolute()); } public function purify(string $html): string diff --git a/app/Util/HtmlPurifier/Filters/UriEnsureScheme.php b/app/Util/HtmlPurifier/Filters/UriEnsureScheme.php index 6b4651b69c3..74380701a8e 100644 --- a/app/Util/HtmlPurifier/Filters/UriEnsureScheme.php +++ b/app/Util/HtmlPurifier/Filters/UriEnsureScheme.php @@ -31,7 +31,9 @@ public function filter(&$uri, $config, $context): bool $defaultScheme = $def->defaultScheme ?? ''; if (empty($uri->scheme) && $defaultScheme) { - $uri->scheme = $defaultScheme; + if (!str_starts_with($uri->toString(), '#')) { + $uri->scheme = $defaultScheme; + } } return true; diff --git a/app/Util/HtmlPurifier/Filters/UriMakeAbsolute.php b/app/Util/HtmlPurifier/Filters/UriMakeAbsolute.php new file mode 100644 index 00000000000..fc005a7408a --- /dev/null +++ b/app/Util/HtmlPurifier/Filters/UriMakeAbsolute.php @@ -0,0 +1,39 @@ +toString(), '#')) { + return true; + } + + return parent::filter($uri, $config, $context); + } +} diff --git a/app/Util/HtmlPurifier/SftpUriScheme.php b/app/Util/HtmlPurifier/SftpUriScheme.php new file mode 100644 index 00000000000..889cb7f38cc --- /dev/null +++ b/app/Util/HtmlPurifier/SftpUriScheme.php @@ -0,0 +1,10 @@ +middleware(function (Request $request, Closure $next) { + $this->preventGuestAccess(); + return $next($request); + }); + } + + /** + * Start editing the layout for a specific location. + */ + public function edit(string $location) + { + $namedLocations = $this->viewBlocks->getNamedLocations(); + $locationName = $namedLocations[$location] ?? $location; + $blocks = $this->viewBlocks->getForLocationForCurrentUser($location); + + $this->setPageTitle(trans('preferences.layout_edit')); + + return view('settings.layouts.edit', [ + 'location' => $location, + 'locationName' => $locationName, + 'namedLocations' => $namedLocations, + 'blocks' => $blocks, + ]); + } + + /** + * Update the layout for a specific location for the current user. + */ + public function update(string $location, Request $request) + { + $data = $this->validate($request, [ + 'layout' => ['required', 'string', 'json'], + ]); + + $layoutData = json_decode($data['layout'], true, 5); + if (is_array($layoutData)) { + $this->viewBlocks->updatePreferencesFromIdPositionMap($location, $layoutData); + } + + $this->showSuccessNotification(trans('preferences.layout_update_success')); + + return redirect("/layouts/{$location}"); + } + + /** + * Reset the layout for a specific location, for the current user, + * back to system defaults by removing any user-specific preferences. + */ + public function reset(string $location) + { + $this->viewBlockPreferences->clearForLocation($location); + + $this->showSuccessNotification(trans('preferences.layout_reset_success')); + + return redirect("/layouts/{$location}"); + } +} diff --git a/app/View/ViewBlock.php b/app/View/ViewBlock.php new file mode 100644 index 00000000000..f4ec62be45d --- /dev/null +++ b/app/View/ViewBlock.php @@ -0,0 +1,34 @@ +[]>> + */ + protected static array $defaults = [ + 'home-default' => [ + 'left' => [ + ViewBlocks\HomeRecentDrafts::class, + ViewBlocks\HomeRecentlyViewedOrRecentBooks::class, + ], + 'center' => [ + ViewBlocks\HomeTopFavourites::class, + ViewBlocks\HomeRecentlyUpdatedPages::class, + ], + 'right' => [ + ViewBlocks\HomeRecentActivity::class, + ], + ], + 'home-non-default' => [ + 'left' => [ + ViewBlocks\HomeRecentDrafts::class, + ViewBlocks\HomeTopFavourites::class, + ViewBlocks\HomeRecentlyViewedOrRecentBooks::class, + ViewBlocks\HomeRecentlyUpdatedPages::class, + ViewBlocks\HomeRecentActivity::class, + ], + 'right' => [ + ViewBlocks\HomeActions::class, + ], + ], + 'shelves-index' => [ + 'left' => [ + ViewBlocks\ShelvesIndexRecents::class, + ViewBlocks\ShelvesIndexPopular::class, + ViewBlocks\ShelvesIndexNew::class, + ], + 'right' => [ + ViewBlocks\ShelvesIndexActions::class, + ], + ], + 'shelves-show' => [ + 'left' => [ + ViewBlocks\ShelvesShowTags::class, + ViewBlocks\ShelvesShowDetails::class, + ViewBlocks\ShelvesShowActivity::class, + ], + 'right' => [ + ViewBlocks\ShelvesShowActions::class, + ], + ], + 'books-index' => [ + 'left' => [ + ViewBlocks\BooksIndexRecents::class, + ViewBlocks\BooksIndexPopular::class, + ViewBlocks\BooksIndexNew::class, + ], + 'right' => [ + ViewBlocks\BooksIndexActions::class, + ], + ], + 'books-show' => [ + 'left' => [ + ViewBlocks\BooksShowSearchForm::class, + ViewBlocks\BooksShowTags::class, + ViewBlocks\BooksShowShelves::class, + ViewBlocks\BooksShowActivity::class, + ], + 'right' => [ + ViewBlocks\BooksShowDetails::class, + ViewBlocks\BooksShowActions::class, + ], + ], + 'chapters-show' => [ + 'left' => [ + ViewBlocks\ChaptersShowSearchForm::class, + ViewBlocks\ChaptersShowTags::class, + ViewBlocks\ChaptersShowBookTree::class, + ], + 'right' => [ + ViewBlocks\ChaptersShowDetails::class, + ViewBlocks\ChaptersShowActions::class, + ], + ], + 'pages-show' => [ + 'left' => [ + ViewBlocks\PagesShowTags::class, + ViewBlocks\PagesShowAttachments::class, + ViewBlocks\PagesShowPageNav::class, + ViewBlocks\PagesShowBookTree::class, + ], + 'right' => [ + ViewBlocks\PagesShowDetails::class, + ViewBlocks\PagesShowActions::class, + ], + ], + ]; + + /** + * Get the default view blocks for the given location. + */ + public static function getForLocation(string $location): array + { + return self::$defaults[$location] ?? []; + } + + /** + * Get the locations for all default blocks. + * @return string[] + */ + public static function getLocations(): array + { + return array_keys(self::$defaults); + } + + public static function getLocationLabels(): array + { + return [ + 'home-default' => trans('common.homepage'), + 'home-non-default' => trans('common.homepage'), + 'shelves-index' => trans('entities.shelves'), + 'shelves-show' => trans('entities.shelf'), + 'books-index' => trans('entities.books'), + 'books-show' => trans('entities.book'), + 'chapters-show' => trans('entities.chapter'), + 'pages-show' => trans('entities.page'), + ]; + } +} diff --git a/app/View/ViewBlockInterface.php b/app/View/ViewBlockInterface.php new file mode 100644 index 00000000000..3d05d34d8b0 --- /dev/null +++ b/app/View/ViewBlockInterface.php @@ -0,0 +1,29 @@ + + */ + public function withData(array $viewData): array; +} diff --git a/app/View/ViewBlockManager.php b/app/View/ViewBlockManager.php new file mode 100644 index 00000000000..e18b68715f8 --- /dev/null +++ b/app/View/ViewBlockManager.php @@ -0,0 +1,214 @@ +[]>> + */ + protected array $blocksByLocationAndPosition = []; + + /** + * @var array[]>> + */ + protected array $locationBlockCache = []; + + /** + * Register a block type to be displayed at the given location and position. + * @param class-string $blockClass + */ + public function register(string $location, string $defaultPosition, string $blockClass): void + { + if (!isset($this->blocksByLocationAndPosition[$location])) { + $this->blocksByLocationAndPosition[$location] = []; + } + + if (!isset($this->blocksByLocationAndPosition[$location][$defaultPosition])) { + $this->blocksByLocationAndPosition[$location][$defaultPosition] = []; + } + + // @phpstan-ignore-next-line + if (!is_a($blockClass, ViewBlockInterface::class, true)) { + throw new \InvalidArgumentException('When registering a view block, the block class must implement ViewBlockInterface'); + } + + $this->blocksByLocationAndPosition[$location][$defaultPosition][] = $blockClass; + } + + /** + * Get all blocks registered for a given location and position, considering the + * preferences for the current user. + * @return ViewBlockInterface[] + * @throws BindingResolutionException + */ + public function getInstancesForLocationAndPositionForCurrentUser(string $location, string $position): array + { + $key = $location; + if (isset($this->locationBlockCache[$key])) { + $blocks = $this->locationBlockCache[$key][$position] ?? []; + return $this->blocksToInstances($blocks); + } + + $forLocation = $this->getForLocationForCurrentUser($location); + $this->locationBlockCache[$key] = $forLocation; + + $blocks = $forLocation[$position] ?? []; + return $this->blocksToInstances($blocks); + } + + /** + * Create instances of the given block classes. + * @param class-string[] $blocks + * @return ViewBlockInterface[] + * @throws BindingResolutionException + */ + protected function blocksToInstances(array $blocks): array + { + return array_map(fn (string $blockClass) => app()->make($blockClass), $blocks); + } + + /** + * Get all blocks registered for a given location, as sets of arrays + * keyed by position. + * @return array[]> + */ + protected function getForLocation(string $location): array + { + $defaults = ViewBlockDefaults::getForLocation($location); + $registered = $this->blocksByLocationAndPosition[$location] ?? []; + return array_merge_recursive($defaults, $registered); + } + + /** + * Get all blocks registered for a given location, as sets of arrays + * keyed by position, for the current user. + * Same as above but with user-specific preferences applied. + * @return array>> + * @throws BindingResolutionException + */ + public function getForLocationForCurrentUser(string $location): array + { + $forLocation = $this->getForLocation($location); + $userBlocksByPosition = $this->preferences->getIdByPositionMap($location); + if (empty($userBlocksByPosition)) { + return $forLocation; + } + + $results = []; + $blocksById = $this->blocksByPositionToIdMap($forLocation); + $idPositionMap = $this->blocksByPositionToIdPositionMap($forLocation); + $locations = array_keys($forLocation); + $locations[] = 'unused'; + + // Add based on user preferences + foreach ($locations as $position) { + $userBlockIds = $userBlocksByPosition[$position] ?? []; + $results[$position] = []; + foreach ($userBlockIds as $blockId) { + $block = $blocksById[$blockId] ?? null; + if ($block) { + $results[$position][] = $block; + unset($blocksById[$blockId]); + } + } + } + + // Add remaining blocks based on their default locations + foreach ($blocksById as $block) { + $position = $idPositionMap[$block::getId()] ?? 'unused'; + $results[$position][] = $block; + } + + return $results; + } + + /** + * Get the names of all locations where blocks are registered. + * Returns an array where the keys are location strings, and the + * values are translated labels for that location. + * @return array + */ + public function getNamedLocations(): array + { + $labels = ViewBlockDefaults::getLocationLabels(); + $defaults = ViewBlockDefaults::getLocations(); + $registered = array_keys($this->blocksByLocationAndPosition); + $merged = array_unique(array_merge($defaults, $registered)); + + $results = []; + foreach ($merged as $location) { + $results[$location] = $labels[$location] ?? $location; + } + + $usingDefaultHome = setting('app-homepage-type') === 'default'; + $toIgnore = $usingDefaultHome ? 'home-non-default' : 'home-default'; + unset($results[$toIgnore]); + + return $results; + } + + + /** + * Update user preferences for a given location to match the given layout data map. + * @param array $layoutData + * @throws BindingResolutionException + */ + public function updatePreferencesFromIdPositionMap(string $location, array $layoutData): void + { + $this->preferences->storeByIdPositionMap( + $location, + $layoutData, + $this->getForLocation($location), + ); + } + + /** + * Clear the local user-specific cache of blocks. + * The cache only needs to exist for the current request time since its purpose is to + * avoid duplicate loading across views. + */ + public function clearLocalCache(): void + { + $this->locationBlockCache = []; + } + + /** + * Convert a blocksByPosition array into a map of block IDs to blocks. + * @param array[]> $blocksByPosition + * @return array> + */ + protected function blocksByPositionToIdMap(array $blocksByPosition): array + { + $map = []; + foreach ($blocksByPosition as $position => $blocks) { + foreach ($blocks as $block) { + $map[$block::getId()] = $block; + } + } + return $map; + } + + /** + * Convert a blocksByPosition array into a map of block IDs to their positions. + * @param array[]> $blocksByPosition + * @return array + */ + protected function blocksByPositionToIdPositionMap(array $blocksByPosition): array + { + $map = []; + foreach ($blocksByPosition as $position => $blocks) { + foreach ($blocks as $block) { + $map[$block::getId()] = $position; + } + } + return $map; + } +} diff --git a/app/View/ViewBlockPreferences.php b/app/View/ViewBlockPreferences.php new file mode 100644 index 00000000000..247c3b16c52 --- /dev/null +++ b/app/View/ViewBlockPreferences.php @@ -0,0 +1,95 @@ + ['block-id-1', 'block-id-2'], + * 'position-2' => ['block-id-3'], + * ] + * @param array $layoutData + * @param array[]> $validBlocksByPosition + * @throws BindingResolutionException + */ + public function storeByIdPositionMap( + string $location, + array $layoutData, + array $validBlocksByPosition, + ): void { + $validIds = $this->extractValidBlockIds($validBlocksByPosition); + $validPositions = array_keys($validBlocksByPosition); + $validPositions[] = 'unused'; + + // Ignore updates for invalid/unknown locations + if (empty($validBlocksByPosition)) { + return; + } + + /** @var array $validatedLayoutData */ + $validatedLayoutData = []; + + foreach ($layoutData as $position => $blockIds) { + // @phpstan-ignore-next-line + if (!in_array($position, $validPositions) || !is_array($blockIds)) { + continue; + } + + $validatedLayoutData[$position] = array_filter($blockIds, function ($id) use ($validIds) { + return is_string($id) && in_array($id, $validIds); + }); + } + + $settingKey = $this->getSettingKey($location); + setting()->putForCurrentUser($settingKey, json_encode($validatedLayoutData)); + } + + /** + * Clear the view block preferences for a given location for the current user. + */ + public function clearForLocation(string $location): void + { + $settingKey = $this->getSettingKey($location); + setting()->removeForCurrentUser($settingKey); + } + + /** + * Get the layout data for a given location. + * Provides arrays of block ids keyed by position. + * @return array + */ + public function getIdByPositionMap(string $location): array + { + $settingKey = $this->getSettingKey($location); + $layoutData = setting()->getForCurrentUser($settingKey, '{}'); + return json_decode($layoutData, true) ?? []; + } + + protected function getSettingKey(string $location): string + { + return 'view-layout#' . $location; + } + + /** + * @param array[]> $blocksByPosition + * @return string[] + */ + protected function extractValidBlockIds(array $blocksByPosition): array + { + $ids = []; + + foreach ($blocksByPosition as $blocks) { + foreach ($blocks as $block) { + $ids[] = $block::getId(); + } + } + + return array_unique($ids); + } +} diff --git a/app/View/ViewBlocks/BooksIndexActions.php b/app/View/ViewBlocks/BooksIndexActions.php new file mode 100644 index 00000000000..83327d63dbd --- /dev/null +++ b/app/View/ViewBlocks/BooksIndexActions.php @@ -0,0 +1,20 @@ + $viewData['view'], + ]; + } +} diff --git a/app/View/ViewBlocks/BooksIndexNew.php b/app/View/ViewBlocks/BooksIndexNew.php new file mode 100644 index 00000000000..daab45a4633 --- /dev/null +++ b/app/View/ViewBlocks/BooksIndexNew.php @@ -0,0 +1,31 @@ +queries->visibleForList() + ->orderBy('created_at', 'desc') + ->take(4) + ->get(); + + return [ + 'new' => $new, + ]; + } +} diff --git a/app/View/ViewBlocks/BooksIndexPopular.php b/app/View/ViewBlocks/BooksIndexPopular.php new file mode 100644 index 00000000000..bfaee41e8dd --- /dev/null +++ b/app/View/ViewBlocks/BooksIndexPopular.php @@ -0,0 +1,26 @@ + $this->queries->popularForList()->take(4)->get(), + ]; + } +} diff --git a/app/View/ViewBlocks/BooksIndexRecents.php b/app/View/ViewBlocks/BooksIndexRecents.php new file mode 100644 index 00000000000..48abeb48653 --- /dev/null +++ b/app/View/ViewBlocks/BooksIndexRecents.php @@ -0,0 +1,31 @@ +isGuest()) { + $recents = $this->queries->recentlyViewedForCurrentUser()->take(4)->get(); + } + + return [ + 'recents' => $recents, + ]; + } +} diff --git a/app/View/ViewBlocks/BooksShowActions.php b/app/View/ViewBlocks/BooksShowActions.php new file mode 100644 index 00000000000..c7312f2851f --- /dev/null +++ b/app/View/ViewBlocks/BooksShowActions.php @@ -0,0 +1,26 @@ + $book, + 'watchOptions' => new UserEntityWatchOptions(user(), $book), + ]; + } +} diff --git a/app/View/ViewBlocks/BooksShowActivity.php b/app/View/ViewBlocks/BooksShowActivity.php new file mode 100644 index 00000000000..14943bb8d25 --- /dev/null +++ b/app/View/ViewBlocks/BooksShowActivity.php @@ -0,0 +1,30 @@ + $this->activityQueries->entityActivity($book, 20, 1), + ]; + } +} diff --git a/app/View/ViewBlocks/BooksShowDetails.php b/app/View/ViewBlocks/BooksShowDetails.php new file mode 100644 index 00000000000..582aec8b922 --- /dev/null +++ b/app/View/ViewBlocks/BooksShowDetails.php @@ -0,0 +1,34 @@ +referenceFetcher->getReferenceCountToEntity($book); + + return [ + 'book' => $book, + 'watchOptions' => new UserEntityWatchOptions(user(), $book), + 'referenceCount' => $referenceCount, + ]; + } +} diff --git a/app/View/ViewBlocks/BooksShowSearchForm.php b/app/View/ViewBlocks/BooksShowSearchForm.php new file mode 100644 index 00000000000..2fe099047d0 --- /dev/null +++ b/app/View/ViewBlocks/BooksShowSearchForm.php @@ -0,0 +1,20 @@ + trans('entities.books_search_this'), + ]; + } +} diff --git a/app/View/ViewBlocks/BooksShowShelves.php b/app/View/ViewBlocks/BooksShowShelves.php new file mode 100644 index 00000000000..590311c7f20 --- /dev/null +++ b/app/View/ViewBlocks/BooksShowShelves.php @@ -0,0 +1,25 @@ +shelves()->scopes('visible')->get(); + + return [ + 'shelves' => $shelves, + ]; + } +} diff --git a/app/View/ViewBlocks/BooksShowTags.php b/app/View/ViewBlocks/BooksShowTags.php new file mode 100644 index 00000000000..0448cd970d6 --- /dev/null +++ b/app/View/ViewBlocks/BooksShowTags.php @@ -0,0 +1,20 @@ + $viewData['book'], + ]; + } +} diff --git a/app/View/ViewBlocks/ChaptersShowActions.php b/app/View/ViewBlocks/ChaptersShowActions.php new file mode 100644 index 00000000000..1beb9c591a4 --- /dev/null +++ b/app/View/ViewBlocks/ChaptersShowActions.php @@ -0,0 +1,26 @@ + $chapter, + 'watchOptions' => new UserEntityWatchOptions(user(), $chapter), + ]; + } +} diff --git a/app/View/ViewBlocks/ChaptersShowBookTree.php b/app/View/ViewBlocks/ChaptersShowBookTree.php new file mode 100644 index 00000000000..03f36862d59 --- /dev/null +++ b/app/View/ViewBlocks/ChaptersShowBookTree.php @@ -0,0 +1,25 @@ + $book, + 'bookTree' => $viewData['bookTree'], + ]; + } +} diff --git a/app/View/ViewBlocks/ChaptersShowDetails.php b/app/View/ViewBlocks/ChaptersShowDetails.php new file mode 100644 index 00000000000..700b848f4b0 --- /dev/null +++ b/app/View/ViewBlocks/ChaptersShowDetails.php @@ -0,0 +1,39 @@ +referenceFetcher->getReferenceCountToEntity($chapter); + + return [ + 'chapter' => $chapter, + 'book' => $book, + 'watchOptions' => new UserEntityWatchOptions(user(), $chapter), + 'referenceCount' => $referenceCount, + ]; + } +} diff --git a/app/View/ViewBlocks/ChaptersShowSearchForm.php b/app/View/ViewBlocks/ChaptersShowSearchForm.php new file mode 100644 index 00000000000..0014e30eac8 --- /dev/null +++ b/app/View/ViewBlocks/ChaptersShowSearchForm.php @@ -0,0 +1,20 @@ + trans('entities.chapters_search_this'), + ]; + } +} diff --git a/app/View/ViewBlocks/ChaptersShowTags.php b/app/View/ViewBlocks/ChaptersShowTags.php new file mode 100644 index 00000000000..e513ee66bd6 --- /dev/null +++ b/app/View/ViewBlocks/ChaptersShowTags.php @@ -0,0 +1,24 @@ + $chapter, + ]; + } +} diff --git a/app/View/ViewBlocks/HomeActions.php b/app/View/ViewBlocks/HomeActions.php new file mode 100644 index 00000000000..211a60df5d0 --- /dev/null +++ b/app/View/ViewBlocks/HomeActions.php @@ -0,0 +1,20 @@ + $viewData['view'] ?? '', + 'homeView' => $viewData['homeView'] ?? 'default', + ]; + } +} diff --git a/app/View/ViewBlocks/HomeRecentActivity.php b/app/View/ViewBlocks/HomeRecentActivity.php new file mode 100644 index 00000000000..06869990f0a --- /dev/null +++ b/app/View/ViewBlocks/HomeRecentActivity.php @@ -0,0 +1,42 @@ +activityQueries->latest(10); + return [ + 'activity' => $activity, + ]; + } +} diff --git a/app/View/ViewBlocks/HomeRecentDrafts.php b/app/View/ViewBlocks/HomeRecentDrafts.php new file mode 100644 index 00000000000..dc88925131a --- /dev/null +++ b/app/View/ViewBlocks/HomeRecentDrafts.php @@ -0,0 +1,50 @@ +isGuest()) { + $draftPages = $this->pageQueries->currentUserDraftsForList() + ->orderBy('updated_at', 'desc') + ->with('book') + ->take(6) + ->get(); + } + + return [ + 'draftPages' => $draftPages, + ]; + } +} diff --git a/app/View/ViewBlocks/HomeRecentlyUpdatedPages.php b/app/View/ViewBlocks/HomeRecentlyUpdatedPages.php new file mode 100644 index 00000000000..99bec79281c --- /dev/null +++ b/app/View/ViewBlocks/HomeRecentlyUpdatedPages.php @@ -0,0 +1,47 @@ +queries->visibleForList() + ->where('draft', false) + ->orderBy('updated_at', 'desc') + ->take(8) + ->get(); + + return [ + 'recentlyUpdatedPages' => $recentlyUpdatedPages, + ]; + } +} diff --git a/app/View/ViewBlocks/HomeRecentlyViewedOrRecentBooks.php b/app/View/ViewBlocks/HomeRecentlyViewedOrRecentBooks.php new file mode 100644 index 00000000000..64cb897ef51 --- /dev/null +++ b/app/View/ViewBlocks/HomeRecentlyViewedOrRecentBooks.php @@ -0,0 +1,54 @@ +isGuest() ? 'books_recent' : 'my_recently_viewed'; + return trans("entities.{$key}"); + } + + public function getView(array $viewData): string + { + if ($viewData['homeView'] === 'default') { + return 'home.parts.default-card-recently-viewed-or-recent-books'; + } + + return 'home.parts.configured-section-recently-viewed-or-recent-books'; + } + + public function withData(array $viewData): array + { + if (user()->isGuest()) { + $recents = $this->queries->books->visibleForList() + ->orderBy('created_at', 'desc') + ->take(10) + ->get(); + } else { + $recents = $this->recentlyViewed->run(10, 1); + } + + return [ + 'recents' => $recents + ]; + } +} diff --git a/app/View/ViewBlocks/HomeTopFavourites.php b/app/View/ViewBlocks/HomeTopFavourites.php new file mode 100644 index 00000000000..28eeed15d69 --- /dev/null +++ b/app/View/ViewBlocks/HomeTopFavourites.php @@ -0,0 +1,42 @@ +topFavourites->run(6); + return [ + 'favourites' => $favourites + ]; + } +} diff --git a/app/View/ViewBlocks/PagesShowActions.php b/app/View/ViewBlocks/PagesShowActions.php new file mode 100644 index 00000000000..06a184fa397 --- /dev/null +++ b/app/View/ViewBlocks/PagesShowActions.php @@ -0,0 +1,26 @@ + $page, + 'watchOptions' => new UserEntityWatchOptions(user(), $page), + ]; + } +} diff --git a/app/View/ViewBlocks/PagesShowAttachments.php b/app/View/ViewBlocks/PagesShowAttachments.php new file mode 100644 index 00000000000..d29e9e9a6bb --- /dev/null +++ b/app/View/ViewBlocks/PagesShowAttachments.php @@ -0,0 +1,24 @@ + $page, + ]; + } +} diff --git a/app/View/ViewBlocks/PagesShowBookTree.php b/app/View/ViewBlocks/PagesShowBookTree.php new file mode 100644 index 00000000000..4f5b6af6ca7 --- /dev/null +++ b/app/View/ViewBlocks/PagesShowBookTree.php @@ -0,0 +1,25 @@ + $book, + 'bookTree' => $viewData['bookTree'], + ]; + } +} diff --git a/app/View/ViewBlocks/PagesShowDetails.php b/app/View/ViewBlocks/PagesShowDetails.php new file mode 100644 index 00000000000..6bbe9a8b2c0 --- /dev/null +++ b/app/View/ViewBlocks/PagesShowDetails.php @@ -0,0 +1,39 @@ +referenceFetcher->getReferenceCountToEntity($page); + + return [ + 'page' => $page, + 'book' => $book, + 'watchOptions' => new UserEntityWatchOptions(user(), $page), + 'referenceCount' => $referenceCount, + ]; + } +} diff --git a/app/View/ViewBlocks/PagesShowPageNav.php b/app/View/ViewBlocks/PagesShowPageNav.php new file mode 100644 index 00000000000..2a013ec2f84 --- /dev/null +++ b/app/View/ViewBlocks/PagesShowPageNav.php @@ -0,0 +1,28 @@ +getNavigation($page->html); + + return [ + 'pageNav' => $pageNav, + ]; + } +} diff --git a/app/View/ViewBlocks/PagesShowTags.php b/app/View/ViewBlocks/PagesShowTags.php new file mode 100644 index 00000000000..3edbb3288e1 --- /dev/null +++ b/app/View/ViewBlocks/PagesShowTags.php @@ -0,0 +1,24 @@ + $page, + ]; + } +} diff --git a/app/View/ViewBlocks/ShelvesIndexActions.php b/app/View/ViewBlocks/ShelvesIndexActions.php new file mode 100644 index 00000000000..8cd0f47d3bd --- /dev/null +++ b/app/View/ViewBlocks/ShelvesIndexActions.php @@ -0,0 +1,20 @@ + $viewData['view'], + ]; + } +} diff --git a/app/View/ViewBlocks/ShelvesIndexNew.php b/app/View/ViewBlocks/ShelvesIndexNew.php new file mode 100644 index 00000000000..21f21a4cff0 --- /dev/null +++ b/app/View/ViewBlocks/ShelvesIndexNew.php @@ -0,0 +1,31 @@ +queries->visibleForList() + ->orderBy('created_at', 'desc') + ->take(4) + ->get(); + + return [ + 'new' => $new, + ]; + } +} diff --git a/app/View/ViewBlocks/ShelvesIndexPopular.php b/app/View/ViewBlocks/ShelvesIndexPopular.php new file mode 100644 index 00000000000..df62f7c59c8 --- /dev/null +++ b/app/View/ViewBlocks/ShelvesIndexPopular.php @@ -0,0 +1,26 @@ + $this->queries->popularForList()->take(4)->get(), + ]; + } +} diff --git a/app/View/ViewBlocks/ShelvesIndexRecents.php b/app/View/ViewBlocks/ShelvesIndexRecents.php new file mode 100644 index 00000000000..5f42d395c6f --- /dev/null +++ b/app/View/ViewBlocks/ShelvesIndexRecents.php @@ -0,0 +1,31 @@ +isGuest()) { + $recents = $this->queries->recentlyViewedForCurrentUser()->take(4)->get(); + } + + return [ + 'recents' => $recents, + ]; + } +} diff --git a/app/View/ViewBlocks/ShelvesShowActions.php b/app/View/ViewBlocks/ShelvesShowActions.php new file mode 100644 index 00000000000..08a3cdee17c --- /dev/null +++ b/app/View/ViewBlocks/ShelvesShowActions.php @@ -0,0 +1,24 @@ + $shelf, + ]; + } +} diff --git a/app/View/ViewBlocks/ShelvesShowActivity.php b/app/View/ViewBlocks/ShelvesShowActivity.php new file mode 100644 index 00000000000..a14dbed5fc8 --- /dev/null +++ b/app/View/ViewBlocks/ShelvesShowActivity.php @@ -0,0 +1,30 @@ + $this->activityQueries->entityActivity($shelf, 20, 1), + ]; + } +} diff --git a/app/View/ViewBlocks/ShelvesShowDetails.php b/app/View/ViewBlocks/ShelvesShowDetails.php new file mode 100644 index 00000000000..1ceb21b6fe8 --- /dev/null +++ b/app/View/ViewBlocks/ShelvesShowDetails.php @@ -0,0 +1,32 @@ +referenceFetcher->getReferenceCountToEntity($shelf); + + return [ + 'shelf' => $shelf, + 'referenceCount' => $referenceCount, + ]; + } +} diff --git a/app/View/ViewBlocks/ShelvesShowTags.php b/app/View/ViewBlocks/ShelvesShowTags.php new file mode 100644 index 00000000000..b7af4a7384f --- /dev/null +++ b/app/View/ViewBlocks/ShelvesShowTags.php @@ -0,0 +1,20 @@ + $viewData['shelf'], + ]; + } +} diff --git a/composer.lock b/composer.lock index 3576e38ca2c..67b265547ec 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.389.2", + "version": "3.393.4", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "784e0fb95e752e55c4654b5800b900c78f6d3990" + "reference": "a5880510e500aa0fe13a6410183cd222b3263890" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/784e0fb95e752e55c4654b5800b900c78f6d3990", - "reference": "784e0fb95e752e55c4654b5800b900c78f6d3990", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/a5880510e500aa0fe13a6410183cd222b3263890", + "reference": "a5880510e500aa0fe13a6410183cd222b3263890", "shasum": "" }, "require": { @@ -153,9 +153,9 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.389.2" + "source": "https://github.com/aws/aws-sdk-php/tree/3.393.4" }, - "time": "2026-07-28T18:10:25+00:00" + "time": "2026-08-21T18:24:57+00:00" }, { "name": "bacon/bacon-qr-code", @@ -1119,24 +1119,24 @@ }, { "name": "graham-campbell/result-type", - "version": "v1.1.4", + "version": "v1.2.0", "source": { "type": "git", "url": "https://github.com/GrahamCampbell/Result-Type.git", - "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" + "reference": "adccca3324eece92ca35463648c12b9e6293c05b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", - "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/adccca3324eece92ca35463648c12b9e6293c05b", + "reference": "adccca3324eece92ca35463648c12b9e6293c05b", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.5" + "phpoption/phpoption": "^1.10" }, "require-dev": { - "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + "phpunit/phpunit": "^8.5.52 || ^9.6.34 || ^10.5.63 || ^11.5.55 || ^12.5.14" }, "type": "library", "autoload": { @@ -1165,7 +1165,7 @@ ], "support": { "issues": "https://github.com/GrahamCampbell/Result-Type/issues", - "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.2.0" }, "funding": [ { @@ -1177,26 +1177,26 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:43:20+00:00" + "time": "2026-08-24T09:06:52+00:00" }, { "name": "guzzlehttp/guzzle", - "version": "7.15.2", + "version": "7.15.5", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "744101956d78b7c1384d0cbf379db13e859167bf" + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/744101956d78b7c1384d0cbf379db13e859167bf", - "reference": "744101956d78b7c1384d0cbf379db13e859167bf", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ee80339fd9177ba44c49cdb653ff02a4d1106b9a", + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.5.1", - "guzzlehttp/psr7": "^2.13", + "guzzlehttp/promises": "^2.5.3", + "guzzlehttp/psr7": "^2.13.1", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.5 || ^3.0", @@ -1289,7 +1289,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.15.2" + "source": "https://github.com/guzzle/guzzle/tree/7.15.5" }, "funding": [ { @@ -1305,20 +1305,20 @@ "type": "tidelift" } ], - "time": "2026-07-26T23:23:20+00:00" + "time": "2026-08-24T09:21:06+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.5.1", + "version": "2.5.3", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29" + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/9ad1e4fc607446a055b95870c7f668e93b5cff29", - "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29", + "url": "https://api.github.com/repos/guzzle/promises/zipball/cde49999552d185d64715fe9c1f77a2aadd2f9f1", + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1", "shasum": "" }, "require": { @@ -1373,7 +1373,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.5.1" + "source": "https://github.com/guzzle/promises/tree/2.5.3" }, "funding": [ { @@ -1389,20 +1389,20 @@ "type": "tidelift" } ], - "time": "2026-07-08T15:48:39+00:00" + "time": "2026-08-24T09:11:28+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.13.0", + "version": "2.13.1", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4" + "reference": "95e7828100de18b4e269fb1703be530082d5166d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/dad89620b7a6edb60c15858442eb2e408b45d8f4", - "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/95e7828100de18b4e269fb1703be530082d5166d", + "reference": "95e7828100de18b4e269fb1703be530082d5166d", "shasum": "" }, "require": { @@ -1492,7 +1492,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.13.0" + "source": "https://github.com/guzzle/psr7/tree/2.13.1" }, "funding": [ { @@ -1508,20 +1508,20 @@ "type": "tidelift" } ], - "time": "2026-07-16T22:23:49+00:00" + "time": "2026-08-24T09:13:11+00:00" }, { "name": "guzzlehttp/uri-template", - "version": "v1.0.10", + "version": "v1.0.11", "source": { "type": "git", "url": "https://github.com/guzzle/uri-template.git", - "reference": "f6c24c21f42b990e9a58912b332d0874df6ba839" + "reference": "d0058dccf4299d70c3d9da3378b8908b32780368" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/uri-template/zipball/f6c24c21f42b990e9a58912b332d0874df6ba839", - "reference": "f6c24c21f42b990e9a58912b332d0874df6ba839", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/d0058dccf4299d70c3d9da3378b8908b32780368", + "reference": "d0058dccf4299d70c3d9da3378b8908b32780368", "shasum": "" }, "require": { @@ -1578,7 +1578,7 @@ ], "support": { "issues": "https://github.com/guzzle/uri-template/issues", - "source": "https://github.com/guzzle/uri-template/tree/v1.0.10" + "source": "https://github.com/guzzle/uri-template/tree/v1.0.11" }, "funding": [ { @@ -1594,7 +1594,7 @@ "type": "tidelift" } ], - "time": "2026-07-17T13:53:03+00:00" + "time": "2026-08-24T09:15:32+00:00" }, { "name": "intervention/gif", @@ -1742,16 +1742,16 @@ }, { "name": "knplabs/knp-snappy", - "version": "v1.7.2", + "version": "v1.7.3", "source": { "type": "git", "url": "https://github.com/KnpLabs/snappy.git", - "reference": "1461239a8b265fcc5457b7bdeb842c75b0f066eb" + "reference": "f749a7d2e0f1260f4a0f96925323e34ede898f1f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/KnpLabs/snappy/zipball/1461239a8b265fcc5457b7bdeb842c75b0f066eb", - "reference": "1461239a8b265fcc5457b7bdeb842c75b0f066eb", + "url": "https://api.github.com/repos/KnpLabs/snappy/zipball/f749a7d2e0f1260f4a0f96925323e34ede898f1f", + "reference": "f749a7d2e0f1260f4a0f96925323e34ede898f1f", "shasum": "" }, "require": { @@ -1803,22 +1803,22 @@ ], "support": { "issues": "https://github.com/KnpLabs/snappy/issues", - "source": "https://github.com/KnpLabs/snappy/tree/v1.7.2" + "source": "https://github.com/KnpLabs/snappy/tree/v1.7.3" }, - "time": "2026-05-15T15:04:49+00:00" + "time": "2026-07-29T11:03:07+00:00" }, { "name": "laravel/framework", - "version": "v12.64.0", + "version": "v12.67.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "727a8ea2949c23ca8b5316b86a00984b6017b7a0" + "reference": "fe2cdaba052cbb9f350761ccefc7ac221cbdf0b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/727a8ea2949c23ca8b5316b86a00984b6017b7a0", - "reference": "727a8ea2949c23ca8b5316b86a00984b6017b7a0", + "url": "https://api.github.com/repos/laravel/framework/zipball/fe2cdaba052cbb9f350761ccefc7ac221cbdf0b5", + "reference": "fe2cdaba052cbb9f350761ccefc7ac221cbdf0b5", "shasum": "" }, "require": { @@ -2027,20 +2027,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-07-14T14:25:37+00:00" + "time": "2026-08-18T13:37:47+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.21", + "version": "v0.3.23", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "7753c65c281c2550c7c183f14e18062073b7d821" + "reference": "b7b4c35e5bc47450f6b6238c6cc9c47ba19b2221" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/7753c65c281c2550c7c183f14e18062073b7d821", - "reference": "7753c65c281c2550c7c183f14e18062073b7d821", + "url": "https://api.github.com/repos/laravel/prompts/zipball/b7b4c35e5bc47450f6b6238c6cc9c47ba19b2221", + "reference": "b7b4c35e5bc47450f6b6238c6cc9c47ba19b2221", "shasum": "" }, "require": { @@ -2084,9 +2084,9 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.21" + "source": "https://github.com/laravel/prompts/tree/v0.3.23" }, - "time": "2026-06-26T00:11:25+00:00" + "time": "2026-08-11T18:58:24+00:00" }, { "name": "laravel/serializable-closure", @@ -2151,16 +2151,16 @@ }, { "name": "laravel/socialite", - "version": "v5.29.0", + "version": "v5.30.0", "source": { "type": "git", "url": "https://github.com/laravel/socialite.git", - "reference": "cd343a5841f02292af119ee607edc71300c9ae4f" + "reference": "caf714f55d51ab0d914b40033d8b0f489d6219cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/socialite/zipball/cd343a5841f02292af119ee607edc71300c9ae4f", - "reference": "cd343a5841f02292af119ee607edc71300c9ae4f", + "url": "https://api.github.com/repos/laravel/socialite/zipball/caf714f55d51ab0d914b40033d8b0f489d6219cc", + "reference": "caf714f55d51ab0d914b40033d8b0f489d6219cc", "shasum": "" }, "require": { @@ -2219,7 +2219,7 @@ "issues": "https://github.com/laravel/socialite/issues", "source": "https://github.com/laravel/socialite" }, - "time": "2026-07-01T13:50:23+00:00" + "time": "2026-08-13T23:01:33+00:00" }, { "name": "laravel/tinker", @@ -2289,16 +2289,16 @@ }, { "name": "league/commonmark", - "version": "2.8.3", + "version": "2.10.0", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "1902f60f984235023acbe03db6ad614a37b3c3e7" + "reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/1902f60f984235023acbe03db6ad614a37b3c3e7", - "reference": "1902f60f984235023acbe03db6ad614a37b3c3e7", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d2d1aa8b35e072966c89bc0c66cf926e56767dc4", + "reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4", "shasum": "" }, "require": { @@ -2335,7 +2335,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.9-dev" + "dev-main": "2.11-dev" } }, "autoload": { @@ -2392,7 +2392,7 @@ "type": "tidelift" } ], - "time": "2026-07-12T15:29:16+00:00" + "time": "2026-08-11T16:06:25+00:00" }, { "name": "league/config", @@ -2478,16 +2478,16 @@ }, { "name": "league/flysystem", - "version": "3.35.2", + "version": "3.35.3", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "b277b5dc3d56650b68904117124e79c851e12376" + "reference": "5fc8404762179ae514678487b23494fd69b2309c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376", - "reference": "b277b5dc3d56650b68904117124e79c851e12376", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/5fc8404762179ae514678487b23494fd69b2309c", + "reference": "5fc8404762179ae514678487b23494fd69b2309c", "shasum": "" }, "require": { @@ -2555,22 +2555,22 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.35.2" + "source": "https://github.com/thephpleague/flysystem/tree/3.35.3" }, - "time": "2026-07-06T14:42:07+00:00" + "time": "2026-08-22T12:55:54+00:00" }, { "name": "league/flysystem-aws-s3-v3", - "version": "3.35.2", + "version": "3.35.3", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", - "reference": "8475ef9adfc6498b85469e2abec6fe3118cd08c4" + "reference": "b03780cb97585ee7e48977f40ed599b33b751634" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/8475ef9adfc6498b85469e2abec6fe3118cd08c4", - "reference": "8475ef9adfc6498b85469e2abec6fe3118cd08c4", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/b03780cb97585ee7e48977f40ed599b33b751634", + "reference": "b03780cb97585ee7e48977f40ed599b33b751634", "shasum": "" }, "require": { @@ -2610,22 +2610,22 @@ "storage" ], "support": { - "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.35.2" + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.35.3" }, - "time": "2026-07-01T23:25:49+00:00" + "time": "2026-08-08T16:19:23+00:00" }, { "name": "league/flysystem-local", - "version": "3.31.0", + "version": "3.35.3", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-local.git", - "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079" + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079", - "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/a099b24dce160f3b2239043d13d47c4a1a214ea4", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4", "shasum": "" }, "require": { @@ -2659,9 +2659,9 @@ "local" ], "support": { - "source": "https://github.com/thephpleague/flysystem-local/tree/3.31.0" + "source": "https://github.com/thephpleague/flysystem-local/tree/3.35.3" }, - "time": "2026-01-23T15:30:45+00:00" + "time": "2026-08-12T13:29:21+00:00" }, { "name": "league/html-to-markdown", @@ -3133,24 +3133,24 @@ }, { "name": "masterminds/html5", - "version": "2.10.1", + "version": "2.11.0", "source": { "type": "git", "url": "https://github.com/Masterminds/html5-php.git", - "reference": "fd5018f6815fff903946d0564977b44ce8010e29" + "reference": "a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29", - "reference": "fd5018f6815fff903946d0564977b44ce8010e29", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7", + "reference": "a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7", "shasum": "" }, "require": { "ext-dom": "*", - "php": ">=5.3.0" + "php": ">=7.4" }, "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10" + "phpunit/phpunit": "^6 || ^7 || ^8 || ^9 || ^10" }, "type": "library", "extra": { @@ -3194,9 +3194,9 @@ ], "support": { "issues": "https://github.com/Masterminds/html5-php/issues", - "source": "https://github.com/Masterminds/html5-php/tree/2.10.1" + "source": "https://github.com/Masterminds/html5-php/tree/2.11.0" }, - "time": "2026-06-23T18:43:15+00:00" + "time": "2026-08-18T06:18:41+00:00" }, { "name": "monolog/monolog", @@ -3369,16 +3369,16 @@ }, { "name": "nesbot/carbon", - "version": "3.13.1", + "version": "3.13.2", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "2937ad3d1d2c506fd2bc97d571438a95641f44e2" + "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/2937ad3d1d2c506fd2bc97d571438a95641f44e2", - "reference": "2937ad3d1d2c506fd2bc97d571438a95641f44e2", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/a1c54919f5fff9800cd03c32bd01defd5a4061cb", + "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb", "shasum": "" }, "require": { @@ -3470,20 +3470,20 @@ "type": "tidelift" } ], - "time": "2026-07-09T18:23:49+00:00" + "time": "2026-08-08T11:40:35+00:00" }, { "name": "nette/schema", - "version": "v1.3.5", + "version": "v1.3.6", "source": { "type": "git", "url": "https://github.com/nette/schema.git", - "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" + "reference": "c54350438cd6914616f790a49cb424605f421562" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", - "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", + "url": "https://api.github.com/repos/nette/schema/zipball/c54350438cd6914616f790a49cb424605f421562", + "reference": "c54350438cd6914616f790a49cb424605f421562", "shasum": "" }, "require": { @@ -3535,9 +3535,9 @@ ], "support": { "issues": "https://github.com/nette/schema/issues", - "source": "https://github.com/nette/schema/tree/v1.3.5" + "source": "https://github.com/nette/schema/tree/v1.3.6" }, - "time": "2026-02-23T03:47:12+00:00" + "time": "2026-08-16T21:58:41+00:00" }, { "name": "nette/utils", @@ -3959,16 +3959,16 @@ }, { "name": "phpoption/phpoption", - "version": "1.9.5", + "version": "1.10.0", "source": { "type": "git", "url": "https://github.com/schmittjoh/php-option.git", - "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", - "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/67b192b6a42ec03944b972d6e633ddec78ad2c6d", + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d", "shasum": "" }, "require": { @@ -3976,7 +3976,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + "phpunit/phpunit": "^8.5.54 || ^9.6.36 || ^10.5.64 || ^11.5.56 || ^12.5.33" }, "type": "library", "extra": { @@ -4018,7 +4018,7 @@ ], "support": { "issues": "https://github.com/schmittjoh/php-option/issues", - "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + "source": "https://github.com/schmittjoh/php-option/tree/1.10.0" }, "funding": [ { @@ -4030,20 +4030,20 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:41:33+00:00" + "time": "2026-08-24T00:54:40+00:00" }, { "name": "phpseclib/phpseclib", - "version": "3.0.55", + "version": "3.0.56", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "db9744e6d47e742b1f974e965ad49bdd041105af" + "reference": "7adbbe38cde25e2df2116dbf2673c407e24fa305" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/db9744e6d47e742b1f974e965ad49bdd041105af", - "reference": "db9744e6d47e742b1f974e965ad49bdd041105af", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/7adbbe38cde25e2df2116dbf2673c407e24fa305", + "reference": "7adbbe38cde25e2df2116dbf2673c407e24fa305", "shasum": "" }, "require": { @@ -4124,7 +4124,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.55" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.56" }, "funding": [ { @@ -4140,20 +4140,20 @@ "type": "tidelift" } ], - "time": "2026-06-14T23:24:10+00:00" + "time": "2026-08-03T04:36:50+00:00" }, { "name": "pragmarx/google2fa", - "version": "v9.0.0", + "version": "v9.1.0", "source": { "type": "git", "url": "https://github.com/antonioribeiro/google2fa.git", - "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf" + "reference": "f00bc788c555adfb6765c437ff3538e59cd88af1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/e6bc62dd6ae83acc475f57912e27466019a1f2cf", - "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf", + "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/f00bc788c555adfb6765c437ff3538e59cd88af1", + "reference": "f00bc788c555adfb6765c437ff3538e59cd88af1", "shasum": "" }, "require": { @@ -4161,8 +4161,11 @@ "php": "^7.1|^8.0" }, "require-dev": { - "phpstan/phpstan": "^1.9", - "phpunit/phpunit": "^7.5.15|^8.5|^9.0" + "phpstan/phpstan": "^1.0|^2.0", + "phpstan/phpstan-phpunit": "^1.0|^2.0", + "phpunit/phpunit": "~9|~10|~11|~12|~13", + "psalm/plugin-phpunit": "^0.19|^0.20", + "vimeo/psalm": "^5.26|^6.13" }, "type": "library", "autoload": { @@ -4185,27 +4188,36 @@ "keywords": [ "2fa", "Authentication", + "MFA", "Two Factor Authentication", - "google2fa" + "google-authenticator", + "google2fa", + "hotp", + "otp", + "rfc4226", + "rfc6238", + "totp" ], "support": { + "docs": "https://github.com/antonioribeiro/google2fa#readme", "issues": "https://github.com/antonioribeiro/google2fa/issues", - "source": "https://github.com/antonioribeiro/google2fa/tree/v9.0.0" + "security": "https://github.com/antonioribeiro/google2fa/security/policy", + "source": "https://github.com/antonioribeiro/google2fa" }, - "time": "2025-09-19T22:51:08+00:00" + "time": "2026-08-15T13:22:01+00:00" }, { "name": "predis/predis", - "version": "v3.5.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/predis/predis.git", - "reference": "5c996db191ee2d9bafe651f454b1fca16754271b" + "reference": "2ff20c08bb63697245ffee3f198d1673086c302d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/predis/predis/zipball/5c996db191ee2d9bafe651f454b1fca16754271b", - "reference": "5c996db191ee2d9bafe651f454b1fca16754271b", + "url": "https://api.github.com/repos/predis/predis/zipball/2ff20c08bb63697245ffee3f198d1673086c302d", + "reference": "2ff20c08bb63697245ffee3f198d1673086c302d", "shasum": "" }, "require": { @@ -4247,7 +4259,7 @@ ], "support": { "issues": "https://github.com/predis/predis/issues", - "source": "https://github.com/predis/predis/tree/v3.5.1" + "source": "https://github.com/predis/predis/tree/v3.6.0" }, "funding": [ { @@ -4255,7 +4267,7 @@ "type": "github" } ], - "time": "2026-06-11T16:56:53+00:00" + "time": "2026-08-14T23:07:56+00:00" }, { "name": "psr/clock", @@ -5235,20 +5247,19 @@ }, { "name": "socialiteproviders/microsoft-azure", - "version": "5.2.0", + "version": "5.2.1", "source": { "type": "git", "url": "https://github.com/SocialiteProviders/Microsoft-Azure.git", - "reference": "453d62c9d7e3b3b76e94c913fb46e68a33347b16" + "reference": "d70ea3c60b09cc113a14350761a0618815bfa11c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/SocialiteProviders/Microsoft-Azure/zipball/453d62c9d7e3b3b76e94c913fb46e68a33347b16", - "reference": "453d62c9d7e3b3b76e94c913fb46e68a33347b16", + "url": "https://api.github.com/repos/SocialiteProviders/Microsoft-Azure/zipball/d70ea3c60b09cc113a14350761a0618815bfa11c", + "reference": "d70ea3c60b09cc113a14350761a0618815bfa11c", "shasum": "" }, "require": { - "ext-json": "*", "php": "^8.0", "socialiteproviders/manager": "^4.4" }, @@ -5282,7 +5293,7 @@ "issues": "https://github.com/socialiteproviders/providers/issues", "source": "https://github.com/socialiteproviders/providers" }, - "time": "2024-03-15T03:02:10+00:00" + "time": "2026-08-23T02:40:58+00:00" }, { "name": "socialiteproviders/okta", @@ -5516,16 +5527,16 @@ }, { "name": "symfony/console", - "version": "v7.4.14", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87" + "reference": "962e18f09ebe68a49039b4c82fc0ea4871824fca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", - "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", + "url": "https://api.github.com/repos/symfony/console/zipball/962e18f09ebe68a49039b4c82fc0ea4871824fca", + "reference": "962e18f09ebe68a49039b4c82fc0ea4871824fca", "shasum": "" }, "require": { @@ -5590,7 +5601,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.14" + "source": "https://github.com/symfony/console/tree/v7.4.17" }, "funding": [ { @@ -5610,20 +5621,20 @@ "type": "tidelift" } ], - "time": "2026-06-16T11:50:14+00:00" + "time": "2026-08-21T12:09:28+00:00" }, { "name": "symfony/css-selector", - "version": "v7.4.9", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "b75663ed96cf4756e28e3105476f220f92886cc4" + "reference": "e3822e1cb7013a0a99b3c578130458927ec4eb89" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/b75663ed96cf4756e28e3105476f220f92886cc4", - "reference": "b75663ed96cf4756e28e3105476f220f92886cc4", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/e3822e1cb7013a0a99b3c578130458927ec4eb89", + "reference": "e3822e1cb7013a0a99b3c578130458927ec4eb89", "shasum": "" }, "require": { @@ -5659,7 +5670,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v7.4.9" + "source": "https://github.com/symfony/css-selector/tree/v7.4.17" }, "funding": [ { @@ -5679,7 +5690,7 @@ "type": "tidelift" } ], - "time": "2026-04-18T13:18:21+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/deprecation-contracts", @@ -5754,16 +5765,16 @@ }, { "name": "symfony/error-handler", - "version": "v7.4.14", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "4e1a093b481f323e6e326451f9760c3868430673" + "reference": "8373921e231e190a88e2ad526951bbaa791576fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/4e1a093b481f323e6e326451f9760c3868430673", - "reference": "4e1a093b481f323e6e326451f9760c3868430673", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8373921e231e190a88e2ad526951bbaa791576fa", + "reference": "8373921e231e190a88e2ad526951bbaa791576fa", "shasum": "" }, "require": { @@ -5812,7 +5823,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.14" + "source": "https://github.com/symfony/error-handler/tree/v7.4.17" }, "funding": [ { @@ -5832,20 +5843,20 @@ "type": "tidelift" } ], - "time": "2026-06-05T06:22:21+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v7.4.14", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "51fe3d170227be8d1772214b82ae506e15ed78ff" + "reference": "d269974ee93c61d03620ffee358355bfdb471d66" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/51fe3d170227be8d1772214b82ae506e15ed78ff", - "reference": "51fe3d170227be8d1772214b82ae506e15ed78ff", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d269974ee93c61d03620ffee358355bfdb471d66", + "reference": "d269974ee93c61d03620ffee358355bfdb471d66", "shasum": "" }, "require": { @@ -5897,7 +5908,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.14" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.17" }, "funding": [ { @@ -5917,7 +5928,7 @@ "type": "tidelift" } ], - "time": "2026-06-06T11:10:32+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -6001,16 +6012,16 @@ }, { "name": "symfony/filesystem", - "version": "v7.4.11", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50" + "reference": "ee7bc7bca4c7079b88e57d5000aeeb20df570c8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/d721ea61b4a5fba8c5b6e7c1feda19efea144b50", - "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/ee7bc7bca4c7079b88e57d5000aeeb20df570c8d", + "reference": "ee7bc7bca4c7079b88e57d5000aeeb20df570c8d", "shasum": "" }, "require": { @@ -6047,7 +6058,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.4.11" + "source": "https://github.com/symfony/filesystem/tree/v7.4.17" }, "funding": [ { @@ -6067,20 +6078,20 @@ "type": "tidelift" } ], - "time": "2026-05-11T16:38:44+00:00" + "time": "2026-08-21T12:09:28+00:00" }, { "name": "symfony/finder", - "version": "v7.4.14", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "13b38720174286f55d1761152b575a8d1436fc25" + "reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/13b38720174286f55d1761152b575a8d1436fc25", - "reference": "13b38720174286f55d1761152b575a8d1436fc25", + "url": "https://api.github.com/repos/symfony/finder/zipball/5ce28827081f6d1f0c32eaf3882750f19cb5bbe6", + "reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6", "shasum": "" }, "require": { @@ -6115,7 +6126,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.14" + "source": "https://github.com/symfony/finder/tree/v7.4.17" }, "funding": [ { @@ -6135,20 +6146,20 @@ "type": "tidelift" } ], - "time": "2026-06-27T08:31:18+00:00" + "time": "2026-08-21T12:09:28+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.14", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "06db5ae1552177bf8572f8908839f12e3c06aed3" + "reference": "2ebe78c083501dfb9509b31a7aedcae4d60a391f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/06db5ae1552177bf8572f8908839f12e3c06aed3", - "reference": "06db5ae1552177bf8572f8908839f12e3c06aed3", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/2ebe78c083501dfb9509b31a7aedcae4d60a391f", + "reference": "2ebe78c083501dfb9509b31a7aedcae4d60a391f", "shasum": "" }, "require": { @@ -6197,7 +6208,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.14" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.17" }, "funding": [ { @@ -6217,20 +6228,20 @@ "type": "tidelift" } ], - "time": "2026-06-11T07:31:44+00:00" + "time": "2026-08-20T09:55:18+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.14", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "e99af79b1e776646eda0e1c23b7b45c184ff99be" + "reference": "aa160388d444210e3d01bbb4c5c53af4cd763df4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/e99af79b1e776646eda0e1c23b7b45c184ff99be", - "reference": "e99af79b1e776646eda0e1c23b7b45c184ff99be", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/aa160388d444210e3d01bbb4c5c53af4cd763df4", + "reference": "aa160388d444210e3d01bbb4c5c53af4cd763df4", "shasum": "" }, "require": { @@ -6288,7 +6299,7 @@ "symfony/validator": "^6.4|^7.0|^8.0", "symfony/var-dumper": "^6.4|^7.0|^8.0", "symfony/var-exporter": "^6.4|^7.0|^8.0", - "twig/twig": "^3.12" + "twig/twig": "^3.12|^4.0" }, "type": "library", "autoload": { @@ -6316,7 +6327,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.14" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.17" }, "funding": [ { @@ -6336,20 +6347,20 @@ "type": "tidelift" } ], - "time": "2026-06-27T09:14:35+00:00" + "time": "2026-08-22T13:41:33+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.14", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "f88ce03ae73e3edb5c176ce1f337709996e88495" + "reference": "b17c9bf3a551d5f635638a3b6c05f06c4dc87584" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/f88ce03ae73e3edb5c176ce1f337709996e88495", - "reference": "f88ce03ae73e3edb5c176ce1f337709996e88495", + "url": "https://api.github.com/repos/symfony/mailer/zipball/b17c9bf3a551d5f635638a3b6c05f06c4dc87584", + "reference": "b17c9bf3a551d5f635638a3b6c05f06c4dc87584", "shasum": "" }, "require": { @@ -6400,7 +6411,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.14" + "source": "https://github.com/symfony/mailer/tree/v7.4.17" }, "funding": [ { @@ -6420,20 +6431,20 @@ "type": "tidelift" } ], - "time": "2026-06-13T08:51:35+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/mime", - "version": "v7.4.13", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "a845722765c4f6b2ce88beaf4f4479975b186770" + "reference": "bf328d82105831db3e409195db0540ff57f27c80" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/a845722765c4f6b2ce88beaf4f4479975b186770", - "reference": "a845722765c4f6b2ce88beaf4f4479975b186770", + "url": "https://api.github.com/repos/symfony/mime/zipball/bf328d82105831db3e409195db0540ff57f27c80", + "reference": "bf328d82105831db3e409195db0540ff57f27c80", "shasum": "" }, "require": { @@ -6457,7 +6468,7 @@ "symfony/process": "^6.4|^7.0|^8.0", "symfony/property-access": "^6.4|^7.0|^8.0", "symfony/property-info": "^6.4|^7.0|^8.0", - "symfony/serializer": "^6.4.3|^7.0.3|^8.0" + "symfony/serializer": "^6.4.44|^7.4.17|^8.1.5" }, "type": "library", "autoload": { @@ -6489,7 +6500,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.13" + "source": "https://github.com/symfony/mime/tree/v7.4.17" }, "funding": [ { @@ -6509,7 +6520,7 @@ "type": "tidelift" } ], - "time": "2026-05-23T16:22:37+00:00" + "time": "2026-08-22T09:04:42+00:00" }, { "name": "symfony/polyfill-ctype", @@ -7342,16 +7353,16 @@ }, { "name": "symfony/process", - "version": "v7.4.13", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "f5804be144caceb570f6747519999636b664f24c" + "reference": "058d17fc284cce14efb2385783b55014a461b176" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c", - "reference": "f5804be144caceb570f6747519999636b664f24c", + "url": "https://api.github.com/repos/symfony/process/zipball/058d17fc284cce14efb2385783b55014a461b176", + "reference": "058d17fc284cce14efb2385783b55014a461b176", "shasum": "" }, "require": { @@ -7383,7 +7394,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.13" + "source": "https://github.com/symfony/process/tree/v7.4.17" }, "funding": [ { @@ -7403,20 +7414,20 @@ "type": "tidelift" } ], - "time": "2026-05-23T16:05:06+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/routing", - "version": "v7.4.13", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d" + "reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/3a162171bb008e5e0f15dce6581373a4c0e8390d", - "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d", + "url": "https://api.github.com/repos/symfony/routing/zipball/ddd558991e98f693ae6bf5063cc1b0362c6bbec3", + "reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3", "shasum": "" }, "require": { @@ -7468,7 +7479,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.13" + "source": "https://github.com/symfony/routing/tree/v7.4.17" }, "funding": [ { @@ -7488,7 +7499,7 @@ "type": "tidelift" } ], - "time": "2026-05-24T11:20:33+00:00" + "time": "2026-08-17T13:12:36+00:00" }, { "name": "symfony/service-contracts", @@ -7579,16 +7590,16 @@ }, { "name": "symfony/string", - "version": "v7.4.13", + "version": "v7.4.15", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde" + "reference": "e394af32256bf9e7bf80849d95e589167c10097b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", - "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", + "url": "https://api.github.com/repos/symfony/string/zipball/e394af32256bf9e7bf80849d95e589167c10097b", + "reference": "e394af32256bf9e7bf80849d95e589167c10097b", "shasum": "" }, "require": { @@ -7646,7 +7657,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.4.13" + "source": "https://github.com/symfony/string/tree/v7.4.15" }, "funding": [ { @@ -7666,20 +7677,20 @@ "type": "tidelift" } ], - "time": "2026-05-23T15:23:29+00:00" + "time": "2026-07-28T07:33:02+00:00" }, { "name": "symfony/translation", - "version": "v7.4.14", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "a1af4dacb24eb7ef4f1ca71b94da8ddbce572281" + "reference": "2ee1e4a3b32a528a642babe041ff7c440b213b4b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/a1af4dacb24eb7ef4f1ca71b94da8ddbce572281", - "reference": "a1af4dacb24eb7ef4f1ca71b94da8ddbce572281", + "url": "https://api.github.com/repos/symfony/translation/zipball/2ee1e4a3b32a528a642babe041ff7c440b213b4b", + "reference": "2ee1e4a3b32a528a642babe041ff7c440b213b4b", "shasum": "" }, "require": { @@ -7746,7 +7757,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.4.14" + "source": "https://github.com/symfony/translation/tree/v7.4.17" }, "funding": [ { @@ -7766,7 +7777,7 @@ "type": "tidelift" } ], - "time": "2026-06-06T09:33:19+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/translation-contracts", @@ -7852,16 +7863,16 @@ }, { "name": "symfony/uid", - "version": "v7.4.9", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "2676b524340abcfe4d6151ec698463cebafee439" + "reference": "69d732355a139c6f8881337d28515aa01f12b8be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/2676b524340abcfe4d6151ec698463cebafee439", - "reference": "2676b524340abcfe4d6151ec698463cebafee439", + "url": "https://api.github.com/repos/symfony/uid/zipball/69d732355a139c6f8881337d28515aa01f12b8be", + "reference": "69d732355a139c6f8881337d28515aa01f12b8be", "shasum": "" }, "require": { @@ -7906,7 +7917,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.4.9" + "source": "https://github.com/symfony/uid/tree/v7.4.17" }, "funding": [ { @@ -7926,20 +7937,20 @@ "type": "tidelift" } ], - "time": "2026-04-30T15:19:22+00:00" + "time": "2026-08-11T07:38:58+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.4.14", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358" + "reference": "53712df8727da1744490202eeb9cb50d4b95419d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", - "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/53712df8727da1744490202eeb9cb50d4b95419d", + "reference": "53712df8727da1744490202eeb9cb50d4b95419d", "shasum": "" }, "require": { @@ -7955,7 +7966,7 @@ "symfony/http-kernel": "^6.4|^7.0|^8.0", "symfony/process": "^6.4|^7.0|^8.0", "symfony/uid": "^6.4|^7.0|^8.0", - "twig/twig": "^3.12" + "twig/twig": "^3.12|^4.0" }, "bin": [ "Resources/bin/var-dump-server" @@ -7993,7 +8004,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.14" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.17" }, "funding": [ { @@ -8013,7 +8024,7 @@ "type": "tidelift" } ], - "time": "2026-06-08T20:24:16+00:00" + "time": "2026-08-21T12:09:28+00:00" }, { "name": "thecodingmachine/safe", @@ -8569,19 +8580,21 @@ }, { "name": "hamcrest/hamcrest-php", - "version": "v2.1.1", + "version": "v3.0.0", "source": { "type": "git", "url": "https://github.com/hamcrest/hamcrest-php.git", - "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", - "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/b61cd040da1a4925bc90a51c074f5297e7c0fa52", + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52", "shasum": "" }, "require": { + "ext-ctype": "*", + "ext-dom": "*", "php": "^7.4|^8.0" }, "replace": { @@ -8590,13 +8603,15 @@ "kodova/hamcrest-php": "*" }, "require-dev": { + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.1-dev" + "dev-master": "3.0-dev" } }, "autoload": { @@ -8614,9 +8629,9 @@ ], "support": { "issues": "https://github.com/hamcrest/hamcrest-php/issues", - "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" + "source": "https://github.com/hamcrest/hamcrest-php/tree/v3.0.0" }, - "time": "2025-04-30T06:54:44+00:00" + "time": "2026-03-17T11:56:53+00:00" }, { "name": "iamcal/sql-parser", @@ -8827,29 +8842,28 @@ }, { "name": "mockery/mockery", - "version": "1.6.12", + "version": "1.6.15", "source": { "type": "git", "url": "https://github.com/mockery/mockery.git", - "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" + "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", - "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "url": "https://api.github.com/repos/mockery/mockery/zipball/967a801bd188989a5669bd280f252d51c0fdc9ee", + "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee", "shasum": "" }, "require": { - "hamcrest/hamcrest-php": "^2.0.1", - "lib-pcre": ">=7.0", + "hamcrest/hamcrest-php": "^2.0 || ^3.0", "php": ">=7.3" }, "conflict": { "phpunit/phpunit": "<8.0" }, "require-dev": { - "phpunit/phpunit": "^8.5 || ^9.6.17", - "symplify/easy-coding-standard": "^12.1.14" + "phpunit/phpunit": "^9.6.36", + "symplify/easy-coding-standard": "^13.2.17" }, "type": "library", "autoload": { @@ -8906,24 +8920,24 @@ "security": "https://github.com/mockery/mockery/security/advisories", "source": "https://github.com/mockery/mockery" }, - "time": "2024-05-16T03:13:13+00:00" + "time": "2026-08-19T19:37:52+00:00" }, { "name": "myclabs/deep-copy", - "version": "1.13.4", + "version": "1.14.0", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "php": "^8.0" }, "conflict": { "doctrine/collections": "<1.6.8", @@ -8958,15 +8972,15 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" }, "funding": [ { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", - "type": "tidelift" + "url": "https://github.com/mnapoli", + "type": "github" } ], - "time": "2025-08-01T08:46:24+00:00" + "time": "2026-08-11T10:17:44+00:00" }, { "name": "nunomaduro/collision", @@ -9184,11 +9198,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.6", + "version": "2.2.9", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a6e9b5a9420f6109c091e87d82683bd1a80b87ed", - "reference": "a6e9b5a9420f6109c091e87d82683bd1a80b87ed", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/13d6b4f347bad222da436580c8304fa6f83e6bd0", + "reference": "13d6b4f347bad222da436580c8304fa6f83e6bd0", "shasum": "" }, "require": { @@ -9244,7 +9258,7 @@ "type": "github" } ], - "time": "2026-07-26T21:22:49+00:00" + "time": "2026-08-22T07:38:16+00:00" }, { "name": "phpunit/php-code-coverage", @@ -10675,19 +10689,20 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "4.0.1", + "version": "4.0.4", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "0525c73950de35ded110cffafb9892946d7771b5" + "reference": "bbdc3d0532623e21838b7041a4364383a8126f96" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0525c73950de35ded110cffafb9892946d7771b5", - "reference": "0525c73950de35ded110cffafb9892946d7771b5", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/bbdc3d0532623e21838b7041a4364383a8126f96", + "reference": "bbdc3d0532623e21838b7041a4364383a8126f96", "shasum": "" }, "require": { + "ext-libxml": "*", "ext-simplexml": "*", "ext-tokenizer": "*", "ext-xmlwriter": "*", @@ -10696,6 +10711,10 @@ "require-dev": { "phpunit/phpunit": "^8.4.0 || ^9.3.4 || ^10.5.32 || 11.3.3 - 11.5.28 || ^11.5.31" }, + "suggest": { + "ext-iconv": "For accurate character length calculation when the checked files contain multi-byte characters.", + "ext-pcntl": "For parallel processing support via the --parallel CLI option." + }, "bin": [ "bin/phpcbf", "bin/phpcs" @@ -10750,7 +10769,7 @@ "type": "thanks_dev" } ], - "time": "2025-11-10T16:43:36+00:00" + "time": "2026-08-06T02:45:27+00:00" }, { "name": "ssddanbrown/asserthtml", @@ -10851,16 +10870,16 @@ }, { "name": "symfony/dom-crawler", - "version": "v7.4.12", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "b59b59122690976550fd142c23fab62c84738db6" + "reference": "6b037d62595666cf65de867073c9a1f00184be0c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/b59b59122690976550fd142c23fab62c84738db6", - "reference": "b59b59122690976550fd142c23fab62c84738db6", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/6b037d62595666cf65de867073c9a1f00184be0c", + "reference": "6b037d62595666cf65de867073c9a1f00184be0c", "shasum": "" }, "require": { @@ -10899,7 +10918,7 @@ "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v7.4.12" + "source": "https://github.com/symfony/dom-crawler/tree/v7.4.17" }, "funding": [ { @@ -10919,7 +10938,7 @@ "type": "tidelift" } ], - "time": "2026-05-20T07:20:23+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "theseer/tokenizer", diff --git a/dev/docs/logical-theme-system.md b/dev/docs/logical-theme-system.md index b6342ce5465..7093bc00bb7 100644 --- a/dev/docs/logical-theme-system.md +++ b/dev/docs/logical-theme-system.md @@ -193,4 +193,105 @@ Theme::addSocialDriver('reddit', [ $driver->with(['prompt' => 'select_account']); $driver->scopes(['open_id']); }); -``` \ No newline at end of file +``` + +## Custom View Layout Block Example + +By listening to the `ThemeEvents::VIEW_BLOCKS_REGISTER` event, it's possible to register +custom view blocks which can be shown in a layout in the application. View blocks are typically the sections shown in the sidebar, +or the cards shown on the home view. + +As an example of using this system, we'll define a custom view block which will show on the default home view to state how many +books there are in the system. +To start, we'll need to create a custom class to represent our view block. This must implement `\BookStack\View\ViewBlockInterface`: + +```php +use BookStack\Entities\Queries\BookQueries; +use BookStack\View\ViewBlockInterface; +use BookStack\View\ViewBlockManager; + +class BookTotalBlock implements ViewBlockInterface { + + public function __construct( + protected BookQueries $bookQueries + ) { + } + + public static function getId(): string + { + return 'custom_book_total_block'; + } + + public static function getLabel(): string + { + return 'Total books displays'; + } + + public function getView(array $viewData): string + { + return 'blocks.total-blocks'; + } + + public function withData(array $viewData): array + { + $totalBooks = $this->bookQueries->visibleForList()->count(); + return [ + 'totalBooks' => $totalBooks, + ]; + } +} +``` + +The interface has a few required methods: + +- The `getId` method must provide a unique per-block-type string ID. +- The `getLabel` method must provide a general string label for the block. +- The `getView` method provides a string path to a view file (Can be one custom registered). + - This is provided the available view data at time of render, so it can be dynamic based on context. +- The `withData` method is called when block is being rendered, and it should return an array of data which will be merged with existing view data. + - This is also provided available view data, for use as context. + +In this example, we're also making use of the `BookQueries` internal BookStack class. +You're able to inject any other dependent classes/services via the constructor like this, and BookStack will attempt to auto-resolve them. + +We can then register this block class using the logical theme system like so: + +```php +use BookStack\Facades\Theme; +use BookStack\Theming\ThemeEvents; +use BookStack\View\ViewBlockManager; + +Theme::listen(ThemeEvents::VIEW_BLOCKS_REGISTER, function (ViewBlockManager $manager) { + $manager->register( + 'home-default', // The location/layout where this block will be displayed + 'right', // The default position for the block within the location + BookTotalBlock::class // The block class to register + ); +}); +``` + +The above registration code would typically be within your `functions.php` theme file. +You could also define the above block class in the same file or separate it into its own file +and include it from the `functions.php` via a `require_once()` call. + +Lastly, we'll need to create the view for the registered block. +In our example we return `blocks.total-blocks` from the `getView` method, so our view needs to be located at +`blocks/total-blocks.blade.php` from a view providing directory. +In a theme folder, we can just create it at `blocks/total-blocks.blade.php` within the folder path. +If we were building in a module, we'd need to create this at `views/blocks/total-blocks.blade.php` within our module folder. +For our example, we'll use this content to make use of the data we're passing to the view: + +```html +
+

Total Books

+
+

+ There are currently {{ $totalBooks }} books in the system! +

+
+
+``` + +This will then show up on the default home grid view, in the right column. +The exact position within that location may change depending on user preferences, but the block will be limited +to the location provided during registration unless it has also been registered for other locations. diff --git a/dev/licensing/php-library-licenses.txt b/dev/licensing/php-library-licenses.txt index 0c30fd5bb14..892842c4a9e 100644 --- a/dev/licensing/php-library-licenses.txt +++ b/dev/licensing/php-library-licenses.txt @@ -123,7 +123,7 @@ Link: https://github.com/fruitcake/php-cors graham-campbell/result-type License: MIT License File: vendor/graham-campbell/result-type/LICENSE -Copyright: Copyright (c) 2020-2024 Graham Campbell <*****@**********.**.**> +Copyright: Copyright (c) 2020-2026 Graham Campbell <*****@**********.**.**> Source: https://github.com/GrahamCampbell/Result-Type.git Link: https://github.com/GrahamCampbell/Result-Type.git ----------- diff --git a/lang/bg/activities.php b/lang/bg/activities.php index 344423e0d6c..6195e0ae8fb 100644 --- a/lang/bg/activities.php +++ b/lang/bg/activities.php @@ -111,7 +111,7 @@ 'api_token_delete_notification' => 'API token successfully deleted', // Roles - 'role_create' => 'created role', + 'role_create' => 'създадена роля', 'role_create_notification' => 'Успешна създадена роля', 'role_update' => 'updated role', 'role_update_notification' => 'Успешно обновена роля', diff --git a/lang/bg/auth.php b/lang/bg/auth.php index 91c7495a108..a0e171b42cb 100644 --- a/lang/bg/auth.php +++ b/lang/bg/auth.php @@ -8,7 +8,7 @@ 'failed' => 'Въведените данни не съвпадат с информацията в системата.', 'throttle' => 'Твърде много опити за влизане. Опитайте пак след :seconds секунди.', - 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', + 'mfa_throttle' => 'Твърде много опити за мултифакторна верификация. Опитайте пак след :seconds секунди.', // Login & Register 'sign_up' => 'Регистриране', diff --git a/lang/bg/components.php b/lang/bg/components.php index f88c69fc8be..03a9ff755b9 100644 --- a/lang/bg/components.php +++ b/lang/bg/components.php @@ -6,7 +6,7 @@ // Image Manager 'image_select' => 'Избор на изображение', - 'image_list' => 'Image List', + 'image_list' => 'Списък с изображения', 'image_details' => 'Image Details', 'image_upload' => 'Upload Image', 'image_intro' => 'Here you can select and manage images that have been previously uploaded to the system.', diff --git a/lang/bg/editor.php b/lang/bg/editor.php index 16c951a71a4..a78d9eff94f 100644 --- a/lang/bg/editor.php +++ b/lang/bg/editor.php @@ -48,7 +48,7 @@ 'superscript' => 'Горен индекс', 'subscript' => 'Долен индекс', 'text_color' => 'Цвят на текста', - 'highlight_color' => 'Highlight color', + 'highlight_color' => 'Цвят на подчертаване', 'custom_color' => 'Цвят по избор', 'remove_color' => 'Премахване на цвят', 'background_color' => 'Фонов цвят', diff --git a/lang/bg/notifications.php b/lang/bg/notifications.php index 563ac24e84d..ecee032d6c8 100644 --- a/lang/bg/notifications.php +++ b/lang/bg/notifications.php @@ -15,7 +15,7 @@ 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Page Name:', - 'detail_page_path' => 'Page Path:', + 'detail_page_path' => 'Път към страница:', 'detail_commenter' => 'Commenter:', 'detail_comment' => 'Comment:', 'detail_created_by' => 'Created By:', diff --git a/lang/bg/preferences.php b/lang/bg/preferences.php index 8f5aaa07e90..6bff8fcfa29 100644 --- a/lang/bg/preferences.php +++ b/lang/bg/preferences.php @@ -37,7 +37,7 @@ 'profile' => 'Profile Details', 'profile_desc' => 'Manage the details of your account which represents you to other users, in addition to details that are used for communication and system personalisation.', - 'profile_view_public' => 'View Public Profile', + 'profile_view_public' => 'Виж публичния профил', 'profile_name_desc' => 'Configure your display name which will be visible to other users in the system through the activity you perform, and content you own.', 'profile_email_desc' => 'This email will be used for notifications and, depending on active system authentication, system access.', 'profile_email_no_permission' => 'Unfortunately you don\'t have permission to change your email address. If you want to change this, you\'d need to ask an administrator to change this for you.', diff --git a/lang/bg/settings.php b/lang/bg/settings.php index d347510ff0d..2b04c073a11 100644 --- a/lang/bg/settings.php +++ b/lang/bg/settings.php @@ -51,7 +51,7 @@ 'color_scheme' => 'Application Color Scheme', 'color_scheme_desc' => 'Set the colors to use in the application user interface. Colors can be configured separately for dark and light modes to best fit the theme and ensure legibility.', 'ui_colors_desc' => 'Set the application primary color and default link color. The primary color is mainly used for the header banner, buttons and interface decorations. The default link color is used for text-based links and actions, both within written content and in the application interface.', - 'app_color' => 'Primary Color', + 'app_color' => 'Основен цвят', 'link_color' => 'Default Link Color', 'content_colors_desc' => 'Set colors for all elements in the page organisation hierarchy. Choosing colors with a similar brightness to the default colors is recommended for readability.', 'bookshelf_color' => 'Цвят на рафта', diff --git a/lang/bg/validation.php b/lang/bg/validation.php index e2f9bdaa9c1..0c45ee28cd7 100644 --- a/lang/bg/validation.php +++ b/lang/bg/validation.php @@ -105,11 +105,11 @@ 'url' => 'Форматът на :attribute не е валиден.', 'uploaded' => 'Файлът не можа да бъде качен. Сървърът може да не приема файлове с такъв размер.', - 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', - 'zip_file_size' => 'The file :attribute must not exceed :size MB.', - 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', - 'zip_model_expected' => 'Data object expected but ":type" found.', - 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + 'zip_file' => ':attribute трябва да реферира към файл в ZIP архива.', + 'zip_file_size' => 'Файла :attribute не трябва да надвишава :size MB.', + 'zip_file_mime' => ':attribute трябва да реферира към файл от тип :validTypes, а е намерен :foundType.', + 'zip_model_expected' => 'Очаква се обект с данни, но е открит ":type".', + 'zip_unique' => ':attribute трябва да бъде уникален за типа на обекта в ZIP архива.', // Custom validation lines 'custom' => [ diff --git a/lang/en/common.php b/lang/en/common.php index 06a9e855ce3..46b2a187c25 100644 --- a/lang/en/common.php +++ b/lang/en/common.php @@ -84,6 +84,8 @@ 'status_inactive' => 'Inactive', 'never' => 'Never', 'none' => 'None', + 'move_left' => 'Move Left', + 'move_right' => 'Move Right', // Header 'homepage' => 'Homepage', diff --git a/lang/en/preferences.php b/lang/en/preferences.php index f4459d738e4..1b874efa2f1 100644 --- a/lang/en/preferences.php +++ b/lang/en/preferences.php @@ -19,6 +19,27 @@ 'shortcuts_update_success' => 'Shortcut preferences have been updated!', 'shortcuts_overview_desc' => 'Manage keyboard shortcuts you can use to navigate the system user interface.', + 'interface' => 'Interface Preferences', + 'interface_desc' => 'Here you can find options to customize the appearance of the application user interface.', + 'interface_display_mode' => 'Display Mode', + 'interface_display_mode_desc' => 'Choose whether the application should show in dark or light mode. This can also be toggled from the home view, or via the profile dropdown in the header bar.', + 'layouts' => 'UI Layout Preferences', + 'layouts_desc' => 'Customize the layout of sections shown in the user interface for a range of views.', + 'layout_edit' => 'Edit Layout', + 'layout_edit_desc' => 'Drag and drop sections, or use the action menu found on each, to reconfigure which sections show within this layout in the interface, and where they are displayed.', + 'layout_edit_column_hint' => 'When viewed on smaller screen sizes, right column sections will be stacked on top of left column sections.', + 'layout_edit_save' => 'Save Layout', + 'layout_edit_layouts' => 'Layouts', + 'layout_edit_back_to_preferences' => 'Back to Preferences', + 'layout_edit_left' => 'Left', + 'layout_edit_right' => 'Right', + 'layout_edit_center' => 'Center', + 'layout_edit_unused' => 'Unused', + 'layout_edit_empty' => 'No sections to display', + 'layout_edit_reset_to_defaults' => 'Reset to Default', + 'layout_update_success' => 'Layout preferences have been updated!', + 'layout_reset_success' => 'Layout preferences have been reset!', + 'notifications' => 'Notification Preferences', 'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.', 'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own', diff --git a/lang/en/validation.php b/lang/en/validation.php index ff028525df3..532d861d244 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -16,6 +16,7 @@ 'alpha_num' => 'The :attribute may only contain letters and numbers.', 'array' => 'The :attribute must be an array.', 'backup_codes' => 'The provided code is not valid or has already been used.', + 'base64_uri_mime' => 'The :attribute must be a valid base64 URI containing data of :mime mime type.', 'before' => 'The :attribute must be a date before :date.', 'between' => [ 'numeric' => 'The :attribute must be between :min and :max.', diff --git a/lang/es/activities.php b/lang/es/activities.php index ecfffeb5194..e256a88c9a3 100644 --- a/lang/es/activities.php +++ b/lang/es/activities.php @@ -99,8 +99,8 @@ 'user_update_notification' => 'Usuario actualizado correctamente', 'user_delete' => 'usuario eliminado', 'user_delete_notification' => 'Usuario eliminado correctamente', - 'user_mfa_reset' => 'reset MFA for user', - 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', + 'user_mfa_reset' => 'reiniciar autenticación en dos pasos para el usuario', + 'user_mfa_reset_notification' => 'Métodos de autenticación en dos pasos restablecidos', // API Tokens 'api_token_create' => 'token de API creado', diff --git a/lang/es/auth.php b/lang/es/auth.php index 467508001e9..0a3cba2894a 100644 --- a/lang/es/auth.php +++ b/lang/es/auth.php @@ -8,7 +8,7 @@ 'failed' => 'Estas credenciales no coinciden con nuestros registros.', 'throttle' => 'Demasiados intentos de inicio de sesión. Por favor, inténtalo de nuevo en :seconds segundos.', - 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', + 'mfa_throttle' => 'Demasiados intentos de verificación de autenticación en dos pasos. Por favor, inténtalo de nuevo en :seconds segundos.', // Login & Register 'sign_up' => 'Registrarse', diff --git a/lang/es/entities.php b/lang/es/entities.php index 7814161222b..e5ee473a034 100644 --- a/lang/es/entities.php +++ b/lang/es/entities.php @@ -331,9 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Mostrar/ocultar barra lateral', - 'page_contents' => 'Page Contents', - 'page_contents_none' => 'No headings were found in the page content.', - 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', + 'page_contents' => 'Contenido de Página', + 'page_contents_none' => 'No se encontraron encabezados en el contenido de la página.', + 'page_contents_info' => 'El menú de los contenidos de la página se genera a partir de cualquier formato de encabezado utilizado en la página.', 'page_tags' => 'Etiquetas de Página', 'chapter_tags' => 'Etiquetas de Capítulo', 'book_tags' => 'Etiquetas de Libro', diff --git a/lang/es/settings.php b/lang/es/settings.php index 1f1b84a310e..c776cae32f0 100644 --- a/lang/es/settings.php +++ b/lang/es/settings.php @@ -264,9 +264,9 @@ 'users_mfa_desc' => 'La autenticación en dos pasos añade una capa de seguridad adicional a tu cuenta.', 'users_mfa_x_methods' => ':count método configurado|:count métodos configurados', 'users_mfa_configure' => 'Configurar métodos', - 'users_mfa_reset' => 'Reset Multi-Factor Authentication Methods', - 'users_mfa_reset_desc' => 'This will reset and clear all configured multi-factor authentication methods for this user. If multi-factor authentication is required by any of their roles, they\'ll be prompted to configure new methods on their next login.', - 'users_mfa_reset_confirm' => 'Are you sure you want to reset multi-factor authentication for this user?', + 'users_mfa_reset' => 'Restablecer métodos de autenticación en dos pasos', + 'users_mfa_reset_desc' => 'Esto restablecerá y borrará todos los métodos de autenticación en dos pasos configurados para este usuario. Si la autenticación en dos pasos es requerida por cualquiera de sus roles, se les pedirá que configuren nuevos métodos en su próximo inicio de sesión.', + 'users_mfa_reset_confirm' => '¿Estás seguro de que deseas eliminar la autenticación en dos pasos para este usuario?', // API Tokens 'user_api_token_create' => 'Crear token API', diff --git a/lang/es_AR/activities.php b/lang/es_AR/activities.php index b4ba5690e0e..ececde5badd 100644 --- a/lang/es_AR/activities.php +++ b/lang/es_AR/activities.php @@ -99,8 +99,8 @@ 'user_update_notification' => 'Usuario actualizado con éxito', 'user_delete' => 'usuario eliminado', 'user_delete_notification' => 'El usuario fue eliminado correctamente', - 'user_mfa_reset' => 'reset MFA for user', - 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', + 'user_mfa_reset' => 'reiniciar autenticación en dos pasos para el usuario', + 'user_mfa_reset_notification' => 'Métodos de autenticación en dos pasos restablecidos', // API Tokens 'api_token_create' => 'token de API creado', diff --git a/lang/es_AR/auth.php b/lang/es_AR/auth.php index ad69e651ea0..82bd1a01497 100644 --- a/lang/es_AR/auth.php +++ b/lang/es_AR/auth.php @@ -8,7 +8,7 @@ 'failed' => 'Estas credenciales no concuerdan con nuestros registros.', 'throttle' => 'Demasiados intentos fallidos de inicio de sesión. Por favor intente nuevamente en :seconds segundos.', - 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', + 'mfa_throttle' => 'Demasiados intentos de verificación de autenticación en dos pasos. Por favor, inténtalo de nuevo en :seconds segundos.', // Login & Register 'sign_up' => 'Registrarse', diff --git a/lang/es_AR/entities.php b/lang/es_AR/entities.php index aac46b4477a..72aeb2d9e8d 100644 --- a/lang/es_AR/entities.php +++ b/lang/es_AR/entities.php @@ -331,9 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Mostrar/ocultar barra lateral', - 'page_contents' => 'Page Contents', - 'page_contents_none' => 'No headings were found in the page content.', - 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', + 'page_contents' => 'Contenido de Página', + 'page_contents_none' => 'No se encontraron encabezados en el contenido de la página.', + 'page_contents_info' => 'El menú de los contenidos de la página se genera a partir de cualquier formato de encabezado utilizado en la página.', 'page_tags' => 'Etiquetas de página', 'chapter_tags' => 'Etiquetas de capítulo', 'book_tags' => 'Etiquetas de libro', diff --git a/lang/es_AR/settings.php b/lang/es_AR/settings.php index 0a97aca679c..1baf726d1d5 100644 --- a/lang/es_AR/settings.php +++ b/lang/es_AR/settings.php @@ -265,9 +265,9 @@ 'users_mfa_desc' => 'Configure la autenticación de múltiples factores como una capa extra de seguridad para su cuenta de usuario.', 'users_mfa_x_methods' => ':count método configurado|:count métodos configurados', 'users_mfa_configure' => 'Configurar Métodos', - 'users_mfa_reset' => 'Reset Multi-Factor Authentication Methods', - 'users_mfa_reset_desc' => 'This will reset and clear all configured multi-factor authentication methods for this user. If multi-factor authentication is required by any of their roles, they\'ll be prompted to configure new methods on their next login.', - 'users_mfa_reset_confirm' => 'Are you sure you want to reset multi-factor authentication for this user?', + 'users_mfa_reset' => 'Restablecer métodos de autenticación en dos pasos', + 'users_mfa_reset_desc' => 'Esto restablecerá y borrará todos los métodos de autenticación en dos pasos configurados para este usuario. Si la autenticación en dos pasos es requerida por cualquiera de sus roles, se les pedirá que configuren nuevos métodos en su próximo inicio de sesión.', + 'users_mfa_reset_confirm' => '¿Estás seguro de que deseas eliminar la autenticación en dos pasos para este usuario?', // API Tokens 'user_api_token_create' => 'Crear token API', diff --git a/lang/et/activities.php b/lang/et/activities.php index e42dd7fda36..87d474880b3 100644 --- a/lang/et/activities.php +++ b/lang/et/activities.php @@ -99,8 +99,8 @@ 'user_update_notification' => 'Kasutaja on muudetud', 'user_delete' => 'kustutas kasutaja', 'user_delete_notification' => 'Kasutaja on kustutatud', - 'user_mfa_reset' => 'reset MFA for user', - 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', + 'user_mfa_reset' => 'lähtestas kasutaja MFA', + 'user_mfa_reset_notification' => 'Mitmeastmelise autentimise meetodite lähestamine', // API Tokens 'api_token_create' => 'lisas API tunnuse', diff --git a/lang/et/auth.php b/lang/et/auth.php index afbd2870bc7..56458ea20b1 100644 --- a/lang/et/auth.php +++ b/lang/et/auth.php @@ -8,7 +8,7 @@ 'failed' => 'Kasutajanimi ja parool ei klapi.', 'throttle' => 'Liiga palju sisselogimiskatseid. Proovi uuesti :seconds sekundi pärast.', - 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', + 'mfa_throttle' => 'Liiga palju mitmeastmelise kinnituse katseid. Proovi uuesti :seconds sekundi pärast.', // Login & Register 'sign_up' => 'Registreeru', diff --git a/lang/et/entities.php b/lang/et/entities.php index 11c2f77aeb2..ec2fc183361 100644 --- a/lang/et/entities.php +++ b/lang/et/entities.php @@ -331,9 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Kuva/peida külgriba', - 'page_contents' => 'Page Contents', - 'page_contents_none' => 'No headings were found in the page content.', - 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', + 'page_contents' => 'Lehe sisu', + 'page_contents_none' => 'Lehe sisus ei leitud ühtegi pealkirja.', + 'page_contents_info' => 'Sisukord genereeritakse automaatselt lehel kasutatud pealkirjavormingutest.', 'page_tags' => 'Lehe sildid', 'chapter_tags' => 'Peatüki sildid', 'book_tags' => 'Raamatu sildid', diff --git a/lang/et/settings.php b/lang/et/settings.php index 03b602372e0..58800f5e39e 100644 --- a/lang/et/settings.php +++ b/lang/et/settings.php @@ -207,7 +207,7 @@ 'role_all' => 'Kõik', 'role_own' => 'Enda omad', 'role_controlled_by_asset' => 'Õigused määratud seotud objekti kaudu', - 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', + 'role_controlled_by_page_delete' => 'Määratud lehe kustutamise õiguste kaudu', 'role_save' => 'Salvesta roll', 'role_users' => 'Selle rolliga kasutajad', 'role_users_none' => 'Seda rolli ei ole hetkel ühelgi kasutajal', @@ -264,9 +264,9 @@ 'users_mfa_desc' => 'Seadista mitmeastmeline autentimine, et oma kasutajakonto turvalisust tõsta.', 'users_mfa_x_methods' => ':count meetod seadistatud|:count meetodit seadistatud', 'users_mfa_configure' => 'Seadista meetodid', - 'users_mfa_reset' => 'Reset Multi-Factor Authentication Methods', - 'users_mfa_reset_desc' => 'This will reset and clear all configured multi-factor authentication methods for this user. If multi-factor authentication is required by any of their roles, they\'ll be prompted to configure new methods on their next login.', - 'users_mfa_reset_confirm' => 'Are you sure you want to reset multi-factor authentication for this user?', + 'users_mfa_reset' => 'Lähtesta mitmeastmelise autentimise meetodid', + 'users_mfa_reset_desc' => 'See lähestab kõik selle kasutaja seadistatud mitmeastmelise autentimise meetodid. Kui mõni tema rollidest nõuab mitmeastmelist autentimist, peab ta järgmisel sisselogimisel uued meetodid seadistama.', + 'users_mfa_reset_confirm' => 'Kas oled kindel, et soovid selle kasutaja mitmeastmelise autentimise meetodid lähtestada?', // API Tokens 'user_api_token_create' => 'Lisa API tunnus', diff --git a/lang/fa/activities.php b/lang/fa/activities.php index 876c545a67f..85a2d72cbb6 100644 --- a/lang/fa/activities.php +++ b/lang/fa/activities.php @@ -36,8 +36,8 @@ 'book_update_notification' => 'کتاب با موفقیت به روزرسانی شد', 'book_delete' => 'حذف کتاب', 'book_delete_notification' => 'کتاب با موفقیت حذف شد', - 'book_sort' => 'مرتب سازی کتاب', - 'book_sort_notification' => 'کتاب با موفقیت مرتب سازی شد', + 'book_sort' => 'کتاب‌های مرتب‌شده', + 'book_sort_notification' => 'کتاب با موفقیت مجددا مرتب شد', // Bookshelves 'bookshelf_create' => 'ایجاد قفسه', @@ -99,8 +99,8 @@ 'user_update_notification' => 'کاربر با موفقیت به روز شد', 'user_delete' => 'کاربر حذف شده', 'user_delete_notification' => 'کاربر با موفقیت حذف شد', - 'user_mfa_reset' => 'reset MFA for user', - 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', + 'user_mfa_reset' => 'بازنشانی احراز هویت چندمرحله ای برای کاربر', + 'user_mfa_reset_notification' => 'بازنشانی روش‌های احراز هویت چندمرحله‌ای', // API Tokens 'api_token_create' => 'ایجاد توکن API', diff --git a/lang/fa/auth.php b/lang/fa/auth.php index 1896663996c..2ac7ddd4734 100644 --- a/lang/fa/auth.php +++ b/lang/fa/auth.php @@ -8,7 +8,7 @@ 'failed' => 'مشخصات وارد شده با اطلاعات ما سازگار نیست.', 'throttle' => 'دفعات تلاش شما برای ورود بیش از حد مجاز است. لطفا پس از :seconds ثانیه مجددا تلاش فرمایید.', - 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', + 'mfa_throttle' => 'تلاش‌های تأیید هویت چندمرحله‌ای بیش از حد مجاز است. لطفاً پس از :seconds ثانیه دوباره تلاش کنید.', // Login & Register 'sign_up' => 'ثبت نام', diff --git a/lang/fa/entities.php b/lang/fa/entities.php index afeeca0e045..7533edd43f8 100644 --- a/lang/fa/entities.php +++ b/lang/fa/entities.php @@ -173,7 +173,7 @@ 'books_sort_desc' => 'برای سامان‌دهی محتوای یک کتاب، می‌توانید فصل‌ها و صفحات آن را جابه‌جا کنید. همچنین می‌توانید کتاب‌های دیگری بیفزایید تا جابه‌جایی فصل‌ها و صفحات میان کتاب‌ها آسان شود. در صورت تمایل، می‌توانید قاعده‌ای برای مرتب‌سازی خودکار تعیین کنید تا محتوای کتاب در صورت ایجاد تغییرات، به طور خودکار مرتب شود.', 'books_sort_auto_sort' => 'گزینه مرتب‌سازی خودکار', 'books_sort_auto_sort_active' => 'مرتب‌سازی خودکار با قاعده: :sortName فعال است', - 'books_sort_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', + 'books_sort_auto_sort_creation_hint' => 'قوانین گزینه‌ی مرتب‌سازی خودکار را می‌توان توسط کاربری که دسترسی‌های لازم را دارد، در بخش تنظیمات "فهرست‌ها و مرتب‌سازی" ایجاد کرد.', 'books_sort_named' => 'مرتب‌سازی کتاب:bookName', 'books_sort_name' => 'مرتب‌سازی بر اساس نام', 'books_sort_created' => 'مرتب‌سازی بر اساس تاریخ ایجاد', @@ -331,9 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'نمایش/پنهان‌سازی نوار کناری', - 'page_contents' => 'Page Contents', - 'page_contents_none' => 'No headings were found in the page content.', - 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', + 'page_contents' => 'محتوای صفحه', + 'page_contents_none' => 'هیچ سرتیتری در محتوای صفحه یافت نشد.', + 'page_contents_info' => 'منوی محتوا از هر سرتیتری که در صفحه استفاده شده باشد تولید می شود.', 'page_tags' => 'برچسب‌های صفحه', 'chapter_tags' => 'برچسب‌های فصل', 'book_tags' => 'برچسب های کتاب', diff --git a/lang/fa/errors.php b/lang/fa/errors.php index 777d6e69293..94c268793a7 100644 --- a/lang/fa/errors.php +++ b/lang/fa/errors.php @@ -109,7 +109,7 @@ 'import_zip_cant_read' => 'امکان ایجاد کاربر وجود ندارد؛ زیرا ارسال ایمیل دعوت با خطا مواجه شد.', 'import_zip_cant_decode_data' => 'محتوای data.json در فایل ZIP پیدا یا رمزگشایی نشد.', 'import_zip_no_data' => 'داده‌های فایل ZIP فاقد محتوای کتاب، فصل یا صفحه مورد انتظار است.', - 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', + 'import_zip_data_too_large' => 'محتوای فایل ZIP data.json از حداکثر حجم آپلود مجازِ تعیین‌شده در برنامه بیشتر است.', 'import_validation_failed' => 'اعتبارسنجی فایل ZIP واردشده با خطا مواجه شد:', 'import_zip_failed_notification' => ' فایل ZIP وارد نشد.', 'import_perms_books' => 'شما مجوز لازم برای ایجاد کتاب را ندارید.', @@ -125,7 +125,7 @@ 'api_incorrect_token_secret' => 'راز ارائه شده برای کد API استفاده شده نادرست است', 'api_user_no_api_permission' => 'مالک نشانه API استفاده شده اجازه برقراری تماس های API را ندارد', 'api_user_token_expired' => 'رمز مجوز استفاده شده منقضی شده است', - 'api_cookie_auth_only_get' => 'Only GET requests are allowed when using the API with cookie-based authentication', + 'api_cookie_auth_only_get' => 'برای استفاده از API با احراز هویت مبتنی بر cookie فقط درخواست GET مجاز است', // Settings & Maintenance 'maintenance_test_email_failure' => 'خطا در هنگام ارسال ایمیل آزمایشی:', diff --git a/lang/fa/notifications.php b/lang/fa/notifications.php index d216b04fec6..b602095860c 100644 --- a/lang/fa/notifications.php +++ b/lang/fa/notifications.php @@ -11,11 +11,11 @@ 'updated_page_subject' => 'صفحه جدید: :pageName', 'updated_page_intro' => 'یک صفحه جدید ایجاد شده است در :appName:', 'updated_page_debounce' => 'برای جلوگیری از انبوه اعلان‌ها، برای مدتی اعلان‌ ویرایش‌هایی که توسط همان ویرایشگر در این صفحه انجام می‌شود، ارسال نخواهد شد.', - 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', - 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', + 'comment_mention_subject' => 'شما در نظرات صفحه :pageName تگ شده‌اید', + 'comment_mention_intro' => 'شما در نظری در :appName: تگ شده اید:', 'detail_page_name' => 'نام صفحه:', - 'detail_page_path' => 'نام میسر صفحه:', + 'detail_page_path' => 'نام مسیر صفحه:', 'detail_commenter' => 'نظر دهنده:', 'detail_comment' => 'نظر:', 'detail_created_by' => 'ایجاد شده توسط:', diff --git a/lang/fa/preferences.php b/lang/fa/preferences.php index 00d277bdfd5..b24225c3deb 100644 --- a/lang/fa/preferences.php +++ b/lang/fa/preferences.php @@ -9,7 +9,7 @@ 'shortcuts' => 'میانبرها', 'shortcuts_interface' => 'تنظیمات کلید‌های میانبر رابط کاربری', - 'shortcuts_toggle_desc' => 'در اینجا می توانید میانبرهای سیستم را که برای پیمایش و ... استفاده می شود، فعال یا غیرفعال کنید.', + 'shortcuts_toggle_desc' => 'در اینجا می توانید میانبرهای سیستم را که برای پیمایش و... استفاده می شود، فعال یا غیرفعال کنید.', 'shortcuts_customize_desc' => 'می توانید هر یک از میانبرهای زیر را سفارشی کنید. کافی است پس از انتخاب ورودی برای میانبر، کلید ترکیبی مورد نظر خود را فشار دهید.', 'shortcuts_toggle_label' => 'میانبرهای صفحه کلید فعال شد', 'shortcuts_section_navigation' => 'ناوبری و پیمایش', @@ -23,7 +23,7 @@ 'notifications_desc' => 'تنظیمات اطلاعیه‌های ایمیلی هنگام انجام فعالیت‌های خاص در سیستم.', 'notifications_opt_own_page_changes' => 'در صورت تغییرات در صفحاتی که متعلق به من است، اطلاع بده', 'notifications_opt_own_page_comments' => 'در صورت ثبت نظر در صفحاتی که متعلق به من است، اطلاع بده', - 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', + 'notifications_opt_comment_mentions' => 'وقتی که من در یک نظر تگ شده ام اطلاع بده', 'notifications_opt_comment_replies' => 'پس از درج پاسخ به روی نظراتی که من ثبت کرده‌ام، اطلاع بده', 'notifications_save' => 'ذخیره تنظیمات', 'notifications_update_success' => 'تنظیمات اعلان‌ها به روز شده است!', @@ -36,7 +36,7 @@ 'auth_change_password_success' => 'رمز عبور به روز شد!', 'profile' => 'جزئیات پروفایل', - 'profile_desc' => 'علاوه بر جزئیاتی که برای ارتباط و شخصی‌سازی سیستم استفاده می‌شود، جزییات حساب خود را که نشان دهنده شما برای سایر کاربران است، مدیریت کنید.', + 'profile_desc' => 'علاوه بر جزئیاتی که برای ارتباط و شخصی‌سازی سیستم استفاده می‌شود، جزئیات حساب خود را که نشان دهنده شما برای سایر کاربران است، مدیریت کنید.', 'profile_view_public' => 'مشاهده پروفایل عمومی', 'profile_name_desc' => 'نام نمایشی خود را تنظیم کنید. این نام بسته به فعالیت شما و محتوای متعلق به شما، برای سایر کاربران سیستم قابل مشاهده است.', 'profile_email_desc' => 'این ایمیل برای اعلان‌ها و دسترسی به سیستم استفاده خواهد شد.', diff --git a/lang/fa/settings.php b/lang/fa/settings.php index cf1413b965c..4ba9a1fcd83 100644 --- a/lang/fa/settings.php +++ b/lang/fa/settings.php @@ -75,8 +75,8 @@ 'reg_confirm_restrict_domain_placeholder' => 'بدون محدودیت', // Sorting Settings - 'sorting' => 'Lists & Sorting', - 'sorting_book_default' => 'Default Book Sort Rule', + 'sorting' => 'فهرست‌ها و مرتب‌سازی', + 'sorting_book_default' => 'قانون پیش‌فرض مرتب‌سازی کتاب', 'sorting_book_default_desc' => 'قانون پیش‌فرض مرتب‌سازی را برای کتاب‌های جدید انتخاب کنید. تغییر قانون بر ترتیب کتاب‌های موجود تأثیری ندارد و می‌تواند برای هر کتاب به‌صورت جداگانه تغییر یابد.', 'sorting_rules' => 'قوانین مرتب‌سازی', 'sorting_rules_desc' => 'این‌ها عملیات مرتب‌سازی از پیش تعریف‌شده‌ای هستند که می‌توانید آن‌ها را بر محتوای سیستم اعمال کنید.', @@ -103,8 +103,8 @@ 'sort_rule_op_updated_date' => 'تاریخ به‌روزرسانی', 'sort_rule_op_chapters_first' => 'ابتدا فصل‌ها', 'sort_rule_op_chapters_last' => 'فصل‌ها در آخر', - 'sorting_page_limits' => 'Per-Page Display Limits', - 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using a multiple of 6 is recommended.', + 'sorting_page_limits' => 'محدودیت نمایش در هر صفحه', + 'sorting_page_limits_desc' => 'تعیین کنید که چه تعداد آیتم در فهرست‌های مختلف سیستم در هر صفحه نمایش داده شود. معمولاً مقدار کمتر عملکرد بهتری خواهد داشت، در حالی که مقدار بیشتر نیاز به کلیک کردن در صفحات متعدد را از بین می‌برد. استفاده از ضریبی از ۶ توصیه می‌شود.', // Maintenance settings 'maint' => 'نگهداری', @@ -197,17 +197,17 @@ 'role_import_content' => 'وارد کردن محتوا', 'role_editor_change' => 'تغییر ویرایشگر صفحه', 'role_notifications' => 'دریافت و مدیریت اعلان‌ها', - 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', + 'role_permission_note_users_and_roles' => 'این دسترسی ها از نظر فنی امکان مشاهده و جستجوی کاربران و نقش ها را در سیستم فراهم می کنند.', 'role_asset' => 'مجوزهای دارایی', 'roles_system_warning' => 'توجه داشته باشید که دسترسی به هر یک از سه مجوز فوق می‌تواند به کاربر اجازه دهد تا امتیازات خود یا امتیازات دیگران را در سیستم تغییر دهد. فقط نقش هایی را با این مجوزها به کاربران مورد اعتماد اختصاص دهید.', 'role_asset_desc' => 'این مجوزها دسترسی پیش‌فرض به دارایی‌های درون سیستم را کنترل می‌کنند. مجوزهای مربوط به کتاب‌ها، فصل‌ها و صفحات این مجوزها را لغو می‌کنند.', 'role_asset_admins' => 'به ادمین‌ها به‌طور خودکار به همه محتوا دسترسی داده می‌شود، اما این گزینه‌ها ممکن است گزینه‌های UI را نشان داده یا پنهان کنند.', 'role_asset_image_view_note' => 'این مربوط به مرئی بودن در بخش مدیر تصاویر است. دسترسی عملی به تصاویر آپلود شده بستگی به گزینه ذخیره‌سازی تصویر سیستم دارد.', - 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', + 'role_asset_users_note' => 'این دسترسی ها از نظر فنی امکان مشاهده و جستجوی کاربران و نقش ها را در سیستم فراهم می کنند.', 'role_all' => 'همه', 'role_own' => 'صاحب', 'role_controlled_by_asset' => 'توسط دارایی که در آن آپلود می شود کنترل می شود', - 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', + 'role_controlled_by_page_delete' => 'کنترل شده با دسترسی حذف صفحه', 'role_save' => 'ذخیره نقش', 'role_users' => 'کاربران در این نقش', 'role_users_none' => 'در حال حاضر هیچ کاربری به این نقش اختصاص داده نشده است', @@ -264,9 +264,9 @@ 'users_mfa_desc' => 'تنظیم احراز هویت چند مرحله ای یک لایه امنیتی دیگر به حساب شما اضافه میکند.', 'users_mfa_x_methods' => ':count روش پیکربندی شده است|:count روش های پیکربندی شده', 'users_mfa_configure' => 'روش پیکربندی', - 'users_mfa_reset' => 'Reset Multi-Factor Authentication Methods', - 'users_mfa_reset_desc' => 'This will reset and clear all configured multi-factor authentication methods for this user. If multi-factor authentication is required by any of their roles, they\'ll be prompted to configure new methods on their next login.', - 'users_mfa_reset_confirm' => 'Are you sure you want to reset multi-factor authentication for this user?', + 'users_mfa_reset' => 'بازنشانی روش های احراز هویت چندمرحله ای', + 'users_mfa_reset_desc' => 'این کار تمام روش‌های احراز هویت چندمرحله‌ای پیکربندی‌شده برای این کاربر را بازنشانی و پاک می‌کند. اگر احراز هویت چندمرحله‌ای توسط هر یک از نقش‌های آن‌ها الزامی شده باشد، در ورود بعدی از آن‌ها خواسته می‌شود که روش‌های جدیدی را پیکربندی کنند.', + 'users_mfa_reset_confirm' => 'آیا شما از بازنشانی احراز هویت چندمرحله ای برای این کاربر اطمینان دارید؟', // API Tokens 'user_api_token_create' => 'ایجاد توکن API', diff --git a/lang/fa/validation.php b/lang/fa/validation.php index 93c7dcb6669..c4ccabfca19 100644 --- a/lang/fa/validation.php +++ b/lang/fa/validation.php @@ -106,7 +106,7 @@ 'uploaded' => 'بارگذاری فایل :attribute موفقیت آمیز نبود.', 'zip_file' => 'ویژگی :attribute باید به یک فایل درون پرونده فشرده شده اشاره کند.', - 'zip_file_size' => 'The file :attribute must not exceed :size MB.', + 'zip_file_size' => 'حجم فایل :attribute نباید بیشتر از :size مگابایت باشد.', 'zip_file_mime' => 'ویژگی :attribute باید به فایلی با نوع :validTypes اشاره کند، اما نوع یافت‌شده :foundType است.', 'zip_model_expected' => 'سیستم در این بخش انتظار دریافت یک شیء داده‌ای را داشت، اما «:type» دریافت گردید', 'zip_unique' => 'برای هر نوع شیء در فایل ZIP، مقدار ویژگی :attribute باید یکتا و بدون تکرار باشد.', diff --git a/lang/ja/activities.php b/lang/ja/activities.php index 25ded1d49b7..93a3597f83a 100644 --- a/lang/ja/activities.php +++ b/lang/ja/activities.php @@ -99,8 +99,8 @@ 'user_update_notification' => 'ユーザーを更新しました', 'user_delete' => 'がユーザを削除', 'user_delete_notification' => 'ユーザーを削除しました', - 'user_mfa_reset' => 'reset MFA for user', - 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', + 'user_mfa_reset' => 'ユーザーの多要素認証をリセット', + 'user_mfa_reset_notification' => '多要素認証方法をリセット', // API Tokens 'api_token_create' => 'がAPIトークンを作成', diff --git a/lang/ja/auth.php b/lang/ja/auth.php index b4ee5f4acf9..6a68de99c1a 100644 --- a/lang/ja/auth.php +++ b/lang/ja/auth.php @@ -8,7 +8,7 @@ 'failed' => 'この資格情報は登録されていません。', 'throttle' => 'ログイン試行回数が制限を超えました。:seconds秒後に再試行してください。', - 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', + 'mfa_throttle' => '多要素認証の試行回数が制限を越えました。:seconds 秒後に再試行してください。', // Login & Register 'sign_up' => '新規登録', diff --git a/lang/ja/entities.php b/lang/ja/entities.php index 66019fa1aca..164004451b5 100644 --- a/lang/ja/entities.php +++ b/lang/ja/entities.php @@ -332,9 +332,9 @@ // Editor Sidebar 'toggle_sidebar' => 'サイドバーの切り替え', - 'page_contents' => 'Page Contents', - 'page_contents_none' => 'No headings were found in the page content.', - 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', + 'page_contents' => 'ページの内容', + 'page_contents_none' => 'ページ内に見出しが見つかりませんでした。', + 'page_contents_info' => '目次メニューは、ページ内で使用されている見出し書式から生成されます。', 'page_tags' => 'タグ', 'chapter_tags' => 'チャプターのタグ', 'book_tags' => 'ブックのタグ', diff --git a/lang/ja/settings.php b/lang/ja/settings.php index 5dbb35c0303..023ce2780db 100644 --- a/lang/ja/settings.php +++ b/lang/ja/settings.php @@ -264,9 +264,9 @@ 'users_mfa_desc' => 'アカウントのセキュリティを強化するために、多要素認証を設定してください。', 'users_mfa_x_methods' => ':count個の手段が設定されています|:count個の手段が設定されています', 'users_mfa_configure' => '手段を設定', - 'users_mfa_reset' => 'Reset Multi-Factor Authentication Methods', - 'users_mfa_reset_desc' => 'This will reset and clear all configured multi-factor authentication methods for this user. If multi-factor authentication is required by any of their roles, they\'ll be prompted to configure new methods on their next login.', - 'users_mfa_reset_confirm' => 'Are you sure you want to reset multi-factor authentication for this user?', + 'users_mfa_reset' => '多要素認証方法をリセット', + 'users_mfa_reset_desc' => 'このユーザーに設定されたすべての多要素認証方法をリセットしてクリアします。 いずれかのロールで多要素認証が必須になっている場合、次回のログイン時に新しい認証方法を設定するよう求められます。', + 'users_mfa_reset_confirm' => 'このユーザーの多要素認証をリセットしてもよろしいですか?', // API Tokens 'user_api_token_create' => 'APIトークンの作成', diff --git a/phpstan.neon.dist b/phpstan.neon.dist index bab28ea0eb3..54403adfefc 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -7,7 +7,7 @@ parameters: - app # The level 8 is the highest level - level: 4 + level: 5 phpVersion: min: 80200 diff --git a/resources/icons/chevron-left.svg b/resources/icons/chevron-left.svg new file mode 100644 index 00000000000..e64210047b5 --- /dev/null +++ b/resources/icons/chevron-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/interface.svg b/resources/icons/interface.svg new file mode 100644 index 00000000000..af1c5fc4975 --- /dev/null +++ b/resources/icons/interface.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/js/components/dropdown.js b/resources/js/components/dropdown.js index d2b044ee1ca..4da08f23189 100644 --- a/resources/js/components/dropdown.js +++ b/resources/js/components/dropdown.js @@ -12,7 +12,7 @@ export class Dropdown extends Component { this.container = this.$el; this.menu = this.$refs.menu; this.toggle = this.$refs.toggle; - this.moveMenu = this.$opts.moveMenu; + this.fixedPositionMenu = this.$opts.fixedPositionMenu === 'true'; this.bubbleEscapes = this.$opts.bubbleEscapes === 'true'; this.direction = (document.dir === 'rtl') ? 'right' : 'left'; @@ -33,13 +33,13 @@ export class Dropdown extends Component { const menuOriginalRect = this.menu.getBoundingClientRect(); let heightOffset = 0; const toggleHeight = this.toggle.getBoundingClientRect().height; - const containerBounds = findClosestScrollContainer(this.menu).getBoundingClientRect(); + const containerEl = this.fixedPositionMenu ? this.body : findClosestScrollContainer(this.menu); + const containerBounds = containerEl.getBoundingClientRect(); const dropUpwards = menuOriginalRect.bottom > containerBounds.bottom; const containerRect = this.container.getBoundingClientRect(); // If enabled, Move to body to prevent being trapped within scrollable sections - if (this.moveMenu) { - this.body.appendChild(this.menu); + if (this.fixedPositionMenu) { this.menu.style.position = 'fixed'; this.menu.style.width = `${menuOriginalRect.width}px`; this.menu.style.left = `${menuOriginalRect.left}px`; @@ -99,12 +99,11 @@ export class Dropdown extends Component { this.menu.style.bottom = ''; this.menu.style.maxHeight = ''; - if (this.moveMenu) { + if (this.fixedPositionMenu) { this.menu.style.position = ''; this.menu.style[this.direction] = ''; this.menu.style.width = ''; this.menu.style.left = ''; - this.container.appendChild(this.menu); } this.showing = false; @@ -125,10 +124,6 @@ export class Dropdown extends Component { this.hide(); }); - if (this.moveMenu) { - keyboardNavHandler.shareHandlingToEl(this.menu); - } - // Hide menu on option click this.container.addEventListener('click', event => { const possibleChildren = Array.from(this.menu.querySelectorAll('a')); diff --git a/resources/js/components/index.ts b/resources/js/components/index.ts index 688877227f2..e124046b9fc 100644 --- a/resources/js/components/index.ts +++ b/resources/js/components/index.ts @@ -30,6 +30,7 @@ export {GlobalSearch} from './global-search'; export {HeaderMobileToggle} from './header-mobile-toggle'; export {ImageManager} from './image-manager'; export {ImagePicker} from './image-picker'; +export {LayoutEditor} from './layout-editor'; export {ListSortControl} from './list-sort-control'; export {LoadingButton} from './loading-button'; export {MarkdownEditor} from './markdown-editor'; diff --git a/resources/js/components/layout-editor.ts b/resources/js/components/layout-editor.ts new file mode 100644 index 00000000000..babacc2765c --- /dev/null +++ b/resources/js/components/layout-editor.ts @@ -0,0 +1,82 @@ +import Sortable from "sortablejs"; +import {Component} from "./component"; +import {buildListActions, sortActionClickListener} from "../services/multi-lists"; + + +export class LayoutEditor extends Component { + protected input!: HTMLInputElement; + protected columns!: HTMLElement[]; + protected actionMenus!: HTMLElement[]; + + setup(): void { + this.input = this.$refs.input as HTMLInputElement; + this.columns = this.$manyRefs.column || []; + this.actionMenus = this.$manyRefs.actionMenu || []; + + this.initSortable(); + + const unusedColumn = this.columns.find(column => column.dataset.column === 'unused') as HTMLElement; + const listActions = buildListActions(...this.columns); + listActions.add = this.addBlockToLeastPopulated.bind(this); + listActions.remove = function (item: HTMLElement) { + unusedColumn.appendChild(item); + }; + const sortActionListener = sortActionClickListener(listActions, this.onChange.bind(this)); + for (const actionMenu of this.actionMenus) { + actionMenu.addEventListener('click', sortActionListener); + } + } + + protected initSortable(): void { + const sortAction = this.onChange.bind(this); + + for (const column of this.columns) { + new Sortable(column, { + group: 'layout-editor-blocks', + ghostClass: 'primary-background-light', + handle: '.handle', + animation: 150, + onSort: sortAction, + }); + } + } + + protected onChange(): void { + const configured: Record = {}; + for (const column of this.columns) { + const location = column.dataset.column || ''; + if (!location) continue; + configured[location] = []; + + const blockNodes = column.children; + for (let i = 0; i < blockNodes.length; i++) { + const blockNode = blockNodes[i] as HTMLElement; + const blockId = blockNode.dataset.blockId || ''; + if (!blockId) continue; + configured[location].push(blockId); + } + } + + this.input.value = JSON.stringify(configured, null, 2); + } + + protected addBlockToLeastPopulated(item: HTMLElement): void { + let populationCount = 100; + let leastPopulated: null|HTMLElement = null + for (const column of this.columns) { + const blockCount = column.children.length; + if (column.dataset.column === 'unused') { + continue; + } + + if (blockCount < populationCount) { + leastPopulated = column; + populationCount = blockCount; + } + } + + if (leastPopulated) { + leastPopulated.appendChild(item); + } + } +} \ No newline at end of file diff --git a/resources/js/components/shelf-sort.js b/resources/js/components/shelf-sort.js index b56b01980a1..593bf03a6a9 100644 --- a/resources/js/components/shelf-sort.js +++ b/resources/js/components/shelf-sort.js @@ -1,6 +1,6 @@ import Sortable from 'sortablejs'; import {Component} from './component'; -import {buildListActions, sortActionClickListener} from '../services/dual-lists.ts'; +import {buildListActions, sortActionClickListener} from '../services/multi-lists.ts'; export class ShelfSort extends Component { diff --git a/resources/js/components/sort-rule-manager.ts b/resources/js/components/sort-rule-manager.ts index ff08f4ab878..70d885815b0 100644 --- a/resources/js/components/sort-rule-manager.ts +++ b/resources/js/components/sort-rule-manager.ts @@ -1,6 +1,6 @@ import {Component} from "./component.js"; import Sortable from "sortablejs"; -import {buildListActions, sortActionClickListener} from "../services/dual-lists"; +import {buildListActions, sortActionClickListener} from "../services/multi-lists"; export class SortRuleManager extends Component { diff --git a/resources/js/services/dom.ts b/resources/js/services/dom.ts index 8696fe81639..16942d5033b 100644 --- a/resources/js/services/dom.ts +++ b/resources/js/services/dom.ts @@ -259,7 +259,7 @@ export function hashElement(element: HTMLElement): string { } /** - * Find the closest scroll container parent for the given element + * Find the closest scroll container parent for the given element, * otherwise will default to the body element. */ export function findClosestScrollContainer(start: HTMLElement): HTMLElement { diff --git a/resources/js/services/events.ts b/resources/js/services/events.ts index 6045d51f823..1b39e413fbb 100644 --- a/resources/js/services/events.ts +++ b/resources/js/services/events.ts @@ -67,8 +67,10 @@ export class EventManager { * Notify of standard server-provided validation errors. */ showValidationErrors(responseErr: HttpError): void { - if (responseErr.status === 422 && responseErr.data) { - const message = Object.values(responseErr.data).flat().join('\n'); + if (responseErr.status === 422 && responseErr.data instanceof Object) { + const data = responseErr.data; + const errorValues = Object.values(data.errors ?? data); + const message = errorValues.flat().join('\n'); this.error(message); } } diff --git a/resources/js/services/http.ts b/resources/js/services/http.ts index f9eaafc3912..e8787ad8de9 100644 --- a/resources/js/services/http.ts +++ b/resources/js/services/http.ts @@ -192,9 +192,8 @@ export class HttpManager { } /** - * Parse the response text for an error response to a user - * presentable string. Handles a range of errors responses including - * validation responses & server response text. + * Parse the response text for an error response to a user-presentable string. + * Handles a range of error responses, including validation responses and server response text. */ protected formatErrorResponseText(text: string): string { const data = text.startsWith('{') ? JSON.parse(text) : {message: text}; @@ -206,13 +205,13 @@ export class HttpManager { return data.message || data.error; } - const values = Object.values(data); - const isValidation = values.every(val => { + const errorValues = Object.values(data.errors || {}); + const isValidation = errorValues.length > 0 && errorValues.every(val => { return Array.isArray(val) && val.every(x => typeof x === 'string'); }); if (isValidation) { - return values.flat().join(' '); + return errorValues.flat().join(' '); } return text; diff --git a/resources/js/services/keyboard-navigation.ts b/resources/js/services/keyboard-navigation.ts index 13fbdfecc9d..70f99330d38 100644 --- a/resources/js/services/keyboard-navigation.ts +++ b/resources/js/services/keyboard-navigation.ts @@ -87,7 +87,9 @@ export class KeyboardNavigationHandler { const focusable: HTMLElement[] = []; const selector = '[tabindex]:not([tabindex="-1"]),[href],button:not([tabindex="-1"],[disabled]),input:not([type=hidden])'; for (const container of this.containers) { - const toAdd = [...container.querySelectorAll(selector)].filter(e => isHTMLElement(e)); + const toAdd = [...container.querySelectorAll(selector)].filter(e => { + return isHTMLElement(e) && e.checkVisibility(); + }) as HTMLElement[]; focusable.push(...toAdd); } diff --git a/resources/js/services/dual-lists.ts b/resources/js/services/multi-lists.ts similarity index 61% rename from resources/js/services/dual-lists.ts rename to resources/js/services/multi-lists.ts index 98f2af92daf..29c88d23837 100644 --- a/resources/js/services/dual-lists.ts +++ b/resources/js/services/multi-lists.ts @@ -1,13 +1,12 @@ /** * Service for helping manage common dual-list scenarios. - * (Shelf book manager, sort set manager). + * (Shelf book manager, sort set manager, layout-editor). */ type ListActionsSet = Record void)>; export function buildListActions( - availableList: HTMLElement, - configuredList: HTMLElement, + ...lists: HTMLElement[] ): ListActionsSet { return { move_up(item) { @@ -22,11 +21,29 @@ export function buildListActions( const newIndex = Math.min(index + 2, list.children.length); list.insertBefore(item, list.children[newIndex] || null); }, + move_right(item) { + const list = item.parentNode as HTMLElement; + const listIndex = lists.indexOf(list); + const targetListIndex = Math.min(listIndex + 1, lists.length - 1); + lists[targetListIndex].appendChild(item); + }, + move_left(item) { + const list = item.parentNode as HTMLElement; + const listIndex = lists.indexOf(list); + const targetListIndex = Math.max(listIndex - 1, 0); + lists[targetListIndex].appendChild(item); + }, remove(item) { - availableList.appendChild(item); + const otherList = lists.find(list => list !== item.parentNode); + if (otherList) { + otherList.appendChild(item); + } }, add(item) { - configuredList.appendChild(item); + const otherList = lists.find(list => list !== item.parentNode); + if (otherList) { + otherList.appendChild(item); + } }, }; } diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index bc7a0ed8823..ba53bfb97f2 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -1246,11 +1246,19 @@ input.scroll-box-search, .scroll-box-header-item { display: none; } -.scroll-box > li.empty-state { +.scroll-box.layout-editor-column-left [data-action="move_left"], +.scroll-box.layout-editor-column-right [data-action="move_right"], +.scroll-box.layout-editor-column-unused [data-action="move_right"], +.scroll-box.layout-editor-column-unused [data-action="move_left"], +.scroll-box.layout-editor-column-unused [data-action="remove"], +.scroll-box.layout-editor-column-left [data-action="add"], +.scroll-box.layout-editor-column-right [data-action="add"], +.scroll-box.layout-editor-column-center [data-action="add"], +{ display: none; } -.scroll-box > li.empty-state:last-child { - display: list-item; +.scroll-box:has(> li:not(.empty-state)) .empty-state { + display: none; } details.section-expander summary { diff --git a/resources/views/books/index.blade.php b/resources/views/books/index.blade.php index 660c008dfb1..6e311a88bae 100644 --- a/resources/views/books/index.blade.php +++ b/resources/views/books/index.blade.php @@ -5,11 +5,9 @@ @stop @section('left') - @include('books.parts.index-sidebar-section-recents', ['recents' => $recents]) - @include('books.parts.index-sidebar-section-popular', ['popular' => $popular]) - @include('books.parts.index-sidebar-section-new', ['new' => $new]) + @include('common.view-blocks', ['location' => 'books-index', 'position' => 'left']) @stop @section('right') - @include('books.parts.index-sidebar-section-actions', ['view' => $view]) + @include('common.view-blocks', ['location' => 'books-index', 'position' => 'right']) @stop diff --git a/resources/views/books/parts/show-sidebar-section-details.blade.php b/resources/views/books/parts/show-sidebar-section-details.blade.php index 709d0ffd9a1..2c3d6d141dd 100644 --- a/resources/views/books/parts/show-sidebar-section-details.blade.php +++ b/resources/views/books/parts/show-sidebar-section-details.blade.php @@ -1,7 +1,7 @@
{{ trans('common.details') }}
+ +
+ +@stop diff --git a/resources/views/settings/layouts/parts/block-column.blade.php b/resources/views/settings/layouts/parts/block-column.blade.php new file mode 100644 index 00000000000..20da8dff72f --- /dev/null +++ b/resources/views/settings/layouts/parts/block-column.blade.php @@ -0,0 +1,17 @@ +{{-- +$columnBlocks - array - Blocks to list +$label - string - Section title +$id - string - identifier for location/column +--}} +
+ +
    +
  • {{ trans('preferences.layout_edit_empty') }}
  • + @foreach($columnBlocks as $block) + @include('settings.layouts.parts.block', ['block' => $block]) + @endforeach +
+
\ No newline at end of file diff --git a/resources/views/settings/layouts/parts/block.blade.php b/resources/views/settings/layouts/parts/block.blade.php new file mode 100644 index 00000000000..1885c4254cf --- /dev/null +++ b/resources/views/settings/layouts/parts/block.blade.php @@ -0,0 +1,23 @@ +@php /** @var $block class-string<\BookStack\View\ViewBlockInterface> */ @endphp +
  • +
    @icon('grip')
    +
    {{ $block::getLabel() }}
    + +
  • \ No newline at end of file diff --git a/resources/views/shelves/index.blade.php b/resources/views/shelves/index.blade.php index 70357068d7e..87c6392983f 100644 --- a/resources/views/shelves/index.blade.php +++ b/resources/views/shelves/index.blade.php @@ -4,12 +4,10 @@ @include('shelves.parts.list', ['shelves' => $shelves, 'view' => $view, 'listOptions' => $listOptions]) @stop -@section('right') - @include('shelves.parts.index-sidebar-section-actions', ['view' => $view]) +@section('left') + @include('common.view-blocks', ['location' => 'shelves-index', 'position' => 'left']) @stop -@section('left') - @include('shelves.parts.index-sidebar-section-recents', ['recents' => $recents]) - @include('shelves.parts.index-sidebar-section-popular', ['popular' => $popular]) - @include('shelves.parts.index-sidebar-section-new', ['new' => $new]) -@stop \ No newline at end of file +@section('right') + @include('common.view-blocks', ['location' => 'shelves-index', 'position' => 'right']) +@stop diff --git a/resources/views/shelves/show.blade.php b/resources/views/shelves/show.blade.php index 9d07e5da018..b942f35ff7a 100644 --- a/resources/views/shelves/show.blade.php +++ b/resources/views/shelves/show.blade.php @@ -69,15 +69,9 @@ @stop @section('left') - @include('shelves.parts.show-sidebar-section-tags', ['shelf' => $shelf]) - @include('shelves.parts.show-sidebar-section-details', ['shelf' => $shelf]) - @include('shelves.parts.show-sidebar-section-activity', ['activity' => $activity]) + @include('common.view-blocks', ['location' => 'shelves-show', 'position' => 'left']) @stop @section('right') - @include('shelves.parts.show-sidebar-section-actions', ['shelf' => $shelf, 'view' => $view]) + @include('common.view-blocks', ['location' => 'shelves-show', 'position' => 'right']) @stop - - - - diff --git a/resources/views/users/account/interface.blade.php b/resources/views/users/account/interface.blade.php new file mode 100644 index 00000000000..7d78f08c81b --- /dev/null +++ b/resources/views/users/account/interface.blade.php @@ -0,0 +1,54 @@ +@extends('users.account.layout') + +@section('main') +
    +
    + {{ method_field('put') }} + {{ csrf_field() }} + +

    {{ trans('preferences.interface') }}

    +

    {{ trans('preferences.interface_desc') }}

    + +
    + @include('users.parts.language-option-row', ['value' => old('language') ?? user()->getLocale()->appLocale()]) + @include('users.account.parts.display-mode-option-row') +
    + +
    + +
    + +
    +
    + +
    +

    {{ trans('preferences.layouts') }}

    +

    {{ trans('preferences.layouts_desc') }}

    + +
    + @foreach($namedLocations as $locationKey => $locationName) + + @endforeach +
    + +
    + +
    +
    +
    +

    {{ trans('preferences.shortcuts_interface') }}

    +

    {{ trans('preferences.shortcuts_overview_desc') }}

    +
    + +
    +
    +@stop diff --git a/resources/views/users/account/layout.blade.php b/resources/views/users/account/layout.blade.php index df8ebc2d904..94f1f328de0 100644 --- a/resources/views/users/account/layout.blade.php +++ b/resources/views/users/account/layout.blade.php @@ -11,6 +11,7 @@