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,
20 * Runs the auto-sort for a book if the book has a sort set applied to it.
21 * This does not consider permissions since the sort operations are centrally
22 * managed by admins so considered permitted if existing and assigned.
24 public function runBookAutoSort(Book $book): void
26 $set = $book->sortSet;
31 $sortFunctions = array_map(function (SortSetOperation $op) {
32 return $op->getSortFunction();
33 }, $set->getOperations());
35 $chapters = $book->chapters()
36 ->with('pages:id,name,priority,created_at,updated_at')
37 ->get(['id', 'name', 'priority', 'created_at', 'updated_at']);
39 /** @var (Chapter|Book)[] $topItems */
41 ...$book->directPages()->get(['id', 'name', 'priority', 'created_at', 'updated_at']),
45 foreach ($sortFunctions as $sortFunction) {
46 usort($topItems, $sortFunction);
49 foreach ($topItems as $index => $topItem) {
50 $topItem->priority = $index + 1;
54 foreach ($chapters as $chapter) {
55 $pages = $chapter->pages->all();
56 foreach ($sortFunctions as $sortFunction) {
57 usort($pages, $sortFunction);
60 foreach ($pages as $index => $page) {
61 $page->priority = $index + 1;
69 * Sort the books content using the given sort map.
70 * Returns a list of books that were involved in the operation.
74 public function sortUsingMap(BookSortMap $sortMap): array
76 // Load models into map
77 $modelMap = $this->loadModelsFromSortMap($sortMap);
79 // Sort our changes from our map to be chapters first
80 // Since they need to be process to ensure book alignment for child page changes.
81 $sortMapItems = $sortMap->all();
82 usort($sortMapItems, function (BookSortMapItem $itemA, BookSortMapItem $itemB) {
83 $aScore = $itemA->type === 'page' ? 2 : 1;
84 $bScore = $itemB->type === 'page' ? 2 : 1;
86 return $aScore - $bScore;
90 foreach ($sortMapItems as $item) {
91 $this->applySortUpdates($item, $modelMap);
94 /** @var Book[] $booksInvolved */
95 $booksInvolved = array_values(array_filter($modelMap, function (string $key) {
96 return str_starts_with($key, 'book:');
97 }, ARRAY_FILTER_USE_KEY));
99 // Update permissions of books involved
100 foreach ($booksInvolved as $book) {
101 $book->rebuildPermissions();
104 return $booksInvolved;
108 * Using the given sort map item, detect changes for the related model
109 * and update it if required. Changes where permissions are lacking will
110 * be skipped and not throw an error.
112 * @param array<string, Entity> $modelMap
114 protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void
116 /** @var BookChild $model */
117 $model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null;
122 $priorityChanged = $model->priority !== $sortMapItem->sort;
123 $bookChanged = $model->book_id !== $sortMapItem->parentBookId;
124 $chapterChanged = ($model instanceof Page) && $model->chapter_id !== $sortMapItem->parentChapterId;
126 // Stop if there's no change
127 if (!$priorityChanged && !$bookChanged && !$chapterChanged) {
131 $currentParentKey = 'book:' . $model->book_id;
132 if ($model instanceof Page && $model->chapter_id) {
133 $currentParentKey = 'chapter:' . $model->chapter_id;
136 $currentParent = $modelMap[$currentParentKey] ?? null;
137 /** @var Book $newBook */
138 $newBook = $modelMap['book:' . $sortMapItem->parentBookId] ?? null;
139 /** @var ?Chapter $newChapter */
140 $newChapter = $sortMapItem->parentChapterId ? ($modelMap['chapter:' . $sortMapItem->parentChapterId] ?? null) : null;
142 if (!$this->isSortChangePermissible($sortMapItem, $model, $currentParent, $newBook, $newChapter)) {
146 // Action the required changes
148 $model->changeBook($newBook->id);
151 if ($model instanceof Page && $chapterChanged) {
152 $model->chapter_id = $newChapter->id ?? 0;
155 if ($priorityChanged) {
156 $model->priority = $sortMapItem->sort;
159 if ($chapterChanged || $priorityChanged) {
165 * Check if the current user has permissions to apply the given sorting change.
166 * Is quite complex since items can gain a different parent change. Acts as a:
167 * - Update of old parent element (Change of content/order).
168 * - Update of sorted/moved element.
169 * - Deletion of element (Relative to parent upon move).
170 * - Creation of element within parent (Upon move to new parent).
172 protected function isSortChangePermissible(BookSortMapItem $sortMapItem, BookChild $model, ?Entity $currentParent, ?Entity $newBook, ?Entity $newChapter): bool
174 // Stop if we can't see the current parent or new book.
175 if (!$currentParent || !$newBook) {
179 $hasNewParent = $newBook->id !== $model->book_id || ($model instanceof Page && $model->chapter_id !== ($sortMapItem->parentChapterId ?? 0));
180 if ($model instanceof Chapter) {
181 $hasPermission = userCan('book-update', $currentParent)
182 && userCan('book-update', $newBook)
183 && userCan('chapter-update', $model)
184 && (!$hasNewParent || userCan('chapter-create', $newBook))
185 && (!$hasNewParent || userCan('chapter-delete', $model));
187 if (!$hasPermission) {
192 if ($model instanceof Page) {
193 $parentPermission = ($currentParent instanceof Chapter) ? 'chapter-update' : 'book-update';
194 $hasCurrentParentPermission = userCan($parentPermission, $currentParent);
196 // This needs to check if there was an intended chapter location in the original sort map
197 // rather than inferring from the $newChapter since that variable may be null
198 // due to other reasons (Visibility).
199 $newParent = $sortMapItem->parentChapterId ? $newChapter : $newBook;
204 $hasPageEditPermission = userCan('page-update', $model);
205 $newParentInRightLocation = ($newParent instanceof Book || ($newParent instanceof Chapter && $newParent->book_id === $newBook->id));
206 $newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update';
207 $hasNewParentPermission = userCan($newParentPermission, $newParent);
209 $hasDeletePermissionIfMoving = (!$hasNewParent || userCan('page-delete', $model));
210 $hasCreatePermissionIfMoving = (!$hasNewParent || userCan('page-create', $newParent));
212 $hasPermission = $hasCurrentParentPermission
213 && $newParentInRightLocation
214 && $hasNewParentPermission
215 && $hasPageEditPermission
216 && $hasDeletePermissionIfMoving
217 && $hasCreatePermissionIfMoving;
219 if (!$hasPermission) {
228 * Load models from the database into the given sort map.
230 * @return array<string, Entity>
232 protected function loadModelsFromSortMap(BookSortMap $sortMap): array
241 foreach ($sortMap->all() as $sortMapItem) {
242 $ids[$sortMapItem->type][] = $sortMapItem->id;
243 $ids['book'][] = $sortMapItem->parentBookId;
244 if ($sortMapItem->parentChapterId) {
245 $ids['chapter'][] = $sortMapItem->parentChapterId;
249 $pages = $this->queries->pages->visibleForList()->whereIn('id', array_unique($ids['page']))->get();
250 /** @var Page $page */
251 foreach ($pages as $page) {
252 $modelMap['page:' . $page->id] = $page;
253 $ids['book'][] = $page->book_id;
254 if ($page->chapter_id) {
255 $ids['chapter'][] = $page->chapter_id;
259 $chapters = $this->queries->chapters->visibleForList()->whereIn('id', array_unique($ids['chapter']))->get();
260 /** @var Chapter $chapter */
261 foreach ($chapters as $chapter) {
262 $modelMap['chapter:' . $chapter->id] = $chapter;
263 $ids['book'][] = $chapter->book_id;
266 $books = $this->queries->books->visibleForList()->whereIn('id', array_unique($ids['book']))->get();
267 /** @var Book $book */
268 foreach ($books as $book) {
269 $modelMap['book:' . $book->id] = $book;