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;
11 use BookStack\Entities\Tools\ParentChanger;
12 use BookStack\Permissions\Permission;
16 public function __construct(
17 protected EntityQueries $queries,
18 protected ParentChanger $parentChanger,
22 public function runBookAutoSortForAllWithSet(SortRule $set): void
24 $set->books()->chunk(50, function ($books) {
25 foreach ($books as $book) {
26 $this->runBookAutoSort($book);
32 * Runs the auto-sort for a book if the book has a sort set applied to it.
33 * This does not consider permissions since the sort operations are centrally
34 * managed by admins so considered permitted if existing and assigned.
36 public function runBookAutoSort(Book $book): void
38 $rule = $book->sortRule()->first();
39 if (!($rule instanceof SortRule)) {
43 $sortFunctions = array_map(function (SortRuleOperation $op) {
44 return $op->getSortFunction();
45 }, $rule->getOperations());
47 $chapters = $book->chapters()
48 ->with('pages:id,name,book_id,chapter_id,priority,created_at,updated_at')
49 ->get(['id', 'name', 'priority', 'created_at', 'updated_at']);
51 /** @var (Chapter|Book)[] $topItems */
53 ...$book->directPages()->get(['id', 'book_id', 'name', 'priority', 'created_at', 'updated_at']),
57 foreach ($sortFunctions as $sortFunction) {
58 usort($topItems, $sortFunction);
61 foreach ($topItems as $index => $topItem) {
62 $topItem->priority = $index + 1;
63 $topItem::withoutTimestamps(fn () => $topItem->save());
66 foreach ($chapters as $chapter) {
67 $pages = $chapter->pages->all();
68 foreach ($sortFunctions as $sortFunction) {
69 usort($pages, $sortFunction);
72 foreach ($pages as $index => $page) {
73 $page->priority = $index + 1;
74 $page::withoutTimestamps(fn () => $page->save());
81 * Sort the books content using the given sort map.
82 * Returns a list of books that were involved in the operation.
86 public function sortUsingMap(BookSortMap $sortMap): array
88 // Load models into map
89 $modelMap = $this->loadModelsFromSortMap($sortMap);
91 // Sort our changes from our map to be chapters first
92 // Since they need to be process to ensure book alignment for child page changes.
93 $sortMapItems = $sortMap->all();
94 usort($sortMapItems, function (BookSortMapItem $itemA, BookSortMapItem $itemB) {
95 $aScore = $itemA->type === 'page' ? 2 : 1;
96 $bScore = $itemB->type === 'page' ? 2 : 1;
98 return $aScore - $bScore;
102 foreach ($sortMapItems as $item) {
103 $this->applySortUpdates($item, $modelMap);
106 /** @var Book[] $booksInvolved */
107 $booksInvolved = array_values(array_filter($modelMap, function (string $key) {
108 return str_starts_with($key, 'book:');
109 }, ARRAY_FILTER_USE_KEY));
111 // Update permissions of books involved
112 foreach ($booksInvolved as $book) {
113 $book->rebuildPermissions();
116 return $booksInvolved;
120 * Using the given sort map item, detect changes for the related model
121 * and update it if required. Changes where permissions are lacking will
122 * be skipped and not throw an error.
124 * @param array<string, Entity> $modelMap
126 protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void
128 /** @var BookChild $model */
129 $model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null;
134 $priorityChanged = $model->priority !== $sortMapItem->sort;
135 $bookChanged = $model->book_id !== $sortMapItem->parentBookId;
136 $chapterChanged = ($model instanceof Page) && $model->chapter_id !== $sortMapItem->parentChapterId;
138 // Stop if there's no change
139 if (!$priorityChanged && !$bookChanged && !$chapterChanged) {
143 $currentParentKey = 'book:' . $model->book_id;
144 if ($model instanceof Page && $model->chapter_id) {
145 $currentParentKey = 'chapter:' . $model->chapter_id;
148 $currentParent = $modelMap[$currentParentKey] ?? null;
149 /** @var Book $newBook */
150 $newBook = $modelMap['book:' . $sortMapItem->parentBookId] ?? null;
151 /** @var ?Chapter $newChapter */
152 $newChapter = $sortMapItem->parentChapterId ? ($modelMap['chapter:' . $sortMapItem->parentChapterId] ?? null) : null;
154 if (!$this->isSortChangePermissible($sortMapItem, $model, $currentParent, $newBook, $newChapter)) {
158 // Action the required changes
160 $this->parentChanger->changeBook($model, $newBook->id);
163 if ($model instanceof Page && $chapterChanged) {
164 $model->chapter_id = $newChapter->id ?? 0;
165 $model->unsetRelation('chapter');
168 if ($priorityChanged) {
169 $model->priority = $sortMapItem->sort;
172 if ($chapterChanged || $priorityChanged) {
173 $model::withoutTimestamps(fn () => $model->save());
178 * Check if the current user has permissions to apply the given sorting change.
179 * Is quite complex since items can gain a different parent change. Acts as a:
180 * - Update of old parent element (Change of content/order).
181 * - Update of sorted/moved element.
182 * - Deletion of element (Relative to parent upon move).
183 * - Creation of element within parent (Upon move to new parent).
185 protected function isSortChangePermissible(BookSortMapItem $sortMapItem, BookChild $model, ?Entity $currentParent, ?Entity $newBook, ?Entity $newChapter): bool
187 // Stop if we can't see the current parent or new book.
188 if (!$currentParent || !$newBook) {
192 $hasNewParent = $newBook->id !== $model->book_id || ($model instanceof Page && $model->chapter_id !== ($sortMapItem->parentChapterId ?? 0));
193 if ($model instanceof Chapter) {
194 $hasPermission = userCan(Permission::BookUpdate, $currentParent)
195 && userCan(Permission::BookUpdate, $newBook)
196 && userCan(Permission::ChapterUpdate, $model)
197 && (!$hasNewParent || userCan(Permission::ChapterCreate, $newBook))
198 && (!$hasNewParent || userCan(Permission::ChapterDelete, $model));
200 if (!$hasPermission) {
205 if ($model instanceof Page) {
206 $parentPermission = ($currentParent instanceof Chapter) ? 'chapter-update' : 'book-update';
207 $hasCurrentParentPermission = userCan($parentPermission, $currentParent);
209 // This needs to check if there was an intended chapter location in the original sort map
210 // rather than inferring from the $newChapter since that variable may be null
211 // due to other reasons (Visibility).
212 $newParent = $sortMapItem->parentChapterId ? $newChapter : $newBook;
217 $hasPageEditPermission = userCan(Permission::PageUpdate, $model);
218 $newParentInRightLocation = ($newParent instanceof Book || ($newParent instanceof Chapter && $newParent->book_id === $newBook->id));
219 $newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update';
220 $hasNewParentPermission = userCan($newParentPermission, $newParent);
222 $hasDeletePermissionIfMoving = (!$hasNewParent || userCan(Permission::PageDelete, $model));
223 $hasCreatePermissionIfMoving = (!$hasNewParent || userCan(Permission::PageCreate, $newParent));
225 $hasPermission = $hasCurrentParentPermission
226 && $newParentInRightLocation
227 && $hasNewParentPermission
228 && $hasPageEditPermission
229 && $hasDeletePermissionIfMoving
230 && $hasCreatePermissionIfMoving;
232 if (!$hasPermission) {
241 * Load models from the database into the given sort map.
243 * @return array<string, Entity>
245 protected function loadModelsFromSortMap(BookSortMap $sortMap): array
254 foreach ($sortMap->all() as $sortMapItem) {
255 $ids[$sortMapItem->type][] = $sortMapItem->id;
256 $ids['book'][] = $sortMapItem->parentBookId;
257 if ($sortMapItem->parentChapterId) {
258 $ids['chapter'][] = $sortMapItem->parentChapterId;
262 $pages = $this->queries->pages->visibleForList()->whereIn('id', array_unique($ids['page']))->get();
263 /** @var Page $page */
264 foreach ($pages as $page) {
265 $modelMap['page:' . $page->id] = $page;
266 $ids['book'][] = $page->book_id;
267 if ($page->chapter_id) {
268 $ids['chapter'][] = $page->chapter_id;
272 $chapters = $this->queries->chapters->visibleForList()->whereIn('id', array_unique($ids['chapter']))->get();
273 /** @var Chapter $chapter */
274 foreach ($chapters as $chapter) {
275 $modelMap['chapter:' . $chapter->id] = $chapter;
276 $ids['book'][] = $chapter->book_id;
279 $books = $this->queries->books->visibleForList()->whereIn('id', array_unique($ids['book']))->get();
280 /** @var Book $book */
281 foreach ($books as $book) {
282 $modelMap['book:' . $book->id] = $book;