From 5611c8c0da8927cd4831a9dd23a022a2d9bcfa18 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 30 May 2026 21:44:25 +0100 Subject: [PATCH 01/72] Maintenance: Started work on PHPStan Level 5 --- app/Access/ExternalBaseUserProvider.php | 2 +- app/Access/Guards/ExternalBaseSessionGuard.php | 8 ++++++-- app/Access/Guards/LdapSessionGuard.php | 4 ++++ app/Access/Oidc/OidcJwtWithClaims.php | 2 -- app/Activity/Models/Comment.php | 8 +++++++- app/Api/ApiDocsGenerator.php | 4 ++-- app/App/Providers/ThemeServiceProvider.php | 7 ++++++- app/App/helpers.php | 3 ++- app/Entities/Models/Entity.php | 2 +- app/Entities/Queries/QueryPopular.php | 2 ++ app/Entities/Queries/QueryRecentlyViewed.php | 2 ++ app/Entities/Queries/QueryTopFavourites.php | 2 ++ app/Http/Controller.php | 3 ++- app/Util/DatabaseTransaction.php | 3 ++- phpstan.neon.dist | 2 +- 15 files changed, 40 insertions(+), 14 deletions(-) diff --git a/app/Access/ExternalBaseUserProvider.php b/app/Access/ExternalBaseUserProvider.php index 2165fd459..2bc15cb03 100644 --- a/app/Access/ExternalBaseUserProvider.php +++ b/app/Access/ExternalBaseUserProvider.php @@ -42,7 +42,7 @@ class ExternalBaseUserProvider implements UserProvider /** * Retrieve a user by the given credentials. */ - public function retrieveByCredentials(array $credentials): ?Authenticatable + public function retrieveByCredentials(array $credentials): ?User { return User::query() ->where('external_auth_id', $credentials['external_auth_id']) diff --git a/app/Access/Guards/ExternalBaseSessionGuard.php b/app/Access/Guards/ExternalBaseSessionGuard.php index 91239599b..b7bf174a1 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 9455d530d..f3628c897 100644 --- a/app/Access/Guards/LdapSessionGuard.php +++ b/app/Access/Guards/LdapSessionGuard.php @@ -83,6 +83,10 @@ class LdapSessionGuard extends ExternalBaseSessionGuard } } + 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 9d7eeead1..9763ab158 100644 --- a/app/Access/Oidc/OidcJwtWithClaims.php +++ b/app/Access/Oidc/OidcJwtWithClaims.php @@ -131,8 +131,6 @@ class OidcJwtWithClaims implements ProvidesClaims } }, $this->keys); - $parsedKeys = array_filter($parsedKeys); - $contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1]; /** @var OidcJwtSigningKey $parsedKey */ foreach ($parsedKeys as $parsedKey) { diff --git a/app/Activity/Models/Comment.php b/app/Activity/Models/Comment.php index 3faa76657..b49eb4564 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 @@ class Comment extends Model implements Loggable, OwnableInterface // 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/Api/ApiDocsGenerator.php b/app/Api/ApiDocsGenerator.php index 53cb2890a..8d1be79c8 100644 --- a/app/Api/ApiDocsGenerator.php +++ b/app/Api/ApiDocsGenerator.php @@ -138,8 +138,8 @@ class ApiDocsGenerator 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/Providers/ThemeServiceProvider.php b/app/App/Providers/ThemeServiceProvider.php index 671e5e1df..9b0f7001e 100644 --- a/app/App/Providers/ThemeServiceProvider.php +++ b/app/App/Providers/ThemeServiceProvider.php @@ -7,6 +7,7 @@ use BookStack\Theming\ThemeService; 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 @@ class ThemeServiceProvider extends ServiceProvider // 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/helpers.php b/app/App/helpers.php index 8f210ecaf..45a84da8d 100644 --- a/app/App/helpers.php +++ b/app/App/helpers.php @@ -6,6 +6,7 @@ use BookStack\Facades\Theme; 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/Entities/Models/Entity.php b/app/Entities/Models/Entity.php index 27cfccaa8..296bd3a66 100644 --- a/app/Entities/Models/Entity.php +++ b/app/Entities/Models/Entity.php @@ -46,7 +46,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; * @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/Queries/QueryPopular.php b/app/Entities/Queries/QueryPopular.php index 065ae82ef..1782070de 100644 --- a/app/Entities/Queries/QueryPopular.php +++ b/app/Entities/Queries/QueryPopular.php @@ -6,6 +6,7 @@ use BookStack\Activity\Models\View; 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 @@ class QueryPopular 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 f28b8f865..8ba1a57ba 100644 --- a/app/Entities/Queries/QueryRecentlyViewed.php +++ b/app/Entities/Queries/QueryRecentlyViewed.php @@ -5,6 +5,7 @@ namespace BookStack\Entities\Queries; 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 @@ class QueryRecentlyViewed 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 6340e35ef..2719e94bc 100644 --- a/app/Entities/Queries/QueryTopFavourites.php +++ b/app/Entities/Queries/QueryTopFavourites.php @@ -5,6 +5,7 @@ namespace BookStack\Entities\Queries; 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 @@ class QueryTopFavourites return collect(); } + /** @var Builder $query */ $query = $this->permissions ->restrictEntityRelationQuery(Favourite::query(), 'favourites', 'favouritable_id', 'favouritable_type') ->select('favourites.*') diff --git a/app/Http/Controller.php b/app/Http/Controller.php index 796505795..09c38ee61 100644 --- a/app/Http/Controller.php +++ b/app/Http/Controller.php @@ -7,6 +7,7 @@ use BookStack\App\Model; 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 @@ abstract class Controller extends BaseController /** * 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/Util/DatabaseTransaction.php b/app/Util/DatabaseTransaction.php index e36bd2ef3..2e57b6479 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 @@ use Throwable; class DatabaseTransaction { /** - * @param (Closure(static): TReturn) $callback + * @param (Closure(Connection): TReturn) $callback */ public function __construct( protected Closure $callback diff --git a/phpstan.neon.dist b/phpstan.neon.dist index bab28ea0e..54403adfe 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 From 03c9ddd14fed16177ebe59adaef36fd6eb5bded4 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 31 May 2026 11:12:23 +0100 Subject: [PATCH 02/72] Maintenance: Further PHPStan level 5 type fixes --- app/Access/Oidc/OidcService.php | 1 + app/Activity/ActivityQueries.php | 3 ++ app/Activity/Tools/WebhookFormatter.php | 8 +++- .../Controllers/PageApiController.php | 20 ++++++---- app/Entities/Controllers/PageController.php | 5 ++- .../Controllers/PageRevisionController.php | 2 +- .../Tools/Markdown/CustomListItemRenderer.php | 6 ++- app/Entities/Tools/PageContent.php | 37 +++++++++++++------ app/Entities/Tools/PageIncludeParser.php | 4 +- app/Entities/Tools/SlugGenerator.php | 2 +- 10 files changed, 59 insertions(+), 29 deletions(-) diff --git a/app/Access/Oidc/OidcService.php b/app/Access/Oidc/OidcService.php index a84bd3205..19120e2d3 100644 --- a/app/Access/Oidc/OidcService.php +++ b/app/Access/Oidc/OidcService.php @@ -80,6 +80,7 @@ class OidcService $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 d5b047937..a3383e809 100644 --- a/app/Activity/ActivityQueries.php +++ b/app/Activity/ActivityQueries.php @@ -11,6 +11,7 @@ use BookStack\Entities\Tools\MixedEntityListLoader; 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 @@ class ActivityQueries */ 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 @@ class ActivityQueries */ 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/Tools/WebhookFormatter.php b/app/Activity/Tools/WebhookFormatter.php index cb4e9cb0a..111efd5d4 100644 --- a/app/Activity/Tools/WebhookFormatter.php +++ b/app/Activity/Tools/WebhookFormatter.php @@ -73,13 +73,17 @@ class WebhookFormatter // 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/Entities/Controllers/PageApiController.php b/app/Entities/Controllers/PageApiController.php index 38042e670..ca8989f07 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 @@ class PageApiController extends ApiController * 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 @@ class PageApiController extends ApiController * 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 @@ class PageApiController extends ApiController * 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 @@ class PageApiController extends ApiController * 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 82edfbc27..dd655e7ab 100644 --- a/app/Entities/Controllers/PageController.php +++ b/app/Entities/Controllers/PageController.php @@ -25,6 +25,7 @@ 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; @@ -358,8 +359,8 @@ class PageController extends Controller */ public function showRecentlyUpdated() { - $visibleBelongsScope = function (BelongsTo $query) { - $query->scopes('visible'); + $visibleBelongsScope = function (Relation $relation): void { + $relation->scopes('visible'); }; $pages = $this->queries->visibleForList() diff --git a/app/Entities/Controllers/PageRevisionController.php b/app/Entities/Controllers/PageRevisionController.php index cc6b79bfe..801aeed3e 100644 --- a/app/Entities/Controllers/PageRevisionController.php +++ b/app/Entities/Controllers/PageRevisionController.php @@ -177,7 +177,7 @@ class PageRevisionController extends Controller */ 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/Tools/Markdown/CustomListItemRenderer.php b/app/Entities/Tools/Markdown/CustomListItemRenderer.php index 0c506d7f9..14cdaa2ea 100644 --- a/app/Entities/Tools/Markdown/CustomListItemRenderer.php +++ b/app/Entities/Tools/Markdown/CustomListItemRenderer.php @@ -25,9 +25,13 @@ class CustomListItemRenderer implements NodeRendererInterface */ 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 9fb4596f5..c66dcbe41 100644 --- a/app/Entities/Tools/PageContent.php +++ b/app/Entities/Tools/PageContent.php @@ -421,28 +421,41 @@ class PageContent */ 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]); + + $filtered = array_values(array_filter($headerDetails)); // 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); + $levelChange = ($minLevel - 1); + foreach ($filtered as $index => $header) { + $filtered[$index]['level'] -= $levelChange; + } - return $header; - }); - - return $tree->toArray(); + return $filtered; } } diff --git a/app/Entities/Tools/PageIncludeParser.php b/app/Entities/Tools/PageIncludeParser.php index af7ed4fc6..16917f41c 100644 --- a/app/Entities/Tools/PageIncludeParser.php +++ b/app/Entities/Tools/PageIncludeParser.php @@ -192,10 +192,10 @@ class PageIncludeParser /** * 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 6eec84a91..ac003b97a 100644 --- a/app/Entities/Tools/SlugGenerator.php +++ b/app/Entities/Tools/SlugGenerator.php @@ -52,7 +52,7 @@ class SlugGenerator { $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; From fa3b7f77b9d430304ac5683a0db374adca2f85d3 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 31 May 2026 15:42:54 +0100 Subject: [PATCH 03/72] Maintenance: Addressed all phpstan level 5 issues --- app/Entities/Models/Book.php | 8 ++++---- app/Entities/Models/Page.php | 4 ++-- app/Exports/ImportRepo.php | 5 +++++ app/Exports/ZipExports/ZipImportRunner.php | 2 +- app/Permissions/JointPermissionBuilder.php | 3 +++ app/References/Reference.php | 2 +- app/References/ReferenceFetcher.php | 4 ++++ app/Search/SearchRunner.php | 4 ++++ app/Settings/SettingService.php | 6 +++--- app/Sorting/SortRule.php | 14 ++++++++++++++ app/Uploads/AttachmentService.php | 2 +- app/Users/Controllers/RoleApiController.php | 2 +- app/Users/Controllers/RoleController.php | 6 +++--- app/Users/Controllers/UserApiController.php | 8 ++++---- 14 files changed, 50 insertions(+), 20 deletions(-) diff --git a/app/Entities/Models/Book.php b/app/Entities/Models/Book.php index 10f04695a..abb081d71 100644 --- a/app/Entities/Models/Book.php +++ b/app/Entities/Models/Book.php @@ -20,10 +20,10 @@ use Illuminate\Support\Collection; * @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/Page.php b/app/Entities/Models/Page.php index d3a392da6..c38f33af3 100644 --- a/app/Entities/Models/Page.php +++ b/app/Entities/Models/Page.php @@ -24,8 +24,8 @@ use Illuminate\Database\Eloquent\Relations\HasOne; * @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/Exports/ImportRepo.php b/app/Exports/ImportRepo.php index 79db69fca..ed631409b 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 @@ class ImportRepo $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/ZipImportRunner.php b/app/Exports/ZipExports/ZipImportRunner.php index 9fa7dec3a..37f6c0b8d 100644 --- a/app/Exports/ZipExports/ZipImportRunner.php +++ b/app/Exports/ZipExports/ZipImportRunner.php @@ -48,7 +48,7 @@ class ZipImportRunner * 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); diff --git a/app/Permissions/JointPermissionBuilder.php b/app/Permissions/JointPermissionBuilder.php index 94f18916d..f2159375d 100644 --- a/app/Permissions/JointPermissionBuilder.php +++ b/app/Permissions/JointPermissionBuilder.php @@ -101,6 +101,7 @@ class JointPermissionBuilder /** * Get a query for fetching a book with its children. + * @return Builder */ protected function bookFetchQuery(): Builder { @@ -117,9 +118,11 @@ class JointPermissionBuilder /** * 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 df8a3a789..602e19eab 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 8588c6e2c..0c76b4698 100644 --- a/app/References/ReferenceFetcher.php +++ b/app/References/ReferenceFetcher.php @@ -22,6 +22,7 @@ class ReferenceFetcher */ 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 @@ class ReferenceFetcher 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 391254172..c2f3b96c9 100644 --- a/app/Search/SearchRunner.php +++ b/app/Search/SearchRunner.php @@ -4,6 +4,7 @@ namespace BookStack\Search; 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 @@ class SearchRunner /** * 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 @@ class SearchRunner /** * 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 e0b136180..d35f77117 100644 --- a/app/Settings/SettingService.php +++ b/app/Settings/SettingService.php @@ -243,9 +243,9 @@ class SettingService /** * 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; } /** @@ -267,7 +267,7 @@ class SettingService /** * 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 bf53365a2..ffe2d4115 100644 --- a/app/Sorting/SortRule.php +++ b/app/Sorting/SortRule.php @@ -38,21 +38,35 @@ class SortRule extends Model implements Loggable $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/Uploads/AttachmentService.php b/app/Uploads/AttachmentService.php index dabd53729..3de84bdd0 100644 --- a/app/Uploads/AttachmentService.php +++ b/app/Uploads/AttachmentService.php @@ -102,7 +102,7 @@ class AttachmentService /** * 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/Users/Controllers/RoleApiController.php b/app/Users/Controllers/RoleApiController.php index 93ecc549b..6f0c59651 100644 --- a/app/Users/Controllers/RoleApiController.php +++ b/app/Users/Controllers/RoleApiController.php @@ -87,7 +87,7 @@ class RoleApiController extends ApiController */ 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 b9f06dace..56a6c7416 100644 --- a/app/Users/Controllers/RoleController.php +++ b/app/Users/Controllers/RoleController.php @@ -94,7 +94,7 @@ class RoleController extends Controller 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 @@ class RoleController extends Controller 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 @@ class RoleController extends Controller 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/UserApiController.php b/app/Users/Controllers/UserApiController.php index ebc17e262..07eefadc7 100644 --- a/app/Users/Controllers/UserApiController.php +++ b/app/Users/Controllers/UserApiController.php @@ -110,7 +110,7 @@ class UserApiController extends ApiController */ 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 @@ class UserApiController extends ApiController */ 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 @@ class UserApiController extends ApiController */ 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); From 16e60b39398476e1963cf3922da7137fe65b004b Mon Sep 17 00:00:00 2001 From: Mesar Hameed Date: Thu, 16 Jul 2026 06:32:57 +0000 Subject: [PATCH 04/72] Views: Added aria-labels to custom checkbox components for screen readers The custom checkbox components (custom-checkbox.blade.php and toggle-switch.blade.php) use a as the visual/interactive element while hiding the native with display:none. The span had no accessible name, so screen readers would announce "checkbox" without any label context. Added aria-label to the role="checkbox" span in both templates. Also added optional parameter to custom-checkbox that falls back to when not provided, allowing callers to supply a more descriptive accessible name. --- resources/views/form/custom-checkbox.blade.php | 2 ++ resources/views/form/toggle-switch.blade.php | 1 + resources/views/settings/roles/parts/checkbox.blade.php | 3 ++- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/resources/views/form/custom-checkbox.blade.php b/resources/views/form/custom-checkbox.blade.php index 7750c6f00..736baf521 100644 --- a/resources/views/form/custom-checkbox.blade.php +++ b/resources/views/form/custom-checkbox.blade.php @@ -3,11 +3,13 @@ $name $value $checked $label +$ariaLabel? (optional, falls back to $label) --}} \ No newline at end of file diff --git a/resources/views/form/toggle-switch.blade.php b/resources/views/form/toggle-switch.blade.php index 375eda3d7..32ce1092c 100644 --- a/resources/views/form/toggle-switch.blade.php +++ b/resources/views/form/toggle-switch.blade.php @@ -3,6 +3,7 @@ @icon('check') {{ $label }} \ No newline at end of file diff --git a/resources/views/settings/roles/parts/checkbox.blade.php b/resources/views/settings/roles/parts/checkbox.blade.php index 44cdd22bd..5818888d7 100644 --- a/resources/views/settings/roles/parts/checkbox.blade.php +++ b/resources/views/settings/roles/parts/checkbox.blade.php @@ -3,5 +3,6 @@ 'name' => 'permissions[' . $permission . ']', 'value' => 'true', 'checked' => old('permissions'.$permission, false)|| (!old('display_name', false) && (isset($role) && $role->hasPermission($permission))), - 'label' => $label + 'label' => $label, + 'ariaLabel' => $ariaLabel ?? null ]) \ No newline at end of file From 9a242ca44968e0f572dc1b25023c3fc3d8f3aff6 Mon Sep 17 00:00:00 2001 From: Mesar Hameed Date: Thu, 16 Jul 2026 06:33:56 +0000 Subject: [PATCH 05/72] Views: Added translatable aria-labels for role permission checkboxes Role permission checkboxes (shelves, books, chapters, pages, revisions, images, attachments) used short labels like 'Own' and 'All' for their aria-labels. Screen readers would announce these without any context about what resource or action the permission applies to. Added 'role_permission_aria' translation key with the pattern ':action :resource: :value' and updated all permission row templates to use trans() with parameters. This gives translators full control over word order and separators while providing screen readers with descriptive labels like 'Create books: All'. --- lang/en/settings.php | 1 + .../parts/asset-permissions-row.blade.php | 26 ++++++++++++------- .../related-asset-permissions-row.blade.php | 17 +++++++----- .../parts/revisions-permissions-row.blade.php | 3 ++- 4 files changed, 31 insertions(+), 16 deletions(-) diff --git a/lang/en/settings.php b/lang/en/settings.php index 0e5ce84cf..ac483f8fd 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -206,6 +206,7 @@ return [ 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'All', 'role_own' => 'Own', + 'role_permission_aria' => ':action :resource: :value', 'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to', 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Save Role', diff --git a/resources/views/settings/roles/parts/asset-permissions-row.blade.php b/resources/views/settings/roles/parts/asset-permissions-row.blade.php index 8bff570cb..200153b1b 100644 --- a/resources/views/settings/roles/parts/asset-permissions-row.blade.php +++ b/resources/views/settings/roles/parts/asset-permissions-row.blade.php @@ -6,27 +6,35 @@
{{ trans('common.create') }}
@if($permissionPrefix === 'page' || $permissionPrefix === 'chapter') - @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-create-own', 'label' => trans('settings.role_own')]) + @php $createOwnAria = trans('settings.role_permission_aria', ['action' => trans('common.create'), 'resource' => strtolower($title), 'value' => trans('settings.role_own')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-create-own', 'label' => trans('settings.role_own'), 'ariaLabel' => $createOwnAria])
@endif - @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-create-all', 'label' => trans('settings.role_all')]) + @php $createAllAria = trans('settings.role_permission_aria', ['action' => trans('common.create'), 'resource' => strtolower($title), 'value' => trans('settings.role_all')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-create-all', 'label' => trans('settings.role_all'), 'ariaLabel' => $createAllAria])
{{ trans('common.view') }}
- @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-view-own', 'label' => trans('settings.role_own')]) + @php $viewOwnAria = trans('settings.role_permission_aria', ['action' => trans('common.view'), 'resource' => strtolower($title), 'value' => trans('settings.role_own')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-view-own', 'label' => trans('settings.role_own'), 'ariaLabel' => $viewOwnAria])
- @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-view-all', 'label' => trans('settings.role_all')]) + @php $viewAllAria = trans('settings.role_permission_aria', ['action' => trans('common.view'), 'resource' => strtolower($title), 'value' => trans('settings.role_all')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-view-all', 'label' => trans('settings.role_all'), 'ariaLabel' => $viewAllAria])
{{ trans('common.edit') }}
- @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-update-own', 'label' => trans('settings.role_own')]) + @php $editOwnAria = trans('settings.role_permission_aria', ['action' => trans('common.edit'), 'resource' => strtolower($title), 'value' => trans('settings.role_own')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-update-own', 'label' => trans('settings.role_own'), 'ariaLabel' => $editOwnAria])
- @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-update-all', 'label' => trans('settings.role_all')]) + @php $editAllAria = trans('settings.role_permission_aria', ['action' => trans('common.edit'), 'resource' => strtolower($title), 'value' => trans('settings.role_all')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-update-all', 'label' => trans('settings.role_all'), 'ariaLabel' => $editAllAria])
{{ trans('common.delete') }}
- @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-delete-own', 'label' => trans('settings.role_own')]) + @php $deleteOwnAria = trans('settings.role_permission_aria', ['action' => trans('common.delete'), 'resource' => strtolower($title), 'value' => trans('settings.role_own')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-delete-own', 'label' => trans('settings.role_own'), 'ariaLabel' => $deleteOwnAria])
- @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-delete-all', 'label' => trans('settings.role_all')]) + @php $deleteAllAria = trans('settings.role_permission_aria', ['action' => trans('common.delete'), 'resource' => strtolower($title), 'value' => trans('settings.role_all')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-delete-all', 'label' => trans('settings.role_all'), 'ariaLabel' => $deleteAllAria])
- \ No newline at end of file + diff --git a/resources/views/settings/roles/parts/related-asset-permissions-row.blade.php b/resources/views/settings/roles/parts/related-asset-permissions-row.blade.php index b9bf5427f..ac8e7422e 100644 --- a/resources/views/settings/roles/parts/related-asset-permissions-row.blade.php +++ b/resources/views/settings/roles/parts/related-asset-permissions-row.blade.php @@ -5,7 +5,8 @@
{{ trans('common.create') }}
- @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-create-all', 'label' => '']) + @php $createAllAria = trans('settings.role_permission_aria', ['action' => trans('common.create'), 'resource' => strtolower($title), 'value' => trans('settings.role_all')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-create-all', 'label' => '', 'ariaLabel' => $createAllAria]) @if($permissionPrefix === 'comment')2@endif
@@ -14,16 +15,20 @@
{{ trans('common.edit') }}
- @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-update-own', 'label' => trans('settings.role_own')]) + @php $editOwnAria = trans('settings.role_permission_aria', ['action' => trans('common.edit'), 'resource' => strtolower($title), 'value' => trans('settings.role_own')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-update-own', 'label' => trans('settings.role_own'), 'ariaLabel' => $editOwnAria]) @if($permissionPrefix === 'comment')2@endif
- @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-update-all', 'label' => trans('settings.role_all')]) + @php $editAllAria = trans('settings.role_permission_aria', ['action' => trans('common.edit'), 'resource' => strtolower($title), 'value' => trans('settings.role_all')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-update-all', 'label' => trans('settings.role_all'), 'ariaLabel' => $editAllAria]) @if($permissionPrefix === 'comment')2@endif
{{ trans('common.delete') }}
- @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-delete-own', 'label' => trans('settings.role_own')]) + @php $deleteOwnAria = trans('settings.role_permission_aria', ['action' => trans('common.delete'), 'resource' => strtolower($title), 'value' => trans('settings.role_own')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-delete-own', 'label' => trans('settings.role_own'), 'ariaLabel' => $deleteOwnAria])
- @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-delete-all', 'label' => trans('settings.role_all')]) + @php $deleteAllAria = trans('settings.role_permission_aria', ['action' => trans('common.delete'), 'resource' => strtolower($title), 'value' => trans('settings.role_all')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-delete-all', 'label' => trans('settings.role_all'), 'ariaLabel' => $deleteAllAria])
- \ No newline at end of file + diff --git a/resources/views/settings/roles/parts/revisions-permissions-row.blade.php b/resources/views/settings/roles/parts/revisions-permissions-row.blade.php index 326925ef9..50b714de4 100644 --- a/resources/views/settings/roles/parts/revisions-permissions-row.blade.php +++ b/resources/views/settings/roles/parts/revisions-permissions-row.blade.php @@ -9,7 +9,8 @@
{{ trans('common.view') }}
- @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-view-all', 'label' => trans('settings.role_all')]) + @php $viewAllAria = trans('settings.role_permission_aria', ['action' => trans('common.view'), 'resource' => strtolower($title), 'value' => trans('settings.role_all')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-view-all', 'label' => trans('settings.role_all'), 'ariaLabel' => $viewAllAria])
{{ trans('common.edit') }}
From fa27485b2252e0d9ceb2fd10e588aa20e5cf70f9 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 2 Aug 2026 16:06:51 +0100 Subject: [PATCH 06/72] Front End: Added page scroll progress indicator to back-to-top element --- resources/js/components/back-to-top.js | 79 +++++++++++++++++-- resources/sass/_components.scss | 34 ++++++-- resources/views/layouts/base.blade.php | 7 +- .../views/layouts/parts/back-to-top.blade.php | 15 ++++ 4 files changed, 119 insertions(+), 16 deletions(-) create mode 100644 resources/views/layouts/parts/back-to-top.blade.php diff --git a/resources/js/components/back-to-top.js b/resources/js/components/back-to-top.js index 046e640d1..9b319a3bd 100644 --- a/resources/js/components/back-to-top.js +++ b/resources/js/components/back-to-top.js @@ -3,10 +3,15 @@ import {Component} from './component'; export class BackToTop extends Component { setup() { - this.button = this.$el; + this.container = this.$el; + this.button = this.$refs.button; + this.progress = this.$refs.progress; + this.progressPath = this.$refs.progressPath; + this.targetElem = document.getElementById('header'); this.showing = false; this.breakPoint = 1200; + this.isAnimating = false; if (document.body.classList.contains('flexbox')) { this.button.style.display = 'none'; @@ -15,23 +20,46 @@ export class BackToTop extends Component { this.button.addEventListener('click', this.scrollToTop.bind(this)); window.addEventListener('scroll', this.onPageScroll.bind(this)); + + this.setupProgressBar(); + } + + setupProgressBar() { + this.button.addEventListener('transitionstart', event => { + if (event.target !== this.button) return; + this.isAnimating = true; + this.renderProgressPath(); + }); + const stopAnimating = event => { + if (event.target !== this.button) return; + this.isAnimating = false; + }; + this.button.addEventListener('transitionend', stopAnimating); + this.button.addEventListener('transitioncancel', stopAnimating); + this.renderProgressPath(); } onPageScroll() { const scrollTopPos = document.documentElement.scrollTop || document.body.scrollTop || 0; if (!this.showing && scrollTopPos > this.breakPoint) { - this.button.style.display = 'block'; + this.container.display = 'block'; this.showing = true; setTimeout(() => { - this.button.style.opacity = 0.4; + this.container.style.opacity = '0.4'; }, 1); } else if (this.showing && scrollTopPos < this.breakPoint) { - this.button.style.opacity = 0; + this.container.style.opacity = '0'; this.showing = false; setTimeout(() => { - this.button.style.display = 'none'; + this.container.display = 'none'; }, 500); } + + if (this.showing) { + const maxScrollTop = document.documentElement.scrollHeight - window.innerHeight; + const scrollTopPercent = (scrollTopPos / maxScrollTop) * 100; + this.updateProgress(scrollTopPercent); + } } scrollToTop() { @@ -55,4 +83,45 @@ export class BackToTop extends Component { requestAnimationFrame(setPos.bind(this)); } + renderProgressPath() { + const bounds = this.button.getBoundingClientRect(); + const progressInset = window.getComputedStyle(this.progress).insetInlineStart; + const offset = Math.abs(Number(progressInset.replace('px', '') || 0)); + const path = this.roundedRectPath(bounds.width, bounds.height, bounds.height / 2, offset); + this.progressPath.setAttribute('d', path); + if (this.isAnimating) { + window.requestAnimationFrame(this.renderProgressPath.bind(this)); + } + } + + updateProgress(percentComplete) { + if (percentComplete < 5) { + percentComplete = 5; + } + this.progressPath.setAttribute('stroke-dasharray', `${Math.ceil(percentComplete)} 100`); + } + + roundedRectPath(w, h, r, offset = 1.5) { + // Expand dimensions outward by the offset + const W = w + 2 * offset; + const H = h + 2 * offset; + + // The corner radius also grows by the offset + const R = Math.min(r + offset, W / 2, H / 2); + + return [ + `M ${W / 2} 0`, // start at top center + `H ${W - R}`, // line to top-right (before arc) + `A ${R} ${R} 0 0 1 ${W} ${R}`, // arc: top-right corner + `V ${H - R}`, // line down right edge + `A ${R} ${R} 0 0 1 ${W - R} ${H}`, // arc: bottom-right corner + `H ${R}`, // line across bottom edge + `A ${R} ${R} 0 0 1 ${0} ${H - R}`, // arc: bottom-left corner + `V ${R}`, // line up left edge + `A ${R} ${R} 0 0 1 ${R} ${0}`, // arc: top-left corner + `Z` // close path back to start + ].join(' '); + } + + } diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index 8608427d8..bc7a0ed88 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -1098,16 +1098,24 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { // Back to top link $btt-size: 40px; -.back-to-top { - background-color: var(--color-primary); +.back-to-top-container { position: fixed; bottom: vars.$m; right: vars.$l; + opacity: 0; + z-index: 999; + transition: opacity ease-in-out 180ms; + &:hover { + opacity: 1 !important; + } +} +.back-to-top { + background-color: var(--color-primary); padding: 5px 7px; cursor: pointer; color: #FFF; fill: #FFF; - svg { + .inner svg { width: math.div($btt-size, 1.5); height: math.div($btt-size, 1.5); margin-inline-end: 4px; @@ -1116,8 +1124,6 @@ $btt-size: 40px; height: $btt-size; border-radius: $btt-size; transition: all ease-in-out 180ms; - opacity: 0; - z-index: 999; overflow: hidden; &:hover { width: $btt-size*3.4; @@ -1125,6 +1131,7 @@ $btt-size: 40px; } .inner { width: $btt-size*3.4; + text-align: start; } span { position: relative; @@ -1133,6 +1140,23 @@ $btt-size: 40px; } } +.back-to-top-progress { + position: absolute; + top: -3px; + inset-inline-start: -3px; + width: 100%; + height: 100%; + pointer-events: none; + overflow: visible; + path { + fill: none; + stroke: var(--color-primary); + stroke-width: 2px; + stroke-linecap: butt; + transition: stroke-dasharray ease-in-out 120ms; + } +} + // Sortable scroll boxes .scroll-box { list-style: none; diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index b868cb888..90d987ccb 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -61,12 +61,7 @@
@include('layouts.parts.footer') - - + @include('layouts.parts.back-to-top') @if($cspNonce ?? false) diff --git a/resources/views/layouts/parts/back-to-top.blade.php b/resources/views/layouts/parts/back-to-top.blade.php new file mode 100644 index 000000000..741859d1c --- /dev/null +++ b/resources/views/layouts/parts/back-to-top.blade.php @@ -0,0 +1,15 @@ +
+ + + + +
\ No newline at end of file From 36fe7c375f4d6961724918fd4e06791fcd0242fd Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 2 Aug 2026 16:15:16 +0100 Subject: [PATCH 07/72] JS: Converted back-to-top component to TS --- .../{back-to-top.js => back-to-top.ts} | 49 ++++++++++--------- .../views/layouts/parts/back-to-top.blade.php | 2 +- 2 files changed, 28 insertions(+), 23 deletions(-) rename resources/js/components/{back-to-top.js => back-to-top.ts} (78%) diff --git a/resources/js/components/back-to-top.js b/resources/js/components/back-to-top.ts similarity index 78% rename from resources/js/components/back-to-top.js rename to resources/js/components/back-to-top.ts index 9b319a3bd..e47a0478a 100644 --- a/resources/js/components/back-to-top.js +++ b/resources/js/components/back-to-top.ts @@ -2,19 +2,26 @@ import {Component} from './component'; export class BackToTop extends Component { - setup() { + private container!: HTMLElement; + private button!: HTMLElement; + private progress!: HTMLElement; + private progressPath!: SVGPathElement; + private targetElem!: HTMLElement; + + private showing: boolean = false; + private breakPoint: number = 1200; + private isAnimating: boolean = false; + + setup(): void { this.container = this.$el; this.button = this.$refs.button; this.progress = this.$refs.progress; - this.progressPath = this.$refs.progressPath; + this.progressPath = this.$refs.progressPath as unknown as SVGPathElement; - this.targetElem = document.getElementById('header'); - this.showing = false; - this.breakPoint = 1200; - this.isAnimating = false; + this.targetElem = document.getElementById('header') as HTMLElement; if (document.body.classList.contains('flexbox')) { - this.button.style.display = 'none'; + this.container.style.display = 'none'; return; } @@ -24,13 +31,13 @@ export class BackToTop extends Component { this.setupProgressBar(); } - setupProgressBar() { + private setupProgressBar(): void { this.button.addEventListener('transitionstart', event => { if (event.target !== this.button) return; this.isAnimating = true; this.renderProgressPath(); }); - const stopAnimating = event => { + const stopAnimating = (event: TransitionEvent) => { if (event.target !== this.button) return; this.isAnimating = false; }; @@ -39,10 +46,10 @@ export class BackToTop extends Component { this.renderProgressPath(); } - onPageScroll() { + private onPageScroll(): void { const scrollTopPos = document.documentElement.scrollTop || document.body.scrollTop || 0; if (!this.showing && scrollTopPos > this.breakPoint) { - this.container.display = 'block'; + this.container.style.display = 'block'; this.showing = true; setTimeout(() => { this.container.style.opacity = '0.4'; @@ -51,7 +58,7 @@ export class BackToTop extends Component { this.container.style.opacity = '0'; this.showing = false; setTimeout(() => { - this.container.display = 'none'; + this.container.style.display = 'none'; }, 500); } @@ -62,28 +69,28 @@ export class BackToTop extends Component { } } - scrollToTop() { + private scrollToTop(): void { const targetTop = this.targetElem.getBoundingClientRect().top; const scrollElem = document.documentElement.scrollTop ? document.documentElement : document.body; const duration = 300; const start = Date.now(); const scrollStart = this.targetElem.getBoundingClientRect().top; - function setPos() { + const setPos = () => { const percentComplete = (1 - ((Date.now() - start) / duration)); const target = Math.abs(percentComplete * scrollStart); if (percentComplete > 0) { scrollElem.scrollTop = target; - requestAnimationFrame(setPos.bind(this)); + requestAnimationFrame(setPos); } else { scrollElem.scrollTop = targetTop; } - } + }; - requestAnimationFrame(setPos.bind(this)); + requestAnimationFrame(setPos); } - renderProgressPath() { + private renderProgressPath(): void { const bounds = this.button.getBoundingClientRect(); const progressInset = window.getComputedStyle(this.progress).insetInlineStart; const offset = Math.abs(Number(progressInset.replace('px', '') || 0)); @@ -94,14 +101,14 @@ export class BackToTop extends Component { } } - updateProgress(percentComplete) { + private updateProgress(percentComplete: number): void { if (percentComplete < 5) { percentComplete = 5; } this.progressPath.setAttribute('stroke-dasharray', `${Math.ceil(percentComplete)} 100`); } - roundedRectPath(w, h, r, offset = 1.5) { + private roundedRectPath(w: number, h: number, r: number, offset: number = 1.5): string { // Expand dimensions outward by the offset const W = w + 2 * offset; const H = h + 2 * offset; @@ -122,6 +129,4 @@ export class BackToTop extends Component { `Z` // close path back to start ].join(' '); } - - } diff --git a/resources/views/layouts/parts/back-to-top.blade.php b/resources/views/layouts/parts/back-to-top.blade.php index 741859d1c..b6efc9749 100644 --- a/resources/views/layouts/parts/back-to-top.blade.php +++ b/resources/views/layouts/parts/back-to-top.blade.php @@ -12,4 +12,4 @@ @icon('chevron-up') {{ trans('common.back_to_top') }} - \ No newline at end of file + From 26a3cb8b7e36a421c4491ad987e053d96ee84075 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 6 Aug 2026 15:32:17 +0100 Subject: [PATCH 08/72] Views: Started implementation of new sidebar back-end --- .../Providers/ViewTweaksServiceProvider.php | 2 + app/Entities/Controllers/BookController.php | 2 - app/View/SidebarSection.php | 16 ++++++ app/View/SidebarSectionInterface.php | 20 ++++++++ app/View/SidebarSectionManager.php | 51 +++++++++++++++++++ .../SidebarSections/BooksIndexRecents.php | 26 ++++++++++ resources/views/books/index.blade.php | 4 +- 7 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 app/View/SidebarSection.php create mode 100644 app/View/SidebarSectionInterface.php create mode 100644 app/View/SidebarSectionManager.php create mode 100644 app/View/SidebarSections/BooksIndexRecents.php diff --git a/app/App/Providers/ViewTweaksServiceProvider.php b/app/App/Providers/ViewTweaksServiceProvider.php index 6771e513f..d2d45abcc 100644 --- a/app/App/Providers/ViewTweaksServiceProvider.php +++ b/app/App/Providers/ViewTweaksServiceProvider.php @@ -4,6 +4,7 @@ namespace BookStack\App\Providers; use BookStack\Entities\BreadcrumbsViewComposer; use BookStack\Util\DateFormatter; +use BookStack\View\SidebarSectionManager; use Illuminate\Pagination\Paginator; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\View; @@ -33,6 +34,7 @@ class ViewTweaksServiceProvider extends ServiceProvider // View Globals View::share('dates', $this->app->make(DateFormatter::class)); + View::share('sidebar', $this->app->make(SidebarSectionManager::class)); // Custom blade view directives Blade::directive('icon', function ($expression) { diff --git a/app/Entities/Controllers/BookController.php b/app/Entities/Controllers/BookController.php index 98470d91c..9fa64d293 100644 --- a/app/Entities/Controllers/BookController.php +++ b/app/Entities/Controllers/BookController.php @@ -53,7 +53,6 @@ class BookController extends Controller $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(); @@ -63,7 +62,6 @@ class BookController extends Controller return view('books.index', [ 'books' => $books, - 'recents' => $recents, 'popular' => $popular, 'new' => $new, 'view' => $view, diff --git a/app/View/SidebarSection.php b/app/View/SidebarSection.php new file mode 100644 index 000000000..68e1aa291 --- /dev/null +++ b/app/View/SidebarSection.php @@ -0,0 +1,16 @@ +view; + } +} diff --git a/app/View/SidebarSectionInterface.php b/app/View/SidebarSectionInterface.php new file mode 100644 index 000000000..84032ab61 --- /dev/null +++ b/app/View/SidebarSectionInterface.php @@ -0,0 +1,20 @@ + + */ + public function withData(array $viewData, Request $request): array; +} diff --git a/app/View/SidebarSectionManager.php b/app/View/SidebarSectionManager.php new file mode 100644 index 000000000..82af3721e --- /dev/null +++ b/app/View/SidebarSectionManager.php @@ -0,0 +1,51 @@ +[]>> + */ + protected array $sectionByLocation = [ + 'books-index' => [ + 'left' => [ + BooksIndexRecents::class, + ] + ], + ]; + + /** + * Register a sidebar section to be displayed at the given location and position. + * @param class-string $sectionClass + */ + public function register(string $location, string $position, string $sectionClass): void + { + if (!isset($this->sectionByLocation[$location])) { + $this->sectionByLocation[$location] = []; + } + + if (!isset($this->sectionByLocation[$location][$position])) { + $this->sectionByLocation[$location][$position] = []; + } + + $this->sectionByLocation[$location][$position][] = $sectionClass; + } + + /** + * Get all sidebar sections registered for a given location. + * + * @return SidebarSectionInterface[] + * @throws BindingResolutionException + */ + public function getSectionsForLocation(string $location, string $position): array + { + $sections = array_unique($this->sectionByLocation[$location][$position] ?? []); + return array_map(function (string $className) { + return app()->make($className); + }, $sections); + } +} diff --git a/app/View/SidebarSections/BooksIndexRecents.php b/app/View/SidebarSections/BooksIndexRecents.php new file mode 100644 index 000000000..485e6d431 --- /dev/null +++ b/app/View/SidebarSections/BooksIndexRecents.php @@ -0,0 +1,26 @@ +isGuest(); + $recents = $userSignedIn ? $this->queries->recentlyViewedForCurrentUser()->take(4)->get() : null; + return [ + 'recents' => $recents, + ]; + } +} diff --git a/resources/views/books/index.blade.php b/resources/views/books/index.blade.php index 660c008df..60152410c 100644 --- a/resources/views/books/index.blade.php +++ b/resources/views/books/index.blade.php @@ -5,7 +5,9 @@ @stop @section('left') - @include('books.parts.index-sidebar-section-recents', ['recents' => $recents]) + @foreach($sidebar->getSectionsForLocation('books-index', 'left') as $section) + @include($section->getView(), $section->withData(get_defined_vars(), request())) + @endforeach @include('books.parts.index-sidebar-section-popular', ['popular' => $popular]) @include('books.parts.index-sidebar-section-new', ['new' => $new]) @stop From dead0bfb5fd5b725230a5020973ddd4fa9560a95 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 7 Aug 2026 08:32:27 +0100 Subject: [PATCH 09/72] Views: Extracted books index/show sidebar sections --- app/Entities/Controllers/BookController.php | 15 +-------- app/View/SidebarSectionManager.php | 28 +++++++++++++++- .../SidebarSections/BooksIndexActions.php | 18 +++++++++++ app/View/SidebarSections/BooksIndexNew.php | 29 +++++++++++++++++ .../SidebarSections/BooksIndexPopular.php | 24 ++++++++++++++ app/View/SidebarSections/BooksShowActions.php | 24 ++++++++++++++ .../SidebarSections/BooksShowActivity.php | 28 ++++++++++++++++ app/View/SidebarSections/BooksShowDetails.php | 32 +++++++++++++++++++ .../SidebarSections/BooksShowSearchForm.php | 18 +++++++++++ app/View/SidebarSections/BooksShowShelves.php | 23 +++++++++++++ app/View/SidebarSections/BooksShowTags.php | 18 +++++++++++ resources/views/books/index.blade.php | 8 ++--- .../show-sidebar-section-details.blade.php | 2 +- .../show-sidebar-section-shelves.blade.php | 4 +-- resources/views/books/show.blade.php | 13 +++----- .../views/common/sidebar-sections.blade.php | 11 +++++++ 16 files changed, 262 insertions(+), 33 deletions(-) create mode 100644 app/View/SidebarSections/BooksIndexActions.php create mode 100644 app/View/SidebarSections/BooksIndexNew.php create mode 100644 app/View/SidebarSections/BooksIndexPopular.php create mode 100644 app/View/SidebarSections/BooksShowActions.php create mode 100644 app/View/SidebarSections/BooksShowActivity.php create mode 100644 app/View/SidebarSections/BooksShowDetails.php create mode 100644 app/View/SidebarSections/BooksShowSearchForm.php create mode 100644 app/View/SidebarSections/BooksShowShelves.php create mode 100644 app/View/SidebarSections/BooksShowTags.php create mode 100644 resources/views/common/sidebar-sections.blade.php diff --git a/app/Entities/Controllers/BookController.php b/app/Entities/Controllers/BookController.php index 9fa64d293..aa4f99daa 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\Exceptions\NotFoundException; 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 @@ class BookController extends Controller protected BookQueries $queries, protected EntityQueries $entityQueries, protected BookshelfQueries $shelfQueries, - protected ReferenceFetcher $referenceFetcher, ) { } @@ -53,8 +49,6 @@ class BookController extends Controller $books = $this->queries->visibleForListWithCover() ->orderBy($listOptions->getSort(), $listOptions->getOrder()) ->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000)); - $popular = $this->queries->popularForList()->take(4)->get(); - $new = $this->queries->visibleForList()->orderBy('created_at', 'desc')->take(4)->get(); $this->shelfContext->clearShelfContext(); @@ -62,8 +56,6 @@ class BookController extends Controller return view('books.index', [ 'books' => $books, - 'popular' => $popular, - 'new' => $new, 'view' => $view, 'listOptions' => $listOptions, ]); @@ -125,7 +117,7 @@ class BookController extends Controller /** * 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); @@ -138,7 +130,6 @@ class BookController extends Controller } $bookChildren = (new BookContents($book))->getTree(true); - $bookParentShelves = $book->shelves()->scopes('visible')->get(); View::incrementFor($book); if ($request->has('shelf')) { @@ -151,10 +142,6 @@ class BookController extends Controller '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/View/SidebarSectionManager.php b/app/View/SidebarSectionManager.php index 82af3721e..d83b7afb0 100644 --- a/app/View/SidebarSectionManager.php +++ b/app/View/SidebarSectionManager.php @@ -2,7 +2,16 @@ namespace BookStack\View; +use BookStack\View\SidebarSections\BooksIndexActions; +use BookStack\View\SidebarSections\BooksIndexNew; +use BookStack\View\SidebarSections\BooksIndexPopular; use BookStack\View\SidebarSections\BooksIndexRecents; +use BookStack\View\SidebarSections\BooksShowActions; +use BookStack\View\SidebarSections\BooksShowActivity; +use BookStack\View\SidebarSections\BooksShowDetails; +use BookStack\View\SidebarSections\BooksShowSearchForm; +use BookStack\View\SidebarSections\BooksShowShelves; +use BookStack\View\SidebarSections\BooksShowTags; use Illuminate\Contracts\Container\BindingResolutionException; class SidebarSectionManager @@ -14,7 +23,24 @@ class SidebarSectionManager 'books-index' => [ 'left' => [ BooksIndexRecents::class, - ] + BooksIndexPopular::class, + BooksIndexNew::class, + ], + 'right' => [ + BooksIndexActions::class, + ], + ], + 'books-show' => [ + 'left' => [ + BooksShowSearchForm::class, + BooksShowTags::class, + BooksShowShelves::class, + BooksShowActivity::class, + ], + 'right' => [ + BooksShowDetails::class, + BooksShowActions::class, + ], ], ]; diff --git a/app/View/SidebarSections/BooksIndexActions.php b/app/View/SidebarSections/BooksIndexActions.php new file mode 100644 index 000000000..62f856a65 --- /dev/null +++ b/app/View/SidebarSections/BooksIndexActions.php @@ -0,0 +1,18 @@ + $viewData['view'], + ]; + } +} diff --git a/app/View/SidebarSections/BooksIndexNew.php b/app/View/SidebarSections/BooksIndexNew.php new file mode 100644 index 000000000..33a57f9d9 --- /dev/null +++ b/app/View/SidebarSections/BooksIndexNew.php @@ -0,0 +1,29 @@ +queries->visibleForList() + ->orderBy('created_at', 'desc') + ->take(4) + ->get(); + + return [ + 'new' => $new, + ]; + } +} diff --git a/app/View/SidebarSections/BooksIndexPopular.php b/app/View/SidebarSections/BooksIndexPopular.php new file mode 100644 index 000000000..9f5cc613f --- /dev/null +++ b/app/View/SidebarSections/BooksIndexPopular.php @@ -0,0 +1,24 @@ + $this->queries->popularForList()->take(4)->get(), + ]; + } +} diff --git a/app/View/SidebarSections/BooksShowActions.php b/app/View/SidebarSections/BooksShowActions.php new file mode 100644 index 000000000..0f97e625c --- /dev/null +++ b/app/View/SidebarSections/BooksShowActions.php @@ -0,0 +1,24 @@ + $book, + 'watchOptions' => new UserEntityWatchOptions(user(), $book), + ]; + } +} diff --git a/app/View/SidebarSections/BooksShowActivity.php b/app/View/SidebarSections/BooksShowActivity.php new file mode 100644 index 000000000..d892c5ea7 --- /dev/null +++ b/app/View/SidebarSections/BooksShowActivity.php @@ -0,0 +1,28 @@ + $this->activityQueries->entityActivity($book, 20, 1), + ]; + } +} diff --git a/app/View/SidebarSections/BooksShowDetails.php b/app/View/SidebarSections/BooksShowDetails.php new file mode 100644 index 000000000..bd91663b2 --- /dev/null +++ b/app/View/SidebarSections/BooksShowDetails.php @@ -0,0 +1,32 @@ +referenceFetcher->getReferenceCountToEntity($book); + + return [ + 'book' => $book, + 'watchOptions' => new UserEntityWatchOptions(user(), $book), + 'referenceCount' => $referenceCount, + ]; + } +} diff --git a/app/View/SidebarSections/BooksShowSearchForm.php b/app/View/SidebarSections/BooksShowSearchForm.php new file mode 100644 index 000000000..8fa24134b --- /dev/null +++ b/app/View/SidebarSections/BooksShowSearchForm.php @@ -0,0 +1,18 @@ + trans('entities.books_search_this'), + ]; + } +} diff --git a/app/View/SidebarSections/BooksShowShelves.php b/app/View/SidebarSections/BooksShowShelves.php new file mode 100644 index 000000000..28d2d8aa9 --- /dev/null +++ b/app/View/SidebarSections/BooksShowShelves.php @@ -0,0 +1,23 @@ +shelves()->scopes('visible')->get(); + + return [ + 'shelves' => $shelves, + ]; + } +} diff --git a/app/View/SidebarSections/BooksShowTags.php b/app/View/SidebarSections/BooksShowTags.php new file mode 100644 index 000000000..351686c7f --- /dev/null +++ b/app/View/SidebarSections/BooksShowTags.php @@ -0,0 +1,18 @@ + $viewData['book'], + ]; + } +} diff --git a/resources/views/books/index.blade.php b/resources/views/books/index.blade.php index 60152410c..020cb0966 100644 --- a/resources/views/books/index.blade.php +++ b/resources/views/books/index.blade.php @@ -5,13 +5,9 @@ @stop @section('left') - @foreach($sidebar->getSectionsForLocation('books-index', 'left') as $section) - @include($section->getView(), $section->withData(get_defined_vars(), request())) - @endforeach - @include('books.parts.index-sidebar-section-popular', ['popular' => $popular]) - @include('books.parts.index-sidebar-section-new', ['new' => $new]) + @include('common.sidebar-sections', ['location' => 'books-index', 'position' => 'left']) @stop @section('right') - @include('books.parts.index-sidebar-section-actions', ['view' => $view]) + @include('common.sidebar-sections', ['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 709d0ffd9..2c3d6d141 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') }}