]> BookStack Code Mirror - bookstack/blob - app/Sorting/BookSorter.php
Testing: Extracted copy tests to their own class
[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 use BookStack\Entities\Tools\ParentChanger;
12 use BookStack\Permissions\Permission;
13
14 class BookSorter
15 {
16     public function __construct(
17         protected EntityQueries $queries,
18         protected ParentChanger $parentChanger,
19     ) {
20     }
21
22     public function runBookAutoSortForAllWithSet(SortRule $set): void
23     {
24         $set->books()->chunk(50, function ($books) {
25             foreach ($books as $book) {
26                 $this->runBookAutoSort($book);
27             }
28         });
29     }
30
31     /**
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.
35      */
36     public function runBookAutoSort(Book $book): void
37     {
38         $rule = $book->sortRule()->first();
39         if (!($rule instanceof SortRule)) {
40             return;
41         }
42
43         $sortFunctions = array_map(function (SortRuleOperation $op) {
44             return $op->getSortFunction();
45         }, $rule->getOperations());
46
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']);
50
51         /** @var (Chapter|Book)[] $topItems */
52         $topItems = [
53             ...$book->directPages()->get(['id', 'book_id', 'name', 'priority', 'created_at', 'updated_at']),
54             ...$chapters,
55         ];
56
57         foreach ($sortFunctions as $sortFunction) {
58             usort($topItems, $sortFunction);
59         }
60
61         foreach ($topItems as $index => $topItem) {
62             $topItem->priority = $index + 1;
63             $topItem::withoutTimestamps(fn () => $topItem->save());
64         }
65
66         foreach ($chapters as $chapter) {
67             $pages = $chapter->pages->all();
68             foreach ($sortFunctions as $sortFunction) {
69                 usort($pages, $sortFunction);
70             }
71
72             foreach ($pages as $index => $page) {
73                 $page->priority = $index + 1;
74                 $page::withoutTimestamps(fn () => $page->save());
75             }
76         }
77     }
78
79
80     /**
81      * Sort the books content using the given sort map.
82      * Returns a list of books that were involved in the operation.
83      *
84      * @return Book[]
85      */
86     public function sortUsingMap(BookSortMap $sortMap): array
87     {
88         // Load models into map
89         $modelMap = $this->loadModelsFromSortMap($sortMap);
90
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;
97
98             return $aScore - $bScore;
99         });
100
101         // Perform the sort
102         foreach ($sortMapItems as $item) {
103             $this->applySortUpdates($item, $modelMap);
104         }
105
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));
110
111         // Update permissions of books involved
112         foreach ($booksInvolved as $book) {
113             $book->rebuildPermissions();
114         }
115
116         return $booksInvolved;
117     }
118
119     /**
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.
123      *
124      * @param array<string, Entity> $modelMap
125      */
126     protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void
127     {
128         /** @var BookChild $model */
129         $model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null;
130         if (!$model) {
131             return;
132         }
133
134         $priorityChanged = $model->priority !== $sortMapItem->sort;
135         $bookChanged = $model->book_id !== $sortMapItem->parentBookId;
136         $chapterChanged = ($model instanceof Page) && $model->chapter_id !== $sortMapItem->parentChapterId;
137
138         // Stop if there's no change
139         if (!$priorityChanged && !$bookChanged && !$chapterChanged) {
140             return;
141         }
142
143         $currentParentKey = 'book:' . $model->book_id;
144         if ($model instanceof Page && $model->chapter_id) {
145             $currentParentKey = 'chapter:' . $model->chapter_id;
146         }
147
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;
153
154         if (!$this->isSortChangePermissible($sortMapItem, $model, $currentParent, $newBook, $newChapter)) {
155             return;
156         }
157
158         // Action the required changes
159         if ($bookChanged) {
160             $this->parentChanger->changeBook($model, $newBook->id);
161         }
162
163         if ($model instanceof Page && $chapterChanged) {
164             $model->chapter_id = $newChapter->id ?? 0;
165             $model->unsetRelation('chapter');
166         }
167
168         if ($priorityChanged) {
169             $model->priority = $sortMapItem->sort;
170         }
171
172         if ($chapterChanged || $priorityChanged) {
173             $model::withoutTimestamps(fn () => $model->save());
174         }
175     }
176
177     /**
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).
184      */
185     protected function isSortChangePermissible(BookSortMapItem $sortMapItem, BookChild $model, ?Entity $currentParent, ?Entity $newBook, ?Entity $newChapter): bool
186     {
187         // Stop if we can't see the current parent or new book.
188         if (!$currentParent || !$newBook) {
189             return false;
190         }
191
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));
199
200             if (!$hasPermission) {
201                 return false;
202             }
203         }
204
205         if ($model instanceof Page) {
206             $parentPermission = ($currentParent instanceof Chapter) ? 'chapter-update' : 'book-update';
207             $hasCurrentParentPermission = userCan($parentPermission, $currentParent);
208
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;
213             if (!$newParent) {
214                 return false;
215             }
216
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);
221
222             $hasDeletePermissionIfMoving = (!$hasNewParent || userCan(Permission::PageDelete, $model));
223             $hasCreatePermissionIfMoving = (!$hasNewParent || userCan(Permission::PageCreate, $newParent));
224
225             $hasPermission = $hasCurrentParentPermission
226                 && $newParentInRightLocation
227                 && $hasNewParentPermission
228                 && $hasPageEditPermission
229                 && $hasDeletePermissionIfMoving
230                 && $hasCreatePermissionIfMoving;
231
232             if (!$hasPermission) {
233                 return false;
234             }
235         }
236
237         return true;
238     }
239
240     /**
241      * Load models from the database into the given sort map.
242      *
243      * @return array<string, Entity>
244      */
245     protected function loadModelsFromSortMap(BookSortMap $sortMap): array
246     {
247         $modelMap = [];
248         $ids = [
249             'chapter' => [],
250             'page'    => [],
251             'book'    => [],
252         ];
253
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;
259             }
260         }
261
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;
269             }
270         }
271
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;
277         }
278
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;
283         }
284
285         return $modelMap;
286     }
287 }