3 namespace BookStack\Sorting;
5 use BookStack\App\Model;
6 use BookStack\Entities\Models\Book;
7 use BookStack\Entities\Models\BookChild;
8 use BookStack\Entities\Models\Chapter;
9 use BookStack\Entities\Models\Entity;
10 use BookStack\Entities\Models\Page;
11 use BookStack\Entities\Queries\EntityQueries;
15 public function __construct(
16 protected EntityQueries $queries,
20 public function runBookAutoSortForAllWithSet(SortRule $set): void
22 $set->books()->chunk(50, function ($books) {
23 foreach ($books as $book) {
24 $this->runBookAutoSort($book);
30 * Runs the auto-sort for a book if the book has a sort set applied to it.
31 * This does not consider permissions since the sort operations are centrally
32 * managed by admins so considered permitted if existing and assigned.
34 public function runBookAutoSort(Book $book): void
36 $set = $book->sortRule;
41 $sortFunctions = array_map(function (SortRuleOperation $op) {
42 return $op->getSortFunction();
43 }, $set->getOperations());
45 $chapters = $book->chapters()
46 ->with('pages:id,name,priority,created_at,updated_at,chapter_id')
47 ->get(['id', 'name', 'priority', 'created_at', 'updated_at']);
49 /** @var (Chapter|Book)[] $topItems */
51 ...$book->directPages()->get(['id', 'name', 'priority', 'created_at', 'updated_at']),
55 foreach ($sortFunctions as $sortFunction) {
56 usort($topItems, $sortFunction);
59 foreach ($topItems as $index => $topItem) {
60 $topItem->priority = $index + 1;
61 $topItem::withoutTimestamps(fn () => $topItem->save());
64 foreach ($chapters as $chapter) {
65 $pages = $chapter->pages->all();
66 foreach ($sortFunctions as $sortFunction) {
67 usort($pages, $sortFunction);
70 foreach ($pages as $index => $page) {
71 $page->priority = $index + 1;
72 $page::withoutTimestamps(fn () => $page->save());
79 * Sort the books content using the given sort map.
80 * Returns a list of books that were involved in the operation.
84 public function sortUsingMap(BookSortMap $sortMap): array
86 // Load models into map
87 $modelMap = $this->loadModelsFromSortMap($sortMap);
89 // Sort our changes from our map to be chapters first
90 // Since they need to be process to ensure book alignment for child page changes.
91 $sortMapItems = $sortMap->all();
92 usort($sortMapItems, function (BookSortMapItem $itemA, BookSortMapItem $itemB) {
93 $aScore = $itemA->type === 'page' ? 2 : 1;
94 $bScore = $itemB->type === 'page' ? 2 : 1;
96 return $aScore - $bScore;
100 foreach ($sortMapItems as $item) {
101 $this->applySortUpdates($item, $modelMap);
104 /** @var Book[] $booksInvolved */
105 $booksInvolved = array_values(array_filter($modelMap, function (string $key) {
106 return str_starts_with($key, 'book:');
107 }, ARRAY_FILTER_USE_KEY));
109 // Update permissions of books involved
110 foreach ($booksInvolved as $book) {
111 $book->rebuildPermissions();
114 return $booksInvolved;
118 * Using the given sort map item, detect changes for the related model
119 * and update it if required. Changes where permissions are lacking will
120 * be skipped and not throw an error.
122 * @param array<string, Entity> $modelMap
124 protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void
126 /** @var BookChild $model */
127 $model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null;
132 $priorityChanged = $model->priority !== $sortMapItem->sort;
133 $bookChanged = $model->book_id !== $sortMapItem->parentBookId;
134 $chapterChanged = ($model instanceof Page) && $model->chapter_id !== $sortMapItem->parentChapterId;
136 // Stop if there's no change
137 if (!$priorityChanged && !$bookChanged && !$chapterChanged) {
141 $currentParentKey = 'book:' . $model->book_id;
142 if ($model instanceof Page && $model->chapter_id) {
143 $currentParentKey = 'chapter:' . $model->chapter_id;
146 $currentParent = $modelMap[$currentParentKey] ?? null;
147 /** @var Book $newBook */
148 $newBook = $modelMap['book:' . $sortMapItem->parentBookId] ?? null;
149 /** @var ?Chapter $newChapter */
150 $newChapter = $sortMapItem->parentChapterId ? ($modelMap['chapter:' . $sortMapItem->parentChapterId] ?? null) : null;
152 if (!$this->isSortChangePermissible($sortMapItem, $model, $currentParent, $newBook, $newChapter)) {
156 // Action the required changes
158 $model->changeBook($newBook->id);
161 if ($model instanceof Page && $chapterChanged) {
162 $model->chapter_id = $newChapter->id ?? 0;
165 if ($priorityChanged) {
166 $model->priority = $sortMapItem->sort;
169 if ($chapterChanged || $priorityChanged) {
170 $model::withoutTimestamps(fn () => $model->save());
175 * Check if the current user has permissions to apply the given sorting change.
176 * Is quite complex since items can gain a different parent change. Acts as a:
177 * - Update of old parent element (Change of content/order).
178 * - Update of sorted/moved element.
179 * - Deletion of element (Relative to parent upon move).
180 * - Creation of element within parent (Upon move to new parent).
182 protected function isSortChangePermissible(BookSortMapItem $sortMapItem, BookChild $model, ?Entity $currentParent, ?Entity $newBook, ?Entity $newChapter): bool
184 // Stop if we can't see the current parent or new book.
185 if (!$currentParent || !$newBook) {
189 $hasNewParent = $newBook->id !== $model->book_id || ($model instanceof Page && $model->chapter_id !== ($sortMapItem->parentChapterId ?? 0));
190 if ($model instanceof Chapter) {
191 $hasPermission = userCan('book-update', $currentParent)
192 && userCan('book-update', $newBook)
193 && userCan('chapter-update', $model)
194 && (!$hasNewParent || userCan('chapter-create', $newBook))
195 && (!$hasNewParent || userCan('chapter-delete', $model));
197 if (!$hasPermission) {
202 if ($model instanceof Page) {
203 $parentPermission = ($currentParent instanceof Chapter) ? 'chapter-update' : 'book-update';
204 $hasCurrentParentPermission = userCan($parentPermission, $currentParent);
206 // This needs to check if there was an intended chapter location in the original sort map
207 // rather than inferring from the $newChapter since that variable may be null
208 // due to other reasons (Visibility).
209 $newParent = $sortMapItem->parentChapterId ? $newChapter : $newBook;
214 $hasPageEditPermission = userCan('page-update', $model);
215 $newParentInRightLocation = ($newParent instanceof Book || ($newParent instanceof Chapter && $newParent->book_id === $newBook->id));
216 $newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update';
217 $hasNewParentPermission = userCan($newParentPermission, $newParent);
219 $hasDeletePermissionIfMoving = (!$hasNewParent || userCan('page-delete', $model));
220 $hasCreatePermissionIfMoving = (!$hasNewParent || userCan('page-create', $newParent));
222 $hasPermission = $hasCurrentParentPermission
223 && $newParentInRightLocation
224 && $hasNewParentPermission
225 && $hasPageEditPermission
226 && $hasDeletePermissionIfMoving
227 && $hasCreatePermissionIfMoving;
229 if (!$hasPermission) {
238 * Load models from the database into the given sort map.
240 * @return array<string, Entity>
242 protected function loadModelsFromSortMap(BookSortMap $sortMap): array
251 foreach ($sortMap->all() as $sortMapItem) {
252 $ids[$sortMapItem->type][] = $sortMapItem->id;
253 $ids['book'][] = $sortMapItem->parentBookId;
254 if ($sortMapItem->parentChapterId) {
255 $ids['chapter'][] = $sortMapItem->parentChapterId;
259 $pages = $this->queries->pages->visibleForList()->whereIn('id', array_unique($ids['page']))->get();
260 /** @var Page $page */
261 foreach ($pages as $page) {
262 $modelMap['page:' . $page->id] = $page;
263 $ids['book'][] = $page->book_id;
264 if ($page->chapter_id) {
265 $ids['chapter'][] = $page->chapter_id;
269 $chapters = $this->queries->chapters->visibleForList()->whereIn('id', array_unique($ids['chapter']))->get();
270 /** @var Chapter $chapter */
271 foreach ($chapters as $chapter) {
272 $modelMap['chapter:' . $chapter->id] = $chapter;
273 $ids['book'][] = $chapter->book_id;
276 $books = $this->queries->books->visibleForList()->whereIn('id', array_unique($ids['book']))->get();
277 /** @var Book $book */
278 foreach ($books as $book) {
279 $modelMap['book:' . $book->id] = $book;