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