3 namespace BookStack\Entities\Tools;
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;
15 protected EntityQueries $queries;
17 public function __construct(
20 $this->queries = app()->make(EntityQueries::class);
24 * Get the current priority of the last item at the top-level of the book.
26 public function getLastPriority(): int
28 $maxPage = $this->book->pages()
29 ->where('draft', '=', false)
30 ->where('chapter_id', '=', 0)
33 $maxChapter = $this->book->chapters()
36 return max($maxChapter, $maxPage, 1);
40 * Get the contents as a sorted collection tree.
42 public function getTree(bool $showDrafts = false, bool $renderPages = false): Collection
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();
50 $pages->groupBy('chapter_id')->each(function ($pages, $chapter_id) use ($chapterMap, &$lonePages) {
51 $chapter = $chapterMap->get($chapter_id);
53 $chapter->setAttribute('visible_pages', collect($pages)->sortBy($this->bookChildSortFunc()));
55 $lonePages = $lonePages->concat($pages);
59 $chapters->whereNull('visible_pages')->each(function (Chapter $chapter) {
60 $chapter->setAttribute('visible_pages', collect([]));
63 $all->each(function (Entity $entity) use ($renderPages) {
64 $entity->setRelation('book', $this->book);
66 if ($renderPages && $entity instanceof Page) {
67 $entity->html = (new PageContent($entity))->render();
71 return collect($chapters)->concat($lonePages)->sortBy($this->bookChildSortFunc());
75 * Function for providing a sorting score for an entity in relation to the
76 * other items within the book.
78 protected function bookChildSortFunc(): callable
80 return function (Entity $entity) {
81 if (isset($entity['draft']) && $entity['draft']) {
85 return $entity['priority'] ?? 0;
90 * Get the visible pages within this book.
92 protected function getPages(bool $showDrafts = false, bool $getPageContent = false): Collection
94 if ($getPageContent) {
95 $query = $this->queries->pages->visibleWithContents();
97 $query = $this->queries->pages->visibleForList();
101 $query->where('draft', '=', false);
104 return $query->where('book_id', '=', $this->book->id)->get();
108 * Sort the books content using the given sort map.
109 * Returns a list of books that were involved in the operation.
113 public function sortUsingMap(BookSortMap $sortMap): array
115 // Load models into map
116 $modelMap = $this->loadModelsFromSortMap($sortMap);
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;
125 return $aScore - $bScore;
129 foreach ($sortMapItems as $item) {
130 $this->applySortUpdates($item, $modelMap);
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));
138 // Update permissions of books involved
139 foreach ($booksInvolved as $book) {
140 $book->rebuildPermissions();
143 return $booksInvolved;
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.
151 * @param array<string, Entity> $modelMap
153 protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void
155 /** @var BookChild $model */
156 $model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null;
161 $priorityChanged = $model->priority !== $sortMapItem->sort;
162 $bookChanged = $model->book_id !== $sortMapItem->parentBookId;
163 $chapterChanged = ($model instanceof Page) && $model->chapter_id !== $sortMapItem->parentChapterId;
165 // Stop if there's no change
166 if (!$priorityChanged && !$bookChanged && !$chapterChanged) {
170 $currentParentKey = 'book:' . $model->book_id;
171 if ($model instanceof Page && $model->chapter_id) {
172 $currentParentKey = 'chapter:' . $model->chapter_id;
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;
181 if (!$this->isSortChangePermissible($sortMapItem, $model, $currentParent, $newBook, $newChapter)) {
185 // Action the required changes
187 $model->changeBook($newBook->id);
190 if ($model instanceof Page && $chapterChanged) {
191 $model->chapter_id = $newChapter->id ?? 0;
194 if ($priorityChanged) {
195 $model->priority = $sortMapItem->sort;
198 if ($chapterChanged || $priorityChanged) {
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).
211 protected function isSortChangePermissible(BookSortMapItem $sortMapItem, BookChild $model, ?Entity $currentParent, ?Entity $newBook, ?Entity $newChapter): bool
213 // Stop if we can't see the current parent or new book.
214 if (!$currentParent || !$newBook) {
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));
226 if (!$hasPermission) {
231 if ($model instanceof Page) {
232 $parentPermission = ($currentParent instanceof Chapter) ? 'chapter-update' : 'book-update';
233 $hasCurrentParentPermission = userCan($parentPermission, $currentParent);
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;
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);
248 $hasDeletePermissionIfMoving = (!$hasNewParent || userCan('page-delete', $model));
249 $hasCreatePermissionIfMoving = (!$hasNewParent || userCan('page-create', $newParent));
251 $hasPermission = $hasCurrentParentPermission
252 && $newParentInRightLocation
253 && $hasNewParentPermission
254 && $hasPageEditPermission
255 && $hasDeletePermissionIfMoving
256 && $hasCreatePermissionIfMoving;
258 if (!$hasPermission) {
267 * Load models from the database into the given sort map.
269 * @return array<string, Entity>
271 protected function loadModelsFromSortMap(BookSortMap $sortMap): array
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;
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;
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;
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;