Entities: Updated repos to act on refreshed clones
Some checks are pending
analyse-php / build (push) Waiting to run
lint-php / build (push) Waiting to run
test-migrations / build (8.2) (push) Waiting to run
test-migrations / build (8.3) (push) Waiting to run
test-migrations / build (8.4) (push) Waiting to run
test-php / build (8.2) (push) Waiting to run
test-php / build (8.3) (push) Waiting to run
test-php / build (8.4) (push) Waiting to run

Changes to core entity models are now done on clones to ensure clean
state before save, and those clones are returned back if changes are
needed after that action.
This commit is contained in:
Dan Brown 2025-09-24 18:19:16 +01:00
parent bf09b42dcf
commit b866dee0cf
No known key found for this signature in database
GPG Key ID: 116094FE15AE65C0
30 changed files with 100 additions and 63 deletions

View File

@ -130,7 +130,7 @@ class ChapterController extends Controller
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
$this->chapterRepo->update($chapter, $validated);
$chapter = $this->chapterRepo->update($chapter, $validated);
return redirect($chapter->getUrl());
}

View File

@ -59,6 +59,9 @@ class Book extends Entity
}
}
// TODO - Still handle cover as relation through containerData (since it's used in code)
// TODO - Remove above since we can access that via containerData
/**
* Get the Page that is used as default template for newly created pages within this Book.
*/

View File

@ -3,7 +3,6 @@
namespace BookStack\Entities\Models;
use BookStack\References\ReferenceUpdater;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
@ -27,25 +26,25 @@ abstract class BookChild extends Entity
/**
* Change the book that this entity belongs to.
*/
public function changeBook(int $newBookId): Entity
public function changeBook(int $newBookId): self
{
$oldUrl = $this->getUrl();
$this->book_id = $newBookId;
$this->refreshSlug();
$this->save();
$this->refresh();
$altered = $this->clone()->refresh();
$oldUrl = $altered->getUrl();
$altered->book_id = $newBookId;
$altered->refreshSlug();
$altered->save();
if ($oldUrl !== $this->getUrl()) {
app()->make(ReferenceUpdater::class)->updateEntityReferences($this, $oldUrl);
if ($oldUrl !== $altered->getUrl()) {
app()->make(ReferenceUpdater::class)->updateEntityReferences($altered, $oldUrl);
}
// Update all child pages if a chapter
if ($this instanceof Chapter) {
foreach ($this->pages()->withTrashed()->get() as $page) {
if ($altered instanceof Chapter) {
foreach ($altered->pages()->withTrashed()->get() as $page) {
$page->changeBook($newBookId);
}
}
return $this;
return $altered;
}
}

View File

@ -59,6 +59,9 @@ class Bookshelf extends Entity
}
}
// TODO - Still handle cover as relation through containerData (since it's used in code)
// TODO - Remove above since we can access that via containerData
/**
* Check if this shelf contains the given book.
*/

View File

@ -101,7 +101,7 @@ abstract class Entity extends Model implements
}
/**
* Get the container-specific data for this page.
* Get the container-specific data for this item.
*/
public function containerData(): HasOne
{

View File

@ -4,6 +4,7 @@ namespace BookStack\Entities\Models;
use BookStack\Uploads\Image;
use BookStack\Util\HtmlContentFilter;
use Exception;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasOne;
@ -27,6 +28,22 @@ class EntityContainerData extends Model
return $this->hasOne(Image::class, 'image_id');
}
/**
* Returns a shelf cover image URL, if cover not exists return default cover image.
*/
public function getCoverUrl(int $width = 440, int $height = 250, string|null $default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='): string|null
{
if (!$this->image_id) {
return $default;
}
try {
return $this->cover->getThumb($width, $height, false) ?? $default;
} catch (Exception $err) {
return $default;
}
}
/**
* Check if this data supports having a default template assigned.
*/
@ -46,7 +63,7 @@ class EntityContainerData extends Model
/**
* Get the description as a cleaned/handled HTML string.
*/
public function descriptionHtml(bool $raw = false): string
public function getDescriptionHtml(bool $raw = false): string
{
$html = $this->description_html ?: '<p>' . nl2br(e($this->description)) . '</p>';
if ($raw) {
@ -69,7 +86,7 @@ class EntityContainerData extends Model
}
if (empty($html) && !empty($plaintext)) {
$this->description_html = $this->descriptionHtml();
$this->description_html = $this->getDescriptionHtml();
}
}
}

View File

@ -3,9 +3,7 @@
namespace BookStack\Entities\Repos;
use BookStack\Activity\TagRepo;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\EntityContainerData;
use BookStack\Entities\Queries\PageQueries;
@ -31,9 +29,13 @@ class BaseRepo
/**
* Create a new entity in the system.
* @template T of Entity
* @param T $entity
* @return T
*/
public function create(Entity $entity, array $input): void
public function create(Entity $entity, array $input): Entity
{
$entity = $entity->clone()->refresh();
$entityInput = array_intersect_key($input, ['name', 'priority']);
$entity->forceFill($entityInput);
$entity->forceFill([
@ -59,13 +61,19 @@ class BaseRepo
$entity->indexForSearch();
$this->referenceStore->updateForEntity($entity);
return $entity;
}
/**
* Update the given entity.
* @template T of Entity
* @param T $entity
* @return T
*/
public function update(Entity $entity, array $input): void
public function update(Entity $entity, array $input): Entity
{
$entity = $entity->clone()->refresh();
$oldUrl = $entity->getUrl();
$entity->fill($input);
@ -78,6 +86,7 @@ class BaseRepo
$entity->save();
if ($entity->shouldHaveContainerData() && $entity->containerData) {
$this->updateContainerDescription($entity->containerData, $input);
$entity->containerData->save();
}
if (isset($input['tags'])) {
@ -91,6 +100,8 @@ class BaseRepo
if ($oldUrl !== $entity->getUrl()) {
$this->referenceUpdater->updateEntityReferences($entity, $oldUrl);
}
return $entity;
}
/**
@ -147,7 +158,7 @@ class BaseRepo
}
/**
* Sort the parent of the given entity, if any auto sort actions are set for it.
* Sort the parent of the given entity if any auto sort actions are set for it.
* Typically ran during create/update/insert events.
*/
public function sortParent(Entity $entity): void
@ -158,6 +169,9 @@ class BaseRepo
}
}
/**
* Update the description of the given container data from input data.
*/
protected function updateContainerDescription(EntityContainerData $data, array $input): void
{
if (isset($input['description_html'])) {

View File

@ -30,9 +30,7 @@ class BookRepo
public function create(array $input): Book
{
return (new DatabaseTransaction(function () use ($input) {
$book = new Book();
$this->baseRepo->create($book, $input);
$book = $this->baseRepo->create(new Book(), $input);
$this->baseRepo->updateCoverImage($book->containerData, $input['image'] ?? null);
$this->baseRepo->updateDefaultTemplate($book->containerData, intval($input['default_template_id'] ?? null));
Activity::add(ActivityType::BOOK_CREATE, $book);
@ -52,7 +50,7 @@ class BookRepo
*/
public function update(Book $book, array $input): Book
{
$this->baseRepo->update($book, $input);
$book = $this->baseRepo->update($book, $input);
if (array_key_exists('default_template_id', $input)) {
$this->baseRepo->updateDefaultTemplate($book->containerData, intval($input['default_template_id']));

View File

@ -25,8 +25,7 @@ class BookshelfRepo
public function create(array $input, array $bookIds): Bookshelf
{
return (new DatabaseTransaction(function () use ($input, $bookIds) {
$shelf = new Bookshelf();
$this->baseRepo->create($shelf, $input);
$shelf = $this->baseRepo->create(new Bookshelf(), $input);
$this->baseRepo->updateCoverImage($shelf->containerData, $input['image'] ?? null);
$this->updateBooks($shelf, $bookIds);
Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf);
@ -39,7 +38,7 @@ class BookshelfRepo
*/
public function update(Bookshelf $shelf, array $input, ?array $bookIds): Bookshelf
{
$this->baseRepo->update($shelf, $input);
$shelf = $this->baseRepo->update($shelf, $input);
if (!is_null($bookIds)) {
$this->updateBooks($shelf, $bookIds);

View File

@ -33,7 +33,8 @@ class ChapterRepo
$chapter = new Chapter();
$chapter->book_id = $parentBook->id;
$chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
$this->baseRepo->create($chapter, $input);
$chapter = $this->baseRepo->create($chapter, $input);
$this->baseRepo->updateDefaultTemplate($chapter->containerData, intval($input['default_template_id'] ?? null));
Activity::add(ActivityType::CHAPTER_CREATE, $chapter);
@ -48,7 +49,7 @@ class ChapterRepo
*/
public function update(Chapter $chapter, array $input): Chapter
{
$this->baseRepo->update($chapter, $input);
$chapter = $this->baseRepo->update($chapter, $input);
if (array_key_exists('default_template_id', $input)) {
$this->baseRepo->updateDefaultTemplate($chapter->containerData, intval($input['default_template_id']));
@ -93,7 +94,7 @@ class ChapterRepo
}
return (new DatabaseTransaction(function () use ($chapter, $parent) {
$chapter->changeBook($parent->id);
$chapter = $chapter->changeBook($parent->id);
$chapter->rebuildPermissions();
Activity::add(ActivityType::CHAPTER_MOVE, $chapter);

View File

@ -85,7 +85,9 @@ class PageRepo
$draft->pageData->revision_count = 1;
$draft->pageData->priority = $this->getNewPriority($draft);
$this->updateTemplateStatusAndContentFromInput($draft, $input);
$this->baseRepo->update($draft, $input);
$draft = $this->baseRepo->update($draft, $input);
$draft->pageData->save();
$draft->rebuildPermissions();
$summary = trim($input['summary'] ?? '') ?: trans('entities.pages_initial_revision');
@ -115,13 +117,15 @@ class PageRepo
*/
public function update(Page $page, array $input): Page
{
$page = $page->clone()->refresh();
// Hold the old details to compare later
$oldName = $page->name;
$oldHtml = $page->pageData->html;
$oldMarkdown = $page->pageData->markdown;
$this->updateTemplateStatusAndContentFromInput($page, $input);
$this->baseRepo->update($page, $input);
$page = $this->baseRepo->update($page, $input);
// Update with new details
$page->pageData->revision_count++;
@ -187,6 +191,7 @@ class PageRepo
if ($page->draft) {
$this->updateTemplateStatusAndContentFromInput($page, $input);
$page->forceFill(array_intersect_key($input, array_flip(['name'])))->save();
$page->pageData->save();
$page->save();
return $page;
@ -285,7 +290,7 @@ class PageRepo
return (new DatabaseTransaction(function () use ($page, $parent) {
$page->pageData->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
$newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id;
$page->changeBook($newBookId);
$page = $page->changeBook($newBookId);
$page->rebuildPermissions();
Activity::add(ActivityType::PAGE_MOVE, $page);

View File

@ -3,13 +3,10 @@
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\Entities\Queries\EntityQueries;
use BookStack\Sorting\BookSortMap;
use BookStack\Sorting\BookSortMapItem;
use Illuminate\Support\Collection;
class BookContents
@ -29,7 +26,7 @@ class BookContents
{
$maxPage = $this->book->pages()
->where('draft', '=', false)
->where('chapter_id', '=', 0)
->whereDoesntHave('chapter')
->max('priority');
$maxChapter = $this->book->chapters()
@ -80,11 +77,11 @@ class BookContents
protected function bookChildSortFunc(): callable
{
return function (Entity $entity) {
if (isset($entity['draft']) && $entity['draft']) {
if ($entity->getAttribute('draft') ?? false) {
return -100;
}
return $entity['priority'] ?? 0;
return $entity->getAttribute('priority') ?? 0;
};
}

View File

@ -34,6 +34,7 @@ class HierarchyTransformer
/** @var Page $page */
foreach ($chapter->pages as $page) {
$page->chapter_id = 0;
$page->save();
$page->changeBook($book->id);
}

View File

@ -318,7 +318,7 @@ class ExportFormatter
{
$text = '# ' . $chapter->name . "\n\n";
$description = (new HtmlToMarkdown($chapter->containerData->descriptionHtml()))->convert();
$description = (new HtmlToMarkdown($chapter->containerData->getDescriptionHtml()))->convert();
if ($description) {
$text .= $description . "\n\n";
}
@ -338,7 +338,7 @@ class ExportFormatter
$bookTree = (new BookContents($book))->getTree(false, true);
$text = '# ' . $book->name . "\n\n";
$description = (new HtmlToMarkdown($book->containerData->descriptionHtml()))->convert();
$description = (new HtmlToMarkdown($book->containerData->getDescriptionHtml()))->convert();
if ($description) {
$text .= $description . "\n\n";
}

View File

@ -55,7 +55,7 @@ final class ZipExportBook extends ZipExportModel
$instance = new self();
$instance->id = $model->id;
$instance->name = $model->name;
$instance->description_html = $model->containerData->descriptionHtml();
$instance->description_html = $model->containerData->getDescriptionHtml();
if ($model->containerData->cover) {
$instance->cover = $files->referenceForImage($model->containerData->cover);

View File

@ -40,7 +40,7 @@ final class ZipExportChapter extends ZipExportModel
$instance = new self();
$instance->id = $model->id;
$instance->name = $model->name;
$instance->description_html = $model->containerData->descriptionHtml();
$instance->description_html = $model->containerData->getDescriptionHtml();
$instance->priority = $model->priority;
$instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all());

View File

@ -70,7 +70,7 @@ class ReferenceUpdater
protected function updateReferencesWithinDescription(EntityContainerData $containerData, string $oldLink, string $newLink): void
{
$html = $this->updateLinksInHtml($containerData->descriptionHtml(true) ?: '', $oldLink, $newLink);
$html = $this->updateLinksInHtml($containerData->getDescriptionHtml(true) ?: '', $oldLink, $newLink);
$containerData->setDescriptionHtml($html);
$containerData->save();
}

View File

@ -155,7 +155,7 @@ class BookSorter
// Action the required changes
if ($bookChanged) {
$model->changeBook($newBook->id);
$model = $model->changeBook($newBook->id);
}
if ($model instanceof Page && $chapterChanged) {

View File

@ -18,7 +18,7 @@
@include('form.image-picker', [
'defaultImage' => url('/book_default_cover.png'),
'currentImage' => (isset($model) && $model->containerData->cover) ? $model->getBookCover() : url('/book_default_cover.png') ,
'currentImage' => (($model ?? null)?->containerData?->getCoverUrl(440, 250, url('/book_default_cover.png')) ?? url('/book_default_cover.png')),
'name' => 'image',
'imageClass' => 'cover'
])

View File

@ -5,7 +5,7 @@
<div class="content">
<h4 class="entity-list-item-name break-text">{{ $book->name }}</h4>
<div class="entity-item-snippet">
<p class="text-muted break-text mb-s text-limit-lines-1">{{ $book->containerData->description }}</p>
<p class="text-muted break-text mb-s text-limit-lines-1">{{ $book->description }}</p>
</div>
</div>
</a>

View File

@ -7,7 +7,7 @@
@stop
@push('social-meta')
<meta property="og:description" content="{{ Str::limit($book->containerData->description, 100, '...') }}">
<meta property="og:description" content="{{ Str::limit($book->description, 100, '...') }}">
@if($book->containerData->cover)
<meta property="og:image" content="{{ $book->getBookCover() }}">
@endif
@ -26,7 +26,7 @@
<main class="content-wrap card">
<h1 class="break-text">{{$book->name}}</h1>
<div refs="entity-search@contentView" class="book-content">
<div class="text-muted break-text">{!! $book->containerData->descriptionHtml() !!}</div>
<div class="text-muted break-text">{!! $book->containerData->getDescriptionHtml() !!}</div>
@if(count($bookChildren) > 0)
<div class="entity-list book-contents">
@foreach($bookChildren as $childElement)

View File

@ -7,7 +7,7 @@
@stop
@push('social-meta')
<meta property="og:description" content="{{ Str::limit($chapter->containerData->description, 100, '...') }}">
<meta property="og:description" content="{{ Str::limit($chapter->description, 100, '...') }}">
@endpush
@include('entities.body-tag-classes', ['entity' => $chapter])
@ -24,7 +24,7 @@
<main class="content-wrap card">
<h1 class="break-text">{{ $chapter->name }}</h1>
<div refs="entity-search@contentView" class="chapter-content">
<div class="text-muted break-text">{!! $chapter->containerData->descriptionHtml() !!}</div>
<div class="text-muted break-text">{!! $chapter->containerData->getDescriptionHtml() !!}</div>
@if(count($pages) > 0)
<div class="entity-list book-contents">
@foreach($pages as $page)

View File

@ -1,7 +1,7 @@
<a href="{{ $entity->getUrl() }}" class="grid-card"
data-entity-type="{{ $entity->getType() }}" data-entity-id="{{ $entity->id }}">
<div class="bg-{{ $entity->getType() }} featured-image-container-wrap">
<div class="featured-image-container" @if($entity->containerData && $entity->containerData->cover) style="background-image: url('{{ $entity->getBookCover() }}')"@endif>
<div class="featured-image-container" @if($entity->containerData && $entity->containerData->cover) style="background-image: url('{{ $entity->containerData->getCoverUrl() }}')"@endif>
</div>
@icon($entity->getType())
</div>

View File

@ -5,7 +5,7 @@
@section('content')
<h1 style="font-size: 4.8em">{{$book->name}}</h1>
<div>{!! $book->containerData->descriptionHtml() !!}</div>
<div>{!! $book->containerData->getDescriptionHtml() !!}</div>
@include('exports.parts.book-contents-menu', ['children' => $bookChildren])

View File

@ -5,7 +5,7 @@
@section('content')
<h1 style="font-size: 4.8em">{{$chapter->name}}</h1>
<div>{!! $chapter->containerData->descriptionHtml() !!}</div>
<div>{!! $chapter->containerData->getDescriptionHtml() !!}</div>
@include('exports.parts.chapter-contents-menu', ['pages' => $pages])

View File

@ -1,7 +1,7 @@
<div class="page-break"></div>
<h1 id="chapter-{{$chapter->id}}">{{ $chapter->name }}</h1>
<div>{!! $chapter->containerData->descriptionHtml() !!}</div>
<div>{!! $chapter->containerData->getDescriptionHtml() !!}</div>
@if(count($chapter->visible_pages) > 0)
@foreach($chapter->visible_pages as $page)

View File

@ -1,7 +1,7 @@
<textarea component="wysiwyg-input"
option:wysiwyg-input:text-direction="{{ $locale->htmlDirection() }}"
id="description_html" name="description_html" rows="5"
@if($errors->has('description_html')) class="text-neg" @endif>@if(isset($model) || old('description_html')){{ old('description_html') ?? $model->containerData->descriptionHtml()}}@endif</textarea>
@if($errors->has('description_html')) class="text-neg" @endif>@if(isset($model) || old('description_html')){{ old('description_html') ?? $model->containerData->getDescriptionHtml()}}@endif</textarea>
@if($errors->has('description_html'))
<div class="text-neg text-small">{{ $errors->first('description_html') }}</div>
@endif

View File

@ -64,7 +64,7 @@
@include('form.image-picker', [
'defaultImage' => url('/book_default_cover.png'),
'currentImage' => (isset($shelf) && $shelf->containerData->cover) ? $shelf->getBookCover() : url('/book_default_cover.png') ,
'currentImage' => (($shelf ?? null)?->containerData?->getCoverUrl(440, 250, url('/book_default_cover.png')) ?? url('/book_default_cover.png')),
'name' => 'image',
'imageClass' => 'cover'
])

View File

@ -1,8 +1,8 @@
@extends('layouts.tri')
@push('social-meta')
<meta property="og:description" content="{{ Str::limit($shelf->containerData->description, 100, '...') }}">
@if($shelf->containerData->cover)
<meta property="og:description" content="{{ Str::limit($shelf->description, 100, '...') }}">
@if($shelf->cover)
<meta property="og:image" content="{{ $shelf->getBookCover() }}">
@endif
@endpush
@ -28,7 +28,7 @@
</div>
<div class="book-content">
<div class="text-muted break-text">{!! $shelf->containerData->descriptionHtml() !!}</div>
<div class="text-muted break-text">{!! $shelf->containerData->getDescriptionHtml() !!}</div>
@if(count($sortedVisibleShelfBooks) > 0)
@if($view === 'list')
<div class="entity-list">

View File

@ -227,7 +227,7 @@ class ZipExportTest extends TestCase
$bookData = $zip->data['book'];
$this->assertEquals($book->id, $bookData['id']);
$this->assertEquals($book->name, $bookData['name']);
$this->assertEquals($book->containerData->descriptionHtml(), $bookData['description_html']);
$this->assertEquals($book->containerData->getDescriptionHtml(), $bookData['description_html']);
$this->assertCount(2, $bookData['tags']);
$this->assertCount($book->directPages()->count(), $bookData['pages']);
$this->assertCount($book->chapters()->count(), $bookData['chapters']);
@ -264,7 +264,7 @@ class ZipExportTest extends TestCase
$chapterData = $zip->data['chapter'];
$this->assertEquals($chapter->id, $chapterData['id']);
$this->assertEquals($chapter->name, $chapterData['name']);
$this->assertEquals($chapter->containerData->descriptionHtml(), $chapterData['description_html']);
$this->assertEquals($chapter->containerData->getDescriptionHtml(), $chapterData['description_html']);
$this->assertCount(2, $chapterData['tags']);
$this->assertEquals($chapter->priority, $chapterData['priority']);
$this->assertCount($chapter->pages()->count(), $chapterData['pages']);