diff --git a/app/Entities/Tools/BookContents.php b/app/Entities/Tools/BookContents.php index 7fa2134b7..7dd3f3e11 100644 --- a/app/Entities/Tools/BookContents.php +++ b/app/Entities/Tools/BookContents.php @@ -8,6 +8,8 @@ use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; use BookStack\Entities\Queries\EntityQueries; +use BookStack\Sorting\BookSortMap; +use BookStack\Sorting\BookSortMapItem; use Illuminate\Support\Collection; class BookContents @@ -103,211 +105,4 @@ class BookContents return $query->where('book_id', '=', $this->book->id)->get(); } - - /** - * Sort the books content using the given sort map. - * Returns a list of books that were involved in the operation. - * - * @returns Book[] - */ - public function sortUsingMap(BookSortMap $sortMap): array - { - // Load models into map - $modelMap = $this->loadModelsFromSortMap($sortMap); - - // 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; - }); - - // 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. Changes where permissions are lacking will - * be skipped and not throw an error. - * - * @param array $modelMap - */ - protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void - { - /** @var BookChild $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; - } - - $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($newBook->id); - } - - if ($model instanceof Page && $chapterChanged) { - $model->chapter_id = $newChapter->id ?? 0; - } - - if ($priorityChanged) { - $model->priority = $sortMapItem->sort; - } - - if ($chapterChanged || $priorityChanged) { - $model->save(); - } - } - - /** - * 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 isSortChangePermissible(BookSortMapItem $sortMapItem, BookChild $model, ?Entity $currentParent, ?Entity $newBook, ?Entity $newChapter): bool - { - // Stop if we can't see the current parent or new book. - if (!$currentParent || !$newBook) { - return false; - } - - $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)); - - if (!$hasPermission) { - return false; - } - } - - 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; - } - - /** - * Load models from the database into the given sort map. - * - * @return array - */ - protected function loadModelsFromSortMap(BookSortMap $sortMap): array - { - $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; - } - } - - $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; - } - - $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 $modelMap; - } } diff --git a/app/Entities/Controllers/BookSortController.php b/app/Sorting/BookSortController.php similarity index 88% rename from app/Entities/Controllers/BookSortController.php rename to app/Sorting/BookSortController.php index 5aefc5832..feed5db4f 100644 --- a/app/Entities/Controllers/BookSortController.php +++ b/app/Sorting/BookSortController.php @@ -1,11 +1,10 @@ queries->findVisibleBySlugOrFail($bookSlug); $this->checkOwnablePermission('book-update', $book); @@ -58,8 +57,7 @@ class BookSortController extends Controller } $sortMap = BookSortMap::fromJson($request->get('sort-tree')); - $bookContents = new BookContents($book); - $booksInvolved = $bookContents->sortUsingMap($sortMap); + $booksInvolved = $sorter->sortUsingMap($sortMap); // Rebuild permissions and add activity for involved books. foreach ($booksInvolved as $bookInvolved) { diff --git a/app/Entities/Tools/BookSortMap.php b/app/Sorting/BookSortMap.php similarity index 96% rename from app/Entities/Tools/BookSortMap.php rename to app/Sorting/BookSortMap.php index ff1ec767f..96c9d342a 100644 --- a/app/Entities/Tools/BookSortMap.php +++ b/app/Sorting/BookSortMap.php @@ -1,6 +1,6 @@ loadModelsFromSortMap($sortMap); + + // 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; + }); + + // 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. Changes where permissions are lacking will + * be skipped and not throw an error. + * + * @param array $modelMap + */ + protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void + { + /** @var BookChild $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; + } + + $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($newBook->id); + } + + if ($model instanceof Page && $chapterChanged) { + $model->chapter_id = $newChapter->id ?? 0; + } + + if ($priorityChanged) { + $model->priority = $sortMapItem->sort; + } + + if ($chapterChanged || $priorityChanged) { + $model->save(); + } + } + + /** + * 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 isSortChangePermissible(BookSortMapItem $sortMapItem, BookChild $model, ?Entity $currentParent, ?Entity $newBook, ?Entity $newChapter): bool + { + // Stop if we can't see the current parent or new book. + if (!$currentParent || !$newBook) { + return false; + } + + $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)); + + if (!$hasPermission) { + return false; + } + } + + 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; + } + + /** + * Load models from the database into the given sort map. + * + * @return array + */ + protected function loadModelsFromSortMap(BookSortMap $sortMap): array + { + $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; + } + } + + $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; + } + + $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 $modelMap; + } +} diff --git a/routes/web.php b/routes/web.php index 5bb9622e7..e1e819dd0 100644 --- a/routes/web.php +++ b/routes/web.php @@ -13,6 +13,7 @@ use BookStack\Permissions\PermissionsController; use BookStack\References\ReferenceController; use BookStack\Search\SearchController; use BookStack\Settings as SettingControllers; +use BookStack\Sorting\BookSortController; use BookStack\Theming\ThemeController; use BookStack\Uploads\Controllers as UploadControllers; use BookStack\Users\Controllers as UserControllers; @@ -66,7 +67,7 @@ Route::middleware('auth')->group(function () { Route::get('/books/{slug}/edit', [EntityControllers\BookController::class, 'edit']); Route::put('/books/{slug}', [EntityControllers\BookController::class, 'update']); Route::delete('/books/{id}', [EntityControllers\BookController::class, 'destroy']); - Route::get('/books/{slug}/sort-item', [EntityControllers\BookSortController::class, 'showItem']); + Route::get('/books/{slug}/sort-item', [BookSortController::class, 'showItem']); Route::get('/books/{slug}', [EntityControllers\BookController::class, 'show']); Route::get('/books/{bookSlug}/permissions', [PermissionsController::class, 'showForBook']); Route::put('/books/{bookSlug}/permissions', [PermissionsController::class, 'updateForBook']); @@ -74,8 +75,8 @@ Route::middleware('auth')->group(function () { Route::get('/books/{bookSlug}/copy', [EntityControllers\BookController::class, 'showCopy']); Route::post('/books/{bookSlug}/copy', [EntityControllers\BookController::class, 'copy']); Route::post('/books/{bookSlug}/convert-to-shelf', [EntityControllers\BookController::class, 'convertToShelf']); - Route::get('/books/{bookSlug}/sort', [EntityControllers\BookSortController::class, 'show']); - Route::put('/books/{bookSlug}/sort', [EntityControllers\BookSortController::class, 'update']); + Route::get('/books/{bookSlug}/sort', [BookSortController::class, 'show']); + Route::put('/books/{bookSlug}/sort', [BookSortController::class, 'update']); Route::get('/books/{slug}/references', [ReferenceController::class, 'book']); Route::get('/books/{bookSlug}/export/html', [ExportControllers\BookExportController::class, 'html']); Route::get('/books/{bookSlug}/export/pdf', [ExportControllers\BookExportController::class, 'pdf']);