]> BookStack Code Mirror - bookstack/blob - app/Sorting/BookSorter.php
7bf1b63f46cfa8d54b3b93b137523626f5fb597c
[bookstack] / app / Sorting / BookSorter.php
1 <?php
2
3 namespace BookStack\Sorting;
4
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
12 class BookSorter
13 {
14     public function __construct(
15         protected EntityQueries $queries,
16     ) {
17     }
18
19     public function runBookAutoSortForAllWithSet(SortRule $set): void
20     {
21         $set->books()->chunk(50, function ($books) {
22             foreach ($books as $book) {
23                 $this->runBookAutoSort($book);
24             }
25         });
26     }
27
28     /**
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.
32      */
33     public function runBookAutoSort(Book $book): void
34     {
35         $set = $book->sortRule;
36         if (!$set) {
37             return;
38         }
39
40         $sortFunctions = array_map(function (SortRuleOperation $op) {
41             return $op->getSortFunction();
42         }, $set->getOperations());
43
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']);
47
48         /** @var (Chapter|Book)[] $topItems */
49         $topItems = [
50             ...$book->directPages()->get(['id', 'name', 'priority', 'created_at', 'updated_at']),
51             ...$chapters,
52         ];
53
54         foreach ($sortFunctions as $sortFunction) {
55             usort($topItems, $sortFunction);
56         }
57
58         foreach ($topItems as $index => $topItem) {
59             $topItem->priority = $index + 1;
60             $topItem->save();
61         }
62
63         foreach ($chapters as $chapter) {
64             $pages = $chapter->pages->all();
65             foreach ($sortFunctions as $sortFunction) {
66                 usort($pages, $sortFunction);
67             }
68
69             foreach ($pages as $index => $page) {
70                 $page->priority = $index + 1;
71                 $page->save();
72             }
73         }
74     }
75
76
77     /**
78      * Sort the books content using the given sort map.
79      * Returns a list of books that were involved in the operation.
80      *
81      * @returns Book[]
82      */
83     public function sortUsingMap(BookSortMap $sortMap): array
84     {
85         // Load models into map
86         $modelMap = $this->loadModelsFromSortMap($sortMap);
87
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;
94
95             return $aScore - $bScore;
96         });
97
98         // Perform the sort
99         foreach ($sortMapItems as $item) {
100             $this->applySortUpdates($item, $modelMap);
101         }
102
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));
107
108         // Update permissions of books involved
109         foreach ($booksInvolved as $book) {
110             $book->rebuildPermissions();
111         }
112
113         return $booksInvolved;
114     }
115
116     /**
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.
120      *
121      * @param array<string, Entity> $modelMap
122      */
123     protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void
124     {
125         /** @var BookChild $model */
126         $model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null;
127         if (!$model) {
128             return;
129         }
130
131         $priorityChanged = $model->priority !== $sortMapItem->sort;
132         $bookChanged = $model->book_id !== $sortMapItem->parentBookId;
133         $chapterChanged = ($model instanceof Page) && $model->chapter_id !== $sortMapItem->parentChapterId;
134
135         // Stop if there's no change
136         if (!$priorityChanged && !$bookChanged && !$chapterChanged) {
137             return;
138         }
139
140         $currentParentKey = 'book:' . $model->book_id;
141         if ($model instanceof Page && $model->chapter_id) {
142             $currentParentKey = 'chapter:' . $model->chapter_id;
143         }
144
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;
150
151         if (!$this->isSortChangePermissible($sortMapItem, $model, $currentParent, $newBook, $newChapter)) {
152             return;
153         }
154
155         // Action the required changes
156         if ($bookChanged) {
157             $model->changeBook($newBook->id);
158         }
159
160         if ($model instanceof Page && $chapterChanged) {
161             $model->chapter_id = $newChapter->id ?? 0;
162         }
163
164         if ($priorityChanged) {
165             $model->priority = $sortMapItem->sort;
166         }
167
168         if ($chapterChanged || $priorityChanged) {
169             $model->save();
170         }
171     }
172
173     /**
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).
180      */
181     protected function isSortChangePermissible(BookSortMapItem $sortMapItem, BookChild $model, ?Entity $currentParent, ?Entity $newBook, ?Entity $newChapter): bool
182     {
183         // Stop if we can't see the current parent or new book.
184         if (!$currentParent || !$newBook) {
185             return false;
186         }
187
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));
195
196             if (!$hasPermission) {
197                 return false;
198             }
199         }
200
201         if ($model instanceof Page) {
202             $parentPermission = ($currentParent instanceof Chapter) ? 'chapter-update' : 'book-update';
203             $hasCurrentParentPermission = userCan($parentPermission, $currentParent);
204
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;
209             if (!$newParent) {
210                 return false;
211             }
212
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);
217
218             $hasDeletePermissionIfMoving = (!$hasNewParent || userCan('page-delete', $model));
219             $hasCreatePermissionIfMoving = (!$hasNewParent || userCan('page-create', $newParent));
220
221             $hasPermission = $hasCurrentParentPermission
222                 && $newParentInRightLocation
223                 && $hasNewParentPermission
224                 && $hasPageEditPermission
225                 && $hasDeletePermissionIfMoving
226                 && $hasCreatePermissionIfMoving;
227
228             if (!$hasPermission) {
229                 return false;
230             }
231         }
232
233         return true;
234     }
235
236     /**
237      * Load models from the database into the given sort map.
238      *
239      * @return array<string, Entity>
240      */
241     protected function loadModelsFromSortMap(BookSortMap $sortMap): array
242     {
243         $modelMap = [];
244         $ids = [
245             'chapter' => [],
246             'page'    => [],
247             'book'    => [],
248         ];
249
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;
255             }
256         }
257
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;
265             }
266         }
267
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;
273         }
274
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;
279         }
280
281         return $modelMap;
282     }
283 }