]> BookStack Code Mirror - bookstack/commitdiff
Merge branch 'inv-hareesh/development' into search_index_updates
authorDan Brown <redacted>
Fri, 14 Feb 2025 19:25:59 +0000 (19:25 +0000)
committerDan Brown <redacted>
Fri, 14 Feb 2025 19:25:59 +0000 (19:25 +0000)
62 files changed:
app/Activity/ActivityType.php
app/Activity/Controllers/AuditLogController.php
app/App/helpers.php
app/Console/Commands/AssignSortRuleCommand.php [new file with mode: 0644]
app/Entities/Models/Book.php
app/Entities/Repos/BaseRepo.php
app/Entities/Repos/BookRepo.php
app/Entities/Repos/ChapterRepo.php
app/Entities/Repos/PageRepo.php
app/Entities/Tools/BookContents.php
app/Search/SearchIndex.php
app/Search/SearchOptions.php
app/Search/SearchTextTokenizer.php [new file with mode: 0644]
app/Sorting/BookSortController.php [moved from app/Entities/Controllers/BookSortController.php with 54% similarity]
app/Sorting/BookSortMap.php [moved from app/Entities/Tools/BookSortMap.php with 96% similarity]
app/Sorting/BookSortMapItem.php [moved from app/Entities/Tools/BookSortMapItem.php with 94% similarity]
app/Sorting/BookSorter.php [new file with mode: 0644]
app/Sorting/SortRule.php [new file with mode: 0644]
app/Sorting/SortRuleController.php [new file with mode: 0644]
app/Sorting/SortRuleOperation.php [new file with mode: 0644]
app/Sorting/SortSetOperationComparisons.php [new file with mode: 0644]
app/Sorting/SortUrl.php [new file with mode: 0644]
database/factories/Entities/Models/BookFactory.php
database/factories/Sorting/SortRuleFactory.php [new file with mode: 0644]
database/migrations/2025_01_29_180933_create_sort_rules_table.php [new file with mode: 0644]
database/migrations/2025_02_05_150842_add_sort_rule_id_to_books.php [new file with mode: 0644]
lang/en/activities.php
lang/en/entities.php
lang/en/settings.php
package-lock.json
package.json
readme.md
resources/icons/auto-sort.svg [new file with mode: 0644]
resources/js/components/index.ts
resources/js/components/shelf-sort.js
resources/js/components/sort-rule-manager.ts [new file with mode: 0644]
resources/js/services/dual-lists.ts [new file with mode: 0644]
resources/sass/_components.scss
resources/sass/_lists.scss
resources/views/books/index.blade.php
resources/views/books/parts/sort-box.blade.php
resources/views/books/sort.blade.php
resources/views/settings/audit.blade.php
resources/views/settings/categories/sorting.blade.php [new file with mode: 0644]
resources/views/settings/layout.blade.php
resources/views/settings/sort-rules/create.blade.php [new file with mode: 0644]
resources/views/settings/sort-rules/edit.blade.php [new file with mode: 0644]
resources/views/settings/sort-rules/parts/form.blade.php [new file with mode: 0644]
resources/views/settings/sort-rules/parts/operation.blade.php [new file with mode: 0644]
resources/views/settings/sort-rules/parts/sort-rule-list-item.blade.php [new file with mode: 0644]
resources/views/shelves/parts/form.blade.php
routes/web.php
tests/Commands/AssignSortRuleCommandTest.php [new file with mode: 0644]
tests/Entity/PageTest.php
tests/Search/EntitySearchTest.php [moved from tests/Entity/EntitySearchTest.php with 74% similarity]
tests/Search/SearchIndexingTest.php [new file with mode: 0644]
tests/Search/SearchOptionsTest.php [moved from tests/Entity/SearchOptionsTest.php with 99% similarity]
tests/Search/SiblingSearchTest.php [new file with mode: 0644]
tests/Sorting/BookSortTest.php [moved from tests/Entity/SortTest.php with 51% similarity]
tests/Sorting/MoveTest.php [new file with mode: 0644]
tests/Sorting/SortRuleTest.php [new file with mode: 0644]
version

index 5ec9b9cf0dc7f452040b2ca3278c24d0ed0c9855..a7f129f71d4317055f23062c22f05363beef5168 100644 (file)
@@ -71,6 +71,10 @@ class ActivityType
     const IMPORT_RUN = 'import_run';
     const IMPORT_DELETE = 'import_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.
      */
index 641106d7f450697b1c189b128264feecc46c9b06..66ca301977ce35300cc26e0d196a5586e5975e5f 100644 (file)
@@ -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')))
         ]);
     }
 }
index 941c267d6cd1950c183f65303915996a35917c89..204b3f06a72494433cbfea8cb9a8c7ffed6126e4 100644 (file)
@@ -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/Console/Commands/AssignSortRuleCommand.php b/app/Console/Commands/AssignSortRuleCommand.php
new file mode 100644 (file)
index 0000000..c438d07
--- /dev/null
@@ -0,0 +1,99 @@
+<?php
+
+namespace BookStack\Console\Commands;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Sorting\BookSorter;
+use BookStack\Sorting\SortRule;
+use Illuminate\Console\Command;
+
+class AssignSortRuleCommand extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'bookstack:assign-sort-rule
+                            {sort-rule=0: ID of the sort rule to apply}
+                            {--all-books : Apply to all books in the system}
+                            {--books-without-sort : Apply to only books without a sort rule already assigned}
+                            {--books-with-sort= : Apply to only books with the sort rule of given id}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Assign a sort rule to content in the system';
+
+    /**
+     * Execute the console command.
+     */
+    public function handle(BookSorter $sorter): int
+    {
+        $sortRuleId = intval($this->argument('sort-rule')) ?? 0;
+        if ($sortRuleId === 0) {
+            return $this->listSortRules();
+        }
+
+        $rule = SortRule::query()->find($sortRuleId);
+        if ($this->option('all-books')) {
+            $query = Book::query();
+        } else if ($this->option('books-without-sort')) {
+            $query = Book::query()->whereNull('sort_rule_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_rule_id', $sortId);
+        } else {
+            $this->error("No option provided to specify target. Run with the -h option to see all available options.");
+            return 1;
+        }
+
+        if (!$rule) {
+            $this->error("Sort rule of provided id {$sortRuleId} not found!");
+            return 1;
+        }
+
+        $count = $query->clone()->count();
+        $this->warn("This will apply sort rule [{$rule->id}: {$rule->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 ($rule, $sorter, $count, &$processed) {
+            $max = min($count, ($processed + 10));
+            $this->info("Applying to {$processed}-{$max} of {$count} books");
+            foreach ($books as $book) {
+                $book->sort_rule_id = $rule->id;
+                $book->save();
+                $sorter->runBookAutoSort($book);
+            }
+            $processed = $max;
+        });
+
+        $this->info("Sort applied to {$processed} book(s)!");
+
+        return 0;
+    }
+
+    protected function listSortRules(): int
+    {
+
+        $rules = SortRule::query()->orderBy('id', 'asc')->get();
+        $this->error("Sort rule ID required!");
+        $this->warn("\nAvailable sort rules:");
+        foreach ($rules as $rule) {
+            $this->info("{$rule->id}: {$rule->name}");
+        }
+
+        return 1;
+    }
+}
index c1644dcf5fb10afa03ebff7d38142e5162785c9d..ede4fc7d5a670e5a1f5af8e36475b598e837e389 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace BookStack\Entities\Models;
 
+use BookStack\Sorting\SortRule;
 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_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 ?SortRule                                 $sortRule
  */
 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 sortRule(): BelongsTo
+    {
+        return $this->belongsTo(SortRule::class);
+    }
+
     /**
      * Get all pages within this book.
      */
index 033350743e0dd824bc259254d67e80edee3c187c..151d5b0555bbc5fc884c23b8612d10e73884b36d 100644 (file)
@@ -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))) {
index 19d159eb1e7e9cc539a86f6031ef8004a20e70ef..92e6a81c337fcc45dbe7d15c477082454526adf2 100644 (file)
@@ -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\SortRule;
 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 && SortRule::query()->find($defaultBookSortSetting)) {
+            $book->sort_rule_id = $defaultBookSortSetting;
+            $book->save();
+        }
+
         return $book;
     }
 
index 17cbccd4133676bc44aac8aaf844478ba771e5c7..fdf2de4e20235b81d39d3fed3ed7b837cc473cc4 100644 (file)
@@ -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;
     }
 }
index 68b1c398f801d22ac3d74211f2e10714505083f4..c3be6d826a26dd87a3eea69aa9646abadcd664ca 100644 (file)
@@ -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;
     }
 
index 7fa2134b7fad60627393c334781d512542133298..7dd3f3e11adade81eea09005082948c1d3c42439 100644 (file)
@@ -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<string, Entity> $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<string, Entity>
-     */
-    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;
-    }
 }
index e10219e2d2f4a63521a86cf52a6b0672a93beb0e..36f71f6ccc7759cd09d3de29e8464a187c1507a6 100644 (file)
@@ -16,7 +16,13 @@ class SearchIndex
     /**
      * A list of delimiter characters used to break-up parsed content into terms for indexing.
      */
-    public static string $delimiters = " \n\t.,!?:;()[]{}<>`'\"«»";
+    public static string $delimiters = " \n\t.-,!?:;()[]{}<>`'\"«»";
+
+    /**
+     * A list of delimiter which could be commonly used within a single term and also indicate a break between terms.
+     * The indexer will index the full term with these delimiters, plus the terms split via these delimiters.
+     */
+    public static string $softDelimiters = ".-";
 
     public function __construct(
         protected EntityProvider $entityProvider
@@ -196,15 +202,36 @@ class SearchIndex
     protected function textToTermCountMap(string $text): array
     {
         $tokenMap = []; // {TextToken => OccurrenceCount}
-        $splitChars = static::$delimiters;
-        $token = strtok($text, $splitChars);
+        $softDelims = static::$softDelimiters;
+        $tokenizer = new SearchTextTokenizer($text, static::$delimiters);
+        $extendedToken = '';
+        $extendedLen = 0;
+
+        $token = $tokenizer->next();
 
         while ($token !== false) {
-            if (!isset($tokenMap[$token])) {
-                $tokenMap[$token] = 0;
+            $delim = $tokenizer->previousDelimiter();
+
+            if ($delim && str_contains($softDelims, $delim) && $token !== '') {
+                $extendedToken .= $delim . $token;
+                $extendedLen++;
+            } else {
+                if ($extendedLen > 1) {
+                    $tokenMap[$extendedToken] = ($tokenMap[$extendedToken] ?? 0) + 1;
+                }
+                $extendedToken = $token;
+                $extendedLen = 1;
             }
-            $tokenMap[$token]++;
-            $token = strtok($splitChars);
+
+            if ($token) {
+                $tokenMap[$token] = ($tokenMap[$token] ?? 0) + 1;
+            }
+
+            $token = $tokenizer->next();
+        }
+
+        if ($extendedLen > 1) {
+            $tokenMap[$extendedToken] = ($tokenMap[$extendedToken] ?? 0) + 1;
         }
 
         return $tokenMap;
index a6f82029920ee7dd0a0ce1de8416a3498764f11b..bf527d9c3058c1a87f988212da0d086c63fe66e6 100644 (file)
@@ -181,7 +181,7 @@ class SearchOptions
     protected static function parseStandardTermString(string $termString): array
     {
         $terms = explode(' ', $termString);
-        $indexDelimiters = SearchIndex::$delimiters;
+        $indexDelimiters = implode('', array_diff(str_split(SearchIndex::$delimiters), str_split(SearchIndex::$softDelimiters)));
         $parsed = [
             'terms'  => [],
             'exacts' => [],
diff --git a/app/Search/SearchTextTokenizer.php b/app/Search/SearchTextTokenizer.php
new file mode 100644 (file)
index 0000000..f43fd56
--- /dev/null
@@ -0,0 +1,70 @@
+<?php
+
+namespace BookStack\Search;
+
+/**
+ * A custom text tokenizer which records & provides insight needed for our search indexing.
+ * We used to use basic strtok() but this class does the following which that lacked:
+ * - Tracks and provides the current/previous delimiter that we've stopped at.
+ * - Returns empty tokens upon parsing a delimiter.
+ */
+class SearchTextTokenizer
+{
+    protected int $currentIndex = 0;
+    protected int $length;
+    protected string $currentDelimiter = '';
+    protected string $previousDelimiter = '';
+
+    public function __construct(
+        protected string $text,
+        protected string $delimiters = ' '
+    ) {
+        $this->length = strlen($this->text);
+    }
+
+    /**
+     * Get the current delimiter to be found.
+     */
+    public function currentDelimiter(): string
+    {
+        return $this->currentDelimiter;
+    }
+
+    /**
+     * Get the previous delimiter found.
+     */
+    public function previousDelimiter(): string
+    {
+        return $this->previousDelimiter;
+    }
+
+    /**
+     * Get the next token between delimiters.
+     * Returns false if there's no further tokens.
+     */
+    public function next(): string|false
+    {
+        $token = '';
+
+        for ($i = $this->currentIndex; $i < $this->length; $i++) {
+            $char = $this->text[$i];
+            if (str_contains($this->delimiters, $char)) {
+                $this->previousDelimiter = $this->currentDelimiter;
+                $this->currentDelimiter = $char;
+                $this->currentIndex = $i + 1;
+                return $token;
+            }
+
+            $token .= $char;
+        }
+
+        if ($token) {
+            $this->currentIndex = $this->length;
+            $this->previousDelimiter = $this->currentDelimiter;
+            $this->currentDelimiter = '';
+            return $token;
+        }
+
+        return false;
+    }
+}
similarity index 54%
rename from app/Entities/Controllers/BookSortController.php
rename to app/Sorting/BookSortController.php
index 5aefc583279dbdf7f97cb42122f1af52cf6489a5..479d1972440dceca53e3c7291fa696d54c92b65b 100644 (file)
@@ -1,11 +1,10 @@
 <?php
 
-namespace BookStack\Entities\Controllers;
+namespace BookStack\Sorting;
 
 use BookStack\Activity\ActivityType;
 use BookStack\Entities\Queries\BookQueries;
 use BookStack\Entities\Tools\BookContents;
-use BookStack\Entities\Tools\BookSortMap;
 use BookStack\Facades\Activity;
 use BookStack\Http\Controller;
 use Illuminate\Http\Request;
@@ -45,25 +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, string $bookSlug)
+    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);
 
-        $sortMap = BookSortMap::fromJson($request->get('sort-tree'));
-        $bookContents = new BookContents($book);
-        $booksInvolved = $bookContents->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;
+                }
+            }
+        }
 
-        // 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 && SortRule::query()->find($sortSetId) === null) {
+                $sortSetId = null;
+            }
+            $book->sort_rule_id = $sortSetId;
+            $book->save();
+            $sorter->runBookAutoSort($book);
+            if (!$loggedActivityForBook) {
+                Activity::add(ActivityType::BOOK_SORT, $book);
+            }
         }
 
         return redirect($book->getUrl());
similarity index 96%
rename from app/Entities/Tools/BookSortMap.php
rename to app/Sorting/BookSortMap.php
index ff1ec767f784458e58c867f4a845acd8efdfdbf5..96c9d342a27b9a419e65a20f455e5a2e1e124fee 100644 (file)
@@ -1,6 +1,6 @@
 <?php
 
-namespace BookStack\Entities\Tools;
+namespace BookStack\Sorting;
 
 class BookSortMap
 {
similarity index 94%
rename from app/Entities/Tools/BookSortMapItem.php
rename to app/Sorting/BookSortMapItem.php
index f76d87f23669001369630bc7d588f91c1923e819..8f517edd6ff5cc31bc4bb20407f67a3a27d795cb 100644 (file)
@@ -1,6 +1,6 @@
 <?php
 
-namespace BookStack\Entities\Tools;
+namespace BookStack\Sorting;
 
 class BookSortMapItem
 {
diff --git a/app/Sorting/BookSorter.php b/app/Sorting/BookSorter.php
new file mode 100644 (file)
index 0000000..6710f07
--- /dev/null
@@ -0,0 +1,284 @@
+<?php
+
+namespace BookStack\Sorting;
+
+use BookStack\App\Model;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\BookChild;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Queries\EntityQueries;
+
+class BookSorter
+{
+    public function __construct(
+        protected EntityQueries $queries,
+    ) {
+    }
+
+    public function runBookAutoSortForAllWithSet(SortRule $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
+     * managed by admins so considered permitted if existing and assigned.
+     */
+    public function runBookAutoSort(Book $book): void
+    {
+        $set = $book->sortRule;
+        if (!$set) {
+            return;
+        }
+
+        $sortFunctions = array_map(function (SortRuleOperation $op) {
+            return $op->getSortFunction();
+        }, $set->getOperations());
+
+        $chapters = $book->chapters()
+            ->with('pages:id,name,priority,created_at,updated_at,chapter_id')
+            ->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::withoutTimestamps(fn () => $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::withoutTimestamps(fn () => $page->save());
+            }
+        }
+    }
+
+
+    /**
+     * 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<string, Entity> $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::withoutTimestamps(fn () => $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<string, Entity>
+     */
+    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/Sorting/SortRule.php b/app/Sorting/SortRule.php
new file mode 100644 (file)
index 0000000..45e5514
--- /dev/null
@@ -0,0 +1,63 @@
+<?php
+
+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\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+
+/**
+ * @property int $id
+ * @property string $name
+ * @property string $sequence
+ * @property Carbon $created_at
+ * @property Carbon $updated_at
+ */
+class SortRule extends Model implements Loggable
+{
+    use HasFactory;
+
+    /**
+     * @return SortRuleOperation[]
+     */
+    public function getOperations(): array
+    {
+        return SortRuleOperation::fromSequence($this->sequence);
+    }
+
+    /**
+     * @param SortRuleOperation[] $options
+     */
+    public function setOperations(array $options): void
+    {
+        $values = array_map(fn (SortRuleOperation $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/rules/{$this->id}");
+    }
+
+    public function books(): HasMany
+    {
+        return $this->hasMany(Book::class);
+    }
+
+    public static function allByName(): Collection
+    {
+        return static::query()
+            ->withCount('books')
+            ->orderBy('name', 'asc')
+            ->get();
+    }
+}
diff --git a/app/Sorting/SortRuleController.php b/app/Sorting/SortRuleController.php
new file mode 100644 (file)
index 0000000..96b8e8e
--- /dev/null
@@ -0,0 +1,114 @@
+<?php
+
+namespace BookStack\Sorting;
+
+use BookStack\Activity\ActivityType;
+use BookStack\Http\Controller;
+use Illuminate\Http\Request;
+
+class SortRuleController extends Controller
+{
+    public function __construct()
+    {
+        $this->middleware('can:settings-manage');
+    }
+
+    public function create()
+    {
+        $this->setPageTitle(trans('settings.sort_rule_create'));
+
+        return view('settings.sort-rules.create');
+    }
+
+    public function store(Request $request)
+    {
+        $this->validate($request, [
+            'name' => ['required', 'string', 'min:1', 'max:200'],
+            'sequence' => ['required', 'string', 'min:1'],
+        ]);
+
+        $operations = SortRuleOperation::fromSequence($request->input('sequence'));
+        if (count($operations) === 0) {
+            return redirect()->withInput()->withErrors(['sequence' => 'No operations set.']);
+        }
+
+        $rule = new SortRule();
+        $rule->name = $request->input('name');
+        $rule->setOperations($operations);
+        $rule->save();
+
+        $this->logActivity(ActivityType::SORT_RULE_CREATE, $rule);
+
+        return redirect('/settings/sorting');
+    }
+
+    public function edit(string $id)
+    {
+        $rule = SortRule::query()->findOrFail($id);
+
+        $this->setPageTitle(trans('settings.sort_rule_edit'));
+
+        return view('settings.sort-rules.edit', ['rule' => $rule]);
+    }
+
+    public function update(string $id, Request $request, BookSorter $bookSorter)
+    {
+        $this->validate($request, [
+            'name' => ['required', 'string', 'min:1', 'max:200'],
+            'sequence' => ['required', 'string', 'min:1'],
+        ]);
+
+        $rule = SortRule::query()->findOrFail($id);
+        $operations = SortRuleOperation::fromSequence($request->input('sequence'));
+        if (count($operations) === 0) {
+            return redirect($rule->getUrl())->withInput()->withErrors(['sequence' => 'No operations set.']);
+        }
+
+        $rule->name = $request->input('name');
+        $rule->setOperations($operations);
+        $changedSequence = $rule->isDirty('sequence');
+        $rule->save();
+
+        $this->logActivity(ActivityType::SORT_RULE_UPDATE, $rule);
+
+        if ($changedSequence) {
+            $bookSorter->runBookAutoSortForAllWithSet($rule);
+        }
+
+        return redirect('/settings/sorting');
+    }
+
+    public function destroy(string $id, Request $request)
+    {
+        $rule = SortRule::query()->findOrFail($id);
+        $confirmed = $request->input('confirm') === 'true';
+        $booksAssigned = $rule->books()->count();
+        $warnings = [];
+
+        if ($booksAssigned > 0) {
+            if ($confirmed) {
+                $rule->books()->update(['sort_rule_id' => null]);
+            } else {
+                $warnings[] = trans('settings.sort_rule_delete_warn_books', ['count' => $booksAssigned]);
+            }
+        }
+
+        $defaultBookSortSetting = intval(setting('sorting-book-default', '0'));
+        if ($defaultBookSortSetting === intval($id)) {
+            if ($confirmed) {
+                setting()->remove('sorting-book-default');
+            } else {
+                $warnings[] = trans('settings.sort_rule_delete_warn_default');
+            }
+        }
+
+        if (count($warnings) > 0) {
+            return redirect($rule->getUrl() . '#delete')->withErrors(['delete' => $warnings]);
+        }
+
+        $rule->delete();
+        $this->logActivity(ActivityType::SORT_RULE_DELETE, $rule);
+
+        return redirect('/settings/sorting');
+    }
+}
diff --git a/app/Sorting/SortRuleOperation.php b/app/Sorting/SortRuleOperation.php
new file mode 100644 (file)
index 0000000..0d8ff23
--- /dev/null
@@ -0,0 +1,68 @@
+<?php
+
+namespace BookStack\Sorting;
+
+use Closure;
+use Illuminate\Support\Str;
+
+enum SortRuleOperation: string
+{
+    case NameAsc = 'name_asc';
+    case NameDesc = 'name_desc';
+    case NameNumericAsc = 'name_numeric_asc';
+    case CreatedDateAsc = 'created_date_asc';
+    case CreatedDateDesc = 'created_date_desc';
+    case UpdateDateAsc = 'updated_date_asc';
+    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_rule_op_asc');
+        } elseif (str_ends_with($key, '_desc')) {
+            $key = substr($key, 0, -5);
+            $label = trans('settings.sort_rule_op_desc');
+        }
+
+        $label = trans('settings.sort_rule_op_' . $key) . ' ' . $label;
+        return trim($label);
+    }
+
+    public function getSortFunction(): callable
+    {
+        $camelValue = Str::camel($this->value);
+        return SortSetOperationComparisons::$camelValue(...);
+    }
+
+    /**
+     * @return SortRuleOperation[]
+     */
+    public static function allExcluding(array $operations): array
+    {
+        $all = SortRuleOperation::cases();
+        $filtered = array_filter($all, function (SortRuleOperation $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 SortRuleOperation[]
+     */
+    public static function fromSequence(string $sequence): array
+    {
+        $strOptions = explode(',', $sequence);
+        $options = array_map(fn ($val) => SortRuleOperation::tryFrom($val), $strOptions);
+        return array_filter($options);
+    }
+}
diff --git a/app/Sorting/SortSetOperationComparisons.php b/app/Sorting/SortSetOperationComparisons.php
new file mode 100644 (file)
index 0000000..e1c3e62
--- /dev/null
@@ -0,0 +1,69 @@
+<?php
+
+namespace BookStack\Sorting;
+
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Entity;
+
+/**
+ * Sort comparison function for each of the possible SortSetOperation values.
+ * Method names should be camelCase names for the SortSetOperation enum value.
+ * TODO - Test to cover each SortSetOperation enum value is covered.
+ */
+class SortSetOperationComparisons
+{
+    public static function nameAsc(Entity $a, Entity $b): int
+    {
+        return $a->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);
+    }
+}
diff --git a/app/Sorting/SortUrl.php b/app/Sorting/SortUrl.php
new file mode 100644 (file)
index 0000000..f01df2c
--- /dev/null
@@ -0,0 +1,49 @@
+<?php
+
+namespace BookStack\Sorting;
+
+/**
+ * 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.
+ */
+class SortUrl
+{
+    public function __construct(
+        protected string $path,
+        protected array $data,
+        protected array $overrideData = []
+    ) {
+    }
+
+    public function withOverrideData(array $overrideData = []): self
+    {
+        return new self($this->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));
+    }
+}
index 9cb8e971c6e14e16542ea4d203da45a4d3511ff5..48d43d7a8b7921124a47be8ee42b63c09d2069f8 100644 (file)
@@ -26,7 +26,9 @@ class BookFactory extends Factory
             'name'        => $this->faker->sentence(),
             'slug'        => Str::random(10),
             'description' => $description,
-            'description_html' => '<p>' . e($description) . '</p>'
+            'description_html' => '<p>' . e($description) . '</p>',
+            'sort_rule_id' => null,
+            'default_template_id' => null,
         ];
     }
 }
diff --git a/database/factories/Sorting/SortRuleFactory.php b/database/factories/Sorting/SortRuleFactory.php
new file mode 100644 (file)
index 0000000..dafe8c3
--- /dev/null
@@ -0,0 +1,30 @@
+<?php
+
+namespace Database\Factories\Sorting;
+
+use BookStack\Sorting\SortRule;
+use BookStack\Sorting\SortRuleOperation;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+class SortRuleFactory extends Factory
+{
+    /**
+     * The name of the factory's corresponding model.
+     *
+     * @var string
+     */
+    protected $model = SortRule::class;
+
+    /**
+     * Define the model's default state.
+     */
+    public function definition(): array
+    {
+        $cases = SortRuleOperation::cases();
+        $op = $cases[array_rand($cases)];
+        return [
+            'name' => $op->name . ' Sort',
+            'sequence' => $op->value,
+        ];
+    }
+}
diff --git a/database/migrations/2025_01_29_180933_create_sort_rules_table.php b/database/migrations/2025_01_29_180933_create_sort_rules_table.php
new file mode 100644 (file)
index 0000000..37d20dd
--- /dev/null
@@ -0,0 +1,29 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('sort_rules', function (Blueprint $table) {
+            $table->increments('id');
+            $table->string('name');
+            $table->text('sequence');
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('sort_rules');
+    }
+};
diff --git a/database/migrations/2025_02_05_150842_add_sort_rule_id_to_books.php b/database/migrations/2025_02_05_150842_add_sort_rule_id_to_books.php
new file mode 100644 (file)
index 0000000..106db05
--- /dev/null
@@ -0,0 +1,28 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('books', function (Blueprint $table) {
+            $table->unsignedInteger('sort_rule_id')->nullable()->default(null);
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::table('books', function (Blueprint $table) {
+            $table->dropColumn('sort_rule_id');
+        });
+    }
+};
index 7c3454d41ca287406316a6eb3f528250c5361c3d..67df53e369a1d3dcbaa3417b04a8ddc9ead23984 100644 (file)
@@ -127,6 +127,14 @@ return [
     'comment_update'              => 'updated comment',
     'comment_delete'              => 'deleted comment',
 
+    // Sort Rules
+    'sort_rule_create' => 'created sort rule',
+    'sort_rule_create_notification' => 'Sort rule successfully created',
+    'sort_rule_update' => 'updated sort rule',
+    'sort_rule_update_notification' => 'Sort rule successfully update',
+    'sort_rule_delete' => 'deleted sort rule',
+    'sort_rule_delete_notification' => 'Sort rule successfully deleted',
+
     // Other
     'permissions_update'          => 'updated permissions',
 ];
index 26a563a7eb534388afd1e89fce92ea91e1a8a5c7..a74785eaacde3ca5316d7153e1e6b139dc603829 100644 (file)
@@ -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 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',
     'books_sort_name' => 'Sort by Name',
     'books_sort_created' => 'Sort by Created Date',
index c0b6b692a57b9b5eaf7ce17d51a0baf0ae6380ab..098479f3be0eddb36598123d69078f8ee6682b67 100644 (file)
@@ -74,6 +74,36 @@ 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. <br> 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 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',
     'maint_image_cleanup' => 'Cleanup Images',
index 1912106c2b7b9c92930214f03672418279cee684..44a735d2f0c3a736b6b556b04b73d3b65b240048 100644 (file)
@@ -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",
         "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",
index 08af25d140f2406a6afbd831384ef0da22a473ef..4571ea77d76032705a17ede6d9437fae0e309e38 100644 (file)
@@ -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",
index 339c7cbd4aeeb8e4a49c07509e2b0cb3a1d65519..d6641a53bb3d511b715fbad2208a39668c3e76a1 100644 (file)
--- a/readme.md
+++ b/readme.md
@@ -48,7 +48,7 @@ Big thanks to these companies for supporting the project.
 #### Gold Sponsor
 
 <table><tbody><tr>
-<td align="center"><a href="https://www.federated.computer/bookstack/" target="_blank">
+<td align="center"><a href="https://www.federated.computer/bookstack-wiki" target="_blank">
     <img width="480" src="https://www.bookstackapp.com/images/sponsors/federated-computer.png" alt="Federated.computer">
 </a></td>
 </tr></tbody></table>
diff --git a/resources/icons/auto-sort.svg b/resources/icons/auto-sort.svg
new file mode 100644 (file)
index 0000000..c3cb2f5
--- /dev/null
@@ -0,0 +1 @@
+<svg version="1.1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m3 18h6v-2h-6zm0-12v2h18v-2zm0 7h11v-2h-11z"/><g transform="matrix(.024132 0 0 .024132 3.6253 26.687)"><path d="m602.72-360v-146.6h-58.639l117.28-205.24v146.6h58.639z" stroke-width=".73298"/></g></svg>
index 12c991a51d89b17c8397253ac2221bdd3cfc5328..10b8025db630137f4c4512edba8126e5780d8ec8 100644 (file)
@@ -50,6 +50,7 @@ export {ShelfSort} from './shelf-sort';
 export {Shortcuts} from './shortcuts';
 export {ShortcutInput} from './shortcut-input';
 export {SortableList} from './sortable-list';
+export {SortRuleManager} from './sort-rule-manager'
 export {SubmitOnChange} from './submit-on-change';
 export {Tabs} from './tabs';
 export {TagManager} from './tag-manager';
index 01ca11a333f10289f1fd185dbce66bc3a704316b..b56b01980a147cc17f9cda63bdff62b2ccdf47e4 100644 (file)
@@ -1,29 +1,6 @@
 import Sortable from 'sortablejs';
 import {Component} from './component';
-
-/**
- * @type {Object<string, function(HTMLElement, HTMLElement, HTMLElement)>}
- */
-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-rule-manager.ts b/resources/js/components/sort-rule-manager.ts
new file mode 100644 (file)
index 0000000..ff08f4a
--- /dev/null
@@ -0,0 +1,41 @@
+import {Component} from "./component.js";
+import Sortable from "sortablejs";
+import {buildListActions, sortActionClickListener} from "../services/dual-lists";
+
+
+export class SortRuleManager 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-rule-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 (file)
index 0000000..98f2af9
--- /dev/null
@@ -0,0 +1,51 @@
+/**
+ * Service for helping manage common dual-list scenarios.
+ * (Shelf book manager, sort set manager).
+ */
+
+type ListActionsSet = Record<string, ((item: HTMLElement) => 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();
+        }
+    };
+}
+
index 888b325275ea61890752f180c1c0ab370df1e799..58d39d3ee6e0e9c3f7e29d98bc17269fd6816ab7 100644 (file)
@@ -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
index fd76f498ed186bf6a23be71151d15dbd2f726e48..1e503dd0ffe50a54eb428c2ca19e63ece3737afe 100644 (file)
   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: '';
index 418c0fea8d1d40ae16e4af61c45609e0180ebd28..197de011d0133c2de553cd993b9387b05da60611 100644 (file)
@@ -23,7 +23,7 @@
 
     <div id="new" class="mb-xl">
         <h5>{{ trans('entities.books_new') }}</h5>
-        @if(count($popular) > 0)
+        @if(count($new) > 0)
             @include('entities.list', ['entities' => $new, 'style' => 'compact'])
         @else
             <p class="text-muted pb-l mb-none">{{ trans('entities.books_new_empty') }}</p>
@@ -59,4 +59,4 @@
         </div>
     </div>
 
-@stop
\ No newline at end of file
+@stop
index 03998e261781bd7db46d9ba427963c93bd721a54..6fdb1819e617742c96b47b7a6a7af9951e41e727 100644 (file)
@@ -8,14 +8,24 @@
                 <span>@icon('book')</span>
                 <span>{{ $book->name }}</span>
             </div>
+            <div class="flex-container-row items-center text-book">
+                @if($book->sortRule)
+                    <span title="{{ trans('entities.books_sort_auto_sort_active', ['sortName' => $book->sortRule->name]) }}">@icon('auto-sort')</span>
+                @endif
+            </div>
         </h5>
     </summary>
     <div class="sort-box-options pb-sm">
-        <button type="button" data-sort="name" class="button outline small">{{ trans('entities.books_sort_name') }}</button>
-        <button type="button" data-sort="created" class="button outline small">{{ trans('entities.books_sort_created') }}</button>
-        <button type="button" data-sort="updated" class="button outline small">{{ trans('entities.books_sort_updated') }}</button>
-        <button type="button" data-sort="chaptersFirst" class="button outline small">{{ trans('entities.books_sort_chapters_first') }}</button>
-        <button type="button" data-sort="chaptersLast" class="button outline small">{{ trans('entities.books_sort_chapters_last') }}</button>
+        <button type="button" data-sort="name"
+                class="button outline small">{{ trans('entities.books_sort_name') }}</button>
+        <button type="button" data-sort="created"
+                class="button outline small">{{ trans('entities.books_sort_created') }}</button>
+        <button type="button" data-sort="updated"
+                class="button outline small">{{ trans('entities.books_sort_updated') }}</button>
+        <button type="button" data-sort="chaptersFirst"
+                class="button outline small">{{ trans('entities.books_sort_chapters_first') }}</button>
+        <button type="button" data-sort="chaptersLast"
+                class="button outline small">{{ trans('entities.books_sort_chapters_last') }}</button>
     </div>
     <ul class="sortable-page-list sort-list">
 
index c82ad4e3b1e7f7cec7f2ffdbcecd9e7b6b49ea23..e090708b1d71eb1597942cd363d9c3e6089fd4f6 100644 (file)
             <div>
                 <div component="book-sort" class="card content-wrap auto-height">
                     <h1 class="list-heading">{{ trans('entities.books_sort') }}</h1>
-                    <p class="text-muted">{{ trans('entities.books_sort_desc') }}</p>
+
+                    <div class="flex-container-row gap-m wrap mb-m">
+                        <p class="text-muted flex min-width-s mb-none">{{ trans('entities.books_sort_desc') }}</p>
+                        <div class="min-width-s">
+                            @php
+                                $autoSortVal = intval(old('auto-sort') ?? $book->sort_rule_id ?? 0);
+                            @endphp
+                            <label for="auto-sort">{{ trans('entities.books_sort_auto_sort') }}</label>
+                            <select id="auto-sort"
+                                    name="auto-sort"
+                                    form="sort-form"
+                                    class="{{ $errors->has('auto-sort') ? 'neg' : '' }}">
+                                <option value="0" @if($autoSortVal === 0) selected @endif>-- {{ trans('common.none') }}
+                                    --
+                                </option>
+                                @foreach(\BookStack\Sorting\SortRule::allByName() as $rule)
+                                    <option value="{{$rule->id}}"
+                                            @if($autoSortVal === $rule->id) selected @endif
+                                    >
+                                        {{ $rule->name }}
+                                    </option>
+                                @endforeach
+                            </select>
+                        </div>
+                    </div>
 
                     <div refs="book-sort@sortContainer">
                         @include('books.parts.sort-box', ['book' => $book, 'bookChildren' => $bookChildren])
                     </div>
 
-                    <form action="{{ $book->getUrl('/sort') }}" method="POST">
-                        {!! csrf_field() !!}
+                    <form id="sort-form" action="{{ $book->getUrl('/sort') }}" method="POST">
+                        {{ csrf_field() }}
                         <input type="hidden" name="_method" value="PUT">
                         <input refs="book-sort@input" type="hidden" name="sort-tree">
                         <div class="list text-right">
index 28cdeb8a5a7e5e8a5892b6fffc96bf9b4bb0888a..8e47766804094086d2dfb67a25747c3c86292aec 100644 (file)
                             class="input-base text-left">{{ $filters['event'] ?: trans('settings.audit_event_filter_no_filter') }}</button>
                     <ul refs="dropdown@menu" class="dropdown-menu">
                         <li @if($filters['event'] === '') class="active" @endif><a
-                                    href="{{ sortUrl('/settings/audit', array_filter(request()->except('page')), ['event' => '']) }}"
+                                    href="{{ $filterSortUrl->withOverrideData(['event' => ''])->build() }}"
                                     class="text-item">{{ trans('settings.audit_event_filter_no_filter') }}</a></li>
                         @foreach($activityTypes as $type)
                             <li @if($type === $filters['event']) class="active" @endif><a
-                                        href="{{ sortUrl('/settings/audit', array_filter(request()->except('page')), ['event' => $type]) }}"
+                                        href="{{ $filterSortUrl->withOverrideData(['event' => $type])->build() }}"
                                         class="text-item">{{ $type }}</a></li>
                         @endforeach
                     </ul>
diff --git a/resources/views/settings/categories/sorting.blade.php b/resources/views/settings/categories/sorting.blade.php
new file mode 100644 (file)
index 0000000..9d1d981
--- /dev/null
@@ -0,0 +1,68 @@
+@extends('settings.layout')
+
+@php
+    $sortRules = \BookStack\Sorting\SortRule::allByName();
+@endphp
+
+@section('card')
+    <h1 id="sorting" class="list-heading">{{ trans('settings.sorting') }}</h1>
+    <form action="{{ url("/settings/sorting") }}" method="POST">
+        {{ csrf_field() }}
+        <input type="hidden" name="section" value="sorting">
+
+        <div class="setting-list">
+            <div class="grid half gap-xl items-center">
+                <div>
+                    <label for="setting-sorting-book-default"
+                           class="setting-list-label">{{ trans('settings.sorting_book_default') }}</label>
+                    <p class="small">{{ trans('settings.sorting_book_default_desc') }}</p>
+                </div>
+                <div>
+                    <select id="setting-sorting-book-default" name="setting-sorting-book-default"
+                            @if($errors->has('setting-sorting-book-default')) class="neg" @endif>
+                        <option value="0" @if(intval(setting('sorting-book-default', '0')) === 0) selected @endif>
+                            -- {{ trans('common.none') }} --
+                        </option>
+                        @foreach($sortRules as $set)
+                            <option value="{{$set->id}}"
+                                    @if(intval(setting('sorting-book-default', '0')) === $set->id) selected @endif
+                            >
+                                {{ $set->name }}
+                            </option>
+                        @endforeach
+                    </select>
+                </div>
+            </div>
+
+        </div>
+
+        <div class="form-group text-right">
+            <button type="submit" class="button">{{ trans('settings.settings_save') }}</button>
+        </div>
+    </form>
+@endsection
+
+@section('after-card')
+    <div class="card content-wrap auto-height">
+        <div class="flex-container-row items-center gap-m">
+            <div class="flex">
+                <h2 class="list-heading">{{ trans('settings.sorting_rules') }}</h2>
+                <p class="text-muted">{{ trans('settings.sorting_rules_desc') }}</p>
+            </div>
+            <div>
+                <a href="{{ url('/settings/sorting/rules/new') }}"
+                   class="button outline">{{ trans('settings.sort_rule_create') }}</a>
+            </div>
+        </div>
+
+        @if(empty($sortRules))
+            <p class="italic text-muted">{{ trans('common.no_items') }}</p>
+        @else
+            <div class="item-list">
+                @foreach($sortRules as $rule)
+                    @include('settings.sort-rules.parts.sort-rule-list-item', ['rule' => $rule])
+                @endforeach
+            </div>
+        @endif
+    </div>
+@endsection
\ No newline at end of file
index a59b58d535c431a0bac129a811b6b1683bb4ece4..930d407a508a57992a2a3aeb88d5fde6552f50d2 100644 (file)
@@ -13,6 +13,7 @@
                     <a href="{{ url('/settings/features') }}" class="{{ $category === 'features' ? 'active' : '' }}">@icon('star') {{ trans('settings.app_features_security') }}</a>
                     <a href="{{ url('/settings/customization') }}" class="{{ $category === 'customization' ? 'active' : '' }}">@icon('palette') {{ trans('settings.app_customization') }}</a>
                     <a href="{{ url('/settings/registration') }}" class="{{ $category === 'registration' ? 'active' : '' }}">@icon('security') {{ trans('settings.reg_settings') }}</a>
+                    <a href="{{ url('/settings/sorting') }}" class="{{ $category === 'sorting' ? 'active' : '' }}">@icon('sort') {{ trans('settings.sorting') }}</a>
                 </nav>
 
                 <h5 class="mt-xl">{{ trans('settings.system_version') }}</h5>
@@ -29,6 +30,7 @@
                 <div class="card content-wrap auto-height">
                     @yield('card')
                 </div>
+                @yield('after-card')
             </div>
 
         </div>
diff --git a/resources/views/settings/sort-rules/create.blade.php b/resources/views/settings/sort-rules/create.blade.php
new file mode 100644 (file)
index 0000000..e1d5c7c
--- /dev/null
@@ -0,0 +1,24 @@
+@extends('layouts.simple')
+
+@section('body')
+
+    <div class="container small">
+
+        @include('settings.parts.navbar', ['selected' => 'settings'])
+
+        <div class="card content-wrap auto-height">
+            <h1 class="list-heading">{{ trans('settings.sort_rule_create') }}</h1>
+
+            <form action="{{ url("/settings/sorting/rules") }}" method="POST">
+                {{ csrf_field() }}
+                @include('settings.sort-rules.parts.form', ['model' => null])
+
+                <div class="form-group text-right">
+                    <a href="{{ url("/settings/sorting") }}" class="button outline">{{ trans('common.cancel') }}</a>
+                    <button type="submit" class="button">{{ trans('common.save') }}</button>
+                </div>
+            </form>
+        </div>
+    </div>
+
+@stop
diff --git a/resources/views/settings/sort-rules/edit.blade.php b/resources/views/settings/sort-rules/edit.blade.php
new file mode 100644 (file)
index 0000000..8bf0470
--- /dev/null
@@ -0,0 +1,54 @@
+@extends('layouts.simple')
+
+@section('body')
+
+    <div class="container small">
+
+        @include('settings.parts.navbar', ['selected' => 'settings'])
+
+        <div class="card content-wrap auto-height">
+            <h1 class="list-heading">{{ trans('settings.sort_rule_edit') }}</h1>
+
+            <form action="{{ $rule->getUrl() }}" method="POST">
+                {{ method_field('PUT') }}
+                {{ csrf_field() }}
+
+                @include('settings.sort-rules.parts.form', ['model' => $rule])
+
+                <div class="form-group text-right">
+                    <a href="{{ url("/settings/sorting") }}" class="button outline">{{ trans('common.cancel') }}</a>
+                    <button type="submit" class="button">{{ trans('common.save') }}</button>
+                </div>
+            </form>
+        </div>
+
+        <div id="delete" class="card content-wrap auto-height">
+            <div class="flex-container-row items-center gap-l">
+                <div class="mb-m">
+                    <h2 class="list-heading">{{ trans('settings.sort_rule_delete') }}</h2>
+                    <p class="text-muted mb-xs">{{ trans('settings.sort_rule_delete_desc') }}</p>
+                    @if($errors->has('delete'))
+                        @foreach($errors->get('delete') as $error)
+                            <p class="text-neg mb-xs">{{ $error }}</p>
+                        @endforeach
+                    @endif
+                </div>
+                <div class="flex">
+                    <form action="{{ $rule->getUrl() }}" method="POST">
+                        {{ method_field('DELETE') }}
+                        {{ csrf_field() }}
+
+                        @if($errors->has('delete'))
+                            <input type="hidden" name="confirm" value="true">
+                        @endif
+
+                        <div class="text-right">
+                            <button type="submit" class="button outline">{{ trans('common.delete') }}</button>
+                        </div>
+                    </form>
+                </div>
+            </div>
+        </div>
+    </div>
+
+@stop
diff --git a/resources/views/settings/sort-rules/parts/form.blade.php b/resources/views/settings/sort-rules/parts/form.blade.php
new file mode 100644 (file)
index 0000000..d6de947
--- /dev/null
@@ -0,0 +1,56 @@
+<div class="setting-list">
+    <div class="grid half">
+        <div>
+            <label class="setting-list-label">{{ trans('settings.sort_rule_details') }}</label>
+            <p class="text-muted text-small">{{ trans('settings.sort_rule_details_desc') }}</p>
+        </div>
+        <div>
+            <div class="form-group">
+                <label for="name">{{ trans('common.name') }}</label>
+                @include('form.text', ['name' => 'name'])
+            </div>
+        </div>
+    </div>
+
+    <div component="sort-rule-manager">
+        <label class="setting-list-label">{{ trans('settings.sort_rule_operations') }}</label>
+        <p class="text-muted text-small">{{ trans('settings.sort_rule_operations_desc') }}</p>
+        @include('form.errors', ['name' => 'sequence'])
+
+        <input refs="sort-rule-manager@input" type="hidden" name="sequence"
+               value="{{ old('sequence') ?? $model?->sequence ?? '' }}">
+
+        @php
+            $configuredOps = old('sequence') ? \BookStack\Sorting\SortRuleOperation::fromSequence(old('sequence')) : ($model?->getOperations() ?? []);
+        @endphp
+
+        <div class="grid half">
+            <div class="form-group">
+                <label for="books"
+                       id="sort-rule-configured-operations">{{ trans('settings.sort_rule_configured_operations') }}</label>
+                <ul refs="sort-rule-manager@configured-operations-list"
+                    aria-labelledby="sort-rule-configured-operations"
+                    class="scroll-box configured-option-list">
+                    <li class="text-muted empty-state px-m py-s italic text-small">{{ trans('settings.sort_rule_configured_operations_empty') }}</li>
+
+                    @foreach($configuredOps as $operation)
+                        @include('settings.sort-rules.parts.operation', ['operation' => $operation])
+                    @endforeach
+                </ul>
+            </div>
+
+            <div class="form-group">
+                <label for="books"
+                       id="sort-rule-available-operations">{{ trans('settings.sort_rule_available_operations') }}</label>
+                <ul refs="sort-rule-manager@available-operations-list"
+                    aria-labelledby="sort-rule-available-operations"
+                    class="scroll-box available-option-list">
+                    <li class="text-muted empty-state px-m py-s italic text-small">{{ trans('settings.sort_rule_available_operations_empty') }}</li>
+                    @foreach(\BookStack\Sorting\SortRuleOperation::allExcluding($configuredOps) as $operation)
+                        @include('settings.sort-rules.parts.operation', ['operation' => $operation])
+                    @endforeach
+                </ul>
+            </div>
+        </div>
+    </div>
+</div>
\ No newline at end of file
diff --git a/resources/views/settings/sort-rules/parts/operation.blade.php b/resources/views/settings/sort-rules/parts/operation.blade.php
new file mode 100644 (file)
index 0000000..3feb68a
--- /dev/null
@@ -0,0 +1,15 @@
+<li data-id="{{ $operation->value }}"
+    class="scroll-box-item items-center">
+    <div class="handle px-s">@icon('grip')</div>
+    <div class="text-small">{{ $operation->getLabel() }}</div>
+    <div class="buttons flex-container-row items-center ml-auto px-xxs py-xxs">
+        <button type="button" data-action="move_up" class="icon-button p-xxs"
+                title="{{ trans('entities.books_sort_move_up') }}">@icon('chevron-up')</button>
+        <button type="button" data-action="move_down" class="icon-button p-xxs"
+                title="{{ trans('entities.books_sort_move_down') }}">@icon('chevron-down')</button>
+        <button type="button" data-action="remove" class="icon-button p-xxs"
+                title="{{ trans('common.remove') }}">@icon('remove')</button>
+        <button type="button" data-action="add" class="icon-button p-xxs"
+                title="{{ trans('common.add') }}">@icon('add-small')</button>
+    </div>
+</li>
\ No newline at end of file
diff --git a/resources/views/settings/sort-rules/parts/sort-rule-list-item.blade.php b/resources/views/settings/sort-rules/parts/sort-rule-list-item.blade.php
new file mode 100644 (file)
index 0000000..5236cb4
--- /dev/null
@@ -0,0 +1,12 @@
+<div class="item-list-row flex-container-row py-xs px-m gap-m items-center">
+    <div class="py-xs flex">
+        <a href="{{ $rule->getUrl() }}">{{ $rule->name }}</a>
+    </div>
+    <div class="px-m text-small text-muted ml-auto">
+        {{ implode(', ', array_map(fn ($op) => $op->getLabel(), $rule->getOperations())) }}
+    </div>
+    <div>
+        <span title="{{ trans_choice('settings.sort_rule_assigned_to_x_books', $rule->books_count ?? 0) }}"
+              class="flex fill-area min-width-xxs bold text-right text-book"><span class="opacity-60">@icon('book')</span>{{ $rule->books_count ?? 0 }}</span>
+    </div>
+</div>
\ No newline at end of file
index a75dd6ac1b53b62b3929d33d7d324a827810e966..7790ba5a4e7fab2e994999a8e17fbfaaf3c8e8f5 100644 (file)
@@ -38,7 +38,7 @@
         </div>
         <ul refs="shelf-sort@shelf-book-list"
             aria-labelledby="shelf-sort-books-label"
-            class="scroll-box">
+            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 @@
         <input type="text" refs="shelf-sort@book-search" class="scroll-box-search" placeholder="{{ trans('common.search') }}">
         <ul refs="shelf-sort@all-book-list"
             aria-labelledby="shelf-sort-all-books-label"
-            class="scroll-box">
+            class="scroll-box available-option-list">
             @foreach ($books as $book)
                 @include('shelves.parts.shelf-sort-book-item', ['book' => $book])
             @endforeach
index 5bb9622e7372b96171029eb80d08854bcda3289e..8184725834caae44f3dae98942d2bc981ec84406 100644 (file)
@@ -13,6 +13,7 @@ use BookStack\Permissions\PermissionsController;
 use BookStack\References\ReferenceController;
 use BookStack\Search\SearchController;
 use BookStack\Settings as SettingControllers;
+use BookStack\Sorting as SortingControllers;
 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', [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']);
@@ -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', [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']);
@@ -294,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 Rules
+    Route::get('/settings/sorting/rules/new', [SortingControllers\SortRuleController::class, 'create']);
+    Route::post('/settings/sorting/rules', [SortingControllers\SortRuleController::class, 'store']);
+    Route::get('/settings/sorting/rules/{id}', [SortingControllers\SortRuleController::class, 'edit']);
+    Route::put('/settings/sorting/rules/{id}', [SortingControllers\SortRuleController::class, 'update']);
+    Route::delete('/settings/sorting/rules/{id}', [SortingControllers\SortRuleController::class, 'destroy']);
+
     // Settings
     Route::get('/settings', [SettingControllers\SettingController::class, 'index'])->name('settings');
     Route::get('/settings/{category}', [SettingControllers\SettingController::class, 'category'])->name('settings.category');
diff --git a/tests/Commands/AssignSortRuleCommandTest.php b/tests/Commands/AssignSortRuleCommandTest.php
new file mode 100644 (file)
index 0000000..5b308cd
--- /dev/null
@@ -0,0 +1,112 @@
+<?php
+
+namespace Commands;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Sorting\SortRule;
+use Tests\TestCase;
+
+class AssignSortRuleCommandTest extends TestCase
+{
+    public function test_no_given_sort_rule_lists_options()
+    {
+        $sortRules = SortRule::factory()->createMany(10);
+
+        $commandRun = $this->artisan('bookstack:assign-sort-rule')
+            ->expectsOutputToContain('Sort rule ID required!')
+            ->assertExitCode(1);
+
+        foreach ($sortRules as $sortRule) {
+            $commandRun->expectsOutputToContain("{$sortRule->id}: {$sortRule->name}");
+        }
+    }
+
+    public function test_run_without_options_advises_help()
+    {
+        $this->artisan("bookstack:assign-sort-rule 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-rule 100342 --all-books")
+            ->expectsOutput("Sort rule of provided id 100342 not found!")
+            ->assertExitCode(1);
+    }
+
+    public function test_confirmation_required()
+    {
+        $sortRule = SortRule::factory()->create();
+
+        $this->artisan("bookstack:assign-sort-rule {$sortRule->id} --all-books")
+            ->expectsConfirmation('Are you sure you want to continue?', 'no')
+            ->assertExitCode(1);
+
+        $booksWithSort = Book::query()->whereNotNull('sort_rule_id')->count();
+        $this->assertEquals(0, $booksWithSort);
+    }
+
+    public function test_assign_to_all_books()
+    {
+        $sortRule = SortRule::factory()->create();
+        $booksWithoutSort = Book::query()->whereNull('sort_rule_id')->count();
+        $this->assertGreaterThan(0, $booksWithoutSort);
+
+        $this->artisan("bookstack:assign-sort-rule {$sortRule->id} --all-books")
+            ->expectsOutputToContain("This will apply sort rule [{$sortRule->id}: {$sortRule->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_rule_id')->count();
+        $this->assertEquals(0, $booksWithoutSort);
+    }
+
+    public function test_assign_to_all_books_without_sort()
+    {
+        $totalBooks = Book::query()->count();
+        $book = $this->entities->book();
+        $sortRuleA = SortRule::factory()->create();
+        $sortRuleB = SortRule::factory()->create();
+        $book->sort_rule_id = $sortRuleA->id;
+        $book->save();
+
+        $booksWithoutSort = Book::query()->whereNull('sort_rule_id')->count();
+        $this->assertEquals($totalBooks, $booksWithoutSort + 1);
+
+        $this->artisan("bookstack:assign-sort-rule {$sortRuleB->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_rule_id')->count();
+        $this->assertEquals(0, $booksWithoutSort);
+        $this->assertEquals($totalBooks, $sortRuleB->books()->count() + 1);
+    }
+
+    public function test_assign_to_all_books_with_sort()
+    {
+        $book = $this->entities->book();
+        $sortRuleA = SortRule::factory()->create();
+        $sortRuleB = SortRule::factory()->create();
+        $book->sort_rule_id = $sortRuleA->id;
+        $book->save();
+
+        $this->artisan("bookstack:assign-sort-rule {$sortRuleB->id} --books-with-sort={$sortRuleA->id}")
+            ->expectsConfirmation('Are you sure you want to continue?', 'yes')
+            ->expectsOutputToContain("Sort applied to 1 book(s)")
+            ->assertExitCode(0);
+
+        $book->refresh();
+        $this->assertEquals($sortRuleB->id, $book->sort_rule_id);
+        $this->assertEquals(1, $sortRuleB->books()->count());
+    }
+
+    public function test_assign_to_all_books_with_sort_id_is_validated()
+    {
+        $this->artisan("bookstack:assign-sort-rule 50 --books-with-sort=beans")
+            ->expectsOutputToContain("Provided --books-with-sort option value is invalid")
+            ->assertExitCode(1);
+    }
+}
index deeead099110aa56271004cdc437e305678160ee..e444d165fb314193cb146e6d416a3ad677df74fd 100644 (file)
@@ -300,7 +300,7 @@ class PageTest extends TestCase
         ]);
 
         $resp = $this->asAdmin()->get('/pages/recently-updated');
-        $this->withHtml($resp)->assertElementContains('.entity-list .page:nth-child(1)', 'Updated 0 seconds ago by ' . $user->name);
+        $this->withHtml($resp)->assertElementContains('.entity-list .page:nth-child(1) small', 'by ' . $user->name);
     }
 
     public function test_recently_updated_pages_view_shows_parent_chain()
similarity index 74%
rename from tests/Entity/EntitySearchTest.php
rename to tests/Search/EntitySearchTest.php
index 5ace70e3ab2835c7bdebe6a05d2239eec6c762c3..9c76d0f7136025c716e6965f07271f56504f7bb0 100644 (file)
@@ -1,12 +1,9 @@
 <?php
 
-namespace Tests\Entity;
+namespace Search;
 
 use BookStack\Activity\Models\Tag;
 use BookStack\Entities\Models\Book;
-use BookStack\Entities\Models\Bookshelf;
-use BookStack\Entities\Models\Chapter;
-use Illuminate\Support\Str;
 use Tests\TestCase;
 
 class EntitySearchTest extends TestCase
@@ -312,113 +309,6 @@ class EntitySearchTest extends TestCase
         $defaultListTest->assertDontSee($templatePage->name);
     }
 
-    public function test_sibling_search_for_pages()
-    {
-        $chapter = $this->entities->chapterHasPages();
-        $this->assertGreaterThan(2, count($chapter->pages), 'Ensure we\'re testing with at least 1 sibling');
-        $page = $chapter->pages->first();
-
-        $search = $this->actingAs($this->users->viewer())->get("/search/entity/siblings?entity_id={$page->id}&entity_type=page");
-        $search->assertSuccessful();
-        foreach ($chapter->pages as $page) {
-            $search->assertSee($page->name);
-        }
-
-        $search->assertDontSee($chapter->name);
-    }
-
-    public function test_sibling_search_for_pages_without_chapter()
-    {
-        $page = $this->entities->pageNotWithinChapter();
-        $bookChildren = $page->book->getDirectVisibleChildren();
-        $this->assertGreaterThan(2, count($bookChildren), 'Ensure we\'re testing with at least 1 sibling');
-
-        $search = $this->actingAs($this->users->viewer())->get("/search/entity/siblings?entity_id={$page->id}&entity_type=page");
-        $search->assertSuccessful();
-        foreach ($bookChildren as $child) {
-            $search->assertSee($child->name);
-        }
-
-        $search->assertDontSee($page->book->name);
-    }
-
-    public function test_sibling_search_for_chapters()
-    {
-        $chapter = $this->entities->chapter();
-        $bookChildren = $chapter->book->getDirectVisibleChildren();
-        $this->assertGreaterThan(2, count($bookChildren), 'Ensure we\'re testing with at least 1 sibling');
-
-        $search = $this->actingAs($this->users->viewer())->get("/search/entity/siblings?entity_id={$chapter->id}&entity_type=chapter");
-        $search->assertSuccessful();
-        foreach ($bookChildren as $child) {
-            $search->assertSee($child->name);
-        }
-
-        $search->assertDontSee($chapter->book->name);
-    }
-
-    public function test_sibling_search_for_books()
-    {
-        $books = Book::query()->take(10)->get();
-        $book = $books->first();
-        $this->assertGreaterThan(2, count($books), 'Ensure we\'re testing with at least 1 sibling');
-
-        $search = $this->actingAs($this->users->viewer())->get("/search/entity/siblings?entity_id={$book->id}&entity_type=book");
-        $search->assertSuccessful();
-        foreach ($books as $expectedBook) {
-            $search->assertSee($expectedBook->name);
-        }
-    }
-
-    public function test_sibling_search_for_shelves()
-    {
-        $shelves = Bookshelf::query()->take(10)->get();
-        $shelf = $shelves->first();
-        $this->assertGreaterThan(2, count($shelves), 'Ensure we\'re testing with at least 1 sibling');
-
-        $search = $this->actingAs($this->users->viewer())->get("/search/entity/siblings?entity_id={$shelf->id}&entity_type=bookshelf");
-        $search->assertSuccessful();
-        foreach ($shelves as $expectedShelf) {
-            $search->assertSee($expectedShelf->name);
-        }
-    }
-
-    public function test_sibling_search_for_books_provides_results_in_alphabetical_order()
-    {
-        $contextBook = $this->entities->book();
-        $searchBook = $this->entities->book();
-
-        $searchBook->name = 'Zebras';
-        $searchBook->save();
-
-        $search = $this->actingAs($this->users->viewer())->get("/search/entity/siblings?entity_id={$contextBook->id}&entity_type=book");
-        $this->withHtml($search)->assertElementNotContains('a:first-child', 'Zebras');
-
-        $searchBook->name = '1AAAAAAArdvarks';
-        $searchBook->save();
-
-        $search = $this->actingAs($this->users->viewer())->get("/search/entity/siblings?entity_id={$contextBook->id}&entity_type=book");
-        $this->withHtml($search)->assertElementContains('a:first-child', '1AAAAAAArdvarks');
-    }
-
-    public function test_sibling_search_for_shelves_provides_results_in_alphabetical_order()
-    {
-        $contextShelf = $this->entities->shelf();
-        $searchShelf = $this->entities->shelf();
-
-        $searchShelf->name = 'Zebras';
-        $searchShelf->save();
-
-        $search = $this->actingAs($this->users->viewer())->get("/search/entity/siblings?entity_id={$contextShelf->id}&entity_type=bookshelf");
-        $this->withHtml($search)->assertElementNotContains('a:first-child', 'Zebras');
-
-        $searchShelf->name = '1AAAAAAArdvarks';
-        $searchShelf->save();
-
-        $search = $this->actingAs($this->users->viewer())->get("/search/entity/siblings?entity_id={$contextShelf->id}&entity_type=bookshelf");
-        $this->withHtml($search)->assertElementContains('a:first-child', '1AAAAAAArdvarks');
-    }
-
     public function test_search_works_on_updated_page_content()
     {
         $page = $this->entities->page();
@@ -453,75 +343,6 @@ class EntitySearchTest extends TestCase
         $this->withHtml($search)->assertElementContains('.entity-list > .page:nth-child(2)', 'Test page A');
     }
 
-    public function test_terms_in_headers_have_an_adjusted_index_score()
-    {
-        $page = $this->entities->newPage(['name' => 'Test page A', 'html' => '
-            <p>TermA</p>
-            <h1>TermB <strong>TermNested</strong></h1>
-            <h2>TermC</h2>
-            <h3>TermD</h3>
-            <h4>TermE</h4>
-            <h5>TermF</h5>
-            <h6>TermG</h6>
-        ']);
-
-        $scoreByTerm = $page->searchTerms()->pluck('score', 'term');
-
-        $this->assertEquals(1, $scoreByTerm->get('TermA'));
-        $this->assertEquals(10, $scoreByTerm->get('TermB'));
-        $this->assertEquals(10, $scoreByTerm->get('TermNested'));
-        $this->assertEquals(5, $scoreByTerm->get('TermC'));
-        $this->assertEquals(4, $scoreByTerm->get('TermD'));
-        $this->assertEquals(3, $scoreByTerm->get('TermE'));
-        $this->assertEquals(2, $scoreByTerm->get('TermF'));
-        // Is 1.5 but stored as integer, rounding up
-        $this->assertEquals(2, $scoreByTerm->get('TermG'));
-    }
-
-    public function test_indexing_works_as_expected_for_page_with_lots_of_terms()
-    {
-        $this->markTestSkipped('Time consuming test');
-
-        $count = 100000;
-        $text = '';
-        $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_#';
-        for ($i = 0; $i < $count; $i++) {
-            $text .= substr(str_shuffle($chars), 0, 5) . ' ';
-        }
-
-        $page = $this->entities->newPage(['name' => 'Test page A', 'html' => '<p>' . $text . '</p>']);
-
-        $termCount = $page->searchTerms()->count();
-
-        // Expect at least 90% unique rate
-        $this->assertGreaterThan($count * 0.9, $termCount);
-    }
-
-    public function test_name_and_content_terms_are_merged_to_single_score()
-    {
-        $page = $this->entities->newPage(['name' => 'TermA', 'html' => '
-            <p>TermA</p>
-        ']);
-
-        $scoreByTerm = $page->searchTerms()->pluck('score', 'term');
-
-        // Scores 40 for being in the name then 1 for being in the content
-        $this->assertEquals(41, $scoreByTerm->get('TermA'));
-    }
-
-    public function test_tag_names_and_values_are_indexed_for_search()
-    {
-        $page = $this->entities->newPage(['name' => 'PageA', 'html' => '<p>content</p>', 'tags' => [
-            ['name' => 'Animal', 'value' => 'MeowieCat'],
-            ['name' => 'SuperImportant'],
-        ]]);
-
-        $scoreByTerm = $page->searchTerms()->pluck('score', 'term');
-        $this->assertEquals(5, $scoreByTerm->get('MeowieCat'));
-        $this->assertEquals(3, $scoreByTerm->get('Animal'));
-        $this->assertEquals(3, $scoreByTerm->get('SuperImportant'));
-    }
-
     public function test_matching_terms_in_search_results_are_highlighted()
     {
         $this->entities->newPage(['name' => 'My Meowie Cat', 'html' => '<p>A superimportant page about meowieable animals</p>', 'tags' => [
diff --git a/tests/Search/SearchIndexingTest.php b/tests/Search/SearchIndexingTest.php
new file mode 100644 (file)
index 0000000..6933813
--- /dev/null
@@ -0,0 +1,93 @@
+<?php
+
+namespace Search;
+
+use Tests\TestCase;
+
+class SearchIndexingTest extends TestCase
+{
+    public function test_terms_in_headers_have_an_adjusted_index_score()
+    {
+        $page = $this->entities->newPage(['name' => 'Test page A', 'html' => '
+            <p>TermA</p>
+            <h1>TermB <strong>TermNested</strong></h1>
+            <h2>TermC</h2>
+            <h3>TermD</h3>
+            <h4>TermE</h4>
+            <h5>TermF</h5>
+            <h6>TermG</h6>
+        ']);
+
+        $scoreByTerm = $page->searchTerms()->pluck('score', 'term');
+
+        $this->assertEquals(1, $scoreByTerm->get('TermA'));
+        $this->assertEquals(10, $scoreByTerm->get('TermB'));
+        $this->assertEquals(10, $scoreByTerm->get('TermNested'));
+        $this->assertEquals(5, $scoreByTerm->get('TermC'));
+        $this->assertEquals(4, $scoreByTerm->get('TermD'));
+        $this->assertEquals(3, $scoreByTerm->get('TermE'));
+        $this->assertEquals(2, $scoreByTerm->get('TermF'));
+        // Is 1.5 but stored as integer, rounding up
+        $this->assertEquals(2, $scoreByTerm->get('TermG'));
+    }
+
+    public function test_indexing_works_as_expected_for_page_with_lots_of_terms()
+    {
+        $this->markTestSkipped('Time consuming test');
+
+        $count = 100000;
+        $text = '';
+        $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_#';
+        for ($i = 0; $i < $count; $i++) {
+            $text .= substr(str_shuffle($chars), 0, 5) . ' ';
+        }
+
+        $page = $this->entities->newPage(['name' => 'Test page A', 'html' => '<p>' . $text . '</p>']);
+
+        $termCount = $page->searchTerms()->count();
+
+        // Expect at least 90% unique rate
+        $this->assertGreaterThan($count * 0.9, $termCount);
+    }
+
+    public function test_name_and_content_terms_are_merged_to_single_score()
+    {
+        $page = $this->entities->newPage(['name' => 'TermA', 'html' => '
+            <p>TermA</p>
+        ']);
+
+        $scoreByTerm = $page->searchTerms()->pluck('score', 'term');
+
+        // Scores 40 for being in the name then 1 for being in the content
+        $this->assertEquals(41, $scoreByTerm->get('TermA'));
+    }
+
+    public function test_tag_names_and_values_are_indexed_for_search()
+    {
+        $page = $this->entities->newPage(['name' => 'PageA', 'html' => '<p>content</p>', 'tags' => [
+            ['name' => 'Animal', 'value' => 'MeowieCat'],
+            ['name' => 'SuperImportant'],
+        ]]);
+
+        $scoreByTerm = $page->searchTerms()->pluck('score', 'term');
+        $this->assertEquals(5, $scoreByTerm->get('MeowieCat'));
+        $this->assertEquals(3, $scoreByTerm->get('Animal'));
+        $this->assertEquals(3, $scoreByTerm->get('SuperImportant'));
+    }
+
+    public function test_terms_containing_punctuation_within_retain_original_form_and_split_form_in_index()
+    {
+        $page = $this->entities->newPage(['html' => '<p>super.duper awesome-beans big- barry cheese.</p><p>biscuits</p><p>a-bs</p>']);
+
+        $scoreByTerm = $page->searchTerms()->pluck('score', 'term');
+        $expected = ['super', 'duper', 'super.duper', 'awesome-beans', 'awesome', 'beans', 'big', 'barry', 'cheese', 'biscuits', 'a-bs', 'a', 'bs'];
+        foreach ($expected as $term) {
+            $this->assertNotNull($scoreByTerm->get($term), "Failed asserting that \"$term\" is indexed");
+        }
+
+        $nonExpected = ['big-', 'big-barry', 'cheese.', 'cheese.biscuits'];
+        foreach ($nonExpected as $term) {
+            $this->assertNull($scoreByTerm->get($term), "Failed asserting that \"$term\" is not indexed");
+        }
+    }
+}
similarity index 99%
rename from tests/Entity/SearchOptionsTest.php
rename to tests/Search/SearchOptionsTest.php
index 0c2ad271c58892df4bfc7b186f948a76bf5b20b5..39c20c19591994d813b51e22940a697b6696d0db 100644 (file)
@@ -1,6 +1,6 @@
 <?php
 
-namespace Tests\Entity;
+namespace Search;
 
 use BookStack\Search\Options\ExactSearchOption;
 use BookStack\Search\Options\FilterSearchOption;
diff --git a/tests/Search/SiblingSearchTest.php b/tests/Search/SiblingSearchTest.php
new file mode 100644 (file)
index 0000000..63865af
--- /dev/null
@@ -0,0 +1,117 @@
+<?php
+
+namespace Search;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+use Tests\TestCase;
+
+class SiblingSearchTest extends TestCase
+{
+    public function test_sibling_search_for_pages()
+    {
+        $chapter = $this->entities->chapterHasPages();
+        $this->assertGreaterThan(2, count($chapter->pages), 'Ensure we\'re testing with at least 1 sibling');
+        $page = $chapter->pages->first();
+
+        $search = $this->actingAs($this->users->viewer())->get("/search/entity/siblings?entity_id={$page->id}&entity_type=page");
+        $search->assertSuccessful();
+        foreach ($chapter->pages as $page) {
+            $search->assertSee($page->name);
+        }
+
+        $search->assertDontSee($chapter->name);
+    }
+
+    public function test_sibling_search_for_pages_without_chapter()
+    {
+        $page = $this->entities->pageNotWithinChapter();
+        $bookChildren = $page->book->getDirectVisibleChildren();
+        $this->assertGreaterThan(2, count($bookChildren), 'Ensure we\'re testing with at least 1 sibling');
+
+        $search = $this->actingAs($this->users->viewer())->get("/search/entity/siblings?entity_id={$page->id}&entity_type=page");
+        $search->assertSuccessful();
+        foreach ($bookChildren as $child) {
+            $search->assertSee($child->name);
+        }
+
+        $search->assertDontSee($page->book->name);
+    }
+
+    public function test_sibling_search_for_chapters()
+    {
+        $chapter = $this->entities->chapter();
+        $bookChildren = $chapter->book->getDirectVisibleChildren();
+        $this->assertGreaterThan(2, count($bookChildren), 'Ensure we\'re testing with at least 1 sibling');
+
+        $search = $this->actingAs($this->users->viewer())->get("/search/entity/siblings?entity_id={$chapter->id}&entity_type=chapter");
+        $search->assertSuccessful();
+        foreach ($bookChildren as $child) {
+            $search->assertSee($child->name);
+        }
+
+        $search->assertDontSee($chapter->book->name);
+    }
+
+    public function test_sibling_search_for_books()
+    {
+        $books = Book::query()->take(10)->get();
+        $book = $books->first();
+        $this->assertGreaterThan(2, count($books), 'Ensure we\'re testing with at least 1 sibling');
+
+        $search = $this->actingAs($this->users->viewer())->get("/search/entity/siblings?entity_id={$book->id}&entity_type=book");
+        $search->assertSuccessful();
+        foreach ($books as $expectedBook) {
+            $search->assertSee($expectedBook->name);
+        }
+    }
+
+    public function test_sibling_search_for_shelves()
+    {
+        $shelves = Bookshelf::query()->take(10)->get();
+        $shelf = $shelves->first();
+        $this->assertGreaterThan(2, count($shelves), 'Ensure we\'re testing with at least 1 sibling');
+
+        $search = $this->actingAs($this->users->viewer())->get("/search/entity/siblings?entity_id={$shelf->id}&entity_type=bookshelf");
+        $search->assertSuccessful();
+        foreach ($shelves as $expectedShelf) {
+            $search->assertSee($expectedShelf->name);
+        }
+    }
+
+    public function test_sibling_search_for_books_provides_results_in_alphabetical_order()
+    {
+        $contextBook = $this->entities->book();
+        $searchBook = $this->entities->book();
+
+        $searchBook->name = 'Zebras';
+        $searchBook->save();
+
+        $search = $this->actingAs($this->users->viewer())->get("/search/entity/siblings?entity_id={$contextBook->id}&entity_type=book");
+        $this->withHtml($search)->assertElementNotContains('a:first-child', 'Zebras');
+
+        $searchBook->name = '1AAAAAAArdvarks';
+        $searchBook->save();
+
+        $search = $this->actingAs($this->users->viewer())->get("/search/entity/siblings?entity_id={$contextBook->id}&entity_type=book");
+        $this->withHtml($search)->assertElementContains('a:first-child', '1AAAAAAArdvarks');
+    }
+
+    public function test_sibling_search_for_shelves_provides_results_in_alphabetical_order()
+    {
+        $contextShelf = $this->entities->shelf();
+        $searchShelf = $this->entities->shelf();
+
+        $searchShelf->name = 'Zebras';
+        $searchShelf->save();
+
+        $search = $this->actingAs($this->users->viewer())->get("/search/entity/siblings?entity_id={$contextShelf->id}&entity_type=bookshelf");
+        $this->withHtml($search)->assertElementNotContains('a:first-child', 'Zebras');
+
+        $searchShelf->name = '1AAAAAAArdvarks';
+        $searchShelf->save();
+
+        $search = $this->actingAs($this->users->viewer())->get("/search/entity/siblings?entity_id={$contextShelf->id}&entity_type=bookshelf");
+        $this->withHtml($search)->assertElementContains('a:first-child', '1AAAAAAArdvarks');
+    }
+}
similarity index 51%
rename from tests/Entity/SortTest.php
rename to tests/Sorting/BookSortTest.php
index 9a5a2fe175a43ed0a2bbdd614f60b106ebdd9bb4..c4217a4cc6f0abde570aad08b2ee85781d2cebaf 100644 (file)
@@ -1,15 +1,27 @@
 <?php
 
-namespace Tests\Entity;
+namespace Sorting;
 
-use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Page;
 use BookStack\Entities\Repos\PageRepo;
+use BookStack\Sorting\SortRule;
 use Tests\TestCase;
 
-class SortTest extends TestCase
+class BookSortTest extends TestCase
 {
+    public function test_book_sort_page_shows()
+    {
+        $bookToSort = $this->entities->book();
+
+        $resp = $this->asAdmin()->get($bookToSort->getUrl());
+        $this->withHtml($resp)->assertElementExists('a[href="' . $bookToSort->getUrl('/sort') . '"]');
+
+        $resp = $this->get($bookToSort->getUrl('/sort'));
+        $resp->assertStatus(200);
+        $resp->assertSee($bookToSort->name);
+    }
+
     public function test_drafts_do_not_show_up()
     {
         $this->asAdmin();
@@ -20,232 +32,10 @@ class SortTest extends TestCase
         $resp = $this->get($book->getUrl());
         $resp->assertSee($draft->name);
 
-        $resp = $this->get($book->getUrl() . '/sort');
+        $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();
-
-        $resp = $this->asAdmin()->get($bookToSort->getUrl());
-        $this->withHtml($resp)->assertElementExists('a[href="' . $bookToSort->getUrl('/sort') . '"]');
-
-        $resp = $this->get($bookToSort->getUrl('/sort'));
-        $resp->assertStatus(200);
-        $resp->assertSee($bookToSort->name);
-    }
-
     public function test_book_sort()
     {
         $oldBook = $this->entities->book();
@@ -417,13 +207,39 @@ class SortTest extends TestCase
         ]);
     }
 
+    public function test_book_sort_does_not_change_timestamps_on_just_order_changes()
+    {
+        $book = $this->entities->bookHasChaptersAndPages();
+        $chapter = $book->chapters()->first();
+        \DB::table('chapters')->where('id', '=', $chapter->id)->update([
+            'priority' => 10001,
+            'updated_at' => \Carbon\Carbon::now()->subYear(5),
+        ]);
+
+        $chapter->refresh();
+        $oldUpdatedAt = $chapter->updated_at->unix();
+
+        $sortData = [
+            'id'            => $chapter->id,
+            'sort'          => 0,
+            'parentChapter' => false,
+            'type'          => 'chapter',
+            'book'          => $book->id,
+        ];
+        $this->asEditor()->put($book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect();
+
+        $chapter->refresh();
+        $this->assertNotEquals(10001, $chapter->priority);
+        $this->assertEquals($oldUpdatedAt, $chapter->updated_at->unix());
+    }
+
     public function test_book_sort_item_returns_book_content()
     {
         $bookToSort = $this->entities->book();
         $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 +247,53 @@ class SortTest extends TestCase
         $resp->assertSee($firstChapter->name);
     }
 
+    public function test_book_sort_item_shows_auto_sort_status()
+    {
+        $sort = SortRule::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_rule_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 = SortRule::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 = SortRule::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_rule_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 (file)
index 0000000..edae1f3
--- /dev/null
@@ -0,0 +1,221 @@
+<?php
+
+namespace Sorting;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use Tests\TestCase;
+
+class MoveTest extends TestCase
+{
+    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);
+    }
+}
diff --git a/tests/Sorting/SortRuleTest.php b/tests/Sorting/SortRuleTest.php
new file mode 100644 (file)
index 0000000..0f6f43c
--- /dev/null
@@ -0,0 +1,221 @@
+<?php
+
+namespace Sorting;
+
+use BookStack\Activity\ActivityType;
+use BookStack\Entities\Models\Book;
+use BookStack\Sorting\SortRule;
+use Tests\Api\TestsApi;
+use Tests\TestCase;
+
+class SortRuleTest extends TestCase
+{
+    use TestsApi;
+
+    public function test_manage_settings_permission_required()
+    {
+        $rule = SortRule::factory()->create();
+        $user = $this->users->viewer();
+        $this->actingAs($user);
+
+        $actions = [
+            ['GET', '/settings/sorting'],
+            ['POST', '/settings/sorting/rules'],
+            ['GET', "/settings/sorting/rules/{$rule->id}"],
+            ['PUT', "/settings/sorting/rules/{$rule->id}"],
+            ['DELETE', "/settings/sorting/rules/{$rule->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/rules/new'));
+
+        $resp = $this->get('/settings/sorting/rules/new');
+        $this->withHtml($resp)->assertElementExists('form[action$="/settings/sorting/rules"] input[name="name"]');
+        $resp->assertSeeText('Name - Alphabetical (Asc)');
+
+        $details = ['name' => 'My new sort', 'sequence' => 'name_asc'];
+        $resp = $this->post('/settings/sorting/rules', $details);
+        $resp->assertRedirect('/settings/sorting');
+
+        $this->assertActivityExists(ActivityType::SORT_RULE_CREATE);
+        $this->assertDatabaseHas('sort_rules', $details);
+    }
+
+    public function test_listing_in_settings()
+    {
+        $rule = SortRule::factory()->create(['name' => 'My super sort rule', 'sequence' => 'name_asc']);
+        $books = Book::query()->limit(5)->get();
+        foreach ($books as $book) {
+            $book->sort_rule_id = $rule->id;
+            $book->save();
+        }
+
+        $resp = $this->asAdmin()->get('/settings/sorting');
+        $resp->assertSeeText('My super sort rule');
+        $resp->assertSeeText('Name - Alphabetical (Asc)');
+        $this->withHtml($resp)->assertElementContains('.item-list-row [title="Assigned to 5 Books"]', '5');
+    }
+
+    public function test_update_flow()
+    {
+        $rule = SortRule::factory()->create(['name' => 'My sort rule to update', 'sequence' => 'name_asc']);
+
+        $resp = $this->asAdmin()->get("/settings/sorting/rules/{$rule->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/rules/{$rule->id}", $updateData);
+
+        $resp->assertRedirect('/settings/sorting');
+        $this->assertActivityExists(ActivityType::SORT_RULE_UPDATE);
+        $this->assertDatabaseHas('sort_rules', $updateData);
+    }
+
+    public function test_update_triggers_resort_on_assigned_books()
+    {
+        $book = $this->entities->bookHasChaptersAndPages();
+        $chapter = $book->chapters()->first();
+        $rule = SortRule::factory()->create(['name' => 'My sort rule to update', 'sequence' => 'name_asc']);
+        $book->sort_rule_id = $rule->id;
+        $book->save();
+        $chapter->priority = 10000;
+        $chapter->save();
+
+        $resp = $this->asAdmin()->put("/settings/sorting/rules/{$rule->id}", ['name' => $rule->name, 'sequence' => 'chapters_last']);
+        $resp->assertRedirect('/settings/sorting');
+
+        $chapter->refresh();
+        $this->assertNotEquals(10000, $chapter->priority);
+    }
+
+    public function test_delete_flow()
+    {
+        $rule = SortRule::factory()->create();
+
+        $resp = $this->asAdmin()->get("/settings/sorting/rules/{$rule->id}");
+        $resp->assertSeeText('Delete Sort Rule');
+
+        $resp = $this->delete("settings/sorting/rules/{$rule->id}");
+        $resp->assertRedirect('/settings/sorting');
+
+        $this->assertActivityExists(ActivityType::SORT_RULE_DELETE);
+        $this->assertDatabaseMissing('sort_rules', ['id' => $rule->id]);
+    }
+
+    public function test_delete_requires_confirmation_if_books_assigned()
+    {
+        $rule = SortRule::factory()->create();
+        $books = Book::query()->limit(5)->get();
+        foreach ($books as $book) {
+            $book->sort_rule_id = $rule->id;
+            $book->save();
+        }
+
+        $resp = $this->asAdmin()->get("/settings/sorting/rules/{$rule->id}");
+        $resp->assertSeeText('Delete Sort Rule');
+
+        $resp = $this->delete("settings/sorting/rules/{$rule->id}");
+        $resp->assertRedirect("/settings/sorting/rules/{$rule->id}#delete");
+        $resp = $this->followRedirects($resp);
+
+        $resp->assertSeeText('This sort rule is currently used on 5 book(s). Are you sure you want to delete this?');
+        $this->assertDatabaseHas('sort_rules', ['id' => $rule->id]);
+
+        $resp = $this->delete("settings/sorting/rules/{$rule->id}", ['confirm' => 'true']);
+        $resp->assertRedirect('/settings/sorting');
+        $this->assertDatabaseMissing('sort_rules', ['id' => $rule->id]);
+        $this->assertDatabaseMissing('books', ['sort_rule_id' => $rule->id]);
+    }
+
+    public function test_page_create_triggers_book_sort()
+    {
+        $book = $this->entities->bookHasChaptersAndPages();
+        $rule = SortRule::factory()->create(['sequence' => 'name_asc,chapters_first']);
+        $book->sort_rule_id = $rule->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_auto_book_sort_does_not_touch_timestamps()
+    {
+        $book = $this->entities->bookHasChaptersAndPages();
+        $rule = SortRule::factory()->create(['sequence' => 'name_asc,chapters_first']);
+        $book->sort_rule_id = $rule->id;
+        $book->save();
+        $page = $book->pages()->first();
+        $chapter = $book->chapters()->first();
+
+        $resp = $this->actingAsApiEditor()->put("/api/pages/{$page->id}", [
+            'name' => '1111 page',
+        ]);
+        $resp->assertOk();
+
+        $oldTime = $chapter->updated_at->unix();
+        $oldPriority = $chapter->priority;
+        $chapter->refresh();
+        $this->assertEquals($oldTime, $chapter->updated_at->unix());
+        $this->assertNotEquals($oldPriority, $chapter->priority);
+    }
+
+    public function test_name_numeric_ordering()
+    {
+        $book = Book::factory()->create();
+        $rule = SortRule::factory()->create(['sequence' => 'name_numeric_asc']);
+        $book->sort_rule_id = $rule->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,
+            ]);
+        }
+    }
+}
diff --git a/version b/version
index baf91f15e0009595b54b6fccda0057dfad55a650..51b8fdb60126539de9f783a339ddd9a3b71dbdc8 100644 (file)
--- a/version
+++ b/version
@@ -1 +1 @@
-v24.10-dev
+v25.02-dev