3 namespace BookStack\Sorting;
5 use BookStack\Entities\Models\Book;
6 use BookStack\Entities\Models\BookChild;
7 use BookStack\Entities\Models\Chapter;
8 use BookStack\Entities\Models\Entity;
9 use BookStack\Entities\Models\Page;
10 use BookStack\Entities\Queries\EntityQueries;
14 public function __construct(
15 protected EntityQueries $queries,
19 public function runBookAutoSortForAllWithSet(SortRule $set): void
21 $set->books()->chunk(50, function ($books) {
22 foreach ($books as $book) {
23 $this->runBookAutoSort($book);
29 * Runs the auto-sort for a book if the book has a sort set applied to it.
30 * This does not consider permissions since the sort operations are centrally
31 * managed by admins so considered permitted if existing and assigned.
33 public function runBookAutoSort(Book $book): void
35 $set = $book->sortRule;
40 $sortFunctions = array_map(function (SortRuleOperation $op) {
41 return $op->getSortFunction();
42 }, $set->getOperations());
44 $chapters = $book->chapters()
45 ->with('pages:id,name,priority,created_at,updated_at,chapter_id')
46 ->get(['id', 'name', 'priority', 'created_at', 'updated_at']);
48 /** @var (Chapter|Book)[] $topItems */
50 ...$book->directPages()->get(['id', 'name', 'priority', 'created_at', 'updated_at']),
54 foreach ($sortFunctions as $sortFunction) {
55 usort($topItems, $sortFunction);
58 foreach ($topItems as $index => $topItem) {
59 $topItem->priority = $index + 1;
63 foreach ($chapters as $chapter) {
64 $pages = $chapter->pages->all();
65 foreach ($sortFunctions as $sortFunction) {
66 usort($pages, $sortFunction);
69 foreach ($pages as $index => $page) {
70 $page->priority = $index + 1;
78 * Sort the books content using the given sort map.
79 * Returns a list of books that were involved in the operation.
83 public function sortUsingMap(BookSortMap $sortMap): array
85 // Load models into map
86 $modelMap = $this->loadModelsFromSortMap($sortMap);
88 // Sort our changes from our map to be chapters first
89 // Since they need to be process to ensure book alignment for child page changes.
90 $sortMapItems = $sortMap->all();
91 usort($sortMapItems, function (BookSortMapItem $itemA, BookSortMapItem $itemB) {
92 $aScore = $itemA->type === 'page' ? 2 : 1;
93 $bScore = $itemB->type === 'page' ? 2 : 1;
95 return $aScore - $bScore;
99 foreach ($sortMapItems as $item) {
100 $this->applySortUpdates($item, $modelMap);
103 /** @var Book[] $booksInvolved */
104 $booksInvolved = array_values(array_filter($modelMap, function (string $key) {
105 return str_starts_with($key, 'book:');
106 }, ARRAY_FILTER_USE_KEY));
108 // Update permissions of books involved
109 foreach ($booksInvolved as $book) {
110 $book->rebuildPermissions();
113 return $booksInvolved;
117 * Using the given sort map item, detect changes for the related model
118 * and update it if required. Changes where permissions are lacking will
119 * be skipped and not throw an error.
121 * @param array<string, Entity> $modelMap
123 protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void
125 /** @var BookChild $model */
126 $model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null;
131 $priorityChanged = $model->priority !== $sortMapItem->sort;
132 $bookChanged = $model->book_id !== $sortMapItem->parentBookId;
133 $chapterChanged = ($model instanceof Page) && $model->chapter_id !== $sortMapItem->parentChapterId;
135 // Stop if there's no change
136 if (!$priorityChanged && !$bookChanged && !$chapterChanged) {
140 $currentParentKey = 'book:' . $model->book_id;
141 if ($model instanceof Page && $model->chapter_id) {
142 $currentParentKey = 'chapter:' . $model->chapter_id;
145 $currentParent = $modelMap[$currentParentKey] ?? null;
146 /** @var Book $newBook */
147 $newBook = $modelMap['book:' . $sortMapItem->parentBookId] ?? null;
148 /** @var ?Chapter $newChapter */
149 $newChapter = $sortMapItem->parentChapterId ? ($modelMap['chapter:' . $sortMapItem->parentChapterId] ?? null) : null;
151 if (!$this->isSortChangePermissible($sortMapItem, $model, $currentParent, $newBook, $newChapter)) {
155 // Action the required changes
157 $model->changeBook($newBook->id);
160 if ($model instanceof Page && $chapterChanged) {
161 $model->chapter_id = $newChapter->id ?? 0;
164 if ($priorityChanged) {
165 $model->priority = $sortMapItem->sort;
168 if ($chapterChanged || $priorityChanged) {
174 * Check if the current user has permissions to apply the given sorting change.
175 * Is quite complex since items can gain a different parent change. Acts as a:
176 * - Update of old parent element (Change of content/order).
177 * - Update of sorted/moved element.
178 * - Deletion of element (Relative to parent upon move).
179 * - Creation of element within parent (Upon move to new parent).
181 protected function isSortChangePermissible(BookSortMapItem $sortMapItem, BookChild $model, ?Entity $currentParent, ?Entity $newBook, ?Entity $newChapter): bool
183 // Stop if we can't see the current parent or new book.
184 if (!$currentParent || !$newBook) {
188 $hasNewParent = $newBook->id !== $model->book_id || ($model instanceof Page && $model->chapter_id !== ($sortMapItem->parentChapterId ?? 0));
189 if ($model instanceof Chapter) {
190 $hasPermission = userCan('book-update', $currentParent)
191 && userCan('book-update', $newBook)
192 && userCan('chapter-update', $model)
193 && (!$hasNewParent || userCan('chapter-create', $newBook))
194 && (!$hasNewParent || userCan('chapter-delete', $model));
196 if (!$hasPermission) {
201 if ($model instanceof Page) {
202 $parentPermission = ($currentParent instanceof Chapter) ? 'chapter-update' : 'book-update';
203 $hasCurrentParentPermission = userCan($parentPermission, $currentParent);
205 // This needs to check if there was an intended chapter location in the original sort map
206 // rather than inferring from the $newChapter since that variable may be null
207 // due to other reasons (Visibility).
208 $newParent = $sortMapItem->parentChapterId ? $newChapter : $newBook;
213 $hasPageEditPermission = userCan('page-update', $model);
214 $newParentInRightLocation = ($newParent instanceof Book || ($newParent instanceof Chapter && $newParent->book_id === $newBook->id));
215 $newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update';
216 $hasNewParentPermission = userCan($newParentPermission, $newParent);
218 $hasDeletePermissionIfMoving = (!$hasNewParent || userCan('page-delete', $model));
219 $hasCreatePermissionIfMoving = (!$hasNewParent || userCan('page-create', $newParent));
221 $hasPermission = $hasCurrentParentPermission
222 && $newParentInRightLocation
223 && $hasNewParentPermission
224 && $hasPageEditPermission
225 && $hasDeletePermissionIfMoving
226 && $hasCreatePermissionIfMoving;
228 if (!$hasPermission) {
237 * Load models from the database into the given sort map.
239 * @return array<string, Entity>
241 protected function loadModelsFromSortMap(BookSortMap $sortMap): array
250 foreach ($sortMap->all() as $sortMapItem) {
251 $ids[$sortMapItem->type][] = $sortMapItem->id;
252 $ids['book'][] = $sortMapItem->parentBookId;
253 if ($sortMapItem->parentChapterId) {
254 $ids['chapter'][] = $sortMapItem->parentChapterId;
258 $pages = $this->queries->pages->visibleForList()->whereIn('id', array_unique($ids['page']))->get();
259 /** @var Page $page */
260 foreach ($pages as $page) {
261 $modelMap['page:' . $page->id] = $page;
262 $ids['book'][] = $page->book_id;
263 if ($page->chapter_id) {
264 $ids['chapter'][] = $page->chapter_id;
268 $chapters = $this->queries->chapters->visibleForList()->whereIn('id', array_unique($ids['chapter']))->get();
269 /** @var Chapter $chapter */
270 foreach ($chapters as $chapter) {
271 $modelMap['chapter:' . $chapter->id] = $chapter;
272 $ids['book'][] = $chapter->book_id;
275 $books = $this->queries->books->visibleForList()->whereIn('id', array_unique($ids['book']))->get();
276 /** @var Book $book */
277 foreach ($books as $book) {
278 $modelMap['book:' . $book->id] = $book;