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