X-Git-Url: http://source.bookstackapp.com/bookstack/blobdiff_plain/a6633642232efd164d4708967ab59e498fbff896..refs/pull/5280/head:/app/Entities/Tools/BookContents.php diff --git a/app/Entities/Tools/BookContents.php b/app/Entities/Tools/BookContents.php index 71c8f8393..7fa2134b7 100644 --- a/app/Entities/Tools/BookContents.php +++ b/app/Entities/Tools/BookContents.php @@ -1,40 +1,38 @@ -book = $book; + public function __construct( + protected Book $book, + ) { + $this->queries = app()->make(EntityQueries::class); } /** - * Get the current priority of the last item - * at the top-level of the book. + * Get the current priority of the last item at the top-level of the book. */ public function getLastPriority(): int { - $maxPage = Page::visible()->where('book_id', '=', $this->book->id) + $maxPage = $this->book->pages() ->where('draft', '=', false) - ->where('chapter_id', '=', 0)->max('priority'); - $maxChapter = Chapter::visible()->where('book_id', '=', $this->book->id) + ->where('chapter_id', '=', 0) ->max('priority'); + + $maxChapter = $this->book->chapters() + ->max('priority'); + return max($maxChapter, $maxPage, 1); } @@ -43,8 +41,8 @@ class BookContents */ public function getTree(bool $showDrafts = false, bool $renderPages = false): Collection { - $pages = $this->getPages($showDrafts); - $chapters = Chapter::visible()->where('book_id', '=', $this->book->id)->get(); + $pages = $this->getPages($showDrafts, $renderPages); + $chapters = $this->book->chapters()->scopes('visible')->get(); $all = collect()->concat($pages)->concat($chapters); $chapterMap = $chapters->keyBy('id'); $lonePages = collect(); @@ -65,7 +63,7 @@ class BookContents $all->each(function (Entity $entity) use ($renderPages) { $entity->setRelation('book', $this->book); - if ($renderPages && $entity->isA('page')) { + if ($renderPages && $entity instanceof Page) { $entity->html = (new PageContent($entity))->render(); } }); @@ -83,6 +81,7 @@ class BookContents if (isset($entity['draft']) && $entity['draft']) { return -100; } + return $entity['priority'] ?? 0; }; } @@ -90,121 +89,225 @@ class BookContents /** * Get the visible pages within this book. */ - protected function getPages(bool $showDrafts = false): Collection + protected function getPages(bool $showDrafts = false, bool $getPageContent = false): Collection { - $query = Page::visible()->where('book_id', '=', $this->book->id); + if ($getPageContent) { + $query = $this->queries->pages->visibleWithContents(); + } else { + $query = $this->queries->pages->visibleForList(); + } if (!$showDrafts) { $query->where('draft', '=', false); } - return $query->get(); + return $query->where('book_id', '=', $this->book->id)->get(); } /** - * Sort the books content using the given map. - * The map is a single-dimension collection of objects in the following format: - * { - * +"id": "294" (ID of item) - * +"sort": 1 (Sort order index) - * +"parentChapter": false (ID of parent chapter, as string, or false) - * +"type": "page" (Entity type of item) - * +"book": "1" (Id of book to place item in) - * } - * + * Sort the books content using the given sort map. * Returns a list of books that were involved in the operation. - * @throws SortOperationException + * + * @returns Book[] */ - public function sortUsingMap(Collection $sortMap): Collection + public function sortUsingMap(BookSortMap $sortMap): array { // Load models into map - $this->loadModelsIntoSortMap($sortMap); - $booksInvolved = $this->getBooksInvolvedInSort($sortMap); + $modelMap = $this->loadModelsFromSortMap($sortMap); - // Perform the sort - $sortMap->each(function ($mapItem) { - $this->applySortUpdates($mapItem); + // Sort our changes from our map to be chapters first + // Since they need to be process to ensure book alignment for child page changes. + $sortMapItems = $sortMap->all(); + usort($sortMapItems, function (BookSortMapItem $itemA, BookSortMapItem $itemB) { + $aScore = $itemA->type === 'page' ? 2 : 1; + $bScore = $itemB->type === 'page' ? 2 : 1; + + return $aScore - $bScore; }); - // Update permissions and activity. - $booksInvolved->each(function (Book $book) { + // Perform the sort + foreach ($sortMapItems as $item) { + $this->applySortUpdates($item, $modelMap); + } + + /** @var Book[] $booksInvolved */ + $booksInvolved = array_values(array_filter($modelMap, function (string $key) { + return str_starts_with($key, 'book:'); + }, ARRAY_FILTER_USE_KEY)); + + // Update permissions of books involved + foreach ($booksInvolved as $book) { $book->rebuildPermissions(); - }); + } return $booksInvolved; } /** * Using the given sort map item, detect changes for the related model - * and update it if required. + * and update it if required. Changes where permissions are lacking will + * be skipped and not throw an error. + * + * @param array $modelMap */ - protected function applySortUpdates(\stdClass $sortMapItem) + protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void { /** @var BookChild $model */ - $model = $sortMapItem->model; + $model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null; + if (!$model) { + return; + } + + $priorityChanged = $model->priority !== $sortMapItem->sort; + $bookChanged = $model->book_id !== $sortMapItem->parentBookId; + $chapterChanged = ($model instanceof Page) && $model->chapter_id !== $sortMapItem->parentChapterId; + + // Stop if there's no change + if (!$priorityChanged && !$bookChanged && !$chapterChanged) { + return; + } + + $currentParentKey = 'book:' . $model->book_id; + if ($model instanceof Page && $model->chapter_id) { + $currentParentKey = 'chapter:' . $model->chapter_id; + } - $priorityChanged = intval($model->priority) !== intval($sortMapItem->sort); - $bookChanged = intval($model->book_id) !== intval($sortMapItem->book); - $chapterChanged = ($sortMapItem->type === 'page') && intval($model->chapter_id) !== $sortMapItem->parentChapter; + $currentParent = $modelMap[$currentParentKey] ?? null; + /** @var Book $newBook */ + $newBook = $modelMap['book:' . $sortMapItem->parentBookId] ?? null; + /** @var ?Chapter $newChapter */ + $newChapter = $sortMapItem->parentChapterId ? ($modelMap['chapter:' . $sortMapItem->parentChapterId] ?? null) : null; + if (!$this->isSortChangePermissible($sortMapItem, $model, $currentParent, $newBook, $newChapter)) { + return; + } + + // Action the required changes if ($bookChanged) { - $model->changeBook($sortMapItem->book); + $model->changeBook($newBook->id); } - if ($chapterChanged) { - $model->chapter_id = intval($sortMapItem->parentChapter); - $model->save(); + if ($model instanceof Page && $chapterChanged) { + $model->chapter_id = $newChapter->id ?? 0; } if ($priorityChanged) { - $model->priority = intval($sortMapItem->sort); + $model->priority = $sortMapItem->sort; + } + + if ($chapterChanged || $priorityChanged) { $model->save(); } } /** - * Load models from the database into the given sort map. + * Check if the current user has permissions to apply the given sorting change. + * Is quite complex since items can gain a different parent change. Acts as a: + * - Update of old parent element (Change of content/order). + * - Update of sorted/moved element. + * - Deletion of element (Relative to parent upon move). + * - Creation of element within parent (Upon move to new parent). */ - protected function loadModelsIntoSortMap(Collection $sortMap): void + protected function isSortChangePermissible(BookSortMapItem $sortMapItem, BookChild $model, ?Entity $currentParent, ?Entity $newBook, ?Entity $newChapter): bool { - $keyMap = $sortMap->keyBy(function (\stdClass $sortMapItem) { - return $sortMapItem->type . ':' . $sortMapItem->id; - }); - $pageIds = $sortMap->where('type', '=', 'page')->pluck('id'); - $chapterIds = $sortMap->where('type', '=', 'chapter')->pluck('id'); + // Stop if we can't see the current parent or new book. + if (!$currentParent || !$newBook) { + return false; + } - $pages = Page::visible()->whereIn('id', $pageIds)->get(); - $chapters = Chapter::visible()->whereIn('id', $chapterIds)->get(); + $hasNewParent = $newBook->id !== $model->book_id || ($model instanceof Page && $model->chapter_id !== ($sortMapItem->parentChapterId ?? 0)); + if ($model instanceof Chapter) { + $hasPermission = userCan('book-update', $currentParent) + && userCan('book-update', $newBook) + && userCan('chapter-update', $model) + && (!$hasNewParent || userCan('chapter-create', $newBook)) + && (!$hasNewParent || userCan('chapter-delete', $model)); - foreach ($pages as $page) { - $sortItem = $keyMap->get('page:' . $page->id); - $sortItem->model = $page; + if (!$hasPermission) { + return false; + } } - foreach ($chapters as $chapter) { - $sortItem = $keyMap->get('chapter:' . $chapter->id); - $sortItem->model = $chapter; + if ($model instanceof Page) { + $parentPermission = ($currentParent instanceof Chapter) ? 'chapter-update' : 'book-update'; + $hasCurrentParentPermission = userCan($parentPermission, $currentParent); + + // This needs to check if there was an intended chapter location in the original sort map + // rather than inferring from the $newChapter since that variable may be null + // due to other reasons (Visibility). + $newParent = $sortMapItem->parentChapterId ? $newChapter : $newBook; + if (!$newParent) { + return false; + } + + $hasPageEditPermission = userCan('page-update', $model); + $newParentInRightLocation = ($newParent instanceof Book || ($newParent instanceof Chapter && $newParent->book_id === $newBook->id)); + $newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update'; + $hasNewParentPermission = userCan($newParentPermission, $newParent); + + $hasDeletePermissionIfMoving = (!$hasNewParent || userCan('page-delete', $model)); + $hasCreatePermissionIfMoving = (!$hasNewParent || userCan('page-create', $newParent)); + + $hasPermission = $hasCurrentParentPermission + && $newParentInRightLocation + && $hasNewParentPermission + && $hasPageEditPermission + && $hasDeletePermissionIfMoving + && $hasCreatePermissionIfMoving; + + if (!$hasPermission) { + return false; + } } + + return true; } /** - * Get the books involved in a sort. - * The given sort map should have its models loaded first. - * @throws SortOperationException + * Load models from the database into the given sort map. + * + * @return array */ - protected function getBooksInvolvedInSort(Collection $sortMap): Collection + protected function loadModelsFromSortMap(BookSortMap $sortMap): array { - $bookIdsInvolved = collect([$this->book->id]); - $bookIdsInvolved = $bookIdsInvolved->concat($sortMap->pluck('book')); - $bookIdsInvolved = $bookIdsInvolved->concat($sortMap->pluck('model.book_id')); - $bookIdsInvolved = $bookIdsInvolved->unique()->toArray(); + $modelMap = []; + $ids = [ + 'chapter' => [], + 'page' => [], + 'book' => [], + ]; + + foreach ($sortMap->all() as $sortMapItem) { + $ids[$sortMapItem->type][] = $sortMapItem->id; + $ids['book'][] = $sortMapItem->parentBookId; + if ($sortMapItem->parentChapterId) { + $ids['chapter'][] = $sortMapItem->parentChapterId; + } + } - $books = Book::hasPermission('update')->whereIn('id', $bookIdsInvolved)->get(); + $pages = $this->queries->pages->visibleForList()->whereIn('id', array_unique($ids['page']))->get(); + /** @var Page $page */ + foreach ($pages as $page) { + $modelMap['page:' . $page->id] = $page; + $ids['book'][] = $page->book_id; + if ($page->chapter_id) { + $ids['chapter'][] = $page->chapter_id; + } + } + + $chapters = $this->queries->chapters->visibleForList()->whereIn('id', array_unique($ids['chapter']))->get(); + /** @var Chapter $chapter */ + foreach ($chapters as $chapter) { + $modelMap['chapter:' . $chapter->id] = $chapter; + $ids['book'][] = $chapter->book_id; + } - if (count($books) !== count($bookIdsInvolved)) { - throw new SortOperationException("Could not find all books requested in sort operation"); + $books = $this->queries->books->visibleForList()->whereIn('id', array_unique($ids['book']))->get(); + /** @var Book $book */ + foreach ($books as $book) { + $modelMap['book:' . $book->id] = $book; } - return $books; + return $modelMap; } }