]> BookStack Code Mirror - bookstack/blob - app/Sorting/BookSorter.php
Sorting: Reorganised book sort code to its own directory
[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     /**
21      * Sort the books content using the given sort map.
22      * Returns a list of books that were involved in the operation.
23      *
24      * @returns Book[]
25      */
26     public function sortUsingMap(BookSortMap $sortMap): array
27     {
28         // Load models into map
29         $modelMap = $this->loadModelsFromSortMap($sortMap);
30
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;
37
38             return $aScore - $bScore;
39         });
40
41         // Perform the sort
42         foreach ($sortMapItems as $item) {
43             $this->applySortUpdates($item, $modelMap);
44         }
45
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));
50
51         // Update permissions of books involved
52         foreach ($booksInvolved as $book) {
53             $book->rebuildPermissions();
54         }
55
56         return $booksInvolved;
57     }
58
59     /**
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.
63      *
64      * @param array<string, Entity> $modelMap
65      */
66     protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void
67     {
68         /** @var BookChild $model */
69         $model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null;
70         if (!$model) {
71             return;
72         }
73
74         $priorityChanged = $model->priority !== $sortMapItem->sort;
75         $bookChanged = $model->book_id !== $sortMapItem->parentBookId;
76         $chapterChanged = ($model instanceof Page) && $model->chapter_id !== $sortMapItem->parentChapterId;
77
78         // Stop if there's no change
79         if (!$priorityChanged && !$bookChanged && !$chapterChanged) {
80             return;
81         }
82
83         $currentParentKey = 'book:' . $model->book_id;
84         if ($model instanceof Page && $model->chapter_id) {
85             $currentParentKey = 'chapter:' . $model->chapter_id;
86         }
87
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;
93
94         if (!$this->isSortChangePermissible($sortMapItem, $model, $currentParent, $newBook, $newChapter)) {
95             return;
96         }
97
98         // Action the required changes
99         if ($bookChanged) {
100             $model->changeBook($newBook->id);
101         }
102
103         if ($model instanceof Page && $chapterChanged) {
104             $model->chapter_id = $newChapter->id ?? 0;
105         }
106
107         if ($priorityChanged) {
108             $model->priority = $sortMapItem->sort;
109         }
110
111         if ($chapterChanged || $priorityChanged) {
112             $model->save();
113         }
114     }
115
116     /**
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).
123      */
124     protected function isSortChangePermissible(BookSortMapItem $sortMapItem, BookChild $model, ?Entity $currentParent, ?Entity $newBook, ?Entity $newChapter): bool
125     {
126         // Stop if we can't see the current parent or new book.
127         if (!$currentParent || !$newBook) {
128             return false;
129         }
130
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));
138
139             if (!$hasPermission) {
140                 return false;
141             }
142         }
143
144         if ($model instanceof Page) {
145             $parentPermission = ($currentParent instanceof Chapter) ? 'chapter-update' : 'book-update';
146             $hasCurrentParentPermission = userCan($parentPermission, $currentParent);
147
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;
152             if (!$newParent) {
153                 return false;
154             }
155
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);
160
161             $hasDeletePermissionIfMoving = (!$hasNewParent || userCan('page-delete', $model));
162             $hasCreatePermissionIfMoving = (!$hasNewParent || userCan('page-create', $newParent));
163
164             $hasPermission = $hasCurrentParentPermission
165                 && $newParentInRightLocation
166                 && $hasNewParentPermission
167                 && $hasPageEditPermission
168                 && $hasDeletePermissionIfMoving
169                 && $hasCreatePermissionIfMoving;
170
171             if (!$hasPermission) {
172                 return false;
173             }
174         }
175
176         return true;
177     }
178
179     /**
180      * Load models from the database into the given sort map.
181      *
182      * @return array<string, Entity>
183      */
184     protected function loadModelsFromSortMap(BookSortMap $sortMap): array
185     {
186         $modelMap = [];
187         $ids = [
188             'chapter' => [],
189             'page'    => [],
190             'book'    => [],
191         ];
192
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;
198             }
199         }
200
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;
208             }
209         }
210
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;
216         }
217
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;
222         }
223
224         return $modelMap;
225     }
226 }