]> BookStack Code Mirror - bookstack/blob - app/Entities/Tools/BookContents.php
Adapt tests with displayName array
[bookstack] / app / Entities / Tools / BookContents.php
1 <?php
2
3 namespace BookStack\Entities\Tools;
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 Illuminate\Support\Collection;
12
13 class BookContents
14 {
15     protected EntityQueries $queries;
16
17     public function __construct(
18         protected Book $book,
19     ) {
20         $this->queries = app()->make(EntityQueries::class);
21     }
22
23     /**
24      * Get the current priority of the last item at the top-level of the book.
25      */
26     public function getLastPriority(): int
27     {
28         $maxPage = $this->book->pages()
29             ->where('draft', '=', false)
30             ->where('chapter_id', '=', 0)
31             ->max('priority');
32
33         $maxChapter = $this->book->chapters()
34             ->max('priority');
35
36         return max($maxChapter, $maxPage, 1);
37     }
38
39     /**
40      * Get the contents as a sorted collection tree.
41      */
42     public function getTree(bool $showDrafts = false, bool $renderPages = false): Collection
43     {
44         $pages = $this->getPages($showDrafts, $renderPages);
45         $chapters = $this->book->chapters()->scopes('visible')->get();
46         $all = collect()->concat($pages)->concat($chapters);
47         $chapterMap = $chapters->keyBy('id');
48         $lonePages = collect();
49
50         $pages->groupBy('chapter_id')->each(function ($pages, $chapter_id) use ($chapterMap, &$lonePages) {
51             $chapter = $chapterMap->get($chapter_id);
52             if ($chapter) {
53                 $chapter->setAttribute('visible_pages', collect($pages)->sortBy($this->bookChildSortFunc()));
54             } else {
55                 $lonePages = $lonePages->concat($pages);
56             }
57         });
58
59         $chapters->whereNull('visible_pages')->each(function (Chapter $chapter) {
60             $chapter->setAttribute('visible_pages', collect([]));
61         });
62
63         $all->each(function (Entity $entity) use ($renderPages) {
64             $entity->setRelation('book', $this->book);
65
66             if ($renderPages && $entity instanceof Page) {
67                 $entity->html = (new PageContent($entity))->render();
68             }
69         });
70
71         return collect($chapters)->concat($lonePages)->sortBy($this->bookChildSortFunc());
72     }
73
74     /**
75      * Function for providing a sorting score for an entity in relation to the
76      * other items within the book.
77      */
78     protected function bookChildSortFunc(): callable
79     {
80         return function (Entity $entity) {
81             if (isset($entity['draft']) && $entity['draft']) {
82                 return -100;
83             }
84
85             return $entity['priority'] ?? 0;
86         };
87     }
88
89     /**
90      * Get the visible pages within this book.
91      */
92     protected function getPages(bool $showDrafts = false, bool $getPageContent = false): Collection
93     {
94         if ($getPageContent) {
95             $query = $this->queries->pages->visibleWithContents();
96         } else {
97             $query = $this->queries->pages->visibleForList();
98         }
99
100         if (!$showDrafts) {
101             $query->where('draft', '=', false);
102         }
103
104         return $query->where('book_id', '=', $this->book->id)->get();
105     }
106
107     /**
108      * Sort the books content using the given sort map.
109      * Returns a list of books that were involved in the operation.
110      *
111      * @returns Book[]
112      */
113     public function sortUsingMap(BookSortMap $sortMap): array
114     {
115         // Load models into map
116         $modelMap = $this->loadModelsFromSortMap($sortMap);
117
118         // Sort our changes from our map to be chapters first
119         // Since they need to be process to ensure book alignment for child page changes.
120         $sortMapItems = $sortMap->all();
121         usort($sortMapItems, function (BookSortMapItem $itemA, BookSortMapItem $itemB) {
122             $aScore = $itemA->type === 'page' ? 2 : 1;
123             $bScore = $itemB->type === 'page' ? 2 : 1;
124
125             return $aScore - $bScore;
126         });
127
128         // Perform the sort
129         foreach ($sortMapItems as $item) {
130             $this->applySortUpdates($item, $modelMap);
131         }
132
133         /** @var Book[] $booksInvolved */
134         $booksInvolved = array_values(array_filter($modelMap, function (string $key) {
135             return str_starts_with($key, 'book:');
136         }, ARRAY_FILTER_USE_KEY));
137
138         // Update permissions of books involved
139         foreach ($booksInvolved as $book) {
140             $book->rebuildPermissions();
141         }
142
143         return $booksInvolved;
144     }
145
146     /**
147      * Using the given sort map item, detect changes for the related model
148      * and update it if required. Changes where permissions are lacking will
149      * be skipped and not throw an error.
150      *
151      * @param array<string, Entity> $modelMap
152      */
153     protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void
154     {
155         /** @var BookChild $model */
156         $model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null;
157         if (!$model) {
158             return;
159         }
160
161         $priorityChanged = $model->priority !== $sortMapItem->sort;
162         $bookChanged = $model->book_id !== $sortMapItem->parentBookId;
163         $chapterChanged = ($model instanceof Page) && $model->chapter_id !== $sortMapItem->parentChapterId;
164
165         // Stop if there's no change
166         if (!$priorityChanged && !$bookChanged && !$chapterChanged) {
167             return;
168         }
169
170         $currentParentKey = 'book:' . $model->book_id;
171         if ($model instanceof Page && $model->chapter_id) {
172             $currentParentKey = 'chapter:' . $model->chapter_id;
173         }
174
175         $currentParent = $modelMap[$currentParentKey] ?? null;
176         /** @var Book $newBook */
177         $newBook = $modelMap['book:' . $sortMapItem->parentBookId] ?? null;
178         /** @var ?Chapter $newChapter */
179         $newChapter = $sortMapItem->parentChapterId ? ($modelMap['chapter:' . $sortMapItem->parentChapterId] ?? null) : null;
180
181         if (!$this->isSortChangePermissible($sortMapItem, $model, $currentParent, $newBook, $newChapter)) {
182             return;
183         }
184
185         // Action the required changes
186         if ($bookChanged) {
187             $model->changeBook($newBook->id);
188         }
189
190         if ($model instanceof Page && $chapterChanged) {
191             $model->chapter_id = $newChapter->id ?? 0;
192         }
193
194         if ($priorityChanged) {
195             $model->priority = $sortMapItem->sort;
196         }
197
198         if ($chapterChanged || $priorityChanged) {
199             $model->save();
200         }
201     }
202
203     /**
204      * Check if the current user has permissions to apply the given sorting change.
205      * Is quite complex since items can gain a different parent change. Acts as a:
206      * - Update of old parent element (Change of content/order).
207      * - Update of sorted/moved element.
208      * - Deletion of element (Relative to parent upon move).
209      * - Creation of element within parent (Upon move to new parent).
210      */
211     protected function isSortChangePermissible(BookSortMapItem $sortMapItem, BookChild $model, ?Entity $currentParent, ?Entity $newBook, ?Entity $newChapter): bool
212     {
213         // Stop if we can't see the current parent or new book.
214         if (!$currentParent || !$newBook) {
215             return false;
216         }
217
218         $hasNewParent = $newBook->id !== $model->book_id || ($model instanceof Page && $model->chapter_id !== ($sortMapItem->parentChapterId ?? 0));
219         if ($model instanceof Chapter) {
220             $hasPermission = userCan('book-update', $currentParent)
221                 && userCan('book-update', $newBook)
222                 && userCan('chapter-update', $model)
223                 && (!$hasNewParent || userCan('chapter-create', $newBook))
224                 && (!$hasNewParent || userCan('chapter-delete', $model));
225
226             if (!$hasPermission) {
227                 return false;
228             }
229         }
230
231         if ($model instanceof Page) {
232             $parentPermission = ($currentParent instanceof Chapter) ? 'chapter-update' : 'book-update';
233             $hasCurrentParentPermission = userCan($parentPermission, $currentParent);
234
235             // This needs to check if there was an intended chapter location in the original sort map
236             // rather than inferring from the $newChapter since that variable may be null
237             // due to other reasons (Visibility).
238             $newParent = $sortMapItem->parentChapterId ? $newChapter : $newBook;
239             if (!$newParent) {
240                 return false;
241             }
242
243             $hasPageEditPermission = userCan('page-update', $model);
244             $newParentInRightLocation = ($newParent instanceof Book || ($newParent instanceof Chapter && $newParent->book_id === $newBook->id));
245             $newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update';
246             $hasNewParentPermission = userCan($newParentPermission, $newParent);
247
248             $hasDeletePermissionIfMoving = (!$hasNewParent || userCan('page-delete', $model));
249             $hasCreatePermissionIfMoving = (!$hasNewParent || userCan('page-create', $newParent));
250
251             $hasPermission = $hasCurrentParentPermission
252                 && $newParentInRightLocation
253                 && $hasNewParentPermission
254                 && $hasPageEditPermission
255                 && $hasDeletePermissionIfMoving
256                 && $hasCreatePermissionIfMoving;
257
258             if (!$hasPermission) {
259                 return false;
260             }
261         }
262
263         return true;
264     }
265
266     /**
267      * Load models from the database into the given sort map.
268      *
269      * @return array<string, Entity>
270      */
271     protected function loadModelsFromSortMap(BookSortMap $sortMap): array
272     {
273         $modelMap = [];
274         $ids = [
275             'chapter' => [],
276             'page'    => [],
277             'book'    => [],
278         ];
279
280         foreach ($sortMap->all() as $sortMapItem) {
281             $ids[$sortMapItem->type][] = $sortMapItem->id;
282             $ids['book'][] = $sortMapItem->parentBookId;
283             if ($sortMapItem->parentChapterId) {
284                 $ids['chapter'][] = $sortMapItem->parentChapterId;
285             }
286         }
287
288         $pages = $this->queries->pages->visibleForList()->whereIn('id', array_unique($ids['page']))->get();
289         /** @var Page $page */
290         foreach ($pages as $page) {
291             $modelMap['page:' . $page->id] = $page;
292             $ids['book'][] = $page->book_id;
293             if ($page->chapter_id) {
294                 $ids['chapter'][] = $page->chapter_id;
295             }
296         }
297
298         $chapters = $this->queries->chapters->visibleForList()->whereIn('id', array_unique($ids['chapter']))->get();
299         /** @var Chapter $chapter */
300         foreach ($chapters as $chapter) {
301             $modelMap['chapter:' . $chapter->id] = $chapter;
302             $ids['book'][] = $chapter->book_id;
303         }
304
305         $books = $this->queries->books->visibleForList()->whereIn('id', array_unique($ids['book']))->get();
306         /** @var Book $book */
307         foreach ($books as $book) {
308             $modelMap['book:' . $book->id] = $book;
309         }
310
311         return $modelMap;
312     }
313 }