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,
21 * Sort the books content using the given sort map.
22 * Returns a list of books that were involved in the operation.
26 public function sortUsingMap(BookSortMap $sortMap): array
28 // Load models into map
29 $modelMap = $this->loadModelsFromSortMap($sortMap);
31 // Sort our changes from our map to be chapters first
32 // Since they need to be process to ensure book alignment for child page changes.
33 $sortMapItems = $sortMap->all();
34 usort($sortMapItems, function (BookSortMapItem $itemA, BookSortMapItem $itemB) {
35 $aScore = $itemA->type === 'page' ? 2 : 1;
36 $bScore = $itemB->type === 'page' ? 2 : 1;
38 return $aScore - $bScore;
42 foreach ($sortMapItems as $item) {
43 $this->applySortUpdates($item, $modelMap);
46 /** @var Book[] $booksInvolved */
47 $booksInvolved = array_values(array_filter($modelMap, function (string $key) {
48 return str_starts_with($key, 'book:');
49 }, ARRAY_FILTER_USE_KEY));
51 // Update permissions of books involved
52 foreach ($booksInvolved as $book) {
53 $book->rebuildPermissions();
56 return $booksInvolved;
60 * Using the given sort map item, detect changes for the related model
61 * and update it if required. Changes where permissions are lacking will
62 * be skipped and not throw an error.
64 * @param array<string, Entity> $modelMap
66 protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void
68 /** @var BookChild $model */
69 $model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null;
74 $priorityChanged = $model->priority !== $sortMapItem->sort;
75 $bookChanged = $model->book_id !== $sortMapItem->parentBookId;
76 $chapterChanged = ($model instanceof Page) && $model->chapter_id !== $sortMapItem->parentChapterId;
78 // Stop if there's no change
79 if (!$priorityChanged && !$bookChanged && !$chapterChanged) {
83 $currentParentKey = 'book:' . $model->book_id;
84 if ($model instanceof Page && $model->chapter_id) {
85 $currentParentKey = 'chapter:' . $model->chapter_id;
88 $currentParent = $modelMap[$currentParentKey] ?? null;
89 /** @var Book $newBook */
90 $newBook = $modelMap['book:' . $sortMapItem->parentBookId] ?? null;
91 /** @var ?Chapter $newChapter */
92 $newChapter = $sortMapItem->parentChapterId ? ($modelMap['chapter:' . $sortMapItem->parentChapterId] ?? null) : null;
94 if (!$this->isSortChangePermissible($sortMapItem, $model, $currentParent, $newBook, $newChapter)) {
98 // Action the required changes
100 $model->changeBook($newBook->id);
103 if ($model instanceof Page && $chapterChanged) {
104 $model->chapter_id = $newChapter->id ?? 0;
107 if ($priorityChanged) {
108 $model->priority = $sortMapItem->sort;
111 if ($chapterChanged || $priorityChanged) {
117 * Check if the current user has permissions to apply the given sorting change.
118 * Is quite complex since items can gain a different parent change. Acts as a:
119 * - Update of old parent element (Change of content/order).
120 * - Update of sorted/moved element.
121 * - Deletion of element (Relative to parent upon move).
122 * - Creation of element within parent (Upon move to new parent).
124 protected function isSortChangePermissible(BookSortMapItem $sortMapItem, BookChild $model, ?Entity $currentParent, ?Entity $newBook, ?Entity $newChapter): bool
126 // Stop if we can't see the current parent or new book.
127 if (!$currentParent || !$newBook) {
131 $hasNewParent = $newBook->id !== $model->book_id || ($model instanceof Page && $model->chapter_id !== ($sortMapItem->parentChapterId ?? 0));
132 if ($model instanceof Chapter) {
133 $hasPermission = userCan('book-update', $currentParent)
134 && userCan('book-update', $newBook)
135 && userCan('chapter-update', $model)
136 && (!$hasNewParent || userCan('chapter-create', $newBook))
137 && (!$hasNewParent || userCan('chapter-delete', $model));
139 if (!$hasPermission) {
144 if ($model instanceof Page) {
145 $parentPermission = ($currentParent instanceof Chapter) ? 'chapter-update' : 'book-update';
146 $hasCurrentParentPermission = userCan($parentPermission, $currentParent);
148 // This needs to check if there was an intended chapter location in the original sort map
149 // rather than inferring from the $newChapter since that variable may be null
150 // due to other reasons (Visibility).
151 $newParent = $sortMapItem->parentChapterId ? $newChapter : $newBook;
156 $hasPageEditPermission = userCan('page-update', $model);
157 $newParentInRightLocation = ($newParent instanceof Book || ($newParent instanceof Chapter && $newParent->book_id === $newBook->id));
158 $newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update';
159 $hasNewParentPermission = userCan($newParentPermission, $newParent);
161 $hasDeletePermissionIfMoving = (!$hasNewParent || userCan('page-delete', $model));
162 $hasCreatePermissionIfMoving = (!$hasNewParent || userCan('page-create', $newParent));
164 $hasPermission = $hasCurrentParentPermission
165 && $newParentInRightLocation
166 && $hasNewParentPermission
167 && $hasPageEditPermission
168 && $hasDeletePermissionIfMoving
169 && $hasCreatePermissionIfMoving;
171 if (!$hasPermission) {
180 * Load models from the database into the given sort map.
182 * @return array<string, Entity>
184 protected function loadModelsFromSortMap(BookSortMap $sortMap): array
193 foreach ($sortMap->all() as $sortMapItem) {
194 $ids[$sortMapItem->type][] = $sortMapItem->id;
195 $ids['book'][] = $sortMapItem->parentBookId;
196 if ($sortMapItem->parentChapterId) {
197 $ids['chapter'][] = $sortMapItem->parentChapterId;
201 $pages = $this->queries->pages->visibleForList()->whereIn('id', array_unique($ids['page']))->get();
202 /** @var Page $page */
203 foreach ($pages as $page) {
204 $modelMap['page:' . $page->id] = $page;
205 $ids['book'][] = $page->book_id;
206 if ($page->chapter_id) {
207 $ids['chapter'][] = $page->chapter_id;
211 $chapters = $this->queries->chapters->visibleForList()->whereIn('id', array_unique($ids['chapter']))->get();
212 /** @var Chapter $chapter */
213 foreach ($chapters as $chapter) {
214 $modelMap['chapter:' . $chapter->id] = $chapter;
215 $ids['book'][] = $chapter->book_id;
218 $books = $this->queries->books->visibleForList()->whereIn('id', array_unique($ids['book']))->get();
219 /** @var Book $book */
220 foreach ($books as $book) {
221 $modelMap['book:' . $book->id] = $book;