From ac0cd9995d8b420e33e392ba82d40bde8df94205 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 29 Jan 2025 16:40:11 +0000 Subject: [PATCH 01/17] Sorting: Reorganised book sort code to its own directory --- app/Entities/Tools/BookContents.php | 209 +--------------- .../BookSortController.php | 8 +- .../Tools => Sorting}/BookSortMap.php | 2 +- .../Tools => Sorting}/BookSortMapItem.php | 2 +- app/Sorting/BookSorter.php | 226 ++++++++++++++++++ routes/web.php | 7 +- 6 files changed, 237 insertions(+), 217 deletions(-) rename app/{Entities/Controllers => Sorting}/BookSortController.php (88%) rename app/{Entities/Tools => Sorting}/BookSortMap.php (96%) rename app/{Entities/Tools => Sorting}/BookSortMapItem.php (94%) create mode 100644 app/Sorting/BookSorter.php diff --git a/app/Entities/Tools/BookContents.php b/app/Entities/Tools/BookContents.php index 7fa2134b7..7dd3f3e11 100644 --- a/app/Entities/Tools/BookContents.php +++ b/app/Entities/Tools/BookContents.php @@ -8,6 +8,8 @@ use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; use BookStack\Entities\Queries\EntityQueries; +use BookStack\Sorting\BookSortMap; +use BookStack\Sorting\BookSortMapItem; use Illuminate\Support\Collection; class BookContents @@ -103,211 +105,4 @@ class BookContents return $query->where('book_id', '=', $this->book->id)->get(); } - - /** - * Sort the books content using the given sort map. - * Returns a list of books that were involved in the operation. - * - * @returns Book[] - */ - public function sortUsingMap(BookSortMap $sortMap): array - { - // Load models into map - $modelMap = $this->loadModelsFromSortMap($sortMap); - - // Sort our changes from our map to be chapters first - // Since they need to be process to ensure book alignment for child page changes. - $sortMapItems = $sortMap->all(); - usort($sortMapItems, function (BookSortMapItem $itemA, BookSortMapItem $itemB) { - $aScore = $itemA->type === 'page' ? 2 : 1; - $bScore = $itemB->type === 'page' ? 2 : 1; - - return $aScore - $bScore; - }); - - // Perform the sort - foreach ($sortMapItems as $item) { - $this->applySortUpdates($item, $modelMap); - } - - /** @var Book[] $booksInvolved */ - $booksInvolved = array_values(array_filter($modelMap, function (string $key) { - return str_starts_with($key, 'book:'); - }, ARRAY_FILTER_USE_KEY)); - - // Update permissions of books involved - foreach ($booksInvolved as $book) { - $book->rebuildPermissions(); - } - - return $booksInvolved; - } - - /** - * Using the given sort map item, detect changes for the related model - * and update it if required. Changes where permissions are lacking will - * be skipped and not throw an error. - * - * @param array $modelMap - */ - protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void - { - /** @var BookChild $model */ - $model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null; - if (!$model) { - return; - } - - $priorityChanged = $model->priority !== $sortMapItem->sort; - $bookChanged = $model->book_id !== $sortMapItem->parentBookId; - $chapterChanged = ($model instanceof Page) && $model->chapter_id !== $sortMapItem->parentChapterId; - - // Stop if there's no change - if (!$priorityChanged && !$bookChanged && !$chapterChanged) { - return; - } - - $currentParentKey = 'book:' . $model->book_id; - if ($model instanceof Page && $model->chapter_id) { - $currentParentKey = 'chapter:' . $model->chapter_id; - } - - $currentParent = $modelMap[$currentParentKey] ?? null; - /** @var Book $newBook */ - $newBook = $modelMap['book:' . $sortMapItem->parentBookId] ?? null; - /** @var ?Chapter $newChapter */ - $newChapter = $sortMapItem->parentChapterId ? ($modelMap['chapter:' . $sortMapItem->parentChapterId] ?? null) : null; - - if (!$this->isSortChangePermissible($sortMapItem, $model, $currentParent, $newBook, $newChapter)) { - return; - } - - // Action the required changes - if ($bookChanged) { - $model->changeBook($newBook->id); - } - - if ($model instanceof Page && $chapterChanged) { - $model->chapter_id = $newChapter->id ?? 0; - } - - if ($priorityChanged) { - $model->priority = $sortMapItem->sort; - } - - if ($chapterChanged || $priorityChanged) { - $model->save(); - } - } - - /** - * Check if the current user has permissions to apply the given sorting change. - * Is quite complex since items can gain a different parent change. Acts as a: - * - Update of old parent element (Change of content/order). - * - Update of sorted/moved element. - * - Deletion of element (Relative to parent upon move). - * - Creation of element within parent (Upon move to new parent). - */ - protected function isSortChangePermissible(BookSortMapItem $sortMapItem, BookChild $model, ?Entity $currentParent, ?Entity $newBook, ?Entity $newChapter): bool - { - // Stop if we can't see the current parent or new book. - if (!$currentParent || !$newBook) { - return false; - } - - $hasNewParent = $newBook->id !== $model->book_id || ($model instanceof Page && $model->chapter_id !== ($sortMapItem->parentChapterId ?? 0)); - if ($model instanceof Chapter) { - $hasPermission = userCan('book-update', $currentParent) - && userCan('book-update', $newBook) - && userCan('chapter-update', $model) - && (!$hasNewParent || userCan('chapter-create', $newBook)) - && (!$hasNewParent || userCan('chapter-delete', $model)); - - if (!$hasPermission) { - return false; - } - } - - if ($model instanceof Page) { - $parentPermission = ($currentParent instanceof Chapter) ? 'chapter-update' : 'book-update'; - $hasCurrentParentPermission = userCan($parentPermission, $currentParent); - - // This needs to check if there was an intended chapter location in the original sort map - // rather than inferring from the $newChapter since that variable may be null - // due to other reasons (Visibility). - $newParent = $sortMapItem->parentChapterId ? $newChapter : $newBook; - if (!$newParent) { - return false; - } - - $hasPageEditPermission = userCan('page-update', $model); - $newParentInRightLocation = ($newParent instanceof Book || ($newParent instanceof Chapter && $newParent->book_id === $newBook->id)); - $newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update'; - $hasNewParentPermission = userCan($newParentPermission, $newParent); - - $hasDeletePermissionIfMoving = (!$hasNewParent || userCan('page-delete', $model)); - $hasCreatePermissionIfMoving = (!$hasNewParent || userCan('page-create', $newParent)); - - $hasPermission = $hasCurrentParentPermission - && $newParentInRightLocation - && $hasNewParentPermission - && $hasPageEditPermission - && $hasDeletePermissionIfMoving - && $hasCreatePermissionIfMoving; - - if (!$hasPermission) { - return false; - } - } - - return true; - } - - /** - * Load models from the database into the given sort map. - * - * @return array - */ - protected function loadModelsFromSortMap(BookSortMap $sortMap): array - { - $modelMap = []; - $ids = [ - 'chapter' => [], - 'page' => [], - 'book' => [], - ]; - - foreach ($sortMap->all() as $sortMapItem) { - $ids[$sortMapItem->type][] = $sortMapItem->id; - $ids['book'][] = $sortMapItem->parentBookId; - if ($sortMapItem->parentChapterId) { - $ids['chapter'][] = $sortMapItem->parentChapterId; - } - } - - $pages = $this->queries->pages->visibleForList()->whereIn('id', array_unique($ids['page']))->get(); - /** @var Page $page */ - foreach ($pages as $page) { - $modelMap['page:' . $page->id] = $page; - $ids['book'][] = $page->book_id; - if ($page->chapter_id) { - $ids['chapter'][] = $page->chapter_id; - } - } - - $chapters = $this->queries->chapters->visibleForList()->whereIn('id', array_unique($ids['chapter']))->get(); - /** @var Chapter $chapter */ - foreach ($chapters as $chapter) { - $modelMap['chapter:' . $chapter->id] = $chapter; - $ids['book'][] = $chapter->book_id; - } - - $books = $this->queries->books->visibleForList()->whereIn('id', array_unique($ids['book']))->get(); - /** @var Book $book */ - foreach ($books as $book) { - $modelMap['book:' . $book->id] = $book; - } - - return $modelMap; - } } diff --git a/app/Entities/Controllers/BookSortController.php b/app/Sorting/BookSortController.php similarity index 88% rename from app/Entities/Controllers/BookSortController.php rename to app/Sorting/BookSortController.php index 5aefc5832..feed5db4f 100644 --- a/app/Entities/Controllers/BookSortController.php +++ b/app/Sorting/BookSortController.php @@ -1,11 +1,10 @@ queries->findVisibleBySlugOrFail($bookSlug); $this->checkOwnablePermission('book-update', $book); @@ -58,8 +57,7 @@ class BookSortController extends Controller } $sortMap = BookSortMap::fromJson($request->get('sort-tree')); - $bookContents = new BookContents($book); - $booksInvolved = $bookContents->sortUsingMap($sortMap); + $booksInvolved = $sorter->sortUsingMap($sortMap); // Rebuild permissions and add activity for involved books. foreach ($booksInvolved as $bookInvolved) { diff --git a/app/Entities/Tools/BookSortMap.php b/app/Sorting/BookSortMap.php similarity index 96% rename from app/Entities/Tools/BookSortMap.php rename to app/Sorting/BookSortMap.php index ff1ec767f..96c9d342a 100644 --- a/app/Entities/Tools/BookSortMap.php +++ b/app/Sorting/BookSortMap.php @@ -1,6 +1,6 @@ loadModelsFromSortMap($sortMap); + + // Sort our changes from our map to be chapters first + // Since they need to be process to ensure book alignment for child page changes. + $sortMapItems = $sortMap->all(); + usort($sortMapItems, function (BookSortMapItem $itemA, BookSortMapItem $itemB) { + $aScore = $itemA->type === 'page' ? 2 : 1; + $bScore = $itemB->type === 'page' ? 2 : 1; + + return $aScore - $bScore; + }); + + // Perform the sort + foreach ($sortMapItems as $item) { + $this->applySortUpdates($item, $modelMap); + } + + /** @var Book[] $booksInvolved */ + $booksInvolved = array_values(array_filter($modelMap, function (string $key) { + return str_starts_with($key, 'book:'); + }, ARRAY_FILTER_USE_KEY)); + + // Update permissions of books involved + foreach ($booksInvolved as $book) { + $book->rebuildPermissions(); + } + + return $booksInvolved; + } + + /** + * Using the given sort map item, detect changes for the related model + * and update it if required. Changes where permissions are lacking will + * be skipped and not throw an error. + * + * @param array $modelMap + */ + protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void + { + /** @var BookChild $model */ + $model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null; + if (!$model) { + return; + } + + $priorityChanged = $model->priority !== $sortMapItem->sort; + $bookChanged = $model->book_id !== $sortMapItem->parentBookId; + $chapterChanged = ($model instanceof Page) && $model->chapter_id !== $sortMapItem->parentChapterId; + + // Stop if there's no change + if (!$priorityChanged && !$bookChanged && !$chapterChanged) { + return; + } + + $currentParentKey = 'book:' . $model->book_id; + if ($model instanceof Page && $model->chapter_id) { + $currentParentKey = 'chapter:' . $model->chapter_id; + } + + $currentParent = $modelMap[$currentParentKey] ?? null; + /** @var Book $newBook */ + $newBook = $modelMap['book:' . $sortMapItem->parentBookId] ?? null; + /** @var ?Chapter $newChapter */ + $newChapter = $sortMapItem->parentChapterId ? ($modelMap['chapter:' . $sortMapItem->parentChapterId] ?? null) : null; + + if (!$this->isSortChangePermissible($sortMapItem, $model, $currentParent, $newBook, $newChapter)) { + return; + } + + // Action the required changes + if ($bookChanged) { + $model->changeBook($newBook->id); + } + + if ($model instanceof Page && $chapterChanged) { + $model->chapter_id = $newChapter->id ?? 0; + } + + if ($priorityChanged) { + $model->priority = $sortMapItem->sort; + } + + if ($chapterChanged || $priorityChanged) { + $model->save(); + } + } + + /** + * Check if the current user has permissions to apply the given sorting change. + * Is quite complex since items can gain a different parent change. Acts as a: + * - Update of old parent element (Change of content/order). + * - Update of sorted/moved element. + * - Deletion of element (Relative to parent upon move). + * - Creation of element within parent (Upon move to new parent). + */ + protected function isSortChangePermissible(BookSortMapItem $sortMapItem, BookChild $model, ?Entity $currentParent, ?Entity $newBook, ?Entity $newChapter): bool + { + // Stop if we can't see the current parent or new book. + if (!$currentParent || !$newBook) { + return false; + } + + $hasNewParent = $newBook->id !== $model->book_id || ($model instanceof Page && $model->chapter_id !== ($sortMapItem->parentChapterId ?? 0)); + if ($model instanceof Chapter) { + $hasPermission = userCan('book-update', $currentParent) + && userCan('book-update', $newBook) + && userCan('chapter-update', $model) + && (!$hasNewParent || userCan('chapter-create', $newBook)) + && (!$hasNewParent || userCan('chapter-delete', $model)); + + if (!$hasPermission) { + return false; + } + } + + if ($model instanceof Page) { + $parentPermission = ($currentParent instanceof Chapter) ? 'chapter-update' : 'book-update'; + $hasCurrentParentPermission = userCan($parentPermission, $currentParent); + + // This needs to check if there was an intended chapter location in the original sort map + // rather than inferring from the $newChapter since that variable may be null + // due to other reasons (Visibility). + $newParent = $sortMapItem->parentChapterId ? $newChapter : $newBook; + if (!$newParent) { + return false; + } + + $hasPageEditPermission = userCan('page-update', $model); + $newParentInRightLocation = ($newParent instanceof Book || ($newParent instanceof Chapter && $newParent->book_id === $newBook->id)); + $newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update'; + $hasNewParentPermission = userCan($newParentPermission, $newParent); + + $hasDeletePermissionIfMoving = (!$hasNewParent || userCan('page-delete', $model)); + $hasCreatePermissionIfMoving = (!$hasNewParent || userCan('page-create', $newParent)); + + $hasPermission = $hasCurrentParentPermission + && $newParentInRightLocation + && $hasNewParentPermission + && $hasPageEditPermission + && $hasDeletePermissionIfMoving + && $hasCreatePermissionIfMoving; + + if (!$hasPermission) { + return false; + } + } + + return true; + } + + /** + * Load models from the database into the given sort map. + * + * @return array + */ + protected function loadModelsFromSortMap(BookSortMap $sortMap): array + { + $modelMap = []; + $ids = [ + 'chapter' => [], + 'page' => [], + 'book' => [], + ]; + + foreach ($sortMap->all() as $sortMapItem) { + $ids[$sortMapItem->type][] = $sortMapItem->id; + $ids['book'][] = $sortMapItem->parentBookId; + if ($sortMapItem->parentChapterId) { + $ids['chapter'][] = $sortMapItem->parentChapterId; + } + } + + $pages = $this->queries->pages->visibleForList()->whereIn('id', array_unique($ids['page']))->get(); + /** @var Page $page */ + foreach ($pages as $page) { + $modelMap['page:' . $page->id] = $page; + $ids['book'][] = $page->book_id; + if ($page->chapter_id) { + $ids['chapter'][] = $page->chapter_id; + } + } + + $chapters = $this->queries->chapters->visibleForList()->whereIn('id', array_unique($ids['chapter']))->get(); + /** @var Chapter $chapter */ + foreach ($chapters as $chapter) { + $modelMap['chapter:' . $chapter->id] = $chapter; + $ids['book'][] = $chapter->book_id; + } + + $books = $this->queries->books->visibleForList()->whereIn('id', array_unique($ids['book']))->get(); + /** @var Book $book */ + foreach ($books as $book) { + $modelMap['book:' . $book->id] = $book; + } + + return $modelMap; + } +} diff --git a/routes/web.php b/routes/web.php index 5bb9622e7..e1e819dd0 100644 --- a/routes/web.php +++ b/routes/web.php @@ -13,6 +13,7 @@ use BookStack\Permissions\PermissionsController; use BookStack\References\ReferenceController; use BookStack\Search\SearchController; use BookStack\Settings as SettingControllers; +use BookStack\Sorting\BookSortController; use BookStack\Theming\ThemeController; use BookStack\Uploads\Controllers as UploadControllers; use BookStack\Users\Controllers as UserControllers; @@ -66,7 +67,7 @@ Route::middleware('auth')->group(function () { Route::get('/books/{slug}/edit', [EntityControllers\BookController::class, 'edit']); Route::put('/books/{slug}', [EntityControllers\BookController::class, 'update']); Route::delete('/books/{id}', [EntityControllers\BookController::class, 'destroy']); - Route::get('/books/{slug}/sort-item', [EntityControllers\BookSortController::class, 'showItem']); + Route::get('/books/{slug}/sort-item', [BookSortController::class, 'showItem']); Route::get('/books/{slug}', [EntityControllers\BookController::class, 'show']); Route::get('/books/{bookSlug}/permissions', [PermissionsController::class, 'showForBook']); Route::put('/books/{bookSlug}/permissions', [PermissionsController::class, 'updateForBook']); @@ -74,8 +75,8 @@ Route::middleware('auth')->group(function () { Route::get('/books/{bookSlug}/copy', [EntityControllers\BookController::class, 'showCopy']); Route::post('/books/{bookSlug}/copy', [EntityControllers\BookController::class, 'copy']); Route::post('/books/{bookSlug}/convert-to-shelf', [EntityControllers\BookController::class, 'convertToShelf']); - Route::get('/books/{bookSlug}/sort', [EntityControllers\BookSortController::class, 'show']); - Route::put('/books/{bookSlug}/sort', [EntityControllers\BookSortController::class, 'update']); + Route::get('/books/{bookSlug}/sort', [BookSortController::class, 'show']); + Route::put('/books/{bookSlug}/sort', [BookSortController::class, 'update']); Route::get('/books/{slug}/references', [ReferenceController::class, 'book']); Route::get('/books/{bookSlug}/export/html', [ExportControllers\BookExportController::class, 'html']); Route::get('/books/{bookSlug}/export/pdf', [ExportControllers\BookExportController::class, 'pdf']); From 5b0cb3dd506c108b5d5d13c5c07c4f02e6107608 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 29 Jan 2025 17:02:34 +0000 Subject: [PATCH 02/17] Sorting: Extracted URL sort helper to own class Was only used in one place, so didn't make sense to have extra global helper clutter. --- .../Controllers/AuditLogController.php | 2 + app/App/helpers.php | 32 ------------ app/Sorting/SortUrl.php | 49 +++++++++++++++++++ resources/views/settings/audit.blade.php | 4 +- 4 files changed, 53 insertions(+), 34 deletions(-) create mode 100644 app/Sorting/SortUrl.php diff --git a/app/Activity/Controllers/AuditLogController.php b/app/Activity/Controllers/AuditLogController.php index 641106d7f..66ca30197 100644 --- a/app/Activity/Controllers/AuditLogController.php +++ b/app/Activity/Controllers/AuditLogController.php @@ -5,6 +5,7 @@ namespace BookStack\Activity\Controllers; use BookStack\Activity\ActivityType; use BookStack\Activity\Models\Activity; use BookStack\Http\Controller; +use BookStack\Sorting\SortUrl; use BookStack\Util\SimpleListOptions; use Illuminate\Http\Request; @@ -65,6 +66,7 @@ class AuditLogController extends Controller 'filters' => $filters, 'listOptions' => $listOptions, 'activityTypes' => $types, + 'filterSortUrl' => new SortUrl('settings/audit', array_filter($request->except('page'))) ]); } } diff --git a/app/App/helpers.php b/app/App/helpers.php index 941c267d6..204b3f06a 100644 --- a/app/App/helpers.php +++ b/app/App/helpers.php @@ -96,35 +96,3 @@ function theme_path(string $path = ''): ?string return base_path('themes/' . $theme . ($path ? DIRECTORY_SEPARATOR . $path : $path)); } - -/** - * Generate a URL with multiple parameters for sorting purposes. - * Works out the logic to set the correct sorting direction - * Discards empty parameters and allows overriding. - */ -function sortUrl(string $path, array $data, array $overrideData = []): string -{ - $queryStringSections = []; - $queryData = array_merge($data, $overrideData); - - // Change sorting direction is already sorted on current attribute - if (isset($overrideData['sort']) && $overrideData['sort'] === $data['sort']) { - $queryData['order'] = ($data['order'] === 'asc') ? 'desc' : 'asc'; - } elseif (isset($overrideData['sort'])) { - $queryData['order'] = 'asc'; - } - - foreach ($queryData as $name => $value) { - $trimmedVal = trim($value); - if ($trimmedVal === '') { - continue; - } - $queryStringSections[] = urlencode($name) . '=' . urlencode($trimmedVal); - } - - if (count($queryStringSections) === 0) { - return url($path); - } - - return url($path . '?' . implode('&', $queryStringSections)); -} diff --git a/app/Sorting/SortUrl.php b/app/Sorting/SortUrl.php new file mode 100644 index 000000000..f01df2c36 --- /dev/null +++ b/app/Sorting/SortUrl.php @@ -0,0 +1,49 @@ +path, $this->data, $overrideData); + } + + public function build(): string + { + $queryStringSections = []; + $queryData = array_merge($this->data, $this->overrideData); + + // Change sorting direction if already sorted on current attribute + if (isset($this->overrideData['sort']) && $this->overrideData['sort'] === $this->data['sort']) { + $queryData['order'] = ($this->data['order'] === 'asc') ? 'desc' : 'asc'; + } elseif (isset($this->overrideData['sort'])) { + $queryData['order'] = 'asc'; + } + + foreach ($queryData as $name => $value) { + $trimmedVal = trim($value); + if ($trimmedVal !== '') { + $queryStringSections[] = urlencode($name) . '=' . urlencode($trimmedVal); + } + } + + if (count($queryStringSections) === 0) { + return url($this->path); + } + + return url($this->path . '?' . implode('&', $queryStringSections)); + } +} diff --git a/resources/views/settings/audit.blade.php b/resources/views/settings/audit.blade.php index 28cdeb8a5..8e4776680 100644 --- a/resources/views/settings/audit.blade.php +++ b/resources/views/settings/audit.blade.php @@ -26,11 +26,11 @@ class="input-base text-left">{{ $filters['event'] ?: trans('settings.audit_event_filter_no_filter') }} From b2ac3e0834172e2eaf70d4c893c90704b5aa9bf8 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 29 Jan 2025 17:34:07 +0000 Subject: [PATCH 03/17] Sorting: Added SortSet model & migration --- app/Sorting/SortSetOption.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 app/Sorting/SortSetOption.php diff --git a/app/Sorting/SortSetOption.php b/app/Sorting/SortSetOption.php new file mode 100644 index 000000000..0a78e99c7 --- /dev/null +++ b/app/Sorting/SortSetOption.php @@ -0,0 +1,16 @@ + Date: Thu, 30 Jan 2025 17:49:19 +0000 Subject: [PATCH 04/17] Sorting: Added content misses from last commit, started settings --- app/Sorting/SortSet.php | 35 +++++++++++++ ...25_01_29_180933_create_sort_sets_table.php | 29 +++++++++++ lang/en/settings.php | 7 +++ .../settings/categories/sorting.blade.php | 49 +++++++++++++++++++ resources/views/settings/layout.blade.php | 2 + 5 files changed, 122 insertions(+) create mode 100644 app/Sorting/SortSet.php create mode 100644 database/migrations/2025_01_29_180933_create_sort_sets_table.php create mode 100644 resources/views/settings/categories/sorting.blade.php diff --git a/app/Sorting/SortSet.php b/app/Sorting/SortSet.php new file mode 100644 index 000000000..42e1e0951 --- /dev/null +++ b/app/Sorting/SortSet.php @@ -0,0 +1,35 @@ +sequence); + $options = array_map(fn ($val) => SortSetOption::tryFrom($val), $strOptions); + return array_filter($options); + } + + /** + * @param SortSetOption[] $options + */ + public function setOptions(array $options): void + { + $values = array_map(fn (SortSetOption $opt) => $opt->value, $options); + $this->sequence = implode(',', $values); + } +} diff --git a/database/migrations/2025_01_29_180933_create_sort_sets_table.php b/database/migrations/2025_01_29_180933_create_sort_sets_table.php new file mode 100644 index 000000000..bf9780c5b --- /dev/null +++ b/database/migrations/2025_01_29_180933_create_sort_sets_table.php @@ -0,0 +1,29 @@ +increments('id'); + $table->string('name'); + $table->text('sequence'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('sort_sets'); + } +}; diff --git a/lang/en/settings.php b/lang/en/settings.php index c0b6b692a..b20152bfe 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -74,6 +74,13 @@ return [ 'reg_confirm_restrict_domain_desc' => 'Enter a comma separated list of email domains you would like to restrict registration to. Users will be sent an email to confirm their address before being allowed to interact with the application.
Note that users will be able to change their email addresses after successful registration.', 'reg_confirm_restrict_domain_placeholder' => 'No restriction set', + // Sorting Settings + 'sorting' => 'Sorting', + 'sorting_book_default' => 'Default Book Sort', + 'sorting_book_default_desc' => 'Select the default sort set to apply to new books. This won\'t affect existing books, and can be overridden per-book.', + 'sorting_sets' => 'Sort Sets', + 'sorting_sets_desc' => 'These are predefined sorting operations which can be applied to content in the system.', + // Maintenance settings 'maint' => 'Maintenance', 'maint_image_cleanup' => 'Cleanup Images', diff --git a/resources/views/settings/categories/sorting.blade.php b/resources/views/settings/categories/sorting.blade.php new file mode 100644 index 000000000..153ea0e3b --- /dev/null +++ b/resources/views/settings/categories/sorting.blade.php @@ -0,0 +1,49 @@ +@extends('settings.layout') + +@section('card') +

{{ trans('settings.sorting') }}

+
+ {{ csrf_field() }} + + +
+
+
+ +

{{ trans('settings.sorting_book_default_desc') }}

+
+
+ +
+
+ +
+ +
+ +
+
+@endsection + +@section('after-card') +
+

{{ trans('settings.sorting_sets') }}

+

{{ trans('settings.sorting_sets_desc') }}

+{{-- TODO--}} +
+@endsection \ No newline at end of file diff --git a/resources/views/settings/layout.blade.php b/resources/views/settings/layout.blade.php index a59b58d53..930d407a5 100644 --- a/resources/views/settings/layout.blade.php +++ b/resources/views/settings/layout.blade.php @@ -13,6 +13,7 @@ @icon('star') {{ trans('settings.app_features_security') }} @icon('palette') {{ trans('settings.app_customization') }} @icon('security') {{ trans('settings.reg_settings') }} + @icon('sort') {{ trans('settings.sorting') }}
{{ trans('settings.system_version') }}
@@ -29,6 +30,7 @@
@yield('card')
+ @yield('after-card') From bf8a84a8b1dd02578e6b5e2b39882902809f112a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 3 Feb 2025 16:48:57 +0000 Subject: [PATCH 05/17] Sorting: Started sort set routes and form --- app/Sorting/SortSetController.php | 19 +++++ app/Sorting/SortSetOption.php | 28 +++++++ lang/en/settings.php | 16 ++++ .../settings/categories/sorting.blade.php | 12 ++- .../views/settings/sort-sets/create.blade.php | 24 ++++++ .../settings/sort-sets/parts/form.blade.php | 74 +++++++++++++++++++ routes/web.php | 15 +++- 7 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 app/Sorting/SortSetController.php create mode 100644 resources/views/settings/sort-sets/create.blade.php create mode 100644 resources/views/settings/sort-sets/parts/form.blade.php diff --git a/app/Sorting/SortSetController.php b/app/Sorting/SortSetController.php new file mode 100644 index 000000000..4ef148295 --- /dev/null +++ b/app/Sorting/SortSetController.php @@ -0,0 +1,19 @@ +middleware('can:settings-manage'); + // TODO - Test + } + + public function create() + { + return view('settings.sort-sets.create'); + } +} diff --git a/app/Sorting/SortSetOption.php b/app/Sorting/SortSetOption.php index 0a78e99c7..bb878cf30 100644 --- a/app/Sorting/SortSetOption.php +++ b/app/Sorting/SortSetOption.php @@ -13,4 +13,32 @@ enum SortSetOption: string case UpdateDateDesc = 'updated_date_desc'; case ChaptersFirst = 'chapters_first'; case ChaptersLast = 'chapters_last'; + + /** + * Provide a translated label string for this option. + */ + public function getLabel(): string + { + $key = $this->value; + $label = ''; + if (str_ends_with($key, '_asc')) { + $key = substr($key, 0, -4); + $label = trans('settings.sort_set_op_asc'); + } elseif (str_ends_with($key, '_desc')) { + $key = substr($key, 0, -5); + $label = trans('settings.sort_set_op_desc'); + } + + $label = trans('settings.sort_set_op_' . $key) . ' ' . $label; + return trim($label); + } + + /** + * @return SortSetOption[] + */ + public static function allExcluding(array $options): array + { + $all = SortSetOption::cases(); + return array_diff($all, $options); + } } diff --git a/lang/en/settings.php b/lang/en/settings.php index b20152bfe..b29ec2533 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -80,6 +80,22 @@ return [ 'sorting_book_default_desc' => 'Select the default sort set to apply to new books. This won\'t affect existing books, and can be overridden per-book.', 'sorting_sets' => 'Sort Sets', 'sorting_sets_desc' => 'These are predefined sorting operations which can be applied to content in the system.', + 'sort_set_create' => 'Create Sort Set', + 'sort_set_edit' => 'Edit Sort Set', + 'sort_set_details' => 'Sort Set Details', + 'sort_set_details_desc' => 'Set a name for this sort set, which will appear in lists when users are selecting a sort.', + 'sort_set_operations' => 'Sort Operations', + 'sort_set_operations_desc' => 'Configure the sort actions to be performed in this set by moving them from the list of available operations. Upon use, the operations will be applied in order, from top to bottom.', + 'sort_set_available_operations' => 'Available Operations', + 'sort_set_configured_operations' => 'Configured Operations', + 'sort_set_op_asc' => '(Asc)', + 'sort_set_op_desc' => '(Desc)', + 'sort_set_op_name' => 'Name - Alphabetical', + 'sort_set_op_name_numeric' => 'Name - Numeric', + 'sort_set_op_created_date' => 'Created Date', + 'sort_set_op_updated_date' => 'Updated Date', + 'sort_set_op_chapters_first' => 'Chapters First', + 'sort_set_op_chapters_last' => 'Chapters Last', // Maintenance settings 'maint' => 'Maintenance', diff --git a/resources/views/settings/categories/sorting.blade.php b/resources/views/settings/categories/sorting.blade.php index 153ea0e3b..9de11bb6f 100644 --- a/resources/views/settings/categories/sorting.blade.php +++ b/resources/views/settings/categories/sorting.blade.php @@ -42,8 +42,16 @@ @section('after-card')
-

{{ trans('settings.sorting_sets') }}

-

{{ trans('settings.sorting_sets_desc') }}

+
+
+

{{ trans('settings.sorting_sets') }}

+

{{ trans('settings.sorting_sets_desc') }}

+
+ +
+ {{-- TODO--}}
@endsection \ No newline at end of file diff --git a/resources/views/settings/sort-sets/create.blade.php b/resources/views/settings/sort-sets/create.blade.php new file mode 100644 index 000000000..16f2d2ac7 --- /dev/null +++ b/resources/views/settings/sort-sets/create.blade.php @@ -0,0 +1,24 @@ +@extends('layouts.simple') + +@section('body') + +
+ + @include('settings.parts.navbar', ['selected' => 'settings']) + +
+

{{ trans('settings.sort_set_create') }}

+ +
+ {{ csrf_field() }} + @include('settings.sort-sets.parts.form', ['model' => null]) + +
+ {{ trans('common.cancel') }} + +
+
+
+
+ +@stop diff --git a/resources/views/settings/sort-sets/parts/form.blade.php b/resources/views/settings/sort-sets/parts/form.blade.php new file mode 100644 index 000000000..6df04a721 --- /dev/null +++ b/resources/views/settings/sort-sets/parts/form.blade.php @@ -0,0 +1,74 @@ + +
+
+
+ +

{{ trans('settings.sort_set_details_desc') }}

+
+
+
+ + @include('form.text', ['name' => 'name']) +
+
+
+ +
+ +

{{ trans('settings.sort_set_operations_desc') }}

+ + + +
+
+ +
    + @foreach(($model?->getOptions() ?? []) as $option) +
  • +
    @icon('grip')
    +
    {{ $option->getLabel() }}
    +
    + + + + +
    +
  • + @endforeach +
+
+ +
+ +
    + @foreach(\BookStack\Sorting\SortSetOption::allExcluding($model?->getOptions() ?? []) as $option) +
  • +
    @icon('grip')
    +
    {{ $option->getLabel() }}
    +
    + + + + +
    +
  • + @endforeach +
+
+
+
+
\ No newline at end of file diff --git a/routes/web.php b/routes/web.php index e1e819dd0..62c120f20 100644 --- a/routes/web.php +++ b/routes/web.php @@ -13,7 +13,7 @@ use BookStack\Permissions\PermissionsController; use BookStack\References\ReferenceController; use BookStack\Search\SearchController; use BookStack\Settings as SettingControllers; -use BookStack\Sorting\BookSortController; +use BookStack\Sorting as SortingControllers; use BookStack\Theming\ThemeController; use BookStack\Uploads\Controllers as UploadControllers; use BookStack\Users\Controllers as UserControllers; @@ -67,7 +67,7 @@ Route::middleware('auth')->group(function () { Route::get('/books/{slug}/edit', [EntityControllers\BookController::class, 'edit']); Route::put('/books/{slug}', [EntityControllers\BookController::class, 'update']); Route::delete('/books/{id}', [EntityControllers\BookController::class, 'destroy']); - Route::get('/books/{slug}/sort-item', [BookSortController::class, 'showItem']); + Route::get('/books/{slug}/sort-item', [SortingControllers\BookSortController::class, 'showItem']); Route::get('/books/{slug}', [EntityControllers\BookController::class, 'show']); Route::get('/books/{bookSlug}/permissions', [PermissionsController::class, 'showForBook']); Route::put('/books/{bookSlug}/permissions', [PermissionsController::class, 'updateForBook']); @@ -75,8 +75,8 @@ Route::middleware('auth')->group(function () { Route::get('/books/{bookSlug}/copy', [EntityControllers\BookController::class, 'showCopy']); Route::post('/books/{bookSlug}/copy', [EntityControllers\BookController::class, 'copy']); Route::post('/books/{bookSlug}/convert-to-shelf', [EntityControllers\BookController::class, 'convertToShelf']); - Route::get('/books/{bookSlug}/sort', [BookSortController::class, 'show']); - Route::put('/books/{bookSlug}/sort', [BookSortController::class, 'update']); + Route::get('/books/{bookSlug}/sort', [SortingControllers\BookSortController::class, 'show']); + Route::put('/books/{bookSlug}/sort', [SortingControllers\BookSortController::class, 'update']); Route::get('/books/{slug}/references', [ReferenceController::class, 'book']); Route::get('/books/{bookSlug}/export/html', [ExportControllers\BookExportController::class, 'html']); Route::get('/books/{bookSlug}/export/pdf', [ExportControllers\BookExportController::class, 'pdf']); @@ -295,6 +295,13 @@ Route::middleware('auth')->group(function () { Route::get('/settings/webhooks/{id}/delete', [ActivityControllers\WebhookController::class, 'delete']); Route::delete('/settings/webhooks/{id}', [ActivityControllers\WebhookController::class, 'destroy']); + // Sort Sets + Route::get('/settings/sorting/sets/new', [SortingControllers\SortSetController::class, 'create']); + Route::post('/settings/sorting/sets', [SortingControllers\SortSetController::class, 'store']); + Route::get('/settings/sorting/sets/{id}', [SortingControllers\SortSetController::class, 'edit']); + Route::put('/settings/sorting/sets/{id}', [SortingControllers\SortSetController::class, 'update']); + Route::delete('/settings/sorting/sets/{id}', [SortingControllers\SortSetController::class, 'destroy']); + // Settings Route::get('/settings', [SettingControllers\SettingController::class, 'index'])->name('settings'); Route::get('/settings/{category}', [SettingControllers\SettingController::class, 'category'])->name('settings.category'); From d28278bba63eaa13d7ab691379b4b741c1fb83e6 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 4 Feb 2025 15:14:22 +0000 Subject: [PATCH 06/17] Sorting: Added sort set form manager UI JS Extracted much code to be shared with the shelf books management UI --- app/Sorting/SortSet.php | 12 ++-- ...SortSetOption.php => SortSetOperation.php} | 10 ++-- lang/en/settings.php | 2 + package-lock.json | 9 ++- package.json | 1 + resources/js/components/index.ts | 1 + resources/js/components/shelf-sort.js | 48 ++------------- resources/js/components/sort-set-manager.ts | 41 +++++++++++++ resources/js/services/dual-lists.ts | 51 ++++++++++++++++ resources/sass/_components.scss | 19 ++++-- .../settings/sort-sets/parts/form.blade.php | 58 ++++++------------- .../sort-sets/parts/operation.blade.php | 15 +++++ resources/views/shelves/parts/form.blade.php | 4 +- 13 files changed, 168 insertions(+), 103 deletions(-) rename app/Sorting/{SortSetOption.php => SortSetOperation.php} (82%) create mode 100644 resources/js/components/sort-set-manager.ts create mode 100644 resources/js/services/dual-lists.ts create mode 100644 resources/views/settings/sort-sets/parts/operation.blade.php diff --git a/app/Sorting/SortSet.php b/app/Sorting/SortSet.php index 42e1e0951..ee45c211f 100644 --- a/app/Sorting/SortSet.php +++ b/app/Sorting/SortSet.php @@ -15,21 +15,21 @@ use Illuminate\Database\Eloquent\Model; class SortSet extends Model { /** - * @return SortSetOption[] + * @return SortSetOperation[] */ - public function getOptions(): array + public function getOperations(): array { $strOptions = explode(',', $this->sequence); - $options = array_map(fn ($val) => SortSetOption::tryFrom($val), $strOptions); + $options = array_map(fn ($val) => SortSetOperation::tryFrom($val), $strOptions); return array_filter($options); } /** - * @param SortSetOption[] $options + * @param SortSetOperation[] $options */ - public function setOptions(array $options): void + public function setOperations(array $options): void { - $values = array_map(fn (SortSetOption $opt) => $opt->value, $options); + $values = array_map(fn (SortSetOperation $opt) => $opt->value, $options); $this->sequence = implode(',', $values); } } diff --git a/app/Sorting/SortSetOption.php b/app/Sorting/SortSetOperation.php similarity index 82% rename from app/Sorting/SortSetOption.php rename to app/Sorting/SortSetOperation.php index bb878cf30..12fda669f 100644 --- a/app/Sorting/SortSetOption.php +++ b/app/Sorting/SortSetOperation.php @@ -2,7 +2,7 @@ namespace BookStack\Sorting; -enum SortSetOption: string +enum SortSetOperation: string { case NameAsc = 'name_asc'; case NameDesc = 'name_desc'; @@ -34,11 +34,11 @@ enum SortSetOption: string } /** - * @return SortSetOption[] + * @return SortSetOperation[] */ - public static function allExcluding(array $options): array + public static function allExcluding(array $operations): array { - $all = SortSetOption::cases(); - return array_diff($all, $options); + $all = SortSetOperation::cases(); + return array_diff($all, $operations); } } diff --git a/lang/en/settings.php b/lang/en/settings.php index b29ec2533..8bb2f6ef4 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -87,7 +87,9 @@ return [ 'sort_set_operations' => 'Sort Operations', 'sort_set_operations_desc' => 'Configure the sort actions to be performed in this set by moving them from the list of available operations. Upon use, the operations will be applied in order, from top to bottom.', 'sort_set_available_operations' => 'Available Operations', + 'sort_set_available_operations_empty' => 'No operations remaining', 'sort_set_configured_operations' => 'Configured Operations', + 'sort_set_configured_operations_empty' => 'Drag/add operations from the "Available Operations" list', 'sort_set_op_asc' => '(Asc)', 'sort_set_op_desc' => '(Desc)', 'sort_set_op_name' => 'Name - Alphabetical', diff --git a/package-lock.json b/package-lock.json index 1912106c2..44a735d2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,7 +4,6 @@ "requires": true, "packages": { "": { - "name": "bookstack", "dependencies": { "@codemirror/commands": "^6.7.1", "@codemirror/lang-css": "^6.3.1", @@ -32,6 +31,7 @@ }, "devDependencies": { "@lezer/generator": "^1.7.2", + "@types/sortablejs": "^1.15.8", "chokidar-cli": "^3.0", "esbuild": "^0.24.0", "eslint": "^8.57.1", @@ -2403,6 +2403,13 @@ "undici-types": "~6.19.2" } }, + "node_modules/@types/sortablejs": { + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.8.tgz", + "integrity": "sha512-b79830lW+RZfwaztgs1aVPgbasJ8e7AXtZYHTELNXZPsERt4ymJdjV4OccDbHQAvHrCcFpbF78jkm0R6h/pZVg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", diff --git a/package.json b/package.json index 08af25d14..4571ea77d 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ }, "devDependencies": { "@lezer/generator": "^1.7.2", + "@types/sortablejs": "^1.15.8", "chokidar-cli": "^3.0", "esbuild": "^0.24.0", "eslint": "^8.57.1", diff --git a/resources/js/components/index.ts b/resources/js/components/index.ts index 12c991a51..affa25fcf 100644 --- a/resources/js/components/index.ts +++ b/resources/js/components/index.ts @@ -50,6 +50,7 @@ export {ShelfSort} from './shelf-sort'; export {Shortcuts} from './shortcuts'; export {ShortcutInput} from './shortcut-input'; export {SortableList} from './sortable-list'; +export {SortSetManager} from './sort-set-manager' export {SubmitOnChange} from './submit-on-change'; export {Tabs} from './tabs'; export {TagManager} from './tag-manager'; diff --git a/resources/js/components/shelf-sort.js b/resources/js/components/shelf-sort.js index 01ca11a33..b56b01980 100644 --- a/resources/js/components/shelf-sort.js +++ b/resources/js/components/shelf-sort.js @@ -1,29 +1,6 @@ import Sortable from 'sortablejs'; import {Component} from './component'; - -/** - * @type {Object} - */ -const itemActions = { - move_up(item) { - const list = item.parentNode; - const index = Array.from(list.children).indexOf(item); - const newIndex = Math.max(index - 1, 0); - list.insertBefore(item, list.children[newIndex] || null); - }, - move_down(item) { - const list = item.parentNode; - const index = Array.from(list.children).indexOf(item); - const newIndex = Math.min(index + 2, list.children.length); - list.insertBefore(item, list.children[newIndex] || null); - }, - remove(item, shelfBooksList, allBooksList) { - allBooksList.appendChild(item); - }, - add(item, shelfBooksList) { - shelfBooksList.appendChild(item); - }, -}; +import {buildListActions, sortActionClickListener} from '../services/dual-lists.ts'; export class ShelfSort extends Component { @@ -55,12 +32,9 @@ export class ShelfSort extends Component { } setupListeners() { - this.elem.addEventListener('click', event => { - const sortItemAction = event.target.closest('.scroll-box-item button[data-action]'); - if (sortItemAction) { - this.sortItemActionClick(sortItemAction); - } - }); + const listActions = buildListActions(this.allBookList, this.shelfBookList); + const sortActionListener = sortActionClickListener(listActions, this.onChange.bind(this)); + this.elem.addEventListener('click', sortActionListener); this.bookSearchInput.addEventListener('input', () => { this.filterBooksByName(this.bookSearchInput.value); @@ -93,20 +67,6 @@ export class ShelfSort extends Component { } } - /** - * Called when a sort item action button is clicked. - * @param {HTMLElement} sortItemAction - */ - sortItemActionClick(sortItemAction) { - const sortItem = sortItemAction.closest('.scroll-box-item'); - const {action} = sortItemAction.dataset; - - const actionFunction = itemActions[action]; - actionFunction(sortItem, this.shelfBookList, this.allBookList); - - this.onChange(); - } - onChange() { const shelfBookElems = Array.from(this.shelfBookList.querySelectorAll('[data-id]')); this.input.value = shelfBookElems.map(elem => elem.getAttribute('data-id')).join(','); diff --git a/resources/js/components/sort-set-manager.ts b/resources/js/components/sort-set-manager.ts new file mode 100644 index 000000000..c35ad41fe --- /dev/null +++ b/resources/js/components/sort-set-manager.ts @@ -0,0 +1,41 @@ +import {Component} from "./component.js"; +import Sortable from "sortablejs"; +import {buildListActions, sortActionClickListener} from "../services/dual-lists"; + + +export class SortSetManager extends Component { + + protected input!: HTMLInputElement; + protected configuredList!: HTMLElement; + protected availableList!: HTMLElement; + + setup() { + this.input = this.$refs.input as HTMLInputElement; + this.configuredList = this.$refs.configuredOperationsList; + this.availableList = this.$refs.availableOperationsList; + + this.initSortable(); + + const listActions = buildListActions(this.availableList, this.configuredList); + const sortActionListener = sortActionClickListener(listActions, this.onChange.bind(this)); + this.$el.addEventListener('click', sortActionListener); + } + + initSortable() { + const scrollBoxes = [this.configuredList, this.availableList]; + for (const scrollBox of scrollBoxes) { + new Sortable(scrollBox, { + group: 'sort-set-operations', + ghostClass: 'primary-background-light', + handle: '.handle', + animation: 150, + onSort: this.onChange.bind(this), + }); + } + } + + onChange() { + const configuredOpEls = Array.from(this.configuredList.querySelectorAll('[data-id]')); + this.input.value = configuredOpEls.map(elem => elem.getAttribute('data-id')).join(','); + } +} \ No newline at end of file diff --git a/resources/js/services/dual-lists.ts b/resources/js/services/dual-lists.ts new file mode 100644 index 000000000..98f2af92d --- /dev/null +++ b/resources/js/services/dual-lists.ts @@ -0,0 +1,51 @@ +/** + * Service for helping manage common dual-list scenarios. + * (Shelf book manager, sort set manager). + */ + +type ListActionsSet = Record void)>; + +export function buildListActions( + availableList: HTMLElement, + configuredList: HTMLElement, +): ListActionsSet { + return { + move_up(item) { + const list = item.parentNode as HTMLElement; + const index = Array.from(list.children).indexOf(item); + const newIndex = Math.max(index - 1, 0); + list.insertBefore(item, list.children[newIndex] || null); + }, + move_down(item) { + const list = item.parentNode as HTMLElement; + const index = Array.from(list.children).indexOf(item); + const newIndex = Math.min(index + 2, list.children.length); + list.insertBefore(item, list.children[newIndex] || null); + }, + remove(item) { + availableList.appendChild(item); + }, + add(item) { + configuredList.appendChild(item); + }, + }; +} + +export function sortActionClickListener(actions: ListActionsSet, onChange: () => void) { + return (event: MouseEvent) => { + const sortItemAction = (event.target as Element).closest('.scroll-box-item button[data-action]') as HTMLElement|null; + if (sortItemAction) { + const sortItem = sortItemAction.closest('.scroll-box-item') as HTMLElement; + const action = sortItemAction.dataset.action; + if (!action) { + throw new Error('No action defined for clicked button'); + } + + const actionFunction = actions[action]; + actionFunction(sortItem); + + onChange(); + } + }; +} + diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index 888b32527..58d39d3ee 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -1062,12 +1062,16 @@ $btt-size: 40px; cursor: pointer; @include mixins.lightDark(background-color, #f8f8f8, #333); } + &.items-center { + align-items: center; + } .handle { color: #AAA; cursor: grab; } button { opacity: .6; + line-height: 1; } .handle svg { margin: 0; @@ -1108,12 +1112,19 @@ input.scroll-box-search, .scroll-box-header-item { border-radius: 0 0 3px 3px; } -.scroll-box[refs="shelf-sort@shelf-book-list"] [data-action="add"] { +.scroll-box.configured-option-list [data-action="add"] { display: none; } -.scroll-box[refs="shelf-sort@all-book-list"] [data-action="remove"], -.scroll-box[refs="shelf-sort@all-book-list"] [data-action="move_up"], -.scroll-box[refs="shelf-sort@all-book-list"] [data-action="move_down"], +.scroll-box.available-option-list [data-action="remove"], +.scroll-box.available-option-list [data-action="move_up"], +.scroll-box.available-option-list [data-action="move_down"], { display: none; +} + +.scroll-box > li.empty-state { + display: none; +} +.scroll-box > li.empty-state:last-child { + display: list-item; } \ No newline at end of file diff --git a/resources/views/settings/sort-sets/parts/form.blade.php b/resources/views/settings/sort-sets/parts/form.blade.php index 6df04a721..3f2220947 100644 --- a/resources/views/settings/sort-sets/parts/form.blade.php +++ b/resources/views/settings/sort-sets/parts/form.blade.php @@ -1,4 +1,3 @@ -
@@ -13,59 +12,36 @@
-
+

{{ trans('settings.sort_set_operations_desc') }}

- +
- -
    {{ trans('settings.sort_set_configured_operations') }} +
      - @foreach(($model?->getOptions() ?? []) as $option) -
    • -
      @icon('grip')
      -
      {{ $option->getLabel() }}
      -
      - - - - -
      -
    • + class="scroll-box configured-option-list"> +
    • {{ trans('settings.sort_set_configured_operations_empty') }}
    • + @foreach(($model?->getOperations() ?? []) as $option) + @include('settings.sort-sets.parts.operation') @endforeach
- -
    {{ trans('settings.sort_set_available_operations') }} +
      - @foreach(\BookStack\Sorting\SortSetOption::allExcluding($model?->getOptions() ?? []) as $option) -
    • -
      @icon('grip')
      -
      {{ $option->getLabel() }}
      -
      - - - - -
      -
    • + class="scroll-box available-option-list"> +
    • {{ trans('settings.sort_set_available_operations_empty') }}
    • + @foreach(\BookStack\Sorting\SortSetOperation::allExcluding($model?->getOperations() ?? []) as $operation) + @include('settings.sort-sets.parts.operation', ['operation' => $operation]) @endforeach
diff --git a/resources/views/settings/sort-sets/parts/operation.blade.php b/resources/views/settings/sort-sets/parts/operation.blade.php new file mode 100644 index 000000000..3feb68a47 --- /dev/null +++ b/resources/views/settings/sort-sets/parts/operation.blade.php @@ -0,0 +1,15 @@ +
  • +
    @icon('grip')
    +
    {{ $operation->getLabel() }}
    +
    + + + + +
    +
  • \ No newline at end of file diff --git a/resources/views/shelves/parts/form.blade.php b/resources/views/shelves/parts/form.blade.php index a75dd6ac1..7790ba5a4 100644 --- a/resources/views/shelves/parts/form.blade.php +++ b/resources/views/shelves/parts/form.blade.php @@ -38,7 +38,7 @@
      + class="scroll-box configured-option-list"> @foreach (($shelf->visibleBooks ?? []) as $book) @include('shelves.parts.shelf-sort-book-item', ['book' => $book]) @endforeach @@ -49,7 +49,7 @@
        + class="scroll-box available-option-list"> @foreach ($books as $book) @include('shelves.parts.shelf-sort-book-item', ['book' => $book]) @endforeach From b897af2ed034088193986c8526be9606edaca7d5 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 4 Feb 2025 20:11:35 +0000 Subject: [PATCH 07/17] Sorting: Finished main sort set CRUD work --- app/Activity/ActivityType.php | 4 ++ app/Sorting/SortSet.php | 17 +++-- app/Sorting/SortSetController.php | 69 +++++++++++++++++++ app/Sorting/SortSetOperation.php | 17 ++++- lang/en/activities.php | 8 +++ lang/en/settings.php | 2 + .../settings/categories/sorting.blade.php | 13 +++- .../views/settings/sort-sets/edit.blade.php | 44 ++++++++++++ .../settings/sort-sets/parts/form.blade.php | 16 +++-- .../parts/sort-set-list-item.blade.php | 8 +++ 10 files changed, 187 insertions(+), 11 deletions(-) create mode 100644 resources/views/settings/sort-sets/edit.blade.php create mode 100644 resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php diff --git a/app/Activity/ActivityType.php b/app/Activity/ActivityType.php index 5ec9b9cf0..4a648da6c 100644 --- a/app/Activity/ActivityType.php +++ b/app/Activity/ActivityType.php @@ -71,6 +71,10 @@ class ActivityType const IMPORT_RUN = 'import_run'; const IMPORT_DELETE = 'import_delete'; + const SORT_SET_CREATE = 'sort_set_create'; + const SORT_SET_UPDATE = 'sort_set_update'; + const SORT_SET_DELETE = 'sort_set_delete'; + /** * Get all the possible values. */ diff --git a/app/Sorting/SortSet.php b/app/Sorting/SortSet.php index ee45c211f..971b3e29a 100644 --- a/app/Sorting/SortSet.php +++ b/app/Sorting/SortSet.php @@ -2,6 +2,7 @@ namespace BookStack\Sorting; +use BookStack\Activity\Models\Loggable; use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; @@ -12,16 +13,14 @@ use Illuminate\Database\Eloquent\Model; * @property Carbon $created_at * @property Carbon $updated_at */ -class SortSet extends Model +class SortSet extends Model implements Loggable { /** * @return SortSetOperation[] */ public function getOperations(): array { - $strOptions = explode(',', $this->sequence); - $options = array_map(fn ($val) => SortSetOperation::tryFrom($val), $strOptions); - return array_filter($options); + return SortSetOperation::fromSequence($this->sequence); } /** @@ -32,4 +31,14 @@ class SortSet extends Model $values = array_map(fn (SortSetOperation $opt) => $opt->value, $options); $this->sequence = implode(',', $values); } + + public function logDescriptor(): string + { + return "({$this->id}) {$this->name}"; + } + + public function getUrl(): string + { + return url("/settings/sorting/sets/{$this->id}"); + } } diff --git a/app/Sorting/SortSetController.php b/app/Sorting/SortSetController.php index 4ef148295..0d77bd88f 100644 --- a/app/Sorting/SortSetController.php +++ b/app/Sorting/SortSetController.php @@ -2,7 +2,9 @@ namespace BookStack\Sorting; +use BookStack\Activity\ActivityType; use BookStack\Http\Controller; +use BookStack\Http\Request; class SortSetController extends Controller { @@ -14,6 +16,73 @@ class SortSetController extends Controller public function create() { + $this->setPageTitle(trans('settings.sort_set_create')); + return view('settings.sort-sets.create'); } + + public function store(Request $request) + { + $this->validate($request, [ + 'name' => ['required', 'string', 'min:1', 'max:200'], + 'sequence' => ['required', 'string', 'min:1'], + ]); + + $operations = SortSetOperation::fromSequence($request->input('sequence')); + if (count($operations) === 0) { + return redirect()->withInput()->withErrors(['sequence' => 'No operations set.']); + } + + $set = new SortSet(); + $set->name = $request->input('name'); + $set->setOperations($operations); + $set->save(); + + $this->logActivity(ActivityType::SORT_SET_CREATE, $set); + + return redirect('/settings/sorting'); + } + + public function edit(string $id) + { + $set = SortSet::query()->findOrFail($id); + + $this->setPageTitle(trans('settings.sort_set_edit')); + + return view('settings.sort-sets.edit', ['set' => $set]); + } + + public function update(string $id, Request $request) + { + $this->validate($request, [ + 'name' => ['required', 'string', 'min:1', 'max:200'], + 'sequence' => ['required', 'string', 'min:1'], + ]); + + $set = SortSet::query()->findOrFail($id); + $operations = SortSetOperation::fromSequence($request->input('sequence')); + if (count($operations) === 0) { + return redirect()->withInput()->withErrors(['sequence' => 'No operations set.']); + } + + $set->name = $request->input('name'); + $set->setOperations($operations); + $set->save(); + + $this->logActivity(ActivityType::SORT_SET_UPDATE, $set); + + return redirect('/settings/sorting'); + } + + public function destroy(string $id) + { + $set = SortSet::query()->findOrFail($id); + + // TODO - Check if it's in use + + $set->delete(); + $this->logActivity(ActivityType::SORT_SET_DELETE, $set); + + return redirect('/settings/sorting'); + } } diff --git a/app/Sorting/SortSetOperation.php b/app/Sorting/SortSetOperation.php index 12fda669f..a6dd860f5 100644 --- a/app/Sorting/SortSetOperation.php +++ b/app/Sorting/SortSetOperation.php @@ -39,6 +39,21 @@ enum SortSetOperation: string public static function allExcluding(array $operations): array { $all = SortSetOperation::cases(); - return array_diff($all, $operations); + $filtered = array_filter($all, function (SortSetOperation $operation) use ($operations) { + return !in_array($operation, $operations); + }); + return array_values($filtered); + } + + /** + * Create a set of operations from a string sequence representation. + * (values seperated by commas). + * @return SortSetOperation[] + */ + public static function fromSequence(string $sequence): array + { + $strOptions = explode(',', $sequence); + $options = array_map(fn ($val) => SortSetOperation::tryFrom($val), $strOptions); + return array_filter($options); } } diff --git a/lang/en/activities.php b/lang/en/activities.php index 7c3454d41..7db872c0c 100644 --- a/lang/en/activities.php +++ b/lang/en/activities.php @@ -127,6 +127,14 @@ return [ 'comment_update' => 'updated comment', 'comment_delete' => 'deleted comment', + // Sort Sets + 'sort_set_create' => 'created sort set', + 'sort_set_create_notification' => 'Sort set successfully created', + 'sort_set_update' => 'updated sort set', + 'sort_set_update_notification' => 'Sort set successfully update', + 'sort_set_delete' => 'deleted sort set', + 'sort_set_delete_notification' => 'Sort set successfully deleted', + // Other 'permissions_update' => 'updated permissions', ]; diff --git a/lang/en/settings.php b/lang/en/settings.php index 8bb2f6ef4..cda097590 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -82,6 +82,8 @@ return [ 'sorting_sets_desc' => 'These are predefined sorting operations which can be applied to content in the system.', 'sort_set_create' => 'Create Sort Set', 'sort_set_edit' => 'Edit Sort Set', + 'sort_set_delete' => 'Delete Sort Set', + 'sort_set_delete_desc' => 'Remove this sort set from the system. Deletion will only go ahead if the sort is not in active use.', 'sort_set_details' => 'Sort Set Details', 'sort_set_details_desc' => 'Set a name for this sort set, which will appear in lists when users are selecting a sort.', 'sort_set_operations' => 'Sort Operations', diff --git a/resources/views/settings/categories/sorting.blade.php b/resources/views/settings/categories/sorting.blade.php index 9de11bb6f..b5d613840 100644 --- a/resources/views/settings/categories/sorting.blade.php +++ b/resources/views/settings/categories/sorting.blade.php @@ -52,6 +52,17 @@
    -{{-- TODO--}} + @php + $sortSets = \BookStack\Sorting\SortSet::query()->orderBy('name', 'asc')->get(); + @endphp + @if(empty($sortSets)) +

    {{ trans('common.no_items') }}

    + @else +
    + @foreach($sortSets as $set) + @include('settings.sort-sets.parts.sort-set-list-item', ['set' => $set]) + @endforeach +
    + @endif
    @endsection \ No newline at end of file diff --git a/resources/views/settings/sort-sets/edit.blade.php b/resources/views/settings/sort-sets/edit.blade.php new file mode 100644 index 000000000..3b88c1243 --- /dev/null +++ b/resources/views/settings/sort-sets/edit.blade.php @@ -0,0 +1,44 @@ +@extends('layouts.simple') + +@section('body') + +
    + + @include('settings.parts.navbar', ['selected' => 'settings']) + +
    +

    {{ trans('settings.sort_set_edit') }}

    + +
    + {{ method_field('PUT') }} + {{ csrf_field() }} + + @include('settings.sort-sets.parts.form', ['model' => $set]) + +
    + {{ trans('common.cancel') }} + +
    +
    +
    + +
    +
    +
    +

    {{ trans('settings.sort_set_delete') }}

    +

    {{ trans('settings.sort_set_delete_desc') }}

    +
    +
    +
    + {{ method_field('DELETE') }} + {{ csrf_field() }} +
    + +
    +
    +
    +
    +
    +
    + +@stop diff --git a/resources/views/settings/sort-sets/parts/form.blade.php b/resources/views/settings/sort-sets/parts/form.blade.php index 3f2220947..38db840ac 100644 --- a/resources/views/settings/sort-sets/parts/form.blade.php +++ b/resources/views/settings/sort-sets/parts/form.blade.php @@ -15,9 +15,14 @@

    {{ trans('settings.sort_set_operations_desc') }}

    + @include('form.errors', ['name' => 'sequence']) - + + + @php + $configuredOps = old('sequence') ? \BookStack\Sorting\SortSetOperation::fromSequence(old('sequence')) : ($model?->getOperations() ?? []); + @endphp
    @@ -27,8 +32,9 @@ aria-labelledby="sort-set-configured-operations" class="scroll-box configured-option-list">
  • {{ trans('settings.sort_set_configured_operations_empty') }}
  • - @foreach(($model?->getOperations() ?? []) as $option) - @include('settings.sort-sets.parts.operation') + + @foreach($configuredOps as $operation) + @include('settings.sort-sets.parts.operation', ['operation' => $operation]) @endforeach
    @@ -40,7 +46,7 @@ aria-labelledby="sort-set-available-operations" class="scroll-box available-option-list">
  • {{ trans('settings.sort_set_available_operations_empty') }}
  • - @foreach(\BookStack\Sorting\SortSetOperation::allExcluding($model?->getOperations() ?? []) as $operation) + @foreach(\BookStack\Sorting\SortSetOperation::allExcluding($configuredOps) as $operation) @include('settings.sort-sets.parts.operation', ['operation' => $operation]) @endforeach diff --git a/resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php b/resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php new file mode 100644 index 000000000..e5ee1fb87 --- /dev/null +++ b/resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php @@ -0,0 +1,8 @@ +
    + +
    + {{ implode(', ', array_map(fn ($op) => $op->getLabel(), $set->getOperations())) }} +
    +
    \ No newline at end of file From 7093daa49de63e237d442709a93a03b5acc4f323 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 5 Feb 2025 14:33:46 +0000 Subject: [PATCH 08/17] Sorting: Connected up default sort setting for books --- app/Entities/Models/Book.php | 11 ++++++++ app/Entities/Repos/BookRepo.php | 7 +++++ app/Sorting/SortSet.php | 7 +++++ app/Sorting/SortSetController.php | 13 +++++++-- ..._02_05_150842_add_sort_set_id_to_books.php | 28 +++++++++++++++++++ lang/en/settings.php | 2 ++ .../settings/categories/sorting.blade.php | 23 ++++++++------- 7 files changed, 77 insertions(+), 14 deletions(-) create mode 100644 database/migrations/2025_02_05_150842_add_sort_set_id_to_books.php diff --git a/app/Entities/Models/Book.php b/app/Entities/Models/Book.php index c1644dcf5..7d240e5ca 100644 --- a/app/Entities/Models/Book.php +++ b/app/Entities/Models/Book.php @@ -2,6 +2,7 @@ namespace BookStack\Entities\Models; +use BookStack\Sorting\SortSet; use BookStack\Uploads\Image; use Exception; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -16,12 +17,14 @@ use Illuminate\Support\Collection; * @property string $description * @property int $image_id * @property ?int $default_template_id + * @property ?int $sort_set_id * @property Image|null $cover * @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 ?Page $defaultTemplate + * @property ?SortSet $sortSet */ class Book extends Entity implements HasCoverImage { @@ -82,6 +85,14 @@ class Book extends Entity implements HasCoverImage return $this->belongsTo(Page::class, 'default_template_id'); } + /** + * Get the sort set assigned to this book, if existing. + */ + public function sortSet(): BelongsTo + { + return $this->belongsTo(SortSet::class); + } + /** * Get all pages within this book. */ diff --git a/app/Entities/Repos/BookRepo.php b/app/Entities/Repos/BookRepo.php index 19d159eb1..b3b811647 100644 --- a/app/Entities/Repos/BookRepo.php +++ b/app/Entities/Repos/BookRepo.php @@ -8,6 +8,7 @@ use BookStack\Entities\Models\Book; use BookStack\Entities\Tools\TrashCan; use BookStack\Exceptions\ImageUploadException; use BookStack\Facades\Activity; +use BookStack\Sorting\SortSet; use BookStack\Uploads\ImageRepo; use Exception; use Illuminate\Http\UploadedFile; @@ -33,6 +34,12 @@ class BookRepo $this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id'] ?? null)); Activity::add(ActivityType::BOOK_CREATE, $book); + $defaultBookSortSetting = intval(setting('sorting-book-default', '0')); + if ($defaultBookSortSetting && SortSet::query()->find($defaultBookSortSetting)) { + $book->sort_set_id = $defaultBookSortSetting; + $book->save(); + } + return $book; } diff --git a/app/Sorting/SortSet.php b/app/Sorting/SortSet.php index 971b3e29a..a73407bfa 100644 --- a/app/Sorting/SortSet.php +++ b/app/Sorting/SortSet.php @@ -3,8 +3,10 @@ namespace BookStack\Sorting; use BookStack\Activity\Models\Loggable; +use BookStack\Entities\Models\Book; use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; /** * @property int $id @@ -41,4 +43,9 @@ class SortSet extends Model implements Loggable { return url("/settings/sorting/sets/{$this->id}"); } + + public function books(): HasMany + { + return $this->hasMany(Book::class); + } } diff --git a/app/Sorting/SortSetController.php b/app/Sorting/SortSetController.php index 0d77bd88f..8f5120791 100644 --- a/app/Sorting/SortSetController.php +++ b/app/Sorting/SortSetController.php @@ -62,7 +62,7 @@ class SortSetController extends Controller $set = SortSet::query()->findOrFail($id); $operations = SortSetOperation::fromSequence($request->input('sequence')); if (count($operations) === 0) { - return redirect()->withInput()->withErrors(['sequence' => 'No operations set.']); + return redirect($set->getUrl())->withInput()->withErrors(['sequence' => 'No operations set.']); } $set->name = $request->input('name'); @@ -78,7 +78,16 @@ class SortSetController extends Controller { $set = SortSet::query()->findOrFail($id); - // TODO - Check if it's in use + if ($set->books()->count() > 0) { + $this->showErrorNotification(trans('settings.sort_set_delete_fail_books')); + return redirect($set->getUrl()); + } + + $defaultBookSortSetting = intval(setting('sorting-book-default', '0')); + if ($defaultBookSortSetting === intval($id)) { + $this->showErrorNotification(trans('settings.sort_set_delete_fail_default')); + return redirect($set->getUrl()); + } $set->delete(); $this->logActivity(ActivityType::SORT_SET_DELETE, $set); diff --git a/database/migrations/2025_02_05_150842_add_sort_set_id_to_books.php b/database/migrations/2025_02_05_150842_add_sort_set_id_to_books.php new file mode 100644 index 000000000..c0b32c552 --- /dev/null +++ b/database/migrations/2025_02_05_150842_add_sort_set_id_to_books.php @@ -0,0 +1,28 @@ +unsignedInteger('sort_set_id')->nullable()->default(null); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('books', function (Blueprint $table) { + $table->dropColumn('sort_set_id'); + }); + } +}; diff --git a/lang/en/settings.php b/lang/en/settings.php index cda097590..eb046d278 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -84,6 +84,8 @@ return [ 'sort_set_edit' => 'Edit Sort Set', 'sort_set_delete' => 'Delete Sort Set', 'sort_set_delete_desc' => 'Remove this sort set from the system. Deletion will only go ahead if the sort is not in active use.', + 'sort_set_delete_fail_books' => 'Unable to delete this sort set since it has books assigned.', + 'sort_set_delete_fail_default' => 'Unable to delete this sort set since it\'s used as the default book sort.', 'sort_set_details' => 'Sort Set Details', 'sort_set_details_desc' => 'Set a name for this sort set, which will appear in lists when users are selecting a sort.', 'sort_set_operations' => 'Sort Operations', diff --git a/resources/views/settings/categories/sorting.blade.php b/resources/views/settings/categories/sorting.blade.php index b5d613840..0af3a8fb8 100644 --- a/resources/views/settings/categories/sorting.blade.php +++ b/resources/views/settings/categories/sorting.blade.php @@ -1,5 +1,9 @@ @extends('settings.layout') +@php + $sortSets = \BookStack\Sorting\SortSet::query()->orderBy('name', 'asc')->get(); +@endphp + @section('card')

    {{ trans('settings.sorting') }}

    @@ -19,15 +23,13 @@ -{{-- TODO--}} -{{-- @foreach(\BookStack\Users\Models\Role::all() as $role)--}} -{{-- --}} -{{-- @endforeach--}} + @foreach($sortSets as $set) + + @endforeach
    @@ -52,9 +54,6 @@ - @php - $sortSets = \BookStack\Sorting\SortSet::query()->orderBy('name', 'asc')->get(); - @endphp @if(empty($sortSets))

    {{ trans('common.no_items') }}

    @else From c13ce1883708c184d14b3be10734894ddf7c9e00 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 5 Feb 2025 16:52:20 +0000 Subject: [PATCH 09/17] Sorting: Added book autosort logic --- app/Entities/Repos/BaseRepo.php | 15 +++++ app/Entities/Repos/ChapterRepo.php | 6 ++ app/Entities/Repos/PageRepo.php | 6 ++ app/Sorting/BookSorter.php | 48 ++++++++++++++ app/Sorting/SortSetOperation.php | 9 +++ app/Sorting/SortSetOperationComparisons.php | 69 +++++++++++++++++++++ 6 files changed, 153 insertions(+) create mode 100644 app/Sorting/SortSetOperationComparisons.php diff --git a/app/Entities/Repos/BaseRepo.php b/app/Entities/Repos/BaseRepo.php index 033350743..151d5b055 100644 --- a/app/Entities/Repos/BaseRepo.php +++ b/app/Entities/Repos/BaseRepo.php @@ -4,6 +4,7 @@ namespace BookStack\Entities\Repos; use BookStack\Activity\TagRepo; use BookStack\Entities\Models\Book; +use BookStack\Entities\Models\BookChild; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\HasCoverImage; @@ -12,6 +13,7 @@ use BookStack\Entities\Queries\PageQueries; use BookStack\Exceptions\ImageUploadException; use BookStack\References\ReferenceStore; use BookStack\References\ReferenceUpdater; +use BookStack\Sorting\BookSorter; use BookStack\Uploads\ImageRepo; use BookStack\Util\HtmlDescriptionFilter; use Illuminate\Http\UploadedFile; @@ -24,6 +26,7 @@ class BaseRepo protected ReferenceUpdater $referenceUpdater, protected ReferenceStore $referenceStore, protected PageQueries $pageQueries, + protected BookSorter $bookSorter, ) { } @@ -134,6 +137,18 @@ class BaseRepo $entity->save(); } + /** + * Sort the parent of the given entity, if any auto sort actions are set for it. + * Typical ran during create/update/insert events. + */ + public function sortParent(Entity $entity): void + { + if ($entity instanceof BookChild) { + $book = $entity->book; + $this->bookSorter->runBookAutoSort($book); + } + } + protected function updateDescription(Entity $entity, array $input): void { if (!in_array(HasHtmlDescription::class, class_uses($entity))) { diff --git a/app/Entities/Repos/ChapterRepo.php b/app/Entities/Repos/ChapterRepo.php index 17cbccd41..fdf2de4e2 100644 --- a/app/Entities/Repos/ChapterRepo.php +++ b/app/Entities/Repos/ChapterRepo.php @@ -34,6 +34,8 @@ class ChapterRepo $this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id'] ?? null)); Activity::add(ActivityType::CHAPTER_CREATE, $chapter); + $this->baseRepo->sortParent($chapter); + return $chapter; } @@ -50,6 +52,8 @@ class ChapterRepo Activity::add(ActivityType::CHAPTER_UPDATE, $chapter); + $this->baseRepo->sortParent($chapter); + return $chapter; } @@ -88,6 +92,8 @@ class ChapterRepo $chapter->rebuildPermissions(); Activity::add(ActivityType::CHAPTER_MOVE, $chapter); + $this->baseRepo->sortParent($chapter); + return $parent; } } diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index 68b1c398f..c3be6d826 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -83,6 +83,7 @@ class PageRepo $draft->refresh(); Activity::add(ActivityType::PAGE_CREATE, $draft); + $this->baseRepo->sortParent($draft); return $draft; } @@ -128,6 +129,7 @@ class PageRepo } Activity::add(ActivityType::PAGE_UPDATE, $page); + $this->baseRepo->sortParent($page); return $page; } @@ -243,6 +245,8 @@ class PageRepo Activity::add(ActivityType::PAGE_RESTORE, $page); Activity::add(ActivityType::REVISION_RESTORE, $revision); + $this->baseRepo->sortParent($page); + return $page; } @@ -272,6 +276,8 @@ class PageRepo Activity::add(ActivityType::PAGE_MOVE, $page); + $this->baseRepo->sortParent($page); + return $parent; } diff --git a/app/Sorting/BookSorter.php b/app/Sorting/BookSorter.php index 7268b3543..e89fdaccc 100644 --- a/app/Sorting/BookSorter.php +++ b/app/Sorting/BookSorter.php @@ -16,6 +16,54 @@ class BookSorter ) { } + /** + * Runs the auto-sort for a book if the book has a sort set applied to it. + * This does not consider permissions since the sort operations are centrally + * managed by admins so considered permitted if existing and assigned. + */ + public function runBookAutoSort(Book $book): void + { + $set = $book->sortSet; + if (!$set) { + return; + } + + $sortFunctions = array_map(function (SortSetOperation $op) { + return $op->getSortFunction(); + }, $set->getOperations()); + + $chapters = $book->chapters() + ->with('pages:id,name,priority,created_at,updated_at') + ->get(['id', 'name', 'priority', 'created_at', 'updated_at']); + + /** @var (Chapter|Book)[] $topItems */ + $topItems = [ + ...$book->directPages()->get(['id', 'name', 'priority', 'created_at', 'updated_at']), + ...$chapters, + ]; + + foreach ($sortFunctions as $sortFunction) { + usort($topItems, $sortFunction); + } + + foreach ($topItems as $index => $topItem) { + $topItem->priority = $index + 1; + $topItem->save(); + } + + foreach ($chapters as $chapter) { + $pages = $chapter->pages->all(); + foreach ($sortFunctions as $sortFunction) { + usort($pages, $sortFunction); + } + + foreach ($pages as $index => $page) { + $page->priority = $index + 1; + $page->save(); + } + } + } + /** * Sort the books content using the given sort map. diff --git a/app/Sorting/SortSetOperation.php b/app/Sorting/SortSetOperation.php index a6dd860f5..7fdd0b002 100644 --- a/app/Sorting/SortSetOperation.php +++ b/app/Sorting/SortSetOperation.php @@ -2,6 +2,9 @@ namespace BookStack\Sorting; +use Closure; +use Illuminate\Support\Str; + enum SortSetOperation: string { case NameAsc = 'name_asc'; @@ -33,6 +36,12 @@ enum SortSetOperation: string return trim($label); } + public function getSortFunction(): callable + { + $camelValue = Str::camel($this->value); + return SortSetOperationComparisons::$camelValue(...); + } + /** * @return SortSetOperation[] */ diff --git a/app/Sorting/SortSetOperationComparisons.php b/app/Sorting/SortSetOperationComparisons.php new file mode 100644 index 000000000..e1c3e625f --- /dev/null +++ b/app/Sorting/SortSetOperationComparisons.php @@ -0,0 +1,69 @@ +name <=> $b->name; + } + + public static function nameDesc(Entity $a, Entity $b): int + { + return $b->name <=> $a->name; + } + + public static function nameNumericAsc(Entity $a, Entity $b): int + { + $numRegex = '/^\d+(\.\d+)?/'; + $aMatches = []; + $bMatches = []; + preg_match($numRegex, $a, $aMatches); + preg_match($numRegex, $b, $bMatches); + return ($aMatches[0] ?? 0) <=> ($bMatches[0] ?? 0); + } + + public static function nameNumericDesc(Entity $a, Entity $b): int + { + return -(static::nameNumericAsc($a, $b)); + } + + public static function createdDateAsc(Entity $a, Entity $b): int + { + return $a->created_at->unix() <=> $b->created_at->unix(); + } + + public static function createdDateDesc(Entity $a, Entity $b): int + { + return $b->created_at->unix() <=> $a->created_at->unix(); + } + + public static function updatedDateAsc(Entity $a, Entity $b): int + { + return $a->updated_at->unix() <=> $b->updated_at->unix(); + } + + public static function updatedDateDesc(Entity $a, Entity $b): int + { + return $b->updated_at->unix() <=> $a->updated_at->unix(); + } + + public static function chaptersFirst(Entity $a, Entity $b): int + { + return ($b instanceof Chapter ? 1 : 0) - (($a instanceof Chapter) ? 1 : 0); + } + + public static function chaptersLast(Entity $a, Entity $b): int + { + return ($a instanceof Chapter ? 1 : 0) - (($b instanceof Chapter) ? 1 : 0); + } +} From ccd94684ebc5d8b14a9fa4e7b0808501b3c80cd9 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 6 Feb 2025 14:58:08 +0000 Subject: [PATCH 10/17] Sorting: Improved sort set display, delete, added action on edit - Changes to a sort set will now auto-apply to assinged books (basic chunck through all on save). - Added book count indicator to sort set list items. - Deletion now has confirmation and auto-handling of assigned books/settings. --- app/Sorting/BookSorter.php | 9 ++++++ app/Sorting/SortSetController.php | 32 +++++++++++++++---- lang/en/settings.php | 6 ++-- .../settings/categories/sorting.blade.php | 5 ++- .../views/settings/sort-sets/edit.blade.php | 16 ++++++++-- .../parts/sort-set-list-item.blade.php | 10 ++++-- 6 files changed, 61 insertions(+), 17 deletions(-) diff --git a/app/Sorting/BookSorter.php b/app/Sorting/BookSorter.php index e89fdaccc..fd99a8d37 100644 --- a/app/Sorting/BookSorter.php +++ b/app/Sorting/BookSorter.php @@ -16,6 +16,15 @@ class BookSorter ) { } + public function runBookAutoSortForAllWithSet(SortSet $set): void + { + $set->books()->chunk(50, function ($books) { + foreach ($books as $book) { + $this->runBookAutoSort($book); + } + }); + } + /** * Runs the auto-sort for a book if the book has a sort set applied to it. * This does not consider permissions since the sort operations are centrally diff --git a/app/Sorting/SortSetController.php b/app/Sorting/SortSetController.php index 8f5120791..b0ad2a7d7 100644 --- a/app/Sorting/SortSetController.php +++ b/app/Sorting/SortSetController.php @@ -52,7 +52,7 @@ class SortSetController extends Controller return view('settings.sort-sets.edit', ['set' => $set]); } - public function update(string $id, Request $request) + public function update(string $id, Request $request, BookSorter $bookSorter) { $this->validate($request, [ 'name' => ['required', 'string', 'min:1', 'max:200'], @@ -67,26 +67,44 @@ class SortSetController extends Controller $set->name = $request->input('name'); $set->setOperations($operations); + $changedSequence = $set->isDirty('sequence'); $set->save(); $this->logActivity(ActivityType::SORT_SET_UPDATE, $set); + if ($changedSequence) { + $bookSorter->runBookAutoSortForAllWithSet($set); + } + return redirect('/settings/sorting'); } - public function destroy(string $id) + public function destroy(string $id, Request $request) { $set = SortSet::query()->findOrFail($id); + $confirmed = $request->input('confirm') === 'true'; + $booksAssigned = $set->books()->count(); + $warnings = []; - if ($set->books()->count() > 0) { - $this->showErrorNotification(trans('settings.sort_set_delete_fail_books')); - return redirect($set->getUrl()); + if ($booksAssigned > 0) { + if ($confirmed) { + $set->books()->update(['sort_set_id' => null]); + } else { + $warnings[] = trans('settings.sort_set_delete_warn_books', ['count' => $booksAssigned]); + } } $defaultBookSortSetting = intval(setting('sorting-book-default', '0')); if ($defaultBookSortSetting === intval($id)) { - $this->showErrorNotification(trans('settings.sort_set_delete_fail_default')); - return redirect($set->getUrl()); + if ($confirmed) { + setting()->remove('sorting-book-default'); + } else { + $warnings[] = trans('settings.sort_set_delete_warn_default'); + } + } + + if (count($warnings) > 0) { + return redirect($set->getUrl() . '#delete')->withErrors(['delete' => $warnings]); } $set->delete(); diff --git a/lang/en/settings.php b/lang/en/settings.php index eb046d278..19ffd9240 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -83,9 +83,9 @@ return [ 'sort_set_create' => 'Create Sort Set', 'sort_set_edit' => 'Edit Sort Set', 'sort_set_delete' => 'Delete Sort Set', - 'sort_set_delete_desc' => 'Remove this sort set from the system. Deletion will only go ahead if the sort is not in active use.', - 'sort_set_delete_fail_books' => 'Unable to delete this sort set since it has books assigned.', - 'sort_set_delete_fail_default' => 'Unable to delete this sort set since it\'s used as the default book sort.', + 'sort_set_delete_desc' => 'Remove this sort set from the system. Books using this sort will revert to manual sorting.', + 'sort_set_delete_warn_books' => 'This sort set is currently used on :count book(s). Are you sure you want to delete this?', + 'sort_set_delete_warn_default' => 'This sort set is currently used as the default for books. Are you sure you want to delete this?', 'sort_set_details' => 'Sort Set Details', 'sort_set_details_desc' => 'Set a name for this sort set, which will appear in lists when users are selecting a sort.', 'sort_set_operations' => 'Sort Operations', diff --git a/resources/views/settings/categories/sorting.blade.php b/resources/views/settings/categories/sorting.blade.php index 0af3a8fb8..60fb329b6 100644 --- a/resources/views/settings/categories/sorting.blade.php +++ b/resources/views/settings/categories/sorting.blade.php @@ -1,7 +1,10 @@ @extends('settings.layout') @php - $sortSets = \BookStack\Sorting\SortSet::query()->orderBy('name', 'asc')->get(); + $sortSets = \BookStack\Sorting\SortSet::query() + ->withCount('books') + ->orderBy('name', 'asc') + ->get(); @endphp @section('card') diff --git a/resources/views/settings/sort-sets/edit.blade.php b/resources/views/settings/sort-sets/edit.blade.php index 3b88c1243..febcd9ffe 100644 --- a/resources/views/settings/sort-sets/edit.blade.php +++ b/resources/views/settings/sort-sets/edit.blade.php @@ -22,16 +22,26 @@ -
    +
    -
    +

    {{ trans('settings.sort_set_delete') }}

    -

    {{ trans('settings.sort_set_delete_desc') }}

    +

    {{ trans('settings.sort_set_delete_desc') }}

    + @if($errors->has('delete')) + @foreach($errors->get('delete') as $error) +

    {{ $error }}

    + @endforeach + @endif
    {{ method_field('DELETE') }} {{ csrf_field() }} + + @if($errors->has('delete')) + + @endif +
    diff --git a/resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php b/resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php index e5ee1fb87..e977c286e 100644 --- a/resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php +++ b/resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php @@ -1,8 +1,12 @@ -
    -
    +
    + -
    +
    {{ implode(', ', array_map(fn ($op) => $op->getLabel(), $set->getOperations())) }}
    +
    + @icon('book'){{ $set->books_count ?? 0 }} +
    \ No newline at end of file From ec7951749333fb9b57c3e5ce368f3427065489b3 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 9 Feb 2025 15:16:18 +0000 Subject: [PATCH 11/17] Sorting: Added auto sort option to book sort UI Includes indicator on books added to sort operation. --- app/Sorting/BookSortController.php | 36 +++++++++++++------ app/Sorting/SortSet.php | 9 +++++ lang/en/entities.php | 4 ++- resources/icons/auto-sort.svg | 1 + resources/sass/_lists.scss | 4 +++ .../views/books/parts/sort-box.blade.php | 5 +++ resources/views/books/sort.blade.php | 28 +++++++++++++-- .../settings/categories/sorting.blade.php | 5 +-- 8 files changed, 74 insertions(+), 18 deletions(-) create mode 100644 resources/icons/auto-sort.svg diff --git a/app/Sorting/BookSortController.php b/app/Sorting/BookSortController.php index feed5db4f..98d79d0fd 100644 --- a/app/Sorting/BookSortController.php +++ b/app/Sorting/BookSortController.php @@ -44,24 +44,40 @@ class BookSortController extends Controller } /** - * Sorts a book using a given mapping array. + * Update the sort options of a book, setting the auto-sort and/or updating + * child order via mapping. */ public function update(Request $request, BookSorter $sorter, string $bookSlug) { $book = $this->queries->findVisibleBySlugOrFail($bookSlug); $this->checkOwnablePermission('book-update', $book); + $loggedActivityForBook = false; - // Return if no map sent - if (!$request->filled('sort-tree')) { - return redirect($book->getUrl()); + // Sort via map + if ($request->filled('sort-tree')) { + $sortMap = BookSortMap::fromJson($request->get('sort-tree')); + $booksInvolved = $sorter->sortUsingMap($sortMap); + + // Rebuild permissions and add activity for involved books. + foreach ($booksInvolved as $bookInvolved) { + Activity::add(ActivityType::BOOK_SORT, $bookInvolved); + if ($bookInvolved->id === $book->id) { + $loggedActivityForBook = true; + } + } } - $sortMap = BookSortMap::fromJson($request->get('sort-tree')); - $booksInvolved = $sorter->sortUsingMap($sortMap); - - // Rebuild permissions and add activity for involved books. - foreach ($booksInvolved as $bookInvolved) { - Activity::add(ActivityType::BOOK_SORT, $bookInvolved); + if ($request->filled('auto-sort')) { + $sortSetId = intval($request->get('auto-sort')) ?: null; + if ($sortSetId && SortSet::query()->find($sortSetId) === null) { + $sortSetId = null; + } + $book->sort_set_id = $sortSetId; + $book->save(); + $sorter->runBookAutoSort($book); + if (!$loggedActivityForBook) { + Activity::add(ActivityType::BOOK_SORT, $book); + } } return redirect($book->getUrl()); diff --git a/app/Sorting/SortSet.php b/app/Sorting/SortSet.php index a73407bfa..8cdee1df4 100644 --- a/app/Sorting/SortSet.php +++ b/app/Sorting/SortSet.php @@ -5,6 +5,7 @@ namespace BookStack\Sorting; use BookStack\Activity\Models\Loggable; use BookStack\Entities\Models\Book; use Carbon\Carbon; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -48,4 +49,12 @@ class SortSet extends Model implements Loggable { return $this->hasMany(Book::class); } + + public static function allByName(): Collection + { + return static::query() + ->withCount('books') + ->orderBy('name', 'asc') + ->get(); + } } diff --git a/lang/en/entities.php b/lang/en/entities.php index 26a563a7e..28a209fa2 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -166,7 +166,9 @@ return [ 'books_search_this' => 'Search this book', 'books_navigation' => 'Book Navigation', 'books_sort' => 'Sort Book Contents', - 'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books.', + 'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books. Optionally an auto sort option can be set to automatically sort this book\'s contents upon changes.', + 'books_sort_auto_sort' => 'Auto Sort Option', + 'books_sort_auto_sort_active' => 'Auto Sort Active: :sortName', 'books_sort_named' => 'Sort Book :bookName', 'books_sort_name' => 'Sort by Name', 'books_sort_created' => 'Sort by Created Date', diff --git a/resources/icons/auto-sort.svg b/resources/icons/auto-sort.svg new file mode 100644 index 000000000..c3cb2f516 --- /dev/null +++ b/resources/icons/auto-sort.svg @@ -0,0 +1 @@ + diff --git a/resources/sass/_lists.scss b/resources/sass/_lists.scss index fd76f498e..1e503dd0f 100644 --- a/resources/sass/_lists.scss +++ b/resources/sass/_lists.scss @@ -242,6 +242,10 @@ margin-bottom: vars.$m; padding: vars.$m vars.$xl; position: relative; + summary:focus { + outline: 1px dashed var(--color-primary); + outline-offset: 5px; + } &::before { pointer-events: none; content: ''; diff --git a/resources/views/books/parts/sort-box.blade.php b/resources/views/books/parts/sort-box.blade.php index 03998e261..232616168 100644 --- a/resources/views/books/parts/sort-box.blade.php +++ b/resources/views/books/parts/sort-box.blade.php @@ -8,6 +8,11 @@ @icon('book') {{ $book->name }}
    +
    + @if($book->sortSet) + @icon('auto-sort') + @endif +
    diff --git a/resources/views/books/sort.blade.php b/resources/views/books/sort.blade.php index c82ad4e3b..3c59ac1e0 100644 --- a/resources/views/books/sort.blade.php +++ b/resources/views/books/sort.blade.php @@ -18,14 +18,36 @@

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

    -

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

    + +
    +

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

    +
    + @php + $autoSortVal = intval(old('auto-sort') ?? $book->sort_set_id ?? 0); + @endphp + + +
    +
    @include('books.parts.sort-box', ['book' => $book, 'bookChildren' => $bookChildren])
    - - {!! csrf_field() !!} + + {{ csrf_field() }}
    diff --git a/resources/views/settings/categories/sorting.blade.php b/resources/views/settings/categories/sorting.blade.php index 60fb329b6..6a52873e6 100644 --- a/resources/views/settings/categories/sorting.blade.php +++ b/resources/views/settings/categories/sorting.blade.php @@ -1,10 +1,7 @@ @extends('settings.layout') @php - $sortSets = \BookStack\Sorting\SortSet::query() - ->withCount('books') - ->orderBy('name', 'asc') - ->get(); + $sortSets = \BookStack\Sorting\SortSet::allByName(); @endphp @section('card') From 37d020c08350721320d3e7e3c8533daec544bb03 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 9 Feb 2025 17:44:24 +0000 Subject: [PATCH 12/17] Sorting: Addded command to apply sort sets --- app/Console/Commands/AssignSortSetCommand.php | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 app/Console/Commands/AssignSortSetCommand.php diff --git a/app/Console/Commands/AssignSortSetCommand.php b/app/Console/Commands/AssignSortSetCommand.php new file mode 100644 index 000000000..aca046bae --- /dev/null +++ b/app/Console/Commands/AssignSortSetCommand.php @@ -0,0 +1,99 @@ +argument('sort-set')) ?? 0; + if ($sortSetId === 0) { + return $this->listSortSets(); + } + + $set = SortSet::query()->find($sortSetId); + if ($this->option('all-books')) { + $query = Book::query(); + } else if ($this->option('books-without-sort')) { + $query = Book::query()->whereNull('sort_set_id'); + } else if ($this->option('books-with-sort')) { + $sortId = intval($this->option('books-with-sort')) ?: 0; + if (!$sortId) { + $this->error("Provided --books-with-sort option value is invalid"); + return 1; + } + $query = Book::query()->where('sort_set_id', $sortId); + } else { + $this->error("Either the --all-books or --books-without-sort option must be provided!"); + return 1; + } + + if (!$set) { + $this->error("Sort set of provided id {$sortSetId} not found!"); + return 1; + } + + $count = $query->clone()->count(); + $this->warn("This will apply sort set [{$set->id}: {$set->name}] to {$count} book(s) and run the sort on each."); + $confirmed = $this->confirm("Are you sure you want to continue?"); + + if (!$confirmed) { + return 1; + } + + $processed = 0; + $query->chunkById(10, function ($books) use ($set, $sorter, $count, &$processed) { + $max = min($count, ($processed + 10)); + $this->info("Applying to {$processed}-{$max} of {$count} books"); + foreach ($books as $book) { + $book->sort_set_id = $set->id; + $book->save(); + $sorter->runBookAutoSort($book); + } + $processed = $max; + }); + + $this->info("Sort applied to {$processed} books!"); + + return 0; + } + + protected function listSortSets(): int + { + + $sets = SortSet::query()->orderBy('id', 'asc')->get(); + $this->error("Sort set ID required!"); + $this->warn("\nAvailable sort sets:"); + foreach ($sets as $set) { + $this->info("{$set->id}: {$set->name}"); + } + + return 1; + } +} From 69683d50ecb854211fc9bb4502c9ef512ad23d8e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 9 Feb 2025 23:24:36 +0000 Subject: [PATCH 13/17] Sorting: Added tests to cover AssignSortSetCommand --- app/Console/Commands/AssignSortSetCommand.php | 4 +- app/Sorting/SortSet.php | 3 + database/factories/Sorting/SortSetFactory.php | 30 +++++ tests/Commands/AssignSortSetCommandTest.php | 112 ++++++++++++++++++ 4 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 database/factories/Sorting/SortSetFactory.php create mode 100644 tests/Commands/AssignSortSetCommandTest.php diff --git a/app/Console/Commands/AssignSortSetCommand.php b/app/Console/Commands/AssignSortSetCommand.php index aca046bae..484f69952 100644 --- a/app/Console/Commands/AssignSortSetCommand.php +++ b/app/Console/Commands/AssignSortSetCommand.php @@ -50,7 +50,7 @@ class AssignSortSetCommand extends Command } $query = Book::query()->where('sort_set_id', $sortId); } else { - $this->error("Either the --all-books or --books-without-sort option must be provided!"); + $this->error("No option provided to specify target. Run with the -h option to see all available options."); return 1; } @@ -79,7 +79,7 @@ class AssignSortSetCommand extends Command $processed = $max; }); - $this->info("Sort applied to {$processed} books!"); + $this->info("Sort applied to {$processed} book(s)!"); return 0; } diff --git a/app/Sorting/SortSet.php b/app/Sorting/SortSet.php index 8cdee1df4..cc8879f96 100644 --- a/app/Sorting/SortSet.php +++ b/app/Sorting/SortSet.php @@ -6,6 +6,7 @@ use BookStack\Activity\Models\Loggable; use BookStack\Entities\Models\Book; use Carbon\Carbon; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -18,6 +19,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany; */ class SortSet extends Model implements Loggable { + use HasFactory; + /** * @return SortSetOperation[] */ diff --git a/database/factories/Sorting/SortSetFactory.php b/database/factories/Sorting/SortSetFactory.php new file mode 100644 index 000000000..36e0a6976 --- /dev/null +++ b/database/factories/Sorting/SortSetFactory.php @@ -0,0 +1,30 @@ + $op->name . ' Sort', + 'sequence' => $op->value, + ]; + } +} diff --git a/tests/Commands/AssignSortSetCommandTest.php b/tests/Commands/AssignSortSetCommandTest.php new file mode 100644 index 000000000..01cb5caa5 --- /dev/null +++ b/tests/Commands/AssignSortSetCommandTest.php @@ -0,0 +1,112 @@ +createMany(10); + + $commandRun = $this->artisan('bookstack:assign-sort-set') + ->expectsOutputToContain('Sort set ID required!') + ->assertExitCode(1); + + foreach ($sortSets as $sortSet) { + $commandRun->expectsOutputToContain("{$sortSet->id}: {$sortSet->name}"); + } + } + + public function test_run_without_options_advises_help() + { + $this->artisan("bookstack:assign-sort-set 100") + ->expectsOutput("No option provided to specify target. Run with the -h option to see all available options.") + ->assertExitCode(1); + } + + public function test_run_without_valid_sort_advises_help() + { + $this->artisan("bookstack:assign-sort-set 100342 --all-books") + ->expectsOutput("Sort set of provided id 100342 not found!") + ->assertExitCode(1); + } + + public function test_confirmation_required() + { + $sortSet = SortSet::factory()->create(); + + $this->artisan("bookstack:assign-sort-set {$sortSet->id} --all-books") + ->expectsConfirmation('Are you sure you want to continue?', 'no') + ->assertExitCode(1); + + $booksWithSort = Book::query()->whereNotNull('sort_set_id')->count(); + $this->assertEquals(0, $booksWithSort); + } + + public function test_assign_to_all_books() + { + $sortSet = SortSet::factory()->create(); + $booksWithoutSort = Book::query()->whereNull('sort_set_id')->count(); + $this->assertGreaterThan(0, $booksWithoutSort); + + $this->artisan("bookstack:assign-sort-set {$sortSet->id} --all-books") + ->expectsOutputToContain("This will apply sort set [{$sortSet->id}: {$sortSet->name}] to {$booksWithoutSort} book(s)") + ->expectsConfirmation('Are you sure you want to continue?', 'yes') + ->expectsOutputToContain("Sort applied to {$booksWithoutSort} book(s)") + ->assertExitCode(0); + + $booksWithoutSort = Book::query()->whereNull('sort_set_id')->count(); + $this->assertEquals(0, $booksWithoutSort); + } + + public function test_assign_to_all_books_without_sort() + { + $totalBooks = Book::query()->count(); + $book = $this->entities->book(); + $sortSetA = SortSet::factory()->create(); + $sortSetB = SortSet::factory()->create(); + $book->sort_set_id = $sortSetA->id; + $book->save(); + + $booksWithoutSort = Book::query()->whereNull('sort_set_id')->count(); + $this->assertEquals($totalBooks, $booksWithoutSort + 1); + + $this->artisan("bookstack:assign-sort-set {$sortSetB->id} --books-without-sort") + ->expectsConfirmation('Are you sure you want to continue?', 'yes') + ->expectsOutputToContain("Sort applied to {$booksWithoutSort} book(s)") + ->assertExitCode(0); + + $booksWithoutSort = Book::query()->whereNull('sort_set_id')->count(); + $this->assertEquals(0, $booksWithoutSort); + $this->assertEquals($totalBooks, $sortSetB->books()->count() + 1); + } + + public function test_assign_to_all_books_with_sort() + { + $book = $this->entities->book(); + $sortSetA = SortSet::factory()->create(); + $sortSetB = SortSet::factory()->create(); + $book->sort_set_id = $sortSetA->id; + $book->save(); + + $this->artisan("bookstack:assign-sort-set {$sortSetB->id} --books-with-sort={$sortSetA->id}") + ->expectsConfirmation('Are you sure you want to continue?', 'yes') + ->expectsOutputToContain("Sort applied to 1 book(s)") + ->assertExitCode(0); + + $book->refresh(); + $this->assertEquals($sortSetB->id, $book->sort_set_id); + $this->assertEquals(1, $sortSetB->books()->count()); + } + + public function test_assign_to_all_books_with_sort_id_is_validated() + { + $this->artisan("bookstack:assign-sort-set 50 --books-with-sort=beans") + ->expectsOutputToContain("Provided --books-with-sort option value is invalid") + ->assertExitCode(1); + } +} From a65701294eaad4aad5ba5d9e249cbfb774ee6ca6 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 10 Feb 2025 13:33:10 +0000 Subject: [PATCH 14/17] Sorting: Split out test class, added book autosort tests Just for test view, actual functionality of autosort on change still needs to be tested. --- app/Sorting/BookSorter.php | 2 +- .../SortTest.php => Sorting/BookSortTest.php} | 293 ++++-------------- tests/Sorting/MoveTest.php | 221 +++++++++++++ 3 files changed, 287 insertions(+), 229 deletions(-) rename tests/{Entity/SortTest.php => Sorting/BookSortTest.php} (51%) create mode 100644 tests/Sorting/MoveTest.php diff --git a/app/Sorting/BookSorter.php b/app/Sorting/BookSorter.php index fd99a8d37..b6fe33b9c 100644 --- a/app/Sorting/BookSorter.php +++ b/app/Sorting/BookSorter.php @@ -42,7 +42,7 @@ class BookSorter }, $set->getOperations()); $chapters = $book->chapters() - ->with('pages:id,name,priority,created_at,updated_at') + ->with('pages:id,name,priority,created_at,updated_at,chapter_id') ->get(['id', 'name', 'priority', 'created_at', 'updated_at']); /** @var (Chapter|Book)[] $topItems */ diff --git a/tests/Entity/SortTest.php b/tests/Sorting/BookSortTest.php similarity index 51% rename from tests/Entity/SortTest.php rename to tests/Sorting/BookSortTest.php index 9a5a2fe17..a726da148 100644 --- a/tests/Entity/SortTest.php +++ b/tests/Sorting/BookSortTest.php @@ -1,239 +1,15 @@ asAdmin(); - $pageRepo = app(PageRepo::class); - $book = $this->entities->book(); - $draft = $pageRepo->getNewDraftPage($book); - - $resp = $this->get($book->getUrl()); - $resp->assertSee($draft->name); - - $resp = $this->get($book->getUrl() . '/sort'); - $resp->assertDontSee($draft->name); - } - - public function test_page_move_into_book() - { - $page = $this->entities->page(); - $currentBook = $page->book; - $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); - - $resp = $this->asEditor()->get($page->getUrl('/move')); - $resp->assertSee('Move Page'); - - $movePageResp = $this->put($page->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - $page->refresh(); - - $movePageResp->assertRedirect($page->getUrl()); - $this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book'); - - $newBookResp = $this->get($newBook->getUrl()); - $newBookResp->assertSee('moved page'); - $newBookResp->assertSee($page->name); - } - - public function test_page_move_into_chapter() - { - $page = $this->entities->page(); - $currentBook = $page->book; - $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); - $newChapter = $newBook->chapters()->first(); - - $movePageResp = $this->actingAs($this->users->editor())->put($page->getUrl('/move'), [ - 'entity_selection' => 'chapter:' . $newChapter->id, - ]); - $page->refresh(); - - $movePageResp->assertRedirect($page->getUrl()); - $this->assertTrue($page->book->id == $newBook->id, 'Page parent is now the new chapter'); - - $newChapterResp = $this->get($newChapter->getUrl()); - $newChapterResp->assertSee($page->name); - } - - public function test_page_move_from_chapter_to_book() - { - $oldChapter = Chapter::query()->first(); - $page = $oldChapter->pages()->first(); - $newBook = Book::query()->where('id', '!=', $oldChapter->book_id)->first(); - - $movePageResp = $this->actingAs($this->users->editor())->put($page->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - $page->refresh(); - - $movePageResp->assertRedirect($page->getUrl()); - $this->assertTrue($page->book->id == $newBook->id, 'Page parent is now the new book'); - $this->assertTrue($page->chapter === null, 'Page has no parent chapter'); - - $newBookResp = $this->get($newBook->getUrl()); - $newBookResp->assertSee($page->name); - } - - public function test_page_move_requires_create_permissions_on_parent() - { - $page = $this->entities->page(); - $currentBook = $page->book; - $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); - $editor = $this->users->editor(); - - $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'delete'], $editor->roles->all()); - - $movePageResp = $this->actingAs($editor)->put($page->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - $this->assertPermissionError($movePageResp); - - $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'delete', 'create'], $editor->roles->all()); - $movePageResp = $this->put($page->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - - $page->refresh(); - $movePageResp->assertRedirect($page->getUrl()); - - $this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book'); - } - - public function test_page_move_requires_delete_permissions() - { - $page = $this->entities->page(); - $currentBook = $page->book; - $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); - $editor = $this->users->editor(); - - $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'create', 'delete'], $editor->roles->all()); - $this->permissions->setEntityPermissions($page, ['view', 'update', 'create'], $editor->roles->all()); - - $movePageResp = $this->actingAs($editor)->put($page->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - $this->assertPermissionError($movePageResp); - $pageView = $this->get($page->getUrl()); - $pageView->assertDontSee($page->getUrl('/move')); - - $this->permissions->setEntityPermissions($page, ['view', 'update', 'create', 'delete'], $editor->roles->all()); - $movePageResp = $this->put($page->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - - $page->refresh(); - $movePageResp->assertRedirect($page->getUrl()); - $this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book'); - } - - public function test_chapter_move() - { - $chapter = $this->entities->chapter(); - $currentBook = $chapter->book; - $pageToCheck = $chapter->pages->first(); - $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); - - $chapterMoveResp = $this->asEditor()->get($chapter->getUrl('/move')); - $chapterMoveResp->assertSee('Move Chapter'); - - $moveChapterResp = $this->put($chapter->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - - $chapter = Chapter::query()->find($chapter->id); - $moveChapterResp->assertRedirect($chapter->getUrl()); - $this->assertTrue($chapter->book->id === $newBook->id, 'Chapter Book is now the new book'); - - $newBookResp = $this->get($newBook->getUrl()); - $newBookResp->assertSee('moved chapter'); - $newBookResp->assertSee($chapter->name); - - $pageToCheck = Page::query()->find($pageToCheck->id); - $this->assertTrue($pageToCheck->book_id === $newBook->id, 'Chapter child page\'s book id has changed to the new book'); - $pageCheckResp = $this->get($pageToCheck->getUrl()); - $pageCheckResp->assertSee($newBook->name); - } - - public function test_chapter_move_requires_delete_permissions() - { - $chapter = $this->entities->chapter(); - $currentBook = $chapter->book; - $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); - $editor = $this->users->editor(); - - $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'create', 'delete'], $editor->roles->all()); - $this->permissions->setEntityPermissions($chapter, ['view', 'update', 'create'], $editor->roles->all()); - - $moveChapterResp = $this->actingAs($editor)->put($chapter->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - $this->assertPermissionError($moveChapterResp); - $pageView = $this->get($chapter->getUrl()); - $pageView->assertDontSee($chapter->getUrl('/move')); - - $this->permissions->setEntityPermissions($chapter, ['view', 'update', 'create', 'delete'], $editor->roles->all()); - $moveChapterResp = $this->put($chapter->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - - $chapter = Chapter::query()->find($chapter->id); - $moveChapterResp->assertRedirect($chapter->getUrl()); - $this->assertTrue($chapter->book->id == $newBook->id, 'Page book is now the new book'); - } - - public function test_chapter_move_requires_create_permissions_in_new_book() - { - $chapter = $this->entities->chapter(); - $currentBook = $chapter->book; - $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); - $editor = $this->users->editor(); - - $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'delete'], [$editor->roles->first()]); - $this->permissions->setEntityPermissions($chapter, ['view', 'update', 'create', 'delete'], [$editor->roles->first()]); - - $moveChapterResp = $this->actingAs($editor)->put($chapter->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - $this->assertPermissionError($moveChapterResp); - - $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'create', 'delete'], [$editor->roles->first()]); - $moveChapterResp = $this->put($chapter->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - - $chapter = Chapter::query()->find($chapter->id); - $moveChapterResp->assertRedirect($chapter->getUrl()); - $this->assertTrue($chapter->book->id == $newBook->id, 'Page book is now the new book'); - } - - public function test_chapter_move_changes_book_for_deleted_pages_within() - { - /** @var Chapter $chapter */ - $chapter = Chapter::query()->whereHas('pages')->first(); - $currentBook = $chapter->book; - $pageToCheck = $chapter->pages->first(); - $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); - - $pageToCheck->delete(); - - $this->asEditor()->put($chapter->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - - $pageToCheck->refresh(); - $this->assertEquals($newBook->id, $pageToCheck->book_id); - } - public function test_book_sort_page_shows() { $bookToSort = $this->entities->book(); @@ -246,6 +22,20 @@ class SortTest extends TestCase $resp->assertSee($bookToSort->name); } + public function test_drafts_do_not_show_up() + { + $this->asAdmin(); + $pageRepo = app(PageRepo::class); + $book = $this->entities->book(); + $draft = $pageRepo->getNewDraftPage($book); + + $resp = $this->get($book->getUrl()); + $resp->assertSee($draft->name); + + $resp = $this->get($book->getUrl('/sort')); + $resp->assertDontSee($draft->name); + } + public function test_book_sort() { $oldBook = $this->entities->book(); @@ -423,7 +213,7 @@ class SortTest extends TestCase $firstPage = $bookToSort->pages[0]; $firstChapter = $bookToSort->chapters[0]; - $resp = $this->asAdmin()->get($bookToSort->getUrl() . '/sort-item'); + $resp = $this->asAdmin()->get($bookToSort->getUrl('/sort-item')); // Ensure book details are returned $resp->assertSee($bookToSort->name); @@ -431,6 +221,53 @@ class SortTest extends TestCase $resp->assertSee($firstChapter->name); } + public function test_book_sort_item_shows_auto_sort_status() + { + $sort = SortSet::factory()->create(['name' => 'My sort']); + $book = $this->entities->book(); + + $resp = $this->asAdmin()->get($book->getUrl('/sort-item')); + $this->withHtml($resp)->assertElementNotExists("span[title='Auto Sort Active: My sort']"); + + $book->sort_set_id = $sort->id; + $book->save(); + + $resp = $this->asAdmin()->get($book->getUrl('/sort-item')); + $this->withHtml($resp)->assertElementExists("span[title='Auto Sort Active: My sort']"); + } + + public function test_auto_sort_options_shown_on_sort_page() + { + $sort = SortSet::factory()->create(); + $book = $this->entities->book(); + $resp = $this->asAdmin()->get($book->getUrl('/sort')); + + $this->withHtml($resp)->assertElementExists('select[name="auto-sort"] option[value="' . $sort->id . '"]'); + } + + public function test_auto_sort_option_submit_saves_to_book() + { + $sort = SortSet::factory()->create(); + $book = $this->entities->book(); + $bookPage = $book->pages()->first(); + $bookPage->priority = 10000; + $bookPage->save(); + + $resp = $this->asAdmin()->put($book->getUrl('/sort'), [ + 'auto-sort' => $sort->id, + ]); + + $resp->assertRedirect($book->getUrl()); + $book->refresh(); + $bookPage->refresh(); + + $this->assertEquals($sort->id, $book->sort_set_id); + $this->assertNotEquals(10000, $bookPage->priority); + + $resp = $this->get($book->getUrl('/sort')); + $this->withHtml($resp)->assertElementExists('select[name="auto-sort"] option[value="' . $sort->id . '"][selected]'); + } + public function test_pages_in_book_show_sorted_by_priority() { $book = $this->entities->bookHasChaptersAndPages(); diff --git a/tests/Sorting/MoveTest.php b/tests/Sorting/MoveTest.php new file mode 100644 index 000000000..edae1f3a3 --- /dev/null +++ b/tests/Sorting/MoveTest.php @@ -0,0 +1,221 @@ +entities->page(); + $currentBook = $page->book; + $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); + + $resp = $this->asEditor()->get($page->getUrl('/move')); + $resp->assertSee('Move Page'); + + $movePageResp = $this->put($page->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + $page->refresh(); + + $movePageResp->assertRedirect($page->getUrl()); + $this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book'); + + $newBookResp = $this->get($newBook->getUrl()); + $newBookResp->assertSee('moved page'); + $newBookResp->assertSee($page->name); + } + + public function test_page_move_into_chapter() + { + $page = $this->entities->page(); + $currentBook = $page->book; + $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); + $newChapter = $newBook->chapters()->first(); + + $movePageResp = $this->actingAs($this->users->editor())->put($page->getUrl('/move'), [ + 'entity_selection' => 'chapter:' . $newChapter->id, + ]); + $page->refresh(); + + $movePageResp->assertRedirect($page->getUrl()); + $this->assertTrue($page->book->id == $newBook->id, 'Page parent is now the new chapter'); + + $newChapterResp = $this->get($newChapter->getUrl()); + $newChapterResp->assertSee($page->name); + } + + public function test_page_move_from_chapter_to_book() + { + $oldChapter = Chapter::query()->first(); + $page = $oldChapter->pages()->first(); + $newBook = Book::query()->where('id', '!=', $oldChapter->book_id)->first(); + + $movePageResp = $this->actingAs($this->users->editor())->put($page->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + $page->refresh(); + + $movePageResp->assertRedirect($page->getUrl()); + $this->assertTrue($page->book->id == $newBook->id, 'Page parent is now the new book'); + $this->assertTrue($page->chapter === null, 'Page has no parent chapter'); + + $newBookResp = $this->get($newBook->getUrl()); + $newBookResp->assertSee($page->name); + } + + public function test_page_move_requires_create_permissions_on_parent() + { + $page = $this->entities->page(); + $currentBook = $page->book; + $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); + $editor = $this->users->editor(); + + $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'delete'], $editor->roles->all()); + + $movePageResp = $this->actingAs($editor)->put($page->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + $this->assertPermissionError($movePageResp); + + $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'delete', 'create'], $editor->roles->all()); + $movePageResp = $this->put($page->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + + $page->refresh(); + $movePageResp->assertRedirect($page->getUrl()); + + $this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book'); + } + + public function test_page_move_requires_delete_permissions() + { + $page = $this->entities->page(); + $currentBook = $page->book; + $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); + $editor = $this->users->editor(); + + $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'create', 'delete'], $editor->roles->all()); + $this->permissions->setEntityPermissions($page, ['view', 'update', 'create'], $editor->roles->all()); + + $movePageResp = $this->actingAs($editor)->put($page->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + $this->assertPermissionError($movePageResp); + $pageView = $this->get($page->getUrl()); + $pageView->assertDontSee($page->getUrl('/move')); + + $this->permissions->setEntityPermissions($page, ['view', 'update', 'create', 'delete'], $editor->roles->all()); + $movePageResp = $this->put($page->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + + $page->refresh(); + $movePageResp->assertRedirect($page->getUrl()); + $this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book'); + } + + public function test_chapter_move() + { + $chapter = $this->entities->chapter(); + $currentBook = $chapter->book; + $pageToCheck = $chapter->pages->first(); + $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); + + $chapterMoveResp = $this->asEditor()->get($chapter->getUrl('/move')); + $chapterMoveResp->assertSee('Move Chapter'); + + $moveChapterResp = $this->put($chapter->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + + $chapter = Chapter::query()->find($chapter->id); + $moveChapterResp->assertRedirect($chapter->getUrl()); + $this->assertTrue($chapter->book->id === $newBook->id, 'Chapter Book is now the new book'); + + $newBookResp = $this->get($newBook->getUrl()); + $newBookResp->assertSee('moved chapter'); + $newBookResp->assertSee($chapter->name); + + $pageToCheck = Page::query()->find($pageToCheck->id); + $this->assertTrue($pageToCheck->book_id === $newBook->id, 'Chapter child page\'s book id has changed to the new book'); + $pageCheckResp = $this->get($pageToCheck->getUrl()); + $pageCheckResp->assertSee($newBook->name); + } + + public function test_chapter_move_requires_delete_permissions() + { + $chapter = $this->entities->chapter(); + $currentBook = $chapter->book; + $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); + $editor = $this->users->editor(); + + $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'create', 'delete'], $editor->roles->all()); + $this->permissions->setEntityPermissions($chapter, ['view', 'update', 'create'], $editor->roles->all()); + + $moveChapterResp = $this->actingAs($editor)->put($chapter->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + $this->assertPermissionError($moveChapterResp); + $pageView = $this->get($chapter->getUrl()); + $pageView->assertDontSee($chapter->getUrl('/move')); + + $this->permissions->setEntityPermissions($chapter, ['view', 'update', 'create', 'delete'], $editor->roles->all()); + $moveChapterResp = $this->put($chapter->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + + $chapter = Chapter::query()->find($chapter->id); + $moveChapterResp->assertRedirect($chapter->getUrl()); + $this->assertTrue($chapter->book->id == $newBook->id, 'Page book is now the new book'); + } + + public function test_chapter_move_requires_create_permissions_in_new_book() + { + $chapter = $this->entities->chapter(); + $currentBook = $chapter->book; + $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); + $editor = $this->users->editor(); + + $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'delete'], [$editor->roles->first()]); + $this->permissions->setEntityPermissions($chapter, ['view', 'update', 'create', 'delete'], [$editor->roles->first()]); + + $moveChapterResp = $this->actingAs($editor)->put($chapter->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + $this->assertPermissionError($moveChapterResp); + + $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'create', 'delete'], [$editor->roles->first()]); + $moveChapterResp = $this->put($chapter->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + + $chapter = Chapter::query()->find($chapter->id); + $moveChapterResp->assertRedirect($chapter->getUrl()); + $this->assertTrue($chapter->book->id == $newBook->id, 'Page book is now the new book'); + } + + public function test_chapter_move_changes_book_for_deleted_pages_within() + { + /** @var Chapter $chapter */ + $chapter = Chapter::query()->whereHas('pages')->first(); + $currentBook = $chapter->book; + $pageToCheck = $chapter->pages->first(); + $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); + + $pageToCheck->delete(); + + $this->asEditor()->put($chapter->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + + $pageToCheck->refresh(); + $this->assertEquals($newBook->id, $pageToCheck->book_id); + } +} From a208c46b628505cfdd5caf5294a248560c485f50 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 10 Feb 2025 17:14:06 +0000 Subject: [PATCH 15/17] Sorting: Covered sort set management with tests --- app/Sorting/SortSetController.php | 3 +- .../factories/Entities/Models/BookFactory.php | 4 +- lang/en/settings.php | 1 + .../parts/sort-set-list-item.blade.php | 2 +- tests/Sorting/SortSetTest.php | 200 ++++++++++++++++++ 5 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 tests/Sorting/SortSetTest.php diff --git a/app/Sorting/SortSetController.php b/app/Sorting/SortSetController.php index b0ad2a7d7..7b1c0bc41 100644 --- a/app/Sorting/SortSetController.php +++ b/app/Sorting/SortSetController.php @@ -4,14 +4,13 @@ namespace BookStack\Sorting; use BookStack\Activity\ActivityType; use BookStack\Http\Controller; -use BookStack\Http\Request; +use Illuminate\Http\Request; class SortSetController extends Controller { public function __construct() { $this->middleware('can:settings-manage'); - // TODO - Test } public function create() diff --git a/database/factories/Entities/Models/BookFactory.php b/database/factories/Entities/Models/BookFactory.php index 9cb8e971c..29403a294 100644 --- a/database/factories/Entities/Models/BookFactory.php +++ b/database/factories/Entities/Models/BookFactory.php @@ -26,7 +26,9 @@ class BookFactory extends Factory 'name' => $this->faker->sentence(), 'slug' => Str::random(10), 'description' => $description, - 'description_html' => '

    ' . e($description) . '

    ' + 'description_html' => '

    ' . e($description) . '

    ', + 'sort_set_id' => null, + 'default_template_id' => null, ]; } } diff --git a/lang/en/settings.php b/lang/en/settings.php index 19ffd9240..344c186cb 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -80,6 +80,7 @@ return [ 'sorting_book_default_desc' => 'Select the default sort set to apply to new books. This won\'t affect existing books, and can be overridden per-book.', 'sorting_sets' => 'Sort Sets', 'sorting_sets_desc' => 'These are predefined sorting operations which can be applied to content in the system.', + 'sort_set_assigned_to_x_books' => 'Assigned to :count Book|Assigned to :count Books', 'sort_set_create' => 'Create Sort Set', 'sort_set_edit' => 'Edit Sort Set', 'sort_set_delete' => 'Delete Sort Set', diff --git a/resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php b/resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php index e977c286e..6c0b84047 100644 --- a/resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php +++ b/resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php @@ -6,7 +6,7 @@ {{ implode(', ', array_map(fn ($op) => $op->getLabel(), $set->getOperations())) }}
    - @icon('book'){{ $set->books_count ?? 0 }}
    \ No newline at end of file diff --git a/tests/Sorting/SortSetTest.php b/tests/Sorting/SortSetTest.php new file mode 100644 index 000000000..5f30034bd --- /dev/null +++ b/tests/Sorting/SortSetTest.php @@ -0,0 +1,200 @@ +create(); + $user = $this->users->viewer(); + $this->actingAs($user); + + $actions = [ + ['GET', '/settings/sorting'], + ['POST', '/settings/sorting/sets'], + ['GET', "/settings/sorting/sets/{$set->id}"], + ['PUT', "/settings/sorting/sets/{$set->id}"], + ['DELETE', "/settings/sorting/sets/{$set->id}"], + ]; + + foreach ($actions as [$method, $path]) { + $resp = $this->call($method, $path); + $this->assertPermissionError($resp); + } + + $this->permissions->grantUserRolePermissions($user, ['settings-manage']); + + foreach ($actions as [$method, $path]) { + $resp = $this->call($method, $path); + $this->assertNotPermissionError($resp); + } + } + + public function test_create_flow() + { + $resp = $this->asAdmin()->get('/settings/sorting'); + $this->withHtml($resp)->assertLinkExists(url('/settings/sorting/sets/new')); + + $resp = $this->get('/settings/sorting/sets/new'); + $this->withHtml($resp)->assertElementExists('form[action$="/settings/sorting/sets"] input[name="name"]'); + $resp->assertSeeText('Name - Alphabetical (Asc)'); + + $details = ['name' => 'My new sort', 'sequence' => 'name_asc']; + $resp = $this->post('/settings/sorting/sets', $details); + $resp->assertRedirect('/settings/sorting'); + + $this->assertActivityExists(ActivityType::SORT_SET_CREATE); + $this->assertDatabaseHas('sort_sets', $details); + } + + public function test_listing_in_settings() + { + $set = SortSet::factory()->create(['name' => 'My super sort set', 'sequence' => 'name_asc']); + $books = Book::query()->limit(5)->get(); + foreach ($books as $book) { + $book->sort_set_id = $set->id; + $book->save(); + } + + $resp = $this->asAdmin()->get('/settings/sorting'); + $resp->assertSeeText('My super sort set'); + $resp->assertSeeText('Name - Alphabetical (Asc)'); + $this->withHtml($resp)->assertElementContains('.item-list-row [title="Assigned to 5 Books"]', '5'); + } + + public function test_update_flow() + { + $set = SortSet::factory()->create(['name' => 'My sort set to update', 'sequence' => 'name_asc']); + + $resp = $this->asAdmin()->get("/settings/sorting/sets/{$set->id}"); + $respHtml = $this->withHtml($resp); + $respHtml->assertElementContains('.configured-option-list', 'Name - Alphabetical (Asc)'); + $respHtml->assertElementNotContains('.available-option-list', 'Name - Alphabetical (Asc)'); + + $updateData = ['name' => 'My updated sort', 'sequence' => 'name_desc,chapters_last']; + $resp = $this->put("/settings/sorting/sets/{$set->id}", $updateData); + + $resp->assertRedirect('/settings/sorting'); + $this->assertActivityExists(ActivityType::SORT_SET_UPDATE); + $this->assertDatabaseHas('sort_sets', $updateData); + } + + public function test_update_triggers_resort_on_assigned_books() + { + $book = $this->entities->bookHasChaptersAndPages(); + $chapter = $book->chapters()->first(); + $set = SortSet::factory()->create(['name' => 'My sort set to update', 'sequence' => 'name_asc']); + $book->sort_set_id = $set->id; + $book->save(); + $chapter->priority = 10000; + $chapter->save(); + + $resp = $this->asAdmin()->put("/settings/sorting/sets/{$set->id}", ['name' => $set->name, 'sequence' => 'chapters_last']); + $resp->assertRedirect('/settings/sorting'); + + $chapter->refresh(); + $this->assertNotEquals(10000, $chapter->priority); + } + + public function test_delete_flow() + { + $set = SortSet::factory()->create(); + + $resp = $this->asAdmin()->get("/settings/sorting/sets/{$set->id}"); + $resp->assertSeeText('Delete Sort Set'); + + $resp = $this->delete("settings/sorting/sets/{$set->id}"); + $resp->assertRedirect('/settings/sorting'); + + $this->assertActivityExists(ActivityType::SORT_SET_DELETE); + $this->assertDatabaseMissing('sort_sets', ['id' => $set->id]); + } + + public function test_delete_requires_confirmation_if_books_assigned() + { + $set = SortSet::factory()->create(); + $books = Book::query()->limit(5)->get(); + foreach ($books as $book) { + $book->sort_set_id = $set->id; + $book->save(); + } + + $resp = $this->asAdmin()->get("/settings/sorting/sets/{$set->id}"); + $resp->assertSeeText('Delete Sort Set'); + + $resp = $this->delete("settings/sorting/sets/{$set->id}"); + $resp->assertRedirect("/settings/sorting/sets/{$set->id}#delete"); + $resp = $this->followRedirects($resp); + + $resp->assertSeeText('This sort set is currently used on 5 book(s). Are you sure you want to delete this?'); + $this->assertDatabaseHas('sort_sets', ['id' => $set->id]); + + $resp = $this->delete("settings/sorting/sets/{$set->id}", ['confirm' => 'true']); + $resp->assertRedirect('/settings/sorting'); + $this->assertDatabaseMissing('sort_sets', ['id' => $set->id]); + $this->assertDatabaseMissing('books', ['sort_set_id' => $set->id]); + } + + public function test_page_create_triggers_book_sort() + { + $book = $this->entities->bookHasChaptersAndPages(); + $set = SortSet::factory()->create(['sequence' => 'name_asc,chapters_first']); + $book->sort_set_id = $set->id; + $book->save(); + + $resp = $this->actingAsApiEditor()->post("/api/pages", [ + 'book_id' => $book->id, + 'name' => '1111 page', + 'markdown' => 'Hi' + ]); + $resp->assertOk(); + + $this->assertDatabaseHas('pages', [ + 'book_id' => $book->id, + 'name' => '1111 page', + 'priority' => $book->chapters()->count() + 1, + ]); + } + + public function test_name_numeric_ordering() + { + $book = Book::factory()->create(); + $set = SortSet::factory()->create(['sequence' => 'name_numeric_asc']); + $book->sort_set_id = $set->id; + $book->save(); + $this->permissions->regenerateForEntity($book); + + $namesToAdd = [ + "1 - Pizza", + "2.0 - Tomato", + "2.5 - Beans", + "10 - Bread", + "20 - Milk", + ]; + + foreach ($namesToAdd as $name) { + $this->actingAsApiEditor()->post("/api/pages", [ + 'book_id' => $book->id, + 'name' => $name, + 'markdown' => 'Hello' + ]); + } + + foreach ($namesToAdd as $index => $name) { + $this->assertDatabaseHas('pages', [ + 'book_id' => $book->id, + 'name' => $name, + 'priority' => $index + 1, + ]); + } + } +} From b9306a9029f41f3779b755d08c907a2edafc33df Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 11 Feb 2025 14:36:25 +0000 Subject: [PATCH 16/17] Sorting: Renamed sort set to sort rule Renamed based on feedback from Tim and Script on Discord. Also fixed flaky test --- app/Activity/ActivityType.php | 6 +- app/Console/Commands/AssignSortSetCommand.php | 6 +- app/Entities/Models/Book.php | 10 +-- app/Entities/Repos/BookRepo.php | 6 +- app/Sorting/BookSortController.php | 4 +- app/Sorting/BookSorter.php | 6 +- app/Sorting/{SortSet.php => SortRule.php} | 12 +-- ...tController.php => SortRuleController.php} | 58 ++++++------ ...SetOperation.php => SortRuleOperation.php} | 18 ++-- .../factories/Entities/Models/BookFactory.php | 2 +- ...SortSetFactory.php => SortRuleFactory.php} | 10 +-- ..._01_29_180933_create_sort_rules_table.php} | 4 +- ...2_05_150842_add_sort_rule_id_to_books.php} | 4 +- lang/en/entities.php | 2 +- lang/en/settings.php | 52 +++++------ resources/js/components/index.ts | 2 +- ...rt-set-manager.ts => sort-rule-manager.ts} | 4 +- .../views/books/parts/sort-box.blade.php | 19 ++-- resources/views/books/sort.blade.php | 14 +-- .../settings/categories/sorting.blade.php | 17 ++-- .../create.blade.php | 6 +- .../{sort-sets => sort-rules}/edit.blade.php | 12 +-- .../parts/form.blade.php | 36 ++++---- .../parts/operation.blade.php | 0 .../parts/sort-rule-list-item.blade.php} | 8 +- routes/web.php | 12 +-- tests/Commands/AssignSortSetCommandTest.php | 22 ++--- tests/Entity/PageTest.php | 2 +- tests/Sorting/BookSortTest.php | 12 +-- .../{SortSetTest.php => SortRuleTest.php} | 90 +++++++++---------- 30 files changed, 232 insertions(+), 224 deletions(-) rename app/Sorting/{SortSet.php => SortRule.php} (77%) rename app/Sorting/{SortSetController.php => SortRuleController.php} (51%) rename app/Sorting/{SortSetOperation.php => SortRuleOperation.php} (74%) rename database/factories/Sorting/{SortSetFactory.php => SortRuleFactory.php} (70%) rename database/migrations/{2025_01_29_180933_create_sort_sets_table.php => 2025_01_29_180933_create_sort_rules_table.php} (82%) rename database/migrations/{2025_02_05_150842_add_sort_set_id_to_books.php => 2025_02_05_150842_add_sort_rule_id_to_books.php} (79%) rename resources/js/components/{sort-set-manager.ts => sort-rule-manager.ts} (93%) rename resources/views/settings/{sort-sets => sort-rules}/create.blade.php (70%) rename resources/views/settings/{sort-sets => sort-rules}/edit.blade.php (83%) rename resources/views/settings/{sort-sets => sort-rules}/parts/form.blade.php (53%) rename resources/views/settings/{sort-sets => sort-rules}/parts/operation.blade.php (100%) rename resources/views/settings/{sort-sets/parts/sort-set-list-item.blade.php => sort-rules/parts/sort-rule-list-item.blade.php} (52%) rename tests/Sorting/{SortSetTest.php => SortRuleTest.php} (58%) diff --git a/app/Activity/ActivityType.php b/app/Activity/ActivityType.php index 4a648da6c..a7f129f71 100644 --- a/app/Activity/ActivityType.php +++ b/app/Activity/ActivityType.php @@ -71,9 +71,9 @@ class ActivityType const IMPORT_RUN = 'import_run'; const IMPORT_DELETE = 'import_delete'; - const SORT_SET_CREATE = 'sort_set_create'; - const SORT_SET_UPDATE = 'sort_set_update'; - const SORT_SET_DELETE = 'sort_set_delete'; + const SORT_RULE_CREATE = 'sort_rule_create'; + const SORT_RULE_UPDATE = 'sort_rule_update'; + const SORT_RULE_DELETE = 'sort_rule_delete'; /** * Get all the possible values. diff --git a/app/Console/Commands/AssignSortSetCommand.php b/app/Console/Commands/AssignSortSetCommand.php index 484f69952..6c9d3f764 100644 --- a/app/Console/Commands/AssignSortSetCommand.php +++ b/app/Console/Commands/AssignSortSetCommand.php @@ -4,7 +4,7 @@ namespace BookStack\Console\Commands; use BookStack\Entities\Models\Book; use BookStack\Sorting\BookSorter; -use BookStack\Sorting\SortSet; +use BookStack\Sorting\SortRule; use Illuminate\Console\Command; class AssignSortSetCommand extends Command @@ -37,7 +37,7 @@ class AssignSortSetCommand extends Command return $this->listSortSets(); } - $set = SortSet::query()->find($sortSetId); + $set = SortRule::query()->find($sortSetId); if ($this->option('all-books')) { $query = Book::query(); } else if ($this->option('books-without-sort')) { @@ -87,7 +87,7 @@ class AssignSortSetCommand extends Command protected function listSortSets(): int { - $sets = SortSet::query()->orderBy('id', 'asc')->get(); + $sets = SortRule::query()->orderBy('id', 'asc')->get(); $this->error("Sort set ID required!"); $this->warn("\nAvailable sort sets:"); foreach ($sets as $set) { diff --git a/app/Entities/Models/Book.php b/app/Entities/Models/Book.php index 7d240e5ca..ede4fc7d5 100644 --- a/app/Entities/Models/Book.php +++ b/app/Entities/Models/Book.php @@ -2,7 +2,7 @@ namespace BookStack\Entities\Models; -use BookStack\Sorting\SortSet; +use BookStack\Sorting\SortRule; use BookStack\Uploads\Image; use Exception; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -17,14 +17,14 @@ use Illuminate\Support\Collection; * @property string $description * @property int $image_id * @property ?int $default_template_id - * @property ?int $sort_set_id + * @property ?int $sort_rule_id * @property Image|null $cover * @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 ?Page $defaultTemplate - * @property ?SortSet $sortSet + * @property ?SortRule $sortRule */ class Book extends Entity implements HasCoverImage { @@ -88,9 +88,9 @@ class Book extends Entity implements HasCoverImage /** * Get the sort set assigned to this book, if existing. */ - public function sortSet(): BelongsTo + public function sortRule(): BelongsTo { - return $this->belongsTo(SortSet::class); + return $this->belongsTo(SortRule::class); } /** diff --git a/app/Entities/Repos/BookRepo.php b/app/Entities/Repos/BookRepo.php index b3b811647..92e6a81c3 100644 --- a/app/Entities/Repos/BookRepo.php +++ b/app/Entities/Repos/BookRepo.php @@ -8,7 +8,7 @@ use BookStack\Entities\Models\Book; use BookStack\Entities\Tools\TrashCan; use BookStack\Exceptions\ImageUploadException; use BookStack\Facades\Activity; -use BookStack\Sorting\SortSet; +use BookStack\Sorting\SortRule; use BookStack\Uploads\ImageRepo; use Exception; use Illuminate\Http\UploadedFile; @@ -35,8 +35,8 @@ class BookRepo Activity::add(ActivityType::BOOK_CREATE, $book); $defaultBookSortSetting = intval(setting('sorting-book-default', '0')); - if ($defaultBookSortSetting && SortSet::query()->find($defaultBookSortSetting)) { - $book->sort_set_id = $defaultBookSortSetting; + if ($defaultBookSortSetting && SortRule::query()->find($defaultBookSortSetting)) { + $book->sort_rule_id = $defaultBookSortSetting; $book->save(); } diff --git a/app/Sorting/BookSortController.php b/app/Sorting/BookSortController.php index 98d79d0fd..479d19724 100644 --- a/app/Sorting/BookSortController.php +++ b/app/Sorting/BookSortController.php @@ -69,10 +69,10 @@ class BookSortController extends Controller if ($request->filled('auto-sort')) { $sortSetId = intval($request->get('auto-sort')) ?: null; - if ($sortSetId && SortSet::query()->find($sortSetId) === null) { + if ($sortSetId && SortRule::query()->find($sortSetId) === null) { $sortSetId = null; } - $book->sort_set_id = $sortSetId; + $book->sort_rule_id = $sortSetId; $book->save(); $sorter->runBookAutoSort($book); if (!$loggedActivityForBook) { diff --git a/app/Sorting/BookSorter.php b/app/Sorting/BookSorter.php index b6fe33b9c..7bf1b63f4 100644 --- a/app/Sorting/BookSorter.php +++ b/app/Sorting/BookSorter.php @@ -16,7 +16,7 @@ class BookSorter ) { } - public function runBookAutoSortForAllWithSet(SortSet $set): void + public function runBookAutoSortForAllWithSet(SortRule $set): void { $set->books()->chunk(50, function ($books) { foreach ($books as $book) { @@ -32,12 +32,12 @@ class BookSorter */ public function runBookAutoSort(Book $book): void { - $set = $book->sortSet; + $set = $book->sortRule; if (!$set) { return; } - $sortFunctions = array_map(function (SortSetOperation $op) { + $sortFunctions = array_map(function (SortRuleOperation $op) { return $op->getSortFunction(); }, $set->getOperations()); diff --git a/app/Sorting/SortSet.php b/app/Sorting/SortRule.php similarity index 77% rename from app/Sorting/SortSet.php rename to app/Sorting/SortRule.php index cc8879f96..45e5514fd 100644 --- a/app/Sorting/SortSet.php +++ b/app/Sorting/SortRule.php @@ -17,24 +17,24 @@ use Illuminate\Database\Eloquent\Relations\HasMany; * @property Carbon $created_at * @property Carbon $updated_at */ -class SortSet extends Model implements Loggable +class SortRule extends Model implements Loggable { use HasFactory; /** - * @return SortSetOperation[] + * @return SortRuleOperation[] */ public function getOperations(): array { - return SortSetOperation::fromSequence($this->sequence); + return SortRuleOperation::fromSequence($this->sequence); } /** - * @param SortSetOperation[] $options + * @param SortRuleOperation[] $options */ public function setOperations(array $options): void { - $values = array_map(fn (SortSetOperation $opt) => $opt->value, $options); + $values = array_map(fn (SortRuleOperation $opt) => $opt->value, $options); $this->sequence = implode(',', $values); } @@ -45,7 +45,7 @@ class SortSet extends Model implements Loggable public function getUrl(): string { - return url("/settings/sorting/sets/{$this->id}"); + return url("/settings/sorting/rules/{$this->id}"); } public function books(): HasMany diff --git a/app/Sorting/SortSetController.php b/app/Sorting/SortRuleController.php similarity index 51% rename from app/Sorting/SortSetController.php rename to app/Sorting/SortRuleController.php index 7b1c0bc41..96b8e8ef5 100644 --- a/app/Sorting/SortSetController.php +++ b/app/Sorting/SortRuleController.php @@ -6,7 +6,7 @@ use BookStack\Activity\ActivityType; use BookStack\Http\Controller; use Illuminate\Http\Request; -class SortSetController extends Controller +class SortRuleController extends Controller { public function __construct() { @@ -15,9 +15,9 @@ class SortSetController extends Controller public function create() { - $this->setPageTitle(trans('settings.sort_set_create')); + $this->setPageTitle(trans('settings.sort_rule_create')); - return view('settings.sort-sets.create'); + return view('settings.sort-rules.create'); } public function store(Request $request) @@ -27,28 +27,28 @@ class SortSetController extends Controller 'sequence' => ['required', 'string', 'min:1'], ]); - $operations = SortSetOperation::fromSequence($request->input('sequence')); + $operations = SortRuleOperation::fromSequence($request->input('sequence')); if (count($operations) === 0) { return redirect()->withInput()->withErrors(['sequence' => 'No operations set.']); } - $set = new SortSet(); - $set->name = $request->input('name'); - $set->setOperations($operations); - $set->save(); + $rule = new SortRule(); + $rule->name = $request->input('name'); + $rule->setOperations($operations); + $rule->save(); - $this->logActivity(ActivityType::SORT_SET_CREATE, $set); + $this->logActivity(ActivityType::SORT_RULE_CREATE, $rule); return redirect('/settings/sorting'); } public function edit(string $id) { - $set = SortSet::query()->findOrFail($id); + $rule = SortRule::query()->findOrFail($id); - $this->setPageTitle(trans('settings.sort_set_edit')); + $this->setPageTitle(trans('settings.sort_rule_edit')); - return view('settings.sort-sets.edit', ['set' => $set]); + return view('settings.sort-rules.edit', ['rule' => $rule]); } public function update(string $id, Request $request, BookSorter $bookSorter) @@ -58,21 +58,21 @@ class SortSetController extends Controller 'sequence' => ['required', 'string', 'min:1'], ]); - $set = SortSet::query()->findOrFail($id); - $operations = SortSetOperation::fromSequence($request->input('sequence')); + $rule = SortRule::query()->findOrFail($id); + $operations = SortRuleOperation::fromSequence($request->input('sequence')); if (count($operations) === 0) { - return redirect($set->getUrl())->withInput()->withErrors(['sequence' => 'No operations set.']); + return redirect($rule->getUrl())->withInput()->withErrors(['sequence' => 'No operations set.']); } - $set->name = $request->input('name'); - $set->setOperations($operations); - $changedSequence = $set->isDirty('sequence'); - $set->save(); + $rule->name = $request->input('name'); + $rule->setOperations($operations); + $changedSequence = $rule->isDirty('sequence'); + $rule->save(); - $this->logActivity(ActivityType::SORT_SET_UPDATE, $set); + $this->logActivity(ActivityType::SORT_RULE_UPDATE, $rule); if ($changedSequence) { - $bookSorter->runBookAutoSortForAllWithSet($set); + $bookSorter->runBookAutoSortForAllWithSet($rule); } return redirect('/settings/sorting'); @@ -80,16 +80,16 @@ class SortSetController extends Controller public function destroy(string $id, Request $request) { - $set = SortSet::query()->findOrFail($id); + $rule = SortRule::query()->findOrFail($id); $confirmed = $request->input('confirm') === 'true'; - $booksAssigned = $set->books()->count(); + $booksAssigned = $rule->books()->count(); $warnings = []; if ($booksAssigned > 0) { if ($confirmed) { - $set->books()->update(['sort_set_id' => null]); + $rule->books()->update(['sort_rule_id' => null]); } else { - $warnings[] = trans('settings.sort_set_delete_warn_books', ['count' => $booksAssigned]); + $warnings[] = trans('settings.sort_rule_delete_warn_books', ['count' => $booksAssigned]); } } @@ -98,16 +98,16 @@ class SortSetController extends Controller if ($confirmed) { setting()->remove('sorting-book-default'); } else { - $warnings[] = trans('settings.sort_set_delete_warn_default'); + $warnings[] = trans('settings.sort_rule_delete_warn_default'); } } if (count($warnings) > 0) { - return redirect($set->getUrl() . '#delete')->withErrors(['delete' => $warnings]); + return redirect($rule->getUrl() . '#delete')->withErrors(['delete' => $warnings]); } - $set->delete(); - $this->logActivity(ActivityType::SORT_SET_DELETE, $set); + $rule->delete(); + $this->logActivity(ActivityType::SORT_RULE_DELETE, $rule); return redirect('/settings/sorting'); } diff --git a/app/Sorting/SortSetOperation.php b/app/Sorting/SortRuleOperation.php similarity index 74% rename from app/Sorting/SortSetOperation.php rename to app/Sorting/SortRuleOperation.php index 7fdd0b002..0d8ff239f 100644 --- a/app/Sorting/SortSetOperation.php +++ b/app/Sorting/SortRuleOperation.php @@ -5,7 +5,7 @@ namespace BookStack\Sorting; use Closure; use Illuminate\Support\Str; -enum SortSetOperation: string +enum SortRuleOperation: string { case NameAsc = 'name_asc'; case NameDesc = 'name_desc'; @@ -26,13 +26,13 @@ enum SortSetOperation: string $label = ''; if (str_ends_with($key, '_asc')) { $key = substr($key, 0, -4); - $label = trans('settings.sort_set_op_asc'); + $label = trans('settings.sort_rule_op_asc'); } elseif (str_ends_with($key, '_desc')) { $key = substr($key, 0, -5); - $label = trans('settings.sort_set_op_desc'); + $label = trans('settings.sort_rule_op_desc'); } - $label = trans('settings.sort_set_op_' . $key) . ' ' . $label; + $label = trans('settings.sort_rule_op_' . $key) . ' ' . $label; return trim($label); } @@ -43,12 +43,12 @@ enum SortSetOperation: string } /** - * @return SortSetOperation[] + * @return SortRuleOperation[] */ public static function allExcluding(array $operations): array { - $all = SortSetOperation::cases(); - $filtered = array_filter($all, function (SortSetOperation $operation) use ($operations) { + $all = SortRuleOperation::cases(); + $filtered = array_filter($all, function (SortRuleOperation $operation) use ($operations) { return !in_array($operation, $operations); }); return array_values($filtered); @@ -57,12 +57,12 @@ enum SortSetOperation: string /** * Create a set of operations from a string sequence representation. * (values seperated by commas). - * @return SortSetOperation[] + * @return SortRuleOperation[] */ public static function fromSequence(string $sequence): array { $strOptions = explode(',', $sequence); - $options = array_map(fn ($val) => SortSetOperation::tryFrom($val), $strOptions); + $options = array_map(fn ($val) => SortRuleOperation::tryFrom($val), $strOptions); return array_filter($options); } } diff --git a/database/factories/Entities/Models/BookFactory.php b/database/factories/Entities/Models/BookFactory.php index 29403a294..48d43d7a8 100644 --- a/database/factories/Entities/Models/BookFactory.php +++ b/database/factories/Entities/Models/BookFactory.php @@ -27,7 +27,7 @@ class BookFactory extends Factory 'slug' => Str::random(10), 'description' => $description, 'description_html' => '

    ' . e($description) . '

    ', - 'sort_set_id' => null, + 'sort_rule_id' => null, 'default_template_id' => null, ]; } diff --git a/database/factories/Sorting/SortSetFactory.php b/database/factories/Sorting/SortRuleFactory.php similarity index 70% rename from database/factories/Sorting/SortSetFactory.php rename to database/factories/Sorting/SortRuleFactory.php index 36e0a6976..dafe8c3fa 100644 --- a/database/factories/Sorting/SortSetFactory.php +++ b/database/factories/Sorting/SortRuleFactory.php @@ -2,25 +2,25 @@ namespace Database\Factories\Sorting; -use BookStack\Sorting\SortSet; -use BookStack\Sorting\SortSetOperation; +use BookStack\Sorting\SortRule; +use BookStack\Sorting\SortRuleOperation; use Illuminate\Database\Eloquent\Factories\Factory; -class SortSetFactory extends Factory +class SortRuleFactory extends Factory { /** * The name of the factory's corresponding model. * * @var string */ - protected $model = SortSet::class; + protected $model = SortRule::class; /** * Define the model's default state. */ public function definition(): array { - $cases = SortSetOperation::cases(); + $cases = SortRuleOperation::cases(); $op = $cases[array_rand($cases)]; return [ 'name' => $op->name . ' Sort', diff --git a/database/migrations/2025_01_29_180933_create_sort_sets_table.php b/database/migrations/2025_01_29_180933_create_sort_rules_table.php similarity index 82% rename from database/migrations/2025_01_29_180933_create_sort_sets_table.php rename to database/migrations/2025_01_29_180933_create_sort_rules_table.php index bf9780c5b..37d20ddf6 100644 --- a/database/migrations/2025_01_29_180933_create_sort_sets_table.php +++ b/database/migrations/2025_01_29_180933_create_sort_rules_table.php @@ -11,7 +11,7 @@ return new class extends Migration */ public function up(): void { - Schema::create('sort_sets', function (Blueprint $table) { + Schema::create('sort_rules', function (Blueprint $table) { $table->increments('id'); $table->string('name'); $table->text('sequence'); @@ -24,6 +24,6 @@ return new class extends Migration */ public function down(): void { - Schema::dropIfExists('sort_sets'); + Schema::dropIfExists('sort_rules'); } }; diff --git a/database/migrations/2025_02_05_150842_add_sort_set_id_to_books.php b/database/migrations/2025_02_05_150842_add_sort_rule_id_to_books.php similarity index 79% rename from database/migrations/2025_02_05_150842_add_sort_set_id_to_books.php rename to database/migrations/2025_02_05_150842_add_sort_rule_id_to_books.php index c0b32c552..106db05ca 100644 --- a/database/migrations/2025_02_05_150842_add_sort_set_id_to_books.php +++ b/database/migrations/2025_02_05_150842_add_sort_rule_id_to_books.php @@ -12,7 +12,7 @@ return new class extends Migration public function up(): void { Schema::table('books', function (Blueprint $table) { - $table->unsignedInteger('sort_set_id')->nullable()->default(null); + $table->unsignedInteger('sort_rule_id')->nullable()->default(null); }); } @@ -22,7 +22,7 @@ return new class extends Migration public function down(): void { Schema::table('books', function (Blueprint $table) { - $table->dropColumn('sort_set_id'); + $table->dropColumn('sort_rule_id'); }); } }; diff --git a/lang/en/entities.php b/lang/en/entities.php index 28a209fa2..a74785eaa 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -166,7 +166,7 @@ return [ 'books_search_this' => 'Search this book', 'books_navigation' => 'Book Navigation', 'books_sort' => 'Sort Book Contents', - 'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books. Optionally an auto sort option can be set to automatically sort this book\'s contents upon changes.', + 'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books. Optionally an auto sort rule can be set to automatically sort this book\'s contents upon changes.', 'books_sort_auto_sort' => 'Auto Sort Option', 'books_sort_auto_sort_active' => 'Auto Sort Active: :sortName', 'books_sort_named' => 'Sort Book :bookName', diff --git a/lang/en/settings.php b/lang/en/settings.php index 344c186cb..098479f3b 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -77,32 +77,32 @@ return [ // Sorting Settings 'sorting' => 'Sorting', 'sorting_book_default' => 'Default Book Sort', - 'sorting_book_default_desc' => 'Select the default sort set to apply to new books. This won\'t affect existing books, and can be overridden per-book.', - 'sorting_sets' => 'Sort Sets', - 'sorting_sets_desc' => 'These are predefined sorting operations which can be applied to content in the system.', - 'sort_set_assigned_to_x_books' => 'Assigned to :count Book|Assigned to :count Books', - 'sort_set_create' => 'Create Sort Set', - 'sort_set_edit' => 'Edit Sort Set', - 'sort_set_delete' => 'Delete Sort Set', - 'sort_set_delete_desc' => 'Remove this sort set from the system. Books using this sort will revert to manual sorting.', - 'sort_set_delete_warn_books' => 'This sort set is currently used on :count book(s). Are you sure you want to delete this?', - 'sort_set_delete_warn_default' => 'This sort set is currently used as the default for books. Are you sure you want to delete this?', - 'sort_set_details' => 'Sort Set Details', - 'sort_set_details_desc' => 'Set a name for this sort set, which will appear in lists when users are selecting a sort.', - 'sort_set_operations' => 'Sort Operations', - 'sort_set_operations_desc' => 'Configure the sort actions to be performed in this set by moving them from the list of available operations. Upon use, the operations will be applied in order, from top to bottom.', - 'sort_set_available_operations' => 'Available Operations', - 'sort_set_available_operations_empty' => 'No operations remaining', - 'sort_set_configured_operations' => 'Configured Operations', - 'sort_set_configured_operations_empty' => 'Drag/add operations from the "Available Operations" list', - 'sort_set_op_asc' => '(Asc)', - 'sort_set_op_desc' => '(Desc)', - 'sort_set_op_name' => 'Name - Alphabetical', - 'sort_set_op_name_numeric' => 'Name - Numeric', - 'sort_set_op_created_date' => 'Created Date', - 'sort_set_op_updated_date' => 'Updated Date', - 'sort_set_op_chapters_first' => 'Chapters First', - 'sort_set_op_chapters_last' => 'Chapters Last', + 'sorting_book_default_desc' => 'Select the default sort role to apply to new books. This won\'t affect existing books, and can be overridden per-book.', + 'sorting_rules' => 'Sort Rules', + 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', + 'sort_rule_assigned_to_x_books' => 'Assigned to :count Book|Assigned to :count Books', + 'sort_rule_create' => 'Create Sort Rule', + 'sort_rule_edit' => 'Edit Sort Rule', + 'sort_rule_delete' => 'Delete Sort Rule', + 'sort_rule_delete_desc' => 'Remove this sort rule from the system. Books using this sort will revert to manual sorting.', + 'sort_rule_delete_warn_books' => 'This sort rule is currently used on :count book(s). Are you sure you want to delete this?', + 'sort_rule_delete_warn_default' => 'This sort rule is currently used as the default for books. Are you sure you want to delete this?', + 'sort_rule_details' => 'Sort Rule Details', + 'sort_rule_details_desc' => 'Set a name for this sort rule, which will appear in lists when users are selecting a sort.', + 'sort_rule_operations' => 'Sort Operations', + 'sort_rule_operations_desc' => 'Configure the sort actions to be performed in this set by moving them from the list of available operations. Upon use, the operations will be applied in order, from top to bottom.', + 'sort_rule_available_operations' => 'Available Operations', + 'sort_rule_available_operations_empty' => 'No operations remaining', + 'sort_rule_configured_operations' => 'Configured Operations', + 'sort_rule_configured_operations_empty' => 'Drag/add operations from the "Available Operations" list', + 'sort_rule_op_asc' => '(Asc)', + 'sort_rule_op_desc' => '(Desc)', + 'sort_rule_op_name' => 'Name - Alphabetical', + 'sort_rule_op_name_numeric' => 'Name - Numeric', + 'sort_rule_op_created_date' => 'Created Date', + 'sort_rule_op_updated_date' => 'Updated Date', + 'sort_rule_op_chapters_first' => 'Chapters First', + 'sort_rule_op_chapters_last' => 'Chapters Last', // Maintenance settings 'maint' => 'Maintenance', diff --git a/resources/js/components/index.ts b/resources/js/components/index.ts index affa25fcf..10b8025db 100644 --- a/resources/js/components/index.ts +++ b/resources/js/components/index.ts @@ -50,7 +50,7 @@ export {ShelfSort} from './shelf-sort'; export {Shortcuts} from './shortcuts'; export {ShortcutInput} from './shortcut-input'; export {SortableList} from './sortable-list'; -export {SortSetManager} from './sort-set-manager' +export {SortRuleManager} from './sort-rule-manager' export {SubmitOnChange} from './submit-on-change'; export {Tabs} from './tabs'; export {TagManager} from './tag-manager'; diff --git a/resources/js/components/sort-set-manager.ts b/resources/js/components/sort-rule-manager.ts similarity index 93% rename from resources/js/components/sort-set-manager.ts rename to resources/js/components/sort-rule-manager.ts index c35ad41fe..ff08f4ab8 100644 --- a/resources/js/components/sort-set-manager.ts +++ b/resources/js/components/sort-rule-manager.ts @@ -3,7 +3,7 @@ import Sortable from "sortablejs"; import {buildListActions, sortActionClickListener} from "../services/dual-lists"; -export class SortSetManager extends Component { +export class SortRuleManager extends Component { protected input!: HTMLInputElement; protected configuredList!: HTMLElement; @@ -25,7 +25,7 @@ export class SortSetManager extends Component { const scrollBoxes = [this.configuredList, this.availableList]; for (const scrollBox of scrollBoxes) { new Sortable(scrollBox, { - group: 'sort-set-operations', + group: 'sort-rule-operations', ghostClass: 'primary-background-light', handle: '.handle', animation: 150, diff --git a/resources/views/books/parts/sort-box.blade.php b/resources/views/books/parts/sort-box.blade.php index 232616168..6fdb1819e 100644 --- a/resources/views/books/parts/sort-box.blade.php +++ b/resources/views/books/parts/sort-box.blade.php @@ -9,18 +9,23 @@ {{ $book->name }}
    - @if($book->sortSet) - @icon('auto-sort') + @if($book->sortRule) + @icon('auto-sort') @endif
    - - - - - + + + + +
      diff --git a/resources/views/books/sort.blade.php b/resources/views/books/sort.blade.php index 3c59ac1e0..e090708b1 100644 --- a/resources/views/books/sort.blade.php +++ b/resources/views/books/sort.blade.php @@ -23,19 +23,21 @@

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

      @php - $autoSortVal = intval(old('auto-sort') ?? $book->sort_set_id ?? 0); + $autoSortVal = intval(old('auto-sort') ?? $book->sort_rule_id ?? 0); @endphp diff --git a/resources/views/settings/categories/sorting.blade.php b/resources/views/settings/categories/sorting.blade.php index 6a52873e6..9d1d9814b 100644 --- a/resources/views/settings/categories/sorting.blade.php +++ b/resources/views/settings/categories/sorting.blade.php @@ -1,7 +1,7 @@ @extends('settings.layout') @php - $sortSets = \BookStack\Sorting\SortSet::allByName(); + $sortRules = \BookStack\Sorting\SortRule::allByName(); @endphp @section('card') @@ -23,7 +23,7 @@ - @foreach($sortSets as $set) + @foreach($sortRules as $set)