]> BookStack Code Mirror - bookstack/blob - app/Sorting/BookSorter.php
Merge pull request #5626 from BookStackApp/rubentalstra-development
[bookstack] / app / Sorting / BookSorter.php
1 <?php
2
3 namespace BookStack\Sorting;
4
5 use BookStack\App\Model;
6 use BookStack\Entities\Models\Book;
7 use BookStack\Entities\Models\BookChild;
8 use BookStack\Entities\Models\Chapter;
9 use BookStack\Entities\Models\Entity;
10 use BookStack\Entities\Models\Page;
11 use BookStack\Entities\Queries\EntityQueries;
12
13 class BookSorter
14 {
15     public function __construct(
16         protected EntityQueries $queries,
17     ) {
18     }
19
20     public function runBookAutoSortForAllWithSet(SortRule $set): void
21     {
22         $set->books()->chunk(50, function ($books) {
23             foreach ($books as $book) {
24                 $this->runBookAutoSort($book);
25             }
26         });
27     }
28
29     /**
30      * Runs the auto-sort for a book if the book has a sort set applied to it.
31      * This does not consider permissions since the sort operations are centrally
32      * managed by admins so considered permitted if existing and assigned.
33      */
34     public function runBookAutoSort(Book $book): void
35     {
36         $set = $book->sortRule;
37         if (!$set) {
38             return;
39         }
40
41         $sortFunctions = array_map(function (SortRuleOperation $op) {
42             return $op->getSortFunction();
43         }, $set->getOperations());
44
45         $chapters = $book->chapters()
46             ->with('pages:id,name,priority,created_at,updated_at,chapter_id')
47             ->get(['id', 'name', 'priority', 'created_at', 'updated_at']);
48
49         /** @var (Chapter|Book)[] $topItems */
50         $topItems = [
51             ...$book->directPages()->get(['id', 'name', 'priority', 'created_at', 'updated_at']),
52             ...$chapters,
53         ];
54
55         foreach ($sortFunctions as $sortFunction) {
56             usort($topItems, $sortFunction);
57         }
58
59         foreach ($topItems as $index => $topItem) {
60             $topItem->priority = $index + 1;
61             $topItem::withoutTimestamps(fn () => $topItem->save());
62         }
63
64         foreach ($chapters as $chapter) {
65             $pages = $chapter->pages->all();
66             foreach ($sortFunctions as $sortFunction) {
67                 usort($pages, $sortFunction);
68             }
69
70             foreach ($pages as $index => $page) {
71                 $page->priority = $index + 1;
72                 $page::withoutTimestamps(fn () => $page->save());
73             }
74         }
75     }
76
77
78     /**
79      * Sort the books content using the given sort map.
80      * Returns a list of books that were involved in the operation.
81      *
82      * @returns Book[]
83      */
84     public function sortUsingMap(BookSortMap $sortMap): array
85     {
86         // Load models into map
87         $modelMap = $this->loadModelsFromSortMap($sortMap);
88
89         // Sort our changes from our map to be chapters first
90         // Since they need to be process to ensure book alignment for child page changes.
91         $sortMapItems = $sortMap->all();
92         usort($sortMapItems, function (BookSortMapItem $itemA, BookSortMapItem $itemB) {
93             $aScore = $itemA->type === 'page' ? 2 : 1;
94             $bScore = $itemB->type === 'page' ? 2 : 1;
95
96             return $aScore - $bScore;
97         });
98
99         // Perform the sort
100         foreach ($sortMapItems as $item) {
101             $this->applySortUpdates($item, $modelMap);
102         }
103
104         /** @var Book[] $booksInvolved */
105         $booksInvolved = array_values(array_filter($modelMap, function (string $key) {
106             return str_starts_with($key, 'book:');
107         }, ARRAY_FILTER_USE_KEY));
108
109         // Update permissions of books involved
110         foreach ($booksInvolved as $book) {
111             $book->rebuildPermissions();
112         }
113
114         return $booksInvolved;
115     }
116
117     /**
118      * Using the given sort map item, detect changes for the related model
119      * and update it if required. Changes where permissions are lacking will
120      * be skipped and not throw an error.
121      *
122      * @param array<string, Entity> $modelMap
123      */
124     protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void
125     {
126         /** @var BookChild $model */
127         $model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null;
128         if (!$model) {
129             return;
130         }
131
132         $priorityChanged = $model->priority !== $sortMapItem->sort;
133         $bookChanged = $model->book_id !== $sortMapItem->parentBookId;
134         $chapterChanged = ($model instanceof Page) && $model->chapter_id !== $sortMapItem->parentChapterId;
135
136         // Stop if there's no change
137         if (!$priorityChanged && !$bookChanged && !$chapterChanged) {
138             return;
139         }
140
141         $currentParentKey = 'book:' . $model->book_id;
142         if ($model instanceof Page && $model->chapter_id) {
143             $currentParentKey = 'chapter:' . $model->chapter_id;
144         }
145
146         $currentParent = $modelMap[$currentParentKey] ?? null;
147         /** @var Book $newBook */
148         $newBook = $modelMap['book:' . $sortMapItem->parentBookId] ?? null;
149         /** @var ?Chapter $newChapter */
150         $newChapter = $sortMapItem->parentChapterId ? ($modelMap['chapter:' . $sortMapItem->parentChapterId] ?? null) : null;
151
152         if (!$this->isSortChangePermissible($sortMapItem, $model, $currentParent, $newBook, $newChapter)) {
153             return;
154         }
155
156         // Action the required changes
157         if ($bookChanged) {
158             $model->changeBook($newBook->id);
159         }
160
161         if ($model instanceof Page && $chapterChanged) {
162             $model->chapter_id = $newChapter->id ?? 0;
163         }
164
165         if ($priorityChanged) {
166             $model->priority = $sortMapItem->sort;
167         }
168
169         if ($chapterChanged || $priorityChanged) {
170             $model::withoutTimestamps(fn () => $model->save());
171         }
172     }
173
174     /**
175      * Check if the current user has permissions to apply the given sorting change.
176      * Is quite complex since items can gain a different parent change. Acts as a:
177      * - Update of old parent element (Change of content/order).
178      * - Update of sorted/moved element.
179      * - Deletion of element (Relative to parent upon move).
180      * - Creation of element within parent (Upon move to new parent).
181      */
182     protected function isSortChangePermissible(BookSortMapItem $sortMapItem, BookChild $model, ?Entity $currentParent, ?Entity $newBook, ?Entity $newChapter): bool
183     {
184         // Stop if we can't see the current parent or new book.
185         if (!$currentParent || !$newBook) {
186             return false;
187         }
188
189         $hasNewParent = $newBook->id !== $model->book_id || ($model instanceof Page && $model->chapter_id !== ($sortMapItem->parentChapterId ?? 0));
190         if ($model instanceof Chapter) {
191             $hasPermission = userCan('book-update', $currentParent)
192                 && userCan('book-update', $newBook)
193                 && userCan('chapter-update', $model)
194                 && (!$hasNewParent || userCan('chapter-create', $newBook))
195                 && (!$hasNewParent || userCan('chapter-delete', $model));
196
197             if (!$hasPermission) {
198                 return false;
199             }
200         }
201
202         if ($model instanceof Page) {
203             $parentPermission = ($currentParent instanceof Chapter) ? 'chapter-update' : 'book-update';
204             $hasCurrentParentPermission = userCan($parentPermission, $currentParent);
205
206             // This needs to check if there was an intended chapter location in the original sort map
207             // rather than inferring from the $newChapter since that variable may be null
208             // due to other reasons (Visibility).
209             $newParent = $sortMapItem->parentChapterId ? $newChapter : $newBook;
210             if (!$newParent) {
211                 return false;
212             }
213
214             $hasPageEditPermission = userCan('page-update', $model);
215             $newParentInRightLocation = ($newParent instanceof Book || ($newParent instanceof Chapter && $newParent->book_id === $newBook->id));
216             $newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update';
217             $hasNewParentPermission = userCan($newParentPermission, $newParent);
218
219             $hasDeletePermissionIfMoving = (!$hasNewParent || userCan('page-delete', $model));
220             $hasCreatePermissionIfMoving = (!$hasNewParent || userCan('page-create', $newParent));
221
222             $hasPermission = $hasCurrentParentPermission
223                 && $newParentInRightLocation
224                 && $hasNewParentPermission
225                 && $hasPageEditPermission
226                 && $hasDeletePermissionIfMoving
227                 && $hasCreatePermissionIfMoving;
228
229             if (!$hasPermission) {
230                 return false;
231             }
232         }
233
234         return true;
235     }
236
237     /**
238      * Load models from the database into the given sort map.
239      *
240      * @return array<string, Entity>
241      */
242     protected function loadModelsFromSortMap(BookSortMap $sortMap): array
243     {
244         $modelMap = [];
245         $ids = [
246             'chapter' => [],
247             'page'    => [],
248             'book'    => [],
249         ];
250
251         foreach ($sortMap->all() as $sortMapItem) {
252             $ids[$sortMapItem->type][] = $sortMapItem->id;
253             $ids['book'][] = $sortMapItem->parentBookId;
254             if ($sortMapItem->parentChapterId) {
255                 $ids['chapter'][] = $sortMapItem->parentChapterId;
256             }
257         }
258
259         $pages = $this->queries->pages->visibleForList()->whereIn('id', array_unique($ids['page']))->get();
260         /** @var Page $page */
261         foreach ($pages as $page) {
262             $modelMap['page:' . $page->id] = $page;
263             $ids['book'][] = $page->book_id;
264             if ($page->chapter_id) {
265                 $ids['chapter'][] = $page->chapter_id;
266             }
267         }
268
269         $chapters = $this->queries->chapters->visibleForList()->whereIn('id', array_unique($ids['chapter']))->get();
270         /** @var Chapter $chapter */
271         foreach ($chapters as $chapter) {
272             $modelMap['chapter:' . $chapter->id] = $chapter;
273             $ids['book'][] = $chapter->book_id;
274         }
275
276         $books = $this->queries->books->visibleForList()->whereIn('id', array_unique($ids['book']))->get();
277         /** @var Book $book */
278         foreach ($books as $book) {
279             $modelMap['book:' . $book->id] = $book;
280         }
281
282         return $modelMap;
283     }
284 }
Morty Proxy This is a proxified and sanitized view of the page, visit original site.