285 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			PHP
		
	
	
	
			
		
		
	
	
			285 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			PHP
		
	
	
	
| <?php
 | |
| 
 | |
| namespace BookStack\Sorting;
 | |
| 
 | |
| use BookStack\App\Model;
 | |
| 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\Entities\Queries\EntityQueries;
 | |
| 
 | |
| class BookSorter
 | |
| {
 | |
|     public function __construct(
 | |
|         protected EntityQueries $queries,
 | |
|     ) {
 | |
|     }
 | |
| 
 | |
|     public function runBookAutoSortForAllWithSet(SortRule $set): void
 | |
|     {
 | |
|         $set->books()->chunk(50, function ($books) {
 | |
|             foreach ($books as $book) {
 | |
|                 $this->runBookAutoSort($book);
 | |
|             }
 | |
|         });
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Runs the auto-sort for a book if the book has a sort set applied to it.
 | |
|      * This does not consider permissions since the sort operations are centrally
 | |
|      * managed by admins so considered permitted if existing and assigned.
 | |
|      */
 | |
|     public function runBookAutoSort(Book $book): void
 | |
|     {
 | |
|         $set = $book->sortRule;
 | |
|         if (!$set) {
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         $sortFunctions = array_map(function (SortRuleOperation $op) {
 | |
|             return $op->getSortFunction();
 | |
|         }, $set->getOperations());
 | |
| 
 | |
|         $chapters = $book->chapters()
 | |
|             ->with('pages:id,name,priority,created_at,updated_at,chapter_id')
 | |
|             ->get(['id', 'name', 'priority', 'created_at', 'updated_at']);
 | |
| 
 | |
|         /** @var (Chapter|Book)[] $topItems */
 | |
|         $topItems = [
 | |
|             ...$book->directPages()->get(['id', 'name', 'priority', 'created_at', 'updated_at']),
 | |
|             ...$chapters,
 | |
|         ];
 | |
| 
 | |
|         foreach ($sortFunctions as $sortFunction) {
 | |
|             usort($topItems, $sortFunction);
 | |
|         }
 | |
| 
 | |
|         foreach ($topItems as $index => $topItem) {
 | |
|             $topItem->priority = $index + 1;
 | |
|             $topItem::withoutTimestamps(fn () => $topItem->save());
 | |
|         }
 | |
| 
 | |
|         foreach ($chapters as $chapter) {
 | |
|             $pages = $chapter->pages->all();
 | |
|             foreach ($sortFunctions as $sortFunction) {
 | |
|                 usort($pages, $sortFunction);
 | |
|             }
 | |
| 
 | |
|             foreach ($pages as $index => $page) {
 | |
|                 $page->priority = $index + 1;
 | |
|                 $page::withoutTimestamps(fn () => $page->save());
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| 
 | |
| 
 | |
|     /**
 | |
|      * 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<string, Entity> $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::withoutTimestamps(fn () => $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<string, Entity>
 | |
|      */
 | |
|     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;
 | |
|     }
 | |
| }
 |