Skip to content

Commit

Permalink
Merge branch 'sort_changes'
Browse files Browse the repository at this point in the history
Related to #3134
  • Loading branch information
ssddanbrown committed Jan 6, 2022
2 parents 4d09433 + 2312d07 commit cb0d674
Show file tree
Hide file tree
Showing 11 changed files with 451 additions and 162 deletions.
7 changes: 6 additions & 1 deletion app/Entities/Repos/ChapterRepo.php
Expand Up @@ -10,6 +10,7 @@
use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\PermissionsException;
use BookStack\Facades\Activity;
use Exception;

Expand Down Expand Up @@ -85,15 +86,19 @@ public function destroy(Chapter $chapter)
* 'book:<id>' (book:5).
*
* @throws MoveOperationException
* @throws PermissionsException
*/
public function move(Chapter $chapter, string $parentIdentifier): Book
{
/** @var Book $parent */
$parent = $this->findParentByIdentifier($parentIdentifier);
if (is_null($parent)) {
throw new MoveOperationException('Book to move chapter into not found');
}

if (!userCan('chapter-create', $parent)) {
throw new PermissionsException('User does not have permission to create a chapter within the chosen book');
}

$chapter->changeBook($parent->id);
$chapter->rebuildPermissions();
Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
Expand Down
2 changes: 1 addition & 1 deletion app/Entities/Repos/PageRepo.php
Expand Up @@ -328,7 +328,7 @@ public function restoreRevision(Page $page, int $revisionId): Page
public function move(Page $page, string $parentIdentifier): Entity
{
$parent = $this->findParentByIdentifier($parentIdentifier);
if ($parent === null) {
if (is_null($parent)) {
throw new MoveOperationException('Book or chapter to move page into not found');
}

Expand Down
213 changes: 154 additions & 59 deletions app/Entities/Tools/BookContents.php
Expand Up @@ -7,7 +7,6 @@
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Exceptions\SortOperationException;
use Illuminate\Support\Collection;

class BookContents
Expand Down Expand Up @@ -107,111 +106,207 @@ protected function getPages(bool $showDrafts = false, bool $getPageContent = fal
}

/**
* 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);

// 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
$sortMap->each(function ($mapItem) {
$this->applySortUpdates($mapItem);
});
foreach ($sortMapItems as $item) {
$this->applySortUpdates($item, $modelMap);
}

/** @var Book[] $booksInvolved */
$booksInvolved = array_values(array_filter($modelMap, function (string $key) {
return strpos($key, 'book:') === 0;
}, ARRAY_FILTER_USE_KEY));

// Update permissions and activity.
$booksInvolved->each(function (Book $book) {
// 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 = ($model instanceof 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();
$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->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;
}
}

$pages = Page::visible()->whereIn('id', array_unique($ids['page']))->get(Page::$listAttributes);
/** @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;
}
}

$books = Book::hasPermission('update')->whereIn('id', $bookIdsInvolved)->get();
$chapters = Chapter::visible()->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 = Book::visible()->whereIn('id', array_unique($ids['book']))->get();
/** @var Book $book */
foreach ($books as $book) {
$modelMap['book:' . $book->id] = $book;
}

return $books;
return $modelMap;
}
}
45 changes: 45 additions & 0 deletions app/Entities/Tools/BookSortMap.php
@@ -0,0 +1,45 @@
<?php

namespace BookStack\Entities\Tools;

class BookSortMap
{
/**
* @var BookSortMapItem[]
*/
protected $mapData = [];

public function addItem(BookSortMapItem $mapItem): void
{
$this->mapData[] = $mapItem;
}

/**
* @return BookSortMapItem[]
*/
public function all(): array
{
return $this->mapData;
}

public static function fromJson(string $json): self
{
$map = new static();
$mapData = json_decode($json);

foreach ($mapData as $mapDataItem) {
$item = new BookSortMapItem(
intval($mapDataItem->id),
intval($mapDataItem->sort),
$mapDataItem->parentChapter ? intval($mapDataItem->parentChapter) : null,
$mapDataItem->type,
intval($mapDataItem->book)
);

$map->addItem($item);
}

return $map;
}

}

0 comments on commit cb0d674

Please sign in to comment.