]> BookStack Code Mirror - bookstack/blobdiff - app/Entities/Tools/BookContents.php
respective book and chapter structure added.
[bookstack] / app / Entities / Tools / BookContents.php
index 71c8f8393a22dff67f95a4ec931ce236d4641b49..7fa2134b7fad60627393c334781d512542133298 100644 (file)
@@ -1,40 +1,38 @@
-<?php namespace BookStack\Entities\Tools;
+<?php
+
+namespace BookStack\Entities\Tools;
 
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\BookChild;
 use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Entity;
 use BookStack\Entities\Models\Page;
-use BookStack\Exceptions\SortOperationException;
+use BookStack\Entities\Queries\EntityQueries;
 use Illuminate\Support\Collection;
 
 class BookContents
 {
+    protected EntityQueries $queries;
 
-    /**
-     * @var Book
-     */
-    protected $book;
-
-    /**
-     * BookContents constructor.
-     */
-    public function __construct(Book $book)
-    {
-        $this->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<string, Entity> $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<string, Entity>
      */
-    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;
     }
 }