]> BookStack Code Mirror - bookstack/commitdiff
DB: Aligned entity structure to a common table
authorDan Brown <redacted>
Sat, 18 Oct 2025 12:14:30 +0000 (13:14 +0100)
committerGitHub <redacted>
Sat, 18 Oct 2025 12:14:30 +0000 (13:14 +0100)
As per PR #5800

* DB: Planned out new entity table format via migrations

* DB: Created entity migration logic

Made some other tweaks/fixes while testing.

* DB: Added change of entity relation columns to suit new entities table

* DB: Got most view queries working for new structure

* Entities: Started logic change to new structure

Updated base entity class, and worked through BaseRepo.
Need to go through other repos next.

Removed a couple of redundant interfaces as part of this since we can
move the logic onto the shared ContainerData model as needed.

* Entities: Been through repos to update for new format

* Entities: Updated repos to act on refreshed clones

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.

* Entities: Updated model classes & relations for changes

* Entities: Changed from *Data to a common "contents" system

Added smart loading from builder instances which should hydrate with
"contents()" loaded via join, while keeping the core model original.

* Entities: Moved entity description/covers to own non-model classes

Added back some interfaces.

* Entities: Removed use of contents system for data access

* Entities: Got most queries back to working order

* Entities: Reverted back to data from contents, fixed various issues

* Entities: Started addressing issues from tests

* Entities: Addressed further tests/issues

* Entities: Been through tests to get all passing in dev

Fixed issues and needed test changes along the way.

* Entities: Addressed phpstan errors

* Entities: Reviewed TODO notes

* Entities: Ensured book/shelf relation data removed on destroy

* Entities: Been through API responses & adjusted field visibility

* Entities: Added type index to massively improve query speed

120 files changed:
app/Access/Mfa/MfaSession.php
app/Console/Commands/UpdateUrlCommand.php
app/Entities/Controllers/BookApiController.php
app/Entities/Controllers/BookshelfApiController.php
app/Entities/Controllers/BookshelfController.php
app/Entities/Controllers/ChapterApiController.php
app/Entities/Controllers/ChapterController.php
app/Entities/Controllers/PageController.php
app/Entities/EntityExistsRule.php [new file with mode: 0644]
app/Entities/Models/Book.php
app/Entities/Models/BookChild.php
app/Entities/Models/Bookshelf.php
app/Entities/Models/Chapter.php
app/Entities/Models/ContainerTrait.php [new file with mode: 0644]
app/Entities/Models/CoverImageInterface.php [deleted file]
app/Entities/Models/Entity.php
app/Entities/Models/EntityContainerData.php [new file with mode: 0644]
app/Entities/Models/EntityPageData.php [new file with mode: 0644]
app/Entities/Models/EntityQueryBuilder.php [new file with mode: 0644]
app/Entities/Models/EntityScope.php [new file with mode: 0644]
app/Entities/Models/HasCoverInterface.php [new file with mode: 0644]
app/Entities/Models/HasDefaultTemplateInterface.php [new file with mode: 0644]
app/Entities/Models/HasDescriptionInterface.php [new file with mode: 0644]
app/Entities/Models/HtmlDescriptionInterface.php [deleted file]
app/Entities/Models/HtmlDescriptionTrait.php [deleted file]
app/Entities/Models/Page.php
app/Entities/Queries/BookQueries.php
app/Entities/Queries/BookshelfQueries.php
app/Entities/Queries/ChapterQueries.php
app/Entities/Queries/EntityQueries.php
app/Entities/Queries/PageQueries.php
app/Entities/Queries/ProvidesEntityQueries.php
app/Entities/Repos/BaseRepo.php
app/Entities/Repos/BookRepo.php
app/Entities/Repos/BookshelfRepo.php
app/Entities/Repos/ChapterRepo.php
app/Entities/Repos/DeletionRepo.php
app/Entities/Repos/PageRepo.php
app/Entities/Repos/RevisionRepo.php
app/Entities/Tools/BookContents.php
app/Entities/Tools/Cloner.php
app/Entities/Tools/EntityCover.php [new file with mode: 0644]
app/Entities/Tools/EntityDefaultTemplate.php [new file with mode: 0644]
app/Entities/Tools/EntityHtmlDescription.php [new file with mode: 0644]
app/Entities/Tools/HierarchyTransformer.php
app/Entities/Tools/MixedEntityListLoader.php
app/Entities/Tools/PageContent.php
app/Entities/Tools/TrashCan.php
app/Exports/ExportFormatter.php
app/Exports/ZipExports/Models/ZipExportBook.php
app/Exports/ZipExports/Models/ZipExportChapter.php
app/Exports/ZipExports/ZipImportRunner.php
app/Permissions/PermissionApplicator.php
app/References/ReferenceFetcher.php
app/References/ReferenceUpdater.php
app/Sorting/BookSorter.php
app/Sorting/SortRule.php
app/Sorting/SortRuleController.php
app/Uploads/Controllers/AttachmentApiController.php
app/Uploads/Controllers/AttachmentController.php
app/Uploads/ImageService.php
app/Users/Controllers/UserApiController.php
app/Users/UserRepo.php
database/factories/Entities/Models/ChapterFactory.php
database/factories/Entities/Models/PageFactory.php
database/migrations/2023_01_24_104625_refactor_joint_permissions_storage.php
database/migrations/2025_09_15_132850_create_entities_table.php [new file with mode: 0644]
database/migrations/2025_09_15_134701_migrate_entity_data.php [new file with mode: 0644]
database/migrations/2025_09_15_134751_update_entity_relation_columns.php [new file with mode: 0644]
database/migrations/2025_09_15_134813_drop_old_entity_tables.php [new file with mode: 0644]
database/seeders/DummyContentSeeder.php
dev/api/responses/books-read.json
dev/api/responses/pages-create.json
dev/api/responses/pages-read.json
dev/api/responses/recycle-bin-list.json
resources/views/books/parts/form.blade.php
resources/views/books/parts/list-item.blade.php
resources/views/books/show.blade.php
resources/views/chapters/show.blade.php
resources/views/entities/grid-item.blade.php
resources/views/exports/book.blade.php
resources/views/exports/chapter.blade.php
resources/views/exports/parts/chapter-item.blade.php
resources/views/form/description-html-input.blade.php
resources/views/shelves/parts/form.blade.php
resources/views/shelves/parts/list-item.blade.php
resources/views/shelves/show.blade.php
tests/Api/ApiAuthTest.php
tests/Api/BooksApiTest.php
tests/Api/ChaptersApiTest.php
tests/Api/ContentPermissionsApiTest.php
tests/Api/PagesApiTest.php
tests/Api/RecycleBinApiTest.php
tests/Api/ShelvesApiTest.php
tests/Auth/MfaConfigurationTest.php
tests/Commands/UpdateUrlCommandTest.php
tests/Entity/BookShelfTest.php
tests/Entity/BookTest.php
tests/Entity/ConvertTest.php
tests/Entity/DefaultTemplateTest.php
tests/Entity/PageDraftTest.php
tests/Entity/PageEditorTest.php
tests/Entity/PageRevisionTest.php
tests/Entity/PageTemplateTest.php
tests/Entity/PageTest.php
tests/Exports/MarkdownExportTest.php
tests/Exports/ZipExportTest.php
tests/Exports/ZipImportRunnerTest.php
tests/Helpers/EntityProvider.php
tests/Meta/OpenGraphTest.php
tests/Permissions/EntityOwnerChangeTest.php
tests/Permissions/EntityPermissionsTest.php
tests/PublicActionTest.php
tests/References/ReferencesTest.php
tests/Settings/RecycleBinTest.php
tests/Sorting/BookSortTest.php
tests/Sorting/MoveTest.php
tests/Sorting/SortRuleTest.php
tests/TestCase.php
tests/User/UserManagementTest.php

index 09b9e53b8aeb647d8a70471959e92e37a371336c..b1285341257355c623b7a62b4f2d3e9518fb98d7 100644 (file)
@@ -11,7 +11,6 @@ class MfaSession
      */
     public function isRequiredForUser(User $user): bool
     {
-        // TODO - Test both these cases
         return $user->mfaValues()->exists() || $this->userRoleEnforcesMfa($user);
     }
 
index 71f0b92fe4199e29a7992d0e632a640b593d2170..fd86e0706679aeb355f7fdfb775d693119c70eae 100644 (file)
@@ -45,10 +45,8 @@ class UpdateUrlCommand extends Command
 
         $columnsToUpdateByTable = [
             'attachments' => ['path'],
-            'pages'       => ['html', 'text', 'markdown'],
-            'chapters'    => ['description_html'],
-            'books'       => ['description_html'],
-            'bookshelves' => ['description_html'],
+            'entity_page_data' => ['html', 'text', 'markdown'],
+            'entity_container_data' => ['description_html'],
             'page_revisions' => ['html', 'text', 'markdown'],
             'images'      => ['url'],
             'settings'    => ['value'],
index 5baea163fd683c8cd07ad42de244bd5fd885a381..807f5a69c8f3088b1a9fcc56cf66deebe87bc5e1 100644 (file)
@@ -122,9 +122,10 @@ class BookApiController extends ApiController
         $book = clone $book;
         $book->unsetRelations()->refresh();
 
-        $book->load(['tags', 'cover']);
-        $book->makeVisible('description_html')
-            ->setAttribute('description_html', $book->descriptionHtml());
+        $book->load(['tags']);
+        $book->makeVisible(['cover', 'description_html'])
+            ->setAttribute('description_html', $book->descriptionInfo()->getHtml())
+            ->setAttribute('cover', $book->coverInfo()->getImage());
 
         return $book;
     }
index f4bd394a9e7b54490525ab3a31a0a16b2a0f7919..735742060c5c2dca9863e27e31d91f5dde5fcdaa 100644 (file)
@@ -116,9 +116,10 @@ class BookshelfApiController extends ApiController
         $shelf = clone $shelf;
         $shelf->unsetRelations()->refresh();
 
-        $shelf->load(['tags', 'cover']);
-        $shelf->makeVisible('description_html')
-            ->setAttribute('description_html', $shelf->descriptionHtml());
+        $shelf->load(['tags']);
+        $shelf->makeVisible(['cover', 'description_html'])
+            ->setAttribute('description_html', $shelf->descriptionInfo()->getHtml())
+            ->setAttribute('cover', $shelf->coverInfo()->getImage());
 
         return $shelf;
     }
index f47742ffaa68bac363ce3efb1eba2db855a87514..8d7ffb8f9b04142524507fa974ee03b461e30194 100644 (file)
@@ -116,6 +116,7 @@ class BookshelfController extends Controller
         ]);
 
         $sort = $listOptions->getSort();
+
         $sortedVisibleShelfBooks = $shelf->visibleBooks()
             ->reorder($sort === 'default' ? 'order' : $sort, $listOptions->getOrder())
             ->get()
index 80eab7bb8c166d1cf4856061d364004f78dba5a4..6aa62f887c8f988e2de1cfb5ee094409e6ef191e 100644 (file)
@@ -104,7 +104,7 @@ class ChapterApiController extends ApiController
         $chapter = $this->queries->findVisibleByIdOrFail(intval($id));
         $this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
 
-        if ($request->has('book_id') && $chapter->book_id !== intval($requestData['book_id'])) {
+        if ($request->has('book_id') && $chapter->book_id !== (intval($requestData['book_id']) ?: null)) {
             $this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
 
             try {
@@ -144,7 +144,7 @@ class ChapterApiController extends ApiController
 
         $chapter->load(['tags']);
         $chapter->makeVisible('description_html');
-        $chapter->setAttribute('description_html', $chapter->descriptionHtml());
+        $chapter->setAttribute('description_html', $chapter->descriptionInfo()->getHtml());
 
         /** @var Book $book */
         $book = $chapter->book()->first();
index 9335e0a703424a4d73ca1ad39b0a50fb68784a1a..a1af29de269ec6f710e9f41f3e993d5d4608580a 100644 (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());
     }
index 67ecb0bb3773bfdb5681b6272d899774b19f0d98..603d015ef4a0768aac2cc3c557e8c77aa7f19d71 100644 (file)
@@ -120,6 +120,7 @@ class PageController extends Controller
         $this->validate($request, [
             'name' => ['required', 'string', 'max:255'],
         ]);
+
         $draftPage = $this->queries->findVisibleByIdOrFail($pageId);
         $this->checkOwnablePermission(Permission::PageCreate, $draftPage->getParent());
 
diff --git a/app/Entities/EntityExistsRule.php b/app/Entities/EntityExistsRule.php
new file mode 100644 (file)
index 0000000..da21054
--- /dev/null
@@ -0,0 +1,20 @@
+<?php
+
+namespace BookStack\Entities;
+
+use Illuminate\Validation\Rules\Exists;
+
+class EntityExistsRule implements \Stringable
+{
+    public function __construct(
+        protected string $type,
+    ) {
+    }
+
+    public function __toString()
+    {
+        $existsRule = (new Exists('entities', 'id'))
+            ->where('type', $this->type);
+        return $existsRule->__toString();
+    }
+}
index 5f54e0f6af6a9e3a61154738f6c1626d7c9fb5e1..afd50797b15e070501bd0b28b79cb16f6816a32a 100644 (file)
@@ -2,9 +2,10 @@
 
 namespace BookStack\Entities\Models;
 
+use BookStack\Entities\Tools\EntityCover;
+use BookStack\Entities\Tools\EntityDefaultTemplate;
 use BookStack\Sorting\SortRule;
 use BookStack\Uploads\Image;
-use Exception;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@@ -15,26 +16,25 @@ use Illuminate\Support\Collection;
  * Class Book.
  *
  * @property string                                   $description
+ * @property string                                   $description_html
  * @property int                                      $image_id
  * @property ?int                                     $default_template_id
  * @property ?int                                     $sort_rule_id
- * @property Image|null                               $cover
  * @property \Illuminate\Database\Eloquent\Collection $chapters
  * @property \Illuminate\Database\Eloquent\Collection $pages
  * @property \Illuminate\Database\Eloquent\Collection $directPages
  * @property \Illuminate\Database\Eloquent\Collection $shelves
- * @property ?Page                                    $defaultTemplate
- * @property ?SortRule                                 $sortRule
+ * @property ?SortRule                                $sortRule
  */
-class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterface
+class Book extends Entity implements HasDescriptionInterface, HasCoverInterface, HasDefaultTemplateInterface
 {
     use HasFactory;
-    use HtmlDescriptionTrait;
+    use ContainerTrait;
 
     public float $searchFactor = 1.2;
 
+    protected $hidden = ['pivot', 'deleted_at', 'description_html', 'entity_id', 'entity_type', 'chapter_id', 'book_id', 'priority'];
     protected $fillable = ['name'];
-    protected $hidden = ['pivot', 'image_id', 'deleted_at', 'description_html'];
 
     /**
      * Get the url for this book.
@@ -44,55 +44,6 @@ class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterfa
         return url('/books/' . implode('/', [urlencode($this->slug), trim($path, '/')]));
     }
 
-    /**
-     * Returns book cover image, if book cover not exists return default cover image.
-     */
-    public function getBookCover(int $width = 440, int $height = 250): string
-    {
-        $default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
-        if (!$this->image_id || !$this->cover) {
-            return $default;
-        }
-
-        try {
-            return $this->cover->getThumb($width, $height, false) ?? $default;
-        } catch (Exception $err) {
-            return $default;
-        }
-    }
-
-    /**
-     * Get the cover image of the book.
-     */
-    public function cover(): BelongsTo
-    {
-        return $this->belongsTo(Image::class, 'image_id');
-    }
-
-    /**
-     * Get the type of the image model that is used when storing a cover image.
-     */
-    public function coverImageTypeKey(): string
-    {
-        return 'cover_book';
-    }
-
-    /**
-     * Get the Page that is used as default template for newly created pages within this Book.
-     */
-    public function defaultTemplate(): BelongsTo
-    {
-        return $this->belongsTo(Page::class, 'default_template_id');
-    }
-
-    /**
-     * Get the sort set assigned to this book, if existing.
-     */
-    public function sortRule(): BelongsTo
-    {
-        return $this->belongsTo(SortRule::class);
-    }
-
     /**
      * Get all pages within this book.
      * @return HasMany<Page, $this>
@@ -107,7 +58,7 @@ class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterfa
      */
     public function directPages(): HasMany
     {
-        return $this->pages()->where('chapter_id', '=', '0');
+        return $this->pages()->whereNull('chapter_id');
     }
 
     /**
@@ -116,7 +67,8 @@ class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterfa
      */
     public function chapters(): HasMany
     {
-        return $this->hasMany(Chapter::class);
+        return $this->hasMany(Chapter::class)
+            ->where('type', '=', 'chapter');
     }
 
     /**
@@ -137,4 +89,27 @@ class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterfa
 
         return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft');
     }
+
+    public function defaultTemplate(): EntityDefaultTemplate
+    {
+        return new EntityDefaultTemplate($this);
+    }
+
+    public function cover(): BelongsTo
+    {
+        return $this->belongsTo(Image::class, 'image_id');
+    }
+
+    public function coverInfo(): EntityCover
+    {
+        return new EntityCover($this);
+    }
+
+    /**
+     * Get the sort rule assigned to this container, if existing.
+     */
+    public function sortRule(): BelongsTo
+    {
+        return $this->belongsTo(SortRule::class);
+    }
 }
index ad54fb926a9edb1158b5e07cfec62ea00af76306..4a2e52aedd5e811160e9049f69983e4a7d7ec1d4 100644 (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,13 +26,13 @@ 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->unsetRelation('book');
         $this->refreshSlug();
         $this->save();
-        $this->refresh();
 
         if ($oldUrl !== $this->getUrl()) {
             app()->make(ReferenceUpdater::class)->updateEntityReferences($this, $oldUrl);
index 9ae52abcb0ae036c5c9adbcf36bea0f9a7930030..42dcc8f8f2c7982b4209783470508df28f1a3fea 100644 (file)
@@ -2,34 +2,34 @@
 
 namespace BookStack\Entities\Models;
 
+use BookStack\Entities\Tools\EntityCover;
 use BookStack\Uploads\Image;
-use Exception;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 
-class Bookshelf extends Entity implements CoverImageInterface, HtmlDescriptionInterface
+/**
+ * @property string $description
+ * @property string $description_html
+ */
+class Bookshelf extends Entity implements HasDescriptionInterface, HasCoverInterface
 {
     use HasFactory;
-    use HtmlDescriptionTrait;
-
-    protected $table = 'bookshelves';
+    use ContainerTrait;
 
     public float $searchFactor = 1.2;
 
-    protected $fillable = ['name', 'description', 'image_id'];
-
-    protected $hidden = ['image_id', 'deleted_at', 'description_html'];
+    protected $hidden = ['image_id', 'deleted_at', 'description_html', 'priority', 'default_template_id', 'sort_rule_id', 'entity_id', 'entity_type', 'chapter_id', 'book_id'];
+    protected $fillable = ['name'];
 
     /**
      * Get the books in this shelf.
-     * Should not be used directly since does not take into account permissions.
-     *
-     * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
+     * Should not be used directly since it does not take into account permissions.
      */
-    public function books()
+    public function books(): BelongsToMany
     {
         return $this->belongsToMany(Book::class, 'bookshelves_books', 'bookshelf_id', 'book_id')
+            ->select(['entities.*', 'entity_container_data.*'])
             ->withPivot('order')
             ->orderBy('order', 'asc');
     }
@@ -50,41 +50,6 @@ class Bookshelf extends Entity implements CoverImageInterface, HtmlDescriptionIn
         return url('/shelves/' . implode('/', [urlencode($this->slug), trim($path, '/')]));
     }
 
-    /**
-     * Returns shelf cover image, if cover not exists return default cover image.
-     */
-    public function getBookCover(int $width = 440, int $height = 250): string
-    {
-        // TODO - Make generic, focused on books right now, Perhaps set-up a better image
-        $default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
-        if (!$this->image_id || !$this->cover) {
-            return $default;
-        }
-
-        try {
-            return $this->cover->getThumb($width, $height, false) ?? $default;
-        } catch (Exception $err) {
-            return $default;
-        }
-    }
-
-    /**
-     * Get the cover image of the shelf.
-     * @return BelongsTo<Image, $this>
-     */
-    public function cover(): BelongsTo
-    {
-        return $this->belongsTo(Image::class, 'image_id');
-    }
-
-    /**
-     * Get the type of the image model that is used when storing a cover image.
-     */
-    public function coverImageTypeKey(): string
-    {
-        return 'cover_bookshelf';
-    }
-
     /**
      * Check if this shelf contains the given book.
      */
@@ -96,7 +61,7 @@ class Bookshelf extends Entity implements CoverImageInterface, HtmlDescriptionIn
     /**
      * Add a book to the end of this shelf.
      */
-    public function appendBook(Book $book)
+    public function appendBook(Book $book): void
     {
         if ($this->contains($book)) {
             return;
@@ -106,12 +71,13 @@ class Bookshelf extends Entity implements CoverImageInterface, HtmlDescriptionIn
         $this->books()->attach($book->id, ['order' => $maxOrder + 1]);
     }
 
-    /**
-     * Get a visible shelf by its slug.
-     * @throws \Illuminate\Database\Eloquent\ModelNotFoundException
-     */
-    public static function getBySlug(string $slug): self
+    public function coverInfo(): EntityCover
     {
-        return static::visible()->where('slug', '=', $slug)->firstOrFail();
+        return new EntityCover($this);
+    }
+
+    public function cover(): BelongsTo
+    {
+        return $this->belongsTo(Image::class, 'image_id');
     }
 }
index d70a49e7a98257007cde0e59802c14d682e34c22..2dd4cb77f056e33ef5a07d3ae38180b9da5ac1f8 100644 (file)
@@ -2,27 +2,25 @@
 
 namespace BookStack\Entities\Models;
 
-use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use BookStack\Entities\Tools\EntityDefaultTemplate;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Support\Collection;
 
 /**
- * Class Chapter.
- *
  * @property Collection<Page> $pages
  * @property ?int             $default_template_id
- * @property ?Page            $defaultTemplate
+ * @property string           $description
+ * @property string           $description_html
  */
-class Chapter extends BookChild implements HtmlDescriptionInterface
+class Chapter extends BookChild implements HasDescriptionInterface, HasDefaultTemplateInterface
 {
     use HasFactory;
-    use HtmlDescriptionTrait;
+    use ContainerTrait;
 
     public float $searchFactor = 1.2;
-
-    protected $fillable = ['name', 'description', 'priority'];
-    protected $hidden = ['pivot', 'deleted_at', 'description_html'];
+    protected $hidden = ['pivot', 'deleted_at', 'description_html', 'sort_rule_id', 'image_id', 'entity_id', 'entity_type', 'chapter_id'];
+    protected $fillable = ['name', 'priority'];
 
     /**
      * Get the pages that this chapter contains.
@@ -50,14 +48,6 @@ class Chapter extends BookChild implements HtmlDescriptionInterface
         return url('/' . implode('/', $parts));
     }
 
-    /**
-     * Get the Page that is used as default template for newly created pages within this Chapter.
-     */
-    public function defaultTemplate(): BelongsTo
-    {
-        return $this->belongsTo(Page::class, 'default_template_id');
-    }
-
     /**
      * Get the visible pages in this chapter.
      * @return Collection<Page>
@@ -70,4 +60,9 @@ class Chapter extends BookChild implements HtmlDescriptionInterface
         ->orderBy('priority', 'asc')
         ->get();
     }
+
+    public function defaultTemplate(): EntityDefaultTemplate
+    {
+        return new EntityDefaultTemplate($this);
+    }
 }
diff --git a/app/Entities/Models/ContainerTrait.php b/app/Entities/Models/ContainerTrait.php
new file mode 100644 (file)
index 0000000..9ef5ca8
--- /dev/null
@@ -0,0 +1,26 @@
+<?php
+
+namespace BookStack\Entities\Models;
+
+use BookStack\Entities\Tools\EntityHtmlDescription;
+use Illuminate\Database\Eloquent\Relations\HasOne;
+
+/**
+ * @mixin Entity
+ */
+trait ContainerTrait
+{
+    public function descriptionInfo(): EntityHtmlDescription
+    {
+        return new EntityHtmlDescription($this);
+    }
+
+    /**
+     * @return HasOne<EntityContainerData, $this>
+     */
+    public function relatedData(): HasOne
+    {
+        return $this->hasOne(EntityContainerData::class, 'entity_id', 'id')
+            ->where('entity_type', '=', $this->getMorphClass());
+    }
+}
diff --git a/app/Entities/Models/CoverImageInterface.php b/app/Entities/Models/CoverImageInterface.php
deleted file mode 100644 (file)
index 5f781fe..0000000
+++ /dev/null
@@ -1,18 +0,0 @@
-<?php
-
-namespace BookStack\Entities\Models;
-
-use Illuminate\Database\Eloquent\Relations\BelongsTo;
-
-interface CoverImageInterface
-{
-    /**
-     * Get the cover image for this item.
-     */
-    public function cover(): BelongsTo;
-
-    /**
-     * Get the type of the image model that is used when storing a cover image.
-     */
-    public function coverImageTypeKey(): string;
-}
index 31511aa832d55173b5299b8f36111f590f6c0e22..b71016ea1c108e5cc6b31ba02ac794fb78e024cd 100644 (file)
@@ -28,15 +28,17 @@ use Carbon\Carbon;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Database\Eloquent\Collection;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasOne;
 use Illuminate\Database\Eloquent\Relations\MorphMany;
 use Illuminate\Database\Eloquent\SoftDeletes;
 
 /**
  * Class Entity
- * The base class for book-like items such as pages, chapters & books.
+ * The base class for book-like items such as pages, chapters and books.
  * This is not a database model in itself but extended.
  *
  * @property int        $id
+ * @property string     $type
  * @property string     $name
  * @property string     $slug
  * @property Carbon     $created_at
@@ -77,6 +79,72 @@ abstract class Entity extends Model implements
      */
     public float $searchFactor = 1.0;
 
+    /**
+     * Set the table to be that used by all entities.
+     */
+    protected $table = 'entities';
+
+    /**
+     * Set a custom query builder for entities.
+     */
+    protected static string $builder = EntityQueryBuilder::class;
+
+    public static array $commonFields = [
+        'id',
+        'type',
+        'name',
+        'slug',
+        'book_id',
+        'chapter_id',
+        'priority',
+        'created_at',
+        'updated_at',
+        'deleted_at',
+        'created_by',
+        'updated_by',
+        'owned_by',
+    ];
+
+    /**
+     * Override the save method to also save the contents for convenience.
+     */
+    public function save(array $options = []): bool
+    {
+        /** @var EntityPageData|EntityContainerData $contents */
+        $contents = $this->relatedData()->firstOrNew();
+        $contentFields = $this->getContentsAttributes();
+
+        foreach ($contentFields as $key => $value) {
+            $contents->setAttribute($key, $value);
+            unset($this->attributes[$key]);
+        }
+
+        $this->setAttribute('type', $this->getMorphClass());
+        $result = parent::save($options);
+        $contentsResult = true;
+
+        if ($result && $contents->isDirty()) {
+            $contentsFillData = $contents instanceof EntityPageData ? ['page_id' => $this->id] : ['entity_id' => $this->id, 'entity_type' => $this->getMorphClass()];
+            $contents->forceFill($contentsFillData);
+            $contentsResult = $contents->save();
+            $this->touch();
+        }
+
+        $this->forceFill($contentFields);
+
+        return $result && $contentsResult;
+    }
+
+    /**
+     * Check if this item is a container item.
+     */
+    public function isContainer(): bool
+    {
+        return $this instanceof Bookshelf ||
+            $this instanceof Book ||
+            $this instanceof Chapter;
+    }
+
     /**
      * Get the entities that are visible to the current user.
      */
@@ -91,8 +159,8 @@ abstract class Entity extends Model implements
     public function scopeWithLastView(Builder $query)
     {
         $viewedAtQuery = View::query()->select('updated_at')
-            ->whereColumn('viewable_id', '=', $this->getTable() . '.id')
-            ->where('viewable_type', '=', $this->getMorphClass())
+            ->whereColumn('viewable_id', '=', 'entities.id')
+            ->whereColumn('viewable_type', '=', 'entities.type')
             ->where('user_id', '=', user()->id)
             ->take(1);
 
@@ -102,11 +170,12 @@ abstract class Entity extends Model implements
     /**
      * Query scope to get the total view count of the entities.
      */
-    public function scopeWithViewCount(Builder $query)
+    public function scopeWithViewCount(Builder $query): void
     {
         $viewCountQuery = View::query()->selectRaw('SUM(views) as view_count')
-            ->whereColumn('viewable_id', '=', $this->getTable() . '.id')
-            ->where('viewable_type', '=', $this->getMorphClass())->take(1);
+            ->whereColumn('viewable_id', '=', 'entities.id')
+            ->whereColumn('viewable_type', '=', 'entities.type')
+            ->take(1);
 
         $query->addSelect(['view_count' => $viewCountQuery]);
     }
@@ -162,7 +231,8 @@ abstract class Entity extends Model implements
      */
     public function tags(): MorphMany
     {
-        return $this->morphMany(Tag::class, 'entity')->orderBy('order', 'asc');
+        return $this->morphMany(Tag::class, 'entity')
+            ->orderBy('order', 'asc');
     }
 
     /**
@@ -184,7 +254,7 @@ abstract class Entity extends Model implements
     }
 
     /**
-     * Get this entities restrictions.
+     * Get this entities assigned permissions.
      */
     public function permissions(): MorphMany
     {
@@ -267,7 +337,7 @@ abstract class Entity extends Model implements
     }
 
     /**
-     * Gets a limited-length version of the entities name.
+     * Gets a limited-length version of the entity name.
      */
     public function getShortName(int $length = 25): string
     {
@@ -377,4 +447,27 @@ abstract class Entity extends Model implements
     {
         return "({$this->id}) {$this->name}";
     }
+
+    /**
+     * @return HasOne<covariant (EntityContainerData|EntityPageData), $this>
+     */
+    abstract public function relatedData(): HasOne;
+
+    /**
+     * Get the attributes that are intended for the related contents model.
+     * @return array<string, mixed>
+     */
+    protected function getContentsAttributes(): array
+    {
+        $contentFields = [];
+        $contentModel = $this instanceof Page ? EntityPageData::class : EntityContainerData::class;
+
+        foreach ($this->attributes as $key => $value) {
+            if (in_array($key, $contentModel::$fields)) {
+                $contentFields[$key] = $value;
+            }
+        }
+
+        return $contentFields;
+    }
 }
diff --git a/app/Entities/Models/EntityContainerData.php b/app/Entities/Models/EntityContainerData.php
new file mode 100644 (file)
index 0000000..21bace7
--- /dev/null
@@ -0,0 +1,52 @@
+<?php
+
+namespace BookStack\Entities\Models;
+
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * @property int     $entity_id
+ * @property string  $entity_type
+ * @property string  $description
+ * @property string  $description_html
+ * @property ?int    $default_template_id
+ * @property ?int    $image_id
+ * @property ?int    $sort_rule_id
+ */
+class EntityContainerData extends Model
+{
+    public $timestamps = false;
+    protected $primaryKey = 'entity_id';
+    public $incrementing = false;
+
+    public static array $fields = [
+        'description',
+        'description_html',
+        'default_template_id',
+        'image_id',
+        'sort_rule_id',
+    ];
+
+    /**
+     * Override the default set keys for save query method to make it work with composite keys.
+     */
+    public function setKeysForSaveQuery($query): Builder
+    {
+        $query->where($this->getKeyName(), '=', $this->getKeyForSaveQuery())
+            ->where('entity_type', '=', $this->entity_type);
+
+        return $query;
+    }
+
+    /**
+     * Override the default set keys for a select query method to make it work with composite keys.
+     */
+    protected function setKeysForSelectQuery($query): Builder
+    {
+        $query->where($this->getKeyName(), '=', $this->getKeyForSelectQuery())
+            ->where('entity_type', '=', $this->entity_type);
+
+        return $query;
+    }
+}
diff --git a/app/Entities/Models/EntityPageData.php b/app/Entities/Models/EntityPageData.php
new file mode 100644 (file)
index 0000000..a98b1a9
--- /dev/null
@@ -0,0 +1,25 @@
+<?php
+
+namespace BookStack\Entities\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * @property int    $page_id
+ */
+class EntityPageData extends Model
+{
+    public $timestamps = false;
+    protected $primaryKey = 'page_id';
+    public $incrementing = false;
+
+    public static array $fields = [
+        'draft',
+        'template',
+        'revision_count',
+        'editor',
+        'html',
+        'text',
+        'markdown',
+    ];
+}
diff --git a/app/Entities/Models/EntityQueryBuilder.php b/app/Entities/Models/EntityQueryBuilder.php
new file mode 100644 (file)
index 0000000..7db16b2
--- /dev/null
@@ -0,0 +1,38 @@
+<?php
+
+namespace BookStack\Entities\Models;
+
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Query\Builder as QueryBuilder;
+
+class EntityQueryBuilder extends Builder
+{
+    /**
+     * Create a new Eloquent query builder instance.
+     */
+    public function __construct(QueryBuilder $query)
+    {
+        parent::__construct($query);
+
+        $this->withGlobalScope('entity', new EntityScope());
+    }
+
+    public function withoutGlobalScope($scope): static
+    {
+        // Prevent removal of the entity scope
+        if ($scope === 'entity') {
+            return $this;
+        }
+
+        return parent::withoutGlobalScope($scope);
+    }
+
+    /**
+     * Override the default forceDelete method to add type filter onto the query
+     * since it specifically ignores scopes by default.
+     */
+    public function forceDelete()
+    {
+        return $this->query->where('type', '=', $this->model->getMorphClass())->delete();
+    }
+}
diff --git a/app/Entities/Models/EntityScope.php b/app/Entities/Models/EntityScope.php
new file mode 100644 (file)
index 0000000..deb10c5
--- /dev/null
@@ -0,0 +1,27 @@
+<?php
+
+namespace BookStack\Entities\Models;
+
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Scope;
+use Illuminate\Database\Query\JoinClause;
+
+class EntityScope implements Scope
+{
+    /**
+     * Apply the scope to a given Eloquent query builder.
+     */
+    public function apply(Builder $builder, Model $model): void
+    {
+        $builder = $builder->where('type', '=', $model->getMorphClass());
+        if ($model instanceof Page) {
+            $builder->leftJoin('entity_page_data', 'entity_page_data.page_id', '=', 'entities.id');
+        } else {
+            $builder->leftJoin('entity_container_data', function (JoinClause $join) use ($model) {
+                $join->on('entity_container_data.entity_id', '=', 'entities.id')
+                    ->where('entity_container_data.entity_type', '=', $model->getMorphClass());
+            });
+        }
+    }
+}
diff --git a/app/Entities/Models/HasCoverInterface.php b/app/Entities/Models/HasCoverInterface.php
new file mode 100644 (file)
index 0000000..a4e79e9
--- /dev/null
@@ -0,0 +1,18 @@
+<?php
+
+namespace BookStack\Entities\Models;
+
+use BookStack\Entities\Tools\EntityCover;
+use BookStack\Uploads\Image;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+interface HasCoverInterface
+{
+    public function coverInfo(): EntityCover;
+
+    /**
+     * The cover image of this entity.
+     * @return BelongsTo<Image, covariant Entity>
+     */
+    public function cover(): BelongsTo;
+}
diff --git a/app/Entities/Models/HasDefaultTemplateInterface.php b/app/Entities/Models/HasDefaultTemplateInterface.php
new file mode 100644 (file)
index 0000000..f3af0da
--- /dev/null
@@ -0,0 +1,10 @@
+<?php
+
+namespace BookStack\Entities\Models;
+
+use BookStack\Entities\Tools\EntityDefaultTemplate;
+
+interface HasDefaultTemplateInterface
+{
+    public function defaultTemplate(): EntityDefaultTemplate;
+}
diff --git a/app/Entities/Models/HasDescriptionInterface.php b/app/Entities/Models/HasDescriptionInterface.php
new file mode 100644 (file)
index 0000000..f35dbdc
--- /dev/null
@@ -0,0 +1,10 @@
+<?php
+
+namespace BookStack\Entities\Models;
+
+use BookStack\Entities\Tools\EntityHtmlDescription;
+
+interface HasDescriptionInterface
+{
+    public function descriptionInfo(): EntityHtmlDescription;
+}
diff --git a/app/Entities/Models/HtmlDescriptionInterface.php b/app/Entities/Models/HtmlDescriptionInterface.php
deleted file mode 100644 (file)
index ffe7f0c..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-<?php
-
-namespace BookStack\Entities\Models;
-
-interface HtmlDescriptionInterface
-{
-    /**
-     * Get the HTML-based description for this item.
-     * By default, the content should be sanitised unless raw is set to true.
-     */
-    public function descriptionHtml(bool $raw = false): string;
-
-    /**
-     * Set the HTML-based description for this item.
-     */
-    public function setDescriptionHtml(string $html, string|null $plaintext = null): void;
-}
diff --git a/app/Entities/Models/HtmlDescriptionTrait.php b/app/Entities/Models/HtmlDescriptionTrait.php
deleted file mode 100644 (file)
index ed726cc..0000000
+++ /dev/null
@@ -1,35 +0,0 @@
-<?php
-
-namespace BookStack\Entities\Models;
-
-use BookStack\Util\HtmlContentFilter;
-
-/**
- * @property string $description
- * @property string $description_html
- */
-trait HtmlDescriptionTrait
-{
-    public function descriptionHtml(bool $raw = false): string
-    {
-        $html = $this->description_html ?: '<p>' . nl2br(e($this->description)) . '</p>';
-        if ($raw) {
-            return $html;
-        }
-
-        return HtmlContentFilter::removeScriptsFromHtmlString($html);
-    }
-
-    public function setDescriptionHtml(string $html, string|null $plaintext = null): void
-    {
-        $this->description_html = $html;
-
-        if ($plaintext !== null) {
-            $this->description = $plaintext;
-        }
-
-        if (empty($html) && !empty($plaintext)) {
-            $this->description_html = $this->descriptionHtml();
-        }
-    }
-}
index 499ef4d7288a9219768ceb670b1874ba1ebf5c49..88c59bd1bd033e854edb54f9a48288b0f5c54817 100644 (file)
@@ -3,7 +3,6 @@
 namespace BookStack\Entities\Models;
 
 use BookStack\Entities\Tools\PageContent;
-use BookStack\Entities\Tools\PageEditorType;
 use BookStack\Permissions\PermissionApplicator;
 use BookStack\Uploads\Attachment;
 use Illuminate\Database\Eloquent\Builder;
@@ -15,7 +14,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
 
 /**
  * Class Page.
- *
+ * @property EntityPageData $pageData
  * @property int          $chapter_id
  * @property string       $html
  * @property string       $markdown
@@ -33,12 +32,10 @@ class Page extends BookChild
 {
     use HasFactory;
 
-    protected $fillable = ['name', 'priority'];
-
     public string $textField = 'text';
     public string $htmlField = 'html';
-
-    protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at'];
+    protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at',  'entity_id', 'entity_type'];
+    protected $fillable = ['name', 'priority'];
 
     protected $casts = [
         'draft'    => 'boolean',
@@ -57,10 +54,8 @@ class Page extends BookChild
 
     /**
      * Get the chapter that this page is in, If applicable.
-     *
-     * @return BelongsTo
      */
-    public function chapter()
+    public function chapter(): BelongsTo
     {
         return $this->belongsTo(Chapter::class);
     }
@@ -107,10 +102,8 @@ class Page extends BookChild
 
     /**
      * Get the attachments assigned to this page.
-     *
-     * @return HasMany
      */
-    public function attachments()
+    public function attachments(): HasMany
     {
         return $this->hasMany(Attachment::class, 'uploaded_to')->orderBy('order', 'asc');
     }
@@ -139,8 +132,16 @@ class Page extends BookChild
         $refreshed = $this->refresh()->unsetRelations()->load(['tags', 'createdBy', 'updatedBy', 'ownedBy']);
         $refreshed->setHidden(array_diff($refreshed->getHidden(), ['html', 'markdown']));
         $refreshed->setAttribute('raw_html', $refreshed->html);
-        $refreshed->html = (new PageContent($refreshed))->render();
+        $refreshed->setAttribute('html', (new PageContent($refreshed))->render());
 
         return $refreshed;
     }
+
+    /**
+     * @return HasOne<EntityPageData, $this>
+     */
+    public function relatedData(): HasOne
+    {
+        return $this->hasOne(EntityPageData::class, 'page_id', 'id');
+    }
 }
index 2492f81318ad7bd741e725718318d84eb90a4cbc..a466f37bc0f179d878b27ab6eebd8f67e3b7506a 100644 (file)
@@ -55,6 +55,11 @@ class BookQueries implements ProvidesEntityQueries
             ->select(static::$listAttributes);
     }
 
+    public function visibleForContent(): Builder
+    {
+        return $this->start()->scopes('visible');
+    }
+
     public function visibleForListWithCover(): Builder
     {
         return $this->visibleForList()->with('cover');
index 842011a878113b76716d61f50de24d582679fb84..3fe0a2afcef990e0819efa47e0c816ceb430bfda 100644 (file)
@@ -60,6 +60,11 @@ class BookshelfQueries implements ProvidesEntityQueries
         return $this->start()->scopes('visible')->select(static::$listAttributes);
     }
 
+    public function visibleForContent(): Builder
+    {
+        return $this->start()->scopes('visible');
+    }
+
     public function visibleForListWithCover(): Builder
     {
         return $this->visibleForList()->with('cover');
index 9bf0ff65bfdd38283f7e554536c68b41d5b47f50..9ddeb9b5896f2ca4dab9a2067a0b57c388697a97 100644 (file)
@@ -65,8 +65,14 @@ class ChapterQueries implements ProvidesEntityQueries
             ->scopes('visible')
             ->select(array_merge(static::$listAttributes, ['book_slug' => function ($builder) {
                 $builder->select('slug')
-                    ->from('books')
-                    ->whereColumn('books.id', '=', 'chapters.book_id');
+                    ->from('entities as books')
+                    ->where('type', '=', 'book')
+                    ->whereColumn('books.id', '=', 'entities.book_id');
             }]));
     }
+
+    public function visibleForContent(): Builder
+    {
+        return $this->start()->scopes('visible');
+    }
 }
index 0d2cd7acf645cb5019d5fb8522ca2e270ba06117..a7a037916d50451fbdbf825f479d02fec5d55658 100644 (file)
@@ -43,6 +43,17 @@ class EntityQueries
         return $queries->visibleForList();
     }
 
+    /**
+     * Start a query of visible entities of the given type,
+     * suitable for using the contents of the items.
+     * @return Builder<Entity>
+     */
+    public function visibleForContent(string $entityType): Builder
+    {
+        $queries = $this->getQueriesForType($entityType);
+        return $queries->visibleForContent();
+    }
+
     protected function getQueriesForType(string $type): ProvidesEntityQueries
     {
         $queries = match ($type) {
index ee7b201bc1af6beb04a483487be2b1ae821c402d..f4ecee2dc082d6933859f5711bad91688c2e7517 100644 (file)
@@ -13,7 +13,7 @@ class PageQueries implements ProvidesEntityQueries
 {
     protected static array $contentAttributes = [
         'name', 'id', 'slug', 'book_id', 'chapter_id', 'draft',
-        'template', 'html', 'text', 'created_at', 'updated_at', 'priority',
+        'template', 'html', 'markdown', 'text', 'created_at', 'updated_at', 'priority',
         'created_by', 'updated_by', 'owned_by',
     ];
     protected static array $listAttributes = [
@@ -82,6 +82,14 @@ class PageQueries implements ProvidesEntityQueries
             ->select($this->mergeBookSlugForSelect(static::$listAttributes));
     }
 
+    /**
+     * @return Builder<Page>
+     */
+    public function visibleForContent(): Builder
+    {
+        return $this->start()->scopes('visible');
+    }
+
     public function visibleForChapterList(int $chapterId): Builder
     {
         return $this->visibleForList()
@@ -104,18 +112,19 @@ class PageQueries implements ProvidesEntityQueries
             ->where('created_by', '=', user()->id);
     }
 
-    public function visibleTemplates(): Builder
+    public function visibleTemplates(bool $includeContents = false): Builder
     {
-        return $this->visibleForList()
-            ->where('template', '=', true);
+        $base = $includeContents ? $this->visibleWithContents() : $this->visibleForList();
+        return $base->where('template', '=', true);
     }
 
     protected function mergeBookSlugForSelect(array $columns): array
     {
         return array_merge($columns, ['book_slug' => function ($builder) {
             $builder->select('slug')
-                ->from('books')
-                ->whereColumn('books.id', '=', 'pages.book_id');
+                ->from('entities as books')
+                ->where('type', '=', 'book')
+                ->whereColumn('books.id', '=', 'entities.book_id');
         }]);
     }
 }
index 79fc64b3ab8dfeb9c4188bf5d89c3df778d8a84e..674e96afa243015572b101ce8f95eaabad5432e1 100644 (file)
@@ -35,4 +35,11 @@ interface ProvidesEntityQueries
      * @return Builder<TModel>
      */
     public function visibleForList(): Builder;
+
+    /**
+     * Start a query for items that are visible, with selection
+     * configured for using the content of the items found.
+     * @return Builder<TModel>
+     */
+    public function visibleForContent(): Builder;
 }
index bfc01a58d1d184bc7e9324ba8859e32151dca8f1..fd88625cd9af22cef6da07b597159e1bc83d0855 100644 (file)
@@ -3,13 +3,10 @@
 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\HasCoverInterface;
+use BookStack\Entities\Models\HasDescriptionInterface;
 use BookStack\Entities\Models\Entity;
-use BookStack\Entities\Models\CoverImageInterface;
-use BookStack\Entities\Models\HtmlDescriptionInterface;
-use BookStack\Entities\Models\HtmlDescriptionTrait;
 use BookStack\Entities\Queries\PageQueries;
 use BookStack\Exceptions\ImageUploadException;
 use BookStack\References\ReferenceStore;
@@ -33,17 +30,25 @@ 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)
+    public function create(Entity $entity, array $input): Entity
     {
+        $entity = (clone $entity)->refresh();
         $entity->fill($input);
-        $this->updateDescription($entity, $input);
         $entity->forceFill([
             'created_by' => user()->id,
             'updated_by' => user()->id,
             'owned_by'   => user()->id,
         ]);
         $entity->refreshSlug();
+
+        if ($entity instanceof HasDescriptionInterface) {
+            $this->updateDescription($entity, $input);
+        }
+
         $entity->save();
 
         if (isset($input['tags'])) {
@@ -53,24 +58,33 @@ class BaseRepo
         $entity->refresh();
         $entity->rebuildPermissions();
         $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)
+    public function update(Entity $entity, array $input): Entity
     {
         $oldUrl = $entity->getUrl();
 
         $entity->fill($input);
-        $this->updateDescription($entity, $input);
         $entity->updated_by = user()->id;
 
         if ($entity->isDirty('name') || empty($entity->slug)) {
             $entity->refreshSlug();
         }
 
+        if ($entity instanceof HasDescriptionInterface) {
+            $this->updateDescription($entity, $input);
+        }
+
         $entity->save();
 
         if (isset($input['tags'])) {
@@ -84,59 +98,35 @@ class BaseRepo
         if ($oldUrl !== $entity->getUrl()) {
             $this->referenceUpdater->updateEntityReferences($entity, $oldUrl);
         }
+
+        return $entity;
     }
 
     /**
-     * Update the given items' cover image, or clear it.
+     * Update the given items' cover image or clear it.
      *
      * @throws ImageUploadException
      * @throws \Exception
      */
-    public function updateCoverImage(Entity&CoverImageInterface $entity, ?UploadedFile $coverImage, bool $removeImage = false)
+    public function updateCoverImage(Entity&HasCoverInterface $entity, ?UploadedFile $coverImage, bool $removeImage = false): void
     {
         if ($coverImage) {
-            $imageType = $entity->coverImageTypeKey();
-            $this->imageRepo->destroyImage($entity->cover()->first());
+            $imageType = 'cover_' . $entity->type;
+            $this->imageRepo->destroyImage($entity->coverInfo()->getImage());
             $image = $this->imageRepo->saveNew($coverImage, $imageType, $entity->id, 512, 512, true);
-            $entity->cover()->associate($image);
+            $entity->coverInfo()->setImage($image);
             $entity->save();
         }
 
         if ($removeImage) {
-            $this->imageRepo->destroyImage($entity->cover()->first());
-            $entity->cover()->dissociate();
-            $entity->save();
-        }
-    }
-
-    /**
-     * Update the default page template used for this item.
-     * Checks that, if changing, the provided value is a valid template and the user
-     * has visibility of the provided page template id.
-     */
-    public function updateDefaultTemplate(Book|Chapter $entity, int $templateId): void
-    {
-        $changing = $templateId !== intval($entity->default_template_id);
-        if (!$changing) {
-            return;
-        }
-
-        if ($templateId === 0) {
-            $entity->default_template_id = null;
+            $this->imageRepo->destroyImage($entity->coverInfo()->getImage());
+            $entity->coverInfo()->setImage(null);
             $entity->save();
-            return;
         }
-
-        $templateExists = $this->pageQueries->visibleTemplates()
-            ->where('id', '=', $templateId)
-            ->exists();
-
-        $entity->default_template_id = $templateExists ? $templateId : null;
-        $entity->save();
     }
 
     /**
-     * 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
@@ -147,19 +137,22 @@ class BaseRepo
         }
     }
 
+    /**
+     * Update the description of the given entity from input data.
+     */
     protected function updateDescription(Entity $entity, array $input): void
     {
-        if (!($entity instanceof HtmlDescriptionInterface)) {
+        if (!$entity instanceof HasDescriptionInterface) {
             return;
         }
 
         if (isset($input['description_html'])) {
-            $entity->setDescriptionHtml(
+            $entity->descriptionInfo()->set(
                 HtmlDescriptionFilter::filterFromString($input['description_html']),
                 html_entity_decode(strip_tags($input['description_html']))
             );
         } else if (isset($input['description'])) {
-            $entity->setDescriptionHtml('', $input['description']);
+            $entity->descriptionInfo()->set('', $input['description']);
         }
     }
 }
index 6d28d5d6aabe1796a0f219d641dc5abf816badd3..b4244b9bb77ab91dab48ea754078e3c695d8bf67 100644 (file)
@@ -30,19 +30,18 @@ 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, $input['image'] ?? null);
-            $this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id'] ?? null));
+            $book->defaultTemplate()->setFromId(intval($input['default_template_id'] ?? null));
             Activity::add(ActivityType::BOOK_CREATE, $book);
 
             $defaultBookSortSetting = intval(setting('sorting-book-default', '0'));
             if ($defaultBookSortSetting && SortRule::query()->find($defaultBookSortSetting)) {
                 $book->sort_rule_id = $defaultBookSortSetting;
-                $book->save();
             }
 
+            $book->save();
+
             return $book;
         }))->run();
     }
@@ -52,28 +51,29 @@ 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, intval($input['default_template_id']));
+            $book->defaultTemplate()->setFromId(intval($input['default_template_id']));
         }
 
         if (array_key_exists('image', $input)) {
             $this->baseRepo->updateCoverImage($book, $input['image'], $input['image'] === null);
         }
 
+        $book->save();
         Activity::add(ActivityType::BOOK_UPDATE, $book);
 
         return $book;
     }
 
     /**
-     * Update the given book's cover image, or clear it.
+     * Update the given book's cover image or clear it.
      *
      * @throws ImageUploadException
      * @throws Exception
      */
-    public function updateCoverImage(Book $book, ?UploadedFile $coverImage, bool $removeImage = false)
+    public function updateCoverImage(Book $book, ?UploadedFile $coverImage, bool $removeImage = false): void
     {
         $this->baseRepo->updateCoverImage($book, $coverImage, $removeImage);
     }
@@ -83,7 +83,7 @@ class BookRepo
      *
      * @throws Exception
      */
-    public function destroy(Book $book)
+    public function destroy(Book $book): void
     {
         $this->trashCan->softDestroyBook($book);
         Activity::add(ActivityType::BOOK_DELETE, $book);
index b870ec37747b1cce6e92a784db43f485eca93ff5..bb84b51fd5e77d352e2e996caab5a2ed0f12bfc5 100644 (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, $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);
@@ -96,7 +95,7 @@ class BookshelfRepo
      *
      * @throws Exception
      */
-    public function destroy(Bookshelf $shelf)
+    public function destroy(Bookshelf $shelf): void
     {
         $this->trashCan->softDestroyShelf($shelf);
         Activity::add(ActivityType::BOOKSHELF_DELETE, $shelf);
index 5d4b5297841525b2709952e3d358610f8c3d8871..d5feb30fdfd90fcab252a47d0607655a08d31207 100644 (file)
@@ -33,8 +33,11 @@ class ChapterRepo
             $chapter = new Chapter();
             $chapter->book_id = $parentBook->id;
             $chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
-            $this->baseRepo->create($chapter, $input);
-            $this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id'] ?? null));
+
+            $chapter = $this->baseRepo->create($chapter, $input);
+            $chapter->defaultTemplate()->setFromId(intval($input['default_template_id'] ?? null));
+
+            $chapter->save();
             Activity::add(ActivityType::CHAPTER_CREATE, $chapter);
 
             $this->baseRepo->sortParent($chapter);
@@ -48,12 +51,13 @@ 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, intval($input['default_template_id']));
+            $chapter->defaultTemplate()->setFromId(intval($input['default_template_id']));
         }
 
+        $chapter->save();
         Activity::add(ActivityType::CHAPTER_UPDATE, $chapter);
 
         $this->baseRepo->sortParent($chapter);
@@ -66,7 +70,7 @@ class ChapterRepo
      *
      * @throws Exception
      */
-    public function destroy(Chapter $chapter)
+    public function destroy(Chapter $chapter): void
     {
         $this->trashCan->softDestroyChapter($chapter);
         Activity::add(ActivityType::CHAPTER_DELETE, $chapter);
@@ -93,7 +97,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);
 
index e47192cc2f12266bed38a3f62107ef00a1b7f86a..5b67e5e6b52ce283786d566e5870f33cbd8084f1 100644 (file)
@@ -9,11 +9,9 @@ use BookStack\Facades\Activity;
 
 class DeletionRepo
 {
-    private TrashCan $trashCan;
-
-    public function __construct(TrashCan $trashCan)
-    {
-        $this->trashCan = $trashCan;
+    public function __construct(
+        protected TrashCan $trashCan
+    ) {
     }
 
     public function restore(int $id): int
index 76377f9a6dd0916aefb49809a13e33892c262500..f2e558210aee6330b099d5c575a8b1ee2c9907c5 100644 (file)
@@ -37,7 +37,7 @@ class PageRepo
     /**
      * Get a new draft page belonging to the given parent entity.
      */
-    public function getNewDraftPage(Entity $parent)
+    public function getNewDraftPage(Entity $parent): Page
     {
         $page = (new Page())->forceFill([
             'name'       => trans('entities.pages_initial_name'),
@@ -46,6 +46,9 @@ class PageRepo
             'updated_by' => user()->id,
             'draft'      => true,
             'editor'     => PageEditorType::getSystemDefault()->value,
+            'html'       => '',
+            'markdown'   => '',
+            'text'       => '',
         ]);
 
         if ($parent instanceof Chapter) {
@@ -55,17 +58,18 @@ class PageRepo
             $page->book_id = $parent->id;
         }
 
-        $defaultTemplate = $page->chapter->defaultTemplate ?? $page->book->defaultTemplate;
-        if ($defaultTemplate && userCan(Permission::PageView, $defaultTemplate)) {
+        $defaultTemplate = $page->chapter?->defaultTemplate()->get() ?? $page->book?->defaultTemplate()->get();
+        if ($defaultTemplate) {
             $page->forceFill([
                 'html'  => $defaultTemplate->html,
                 'markdown' => $defaultTemplate->markdown,
             ]);
+            $page->text = (new PageContent($page))->toPlainText();
         }
 
         (new DatabaseTransaction(function () use ($page) {
             $page->save();
-            $page->refresh()->rebuildPermissions();
+            $page->rebuildPermissions();
         }))->run();
 
         return $page;
@@ -81,7 +85,8 @@ class PageRepo
             $draft->revision_count = 1;
             $draft->priority = $this->getNewPriority($draft);
             $this->updateTemplateStatusAndContentFromInput($draft, $input);
-            $this->baseRepo->update($draft, $input);
+
+            $draft = $this->baseRepo->update($draft, $input);
             $draft->rebuildPermissions();
 
             $summary = trim($input['summary'] ?? '') ?: trans('entities.pages_initial_revision');
@@ -112,12 +117,12 @@ class PageRepo
     public function update(Page $page, array $input): Page
     {
         // Hold the old details to compare later
-        $oldHtml = $page->html;
         $oldName = $page->name;
+        $oldHtml = $page->html;
         $oldMarkdown = $page->markdown;
 
         $this->updateTemplateStatusAndContentFromInput($page, $input);
-        $this->baseRepo->update($page, $input);
+        $page = $this->baseRepo->update($page, $input);
 
         // Update with new details
         $page->revision_count++;
@@ -176,12 +181,12 @@ class PageRepo
     /**
      * Save a page update draft.
      */
-    public function updatePageDraft(Page $page, array $input)
+    public function updatePageDraft(Page $page, array $input): Page|PageRevision
     {
-        // If the page itself is a draft simply update that
+        // If the page itself is a draft, simply update that
         if ($page->draft) {
             $this->updateTemplateStatusAndContentFromInput($page, $input);
-            $page->fill($input);
+            $page->forceFill(array_intersect_key($input, array_flip(['name'])))->save();
             $page->save();
 
             return $page;
@@ -209,7 +214,7 @@ class PageRepo
      *
      * @throws Exception
      */
-    public function destroy(Page $page)
+    public function destroy(Page $page): void
     {
         $this->trashCan->softDestroyPage($page);
         Activity::add(ActivityType::PAGE_DELETE, $page);
@@ -279,7 +284,7 @@ class PageRepo
         return (new DatabaseTransaction(function () use ($page, $parent) {
             $page->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);
index d5549a0f14aa0d45c66e9bfea6c5736c4eac536c..2d1371b63bdb075c488061d2b5d607fe6b2d2443 100644 (file)
@@ -23,7 +23,7 @@ class RevisionRepo
 
     /**
      * Get a user update_draft page revision to update for the given page.
-     * Checks for an existing revisions before providing a fresh one.
+     * Checks for an existing revision before providing a fresh one.
      */
     public function getNewDraftForCurrentUser(Page $page): PageRevision
     {
@@ -72,7 +72,7 @@ class RevisionRepo
     /**
      * Delete old revisions, for the given page, from the system.
      */
-    protected function deleteOldRevisions(Page $page)
+    protected function deleteOldRevisions(Page $page): void
     {
         $revisionLimit = config('app.revision_limit');
         if ($revisionLimit === false) {
index 7dd3f3e11adade81eea09005082948c1d3c42439..4bbab6265207329e60d78623654a905801f4eabd 100644 (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;
         };
     }
 
index 05618fef46150990369c1b202cfa55cbf19342a9..ff42ae6e41b8cb53d1cf649f80e3469697b0010b 100644 (file)
@@ -6,8 +6,8 @@ use BookStack\Activity\Models\Tag;
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Bookshelf;
 use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\HasCoverInterface;
 use BookStack\Entities\Models\Entity;
-use BookStack\Entities\Models\CoverImageInterface;
 use BookStack\Entities\Models\Page;
 use BookStack\Entities\Repos\BookRepo;
 use BookStack\Entities\Repos\ChapterRepo;
@@ -106,8 +106,8 @@ class Cloner
         $inputData['tags'] = $this->entityTagsToInputArray($entity);
 
         // Add a cover to the data if existing on the original entity
-        if ($entity instanceof CoverImageInterface) {
-            $cover = $entity->cover()->first();
+        if ($entity instanceof HasCoverInterface) {
+            $cover = $entity->coverInfo()->getImage();
             if ($cover) {
                 $inputData['image'] = $this->imageToUploadedFile($cover);
             }
diff --git a/app/Entities/Tools/EntityCover.php b/app/Entities/Tools/EntityCover.php
new file mode 100644 (file)
index 0000000..1e8fce2
--- /dev/null
@@ -0,0 +1,75 @@
+<?php
+
+namespace BookStack\Entities\Tools;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Uploads\Image;
+use Exception;
+use Illuminate\Database\Eloquent\Builder;
+
+class EntityCover
+{
+    public function __construct(
+        protected Book|Bookshelf $entity,
+    ) {
+    }
+
+    protected function imageQuery(): Builder
+    {
+        return Image::query()->where('id', '=', $this->entity->image_id);
+    }
+
+    /**
+     * Check if a cover image exists for this entity.
+     */
+    public function exists(): bool
+    {
+        return $this->entity->image_id !== null && $this->imageQuery()->exists();
+    }
+
+    /**
+     * Get the assigned cover image model.
+     */
+    public function getImage(): Image|null
+    {
+        if ($this->entity->image_id === null) {
+            return null;
+        }
+
+        $cover = $this->imageQuery()->first();
+        if ($cover instanceof Image) {
+            return $cover;
+        }
+
+        return null;
+    }
+
+    /**
+     * Returns a cover image URL, or the given default if none assigned/existing.
+     */
+    public function getUrl(int $width = 440, int $height = 250, string|null $default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='): string|null
+    {
+        if (!$this->entity->image_id) {
+            return $default;
+        }
+
+        try {
+            return $this->getImage()?->getThumb($width, $height, false) ?? $default;
+        } catch (Exception $err) {
+            return $default;
+        }
+    }
+
+    /**
+     * Set the image to use as the cover for this entity.
+     */
+    public function setImage(Image|null $image): void
+    {
+        if ($image === null) {
+            $this->entity->image_id = null;
+        } else {
+            $this->entity->image_id = $image->id;
+        }
+    }
+}
diff --git a/app/Entities/Tools/EntityDefaultTemplate.php b/app/Entities/Tools/EntityDefaultTemplate.php
new file mode 100644 (file)
index 0000000..d36c3f2
--- /dev/null
@@ -0,0 +1,60 @@
+<?php
+
+namespace BookStack\Entities\Tools;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Queries\PageQueries;
+
+class EntityDefaultTemplate
+{
+    public function __construct(
+        protected Book|Chapter $entity,
+    ) {
+    }
+
+    /**
+     * Set the default template ID for this entity.
+     */
+    public function setFromId(int $templateId): void
+    {
+        $changing = $templateId !== intval($this->entity->default_template_id);
+        if (!$changing) {
+            return;
+        }
+
+        if ($templateId === 0) {
+            $this->entity->default_template_id = null;
+            return;
+        }
+
+        $pageQueries = app()->make(PageQueries::class);
+        $templateExists = $pageQueries->visibleTemplates()
+            ->where('id', '=', $templateId)
+            ->exists();
+
+        $this->entity->default_template_id = $templateExists ? $templateId : null;
+    }
+
+    /**
+     * Get the default template for this entity (if visible).
+     */
+    public function get(): Page|null
+    {
+        if (!$this->entity->default_template_id) {
+            return null;
+        }
+
+        $pageQueries = app()->make(PageQueries::class);
+        $page = $pageQueries->visibleTemplates(true)
+            ->where('id', '=', $this->entity->default_template_id)
+            ->first();
+
+        if ($page instanceof Page) {
+            return $page;
+        }
+
+        return null;
+    }
+}
diff --git a/app/Entities/Tools/EntityHtmlDescription.php b/app/Entities/Tools/EntityHtmlDescription.php
new file mode 100644 (file)
index 0000000..335703c
--- /dev/null
@@ -0,0 +1,60 @@
+<?php
+
+namespace BookStack\Entities\Tools;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Util\HtmlContentFilter;
+
+class EntityHtmlDescription
+{
+    protected string $html = '';
+    protected string $plain = '';
+
+    public function __construct(
+        protected Book|Chapter|Bookshelf $entity,
+    ) {
+        $this->html = $this->entity->description_html ?? '';
+        $this->plain = $this->entity->description ?? '';
+    }
+
+    /**
+     * Update the description from HTML code.
+     * Optionally takes plaintext to use for the model also.
+     */
+    public function set(string $html, string|null $plaintext = null): void
+    {
+        $this->html = $html;
+        $this->entity->description_html = $this->html;
+
+        if ($plaintext !== null) {
+            $this->plain = $plaintext;
+            $this->entity->description = $this->plain;
+        }
+
+        if (empty($html) && !empty($plaintext)) {
+            $this->html = $this->getHtml();
+            $this->entity->description_html = $this->html;
+        }
+    }
+
+    /**
+     * Get the description as HTML.
+     * Optionally returns the raw HTML if requested.
+     */
+    public function getHtml(bool $raw = false): string
+    {
+        $html = $this->html ?: '<p>' . nl2br(e($this->plain)) . '</p>';
+        if ($raw) {
+            return $html;
+        }
+
+        return HtmlContentFilter::removeScriptsFromHtmlString($html);
+    }
+
+    public function getPlain(): string
+    {
+        return $this->plain;
+    }
+}
index b0d8880f402ecb0efd2a1241cd7d750312061d4e..fa45fcd116b5817ea42ec46f34c7c8205b768033 100644 (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);
         }
 
index f9a940b981b7f4261c33776c2e2897b054701e40..0a0f224d86c65441d75dc799b5ca1eabbefd05d5 100644 (file)
@@ -19,7 +19,7 @@ class MixedEntityListLoader
      * This will look for a model id and type via 'name_id' and 'name_type'.
      * @param Model[] $relations
      */
-    public function loadIntoRelations(array $relations, string $relationName, bool $loadParents): void
+    public function loadIntoRelations(array $relations, string $relationName, bool $loadParents, bool $withContents = false): void
     {
         $idsByType = [];
         foreach ($relations as $relation) {
@@ -33,7 +33,7 @@ class MixedEntityListLoader
             $idsByType[$type][] = $id;
         }
 
-        $modelMap = $this->idsByTypeToModelMap($idsByType, $loadParents);
+        $modelMap = $this->idsByTypeToModelMap($idsByType, $loadParents, $withContents);
 
         foreach ($relations as $relation) {
             $type = $relation->getAttribute($relationName . '_type');
@@ -49,13 +49,13 @@ class MixedEntityListLoader
      * @param array<string, int[]> $idsByType
      * @return array<string, array<int, Model>>
      */
-    protected function idsByTypeToModelMap(array $idsByType, bool $eagerLoadParents): array
+    protected function idsByTypeToModelMap(array $idsByType, bool $eagerLoadParents, bool $withContents): array
     {
         $modelMap = [];
 
         foreach ($idsByType as $type => $ids) {
-            $models = $this->queries->visibleForList($type)
-                ->whereIn('id', $ids)
+            $base = $withContents ? $this->queries->visibleForContent($type) : $this->queries->visibleForList($type);
+            $models = $base->whereIn('id', $ids)
                 ->with($eagerLoadParents ? $this->getRelationsToEagerLoad($type) : [])
                 ->get();
 
index 4b1d77db720fd2b16d2eef7c01c33e4376ef6cb8..c7a59216ad0786044c3717177b5c56982c39e175 100644 (file)
@@ -284,7 +284,7 @@ class PageContent
     /**
      * Get a plain-text visualisation of this page.
      */
-    protected function toPlainText(): string
+    public function toPlainText(): string
     {
         $html = $this->render(true);
 
index d457d4f48db30f3c42a47e73119a69f846de600b..cc43b9096250226a90a444b564e31adbc62e7afd 100644 (file)
@@ -6,9 +6,10 @@ use BookStack\Entities\EntityProvider;
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Bookshelf;
 use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\EntityContainerData;
+use BookStack\Entities\Models\HasCoverInterface;
 use BookStack\Entities\Models\Deletion;
 use BookStack\Entities\Models\Entity;
-use BookStack\Entities\Models\CoverImageInterface;
 use BookStack\Entities\Models\Page;
 use BookStack\Entities\Queries\EntityQueries;
 use BookStack\Exceptions\NotifyException;
@@ -140,6 +141,7 @@ class TrashCan
     protected function destroyShelf(Bookshelf $shelf): int
     {
         $this->destroyCommonRelations($shelf);
+        $shelf->books()->detach();
         $shelf->forceDelete();
 
         return 1;
@@ -167,6 +169,7 @@ class TrashCan
         }
 
         $this->destroyCommonRelations($book);
+        $book->shelves()->detach();
         $book->forceDelete();
 
         return $count + 1;
@@ -209,15 +212,19 @@ class TrashCan
             $attachmentService->deleteFile($attachment);
         }
 
-        // Remove book template usages
-        $this->queries->books->start()
+        // Remove use as a template
+        EntityContainerData::query()
             ->where('default_template_id', '=', $page->id)
             ->update(['default_template_id' => null]);
 
-        // Remove chapter template usages
-        $this->queries->chapters->start()
-            ->where('default_template_id', '=', $page->id)
-            ->update(['default_template_id' => null]);
+        // TODO - Handle related images (uploaded_to for gallery/drawings).
+        //   Should maybe reset to null
+        //   But does that present visibility/permission issues if they used to retain their old
+        //   unused ID?
+        //   If so, might be better to leave them as-is like before, but ensure the maintenance
+        //   cleanup command/action can find these "orphaned" images and delete them.
+        //   But that would leave potential attachment to new pages on increment reset scenarios.
+        //   Need to review permission scenarios for null field values relative to storage options.
 
         $page->forceDelete();
 
@@ -398,9 +405,11 @@ class TrashCan
         $entity->referencesTo()->delete();
         $entity->referencesFrom()->delete();
 
-        if ($entity instanceof CoverImageInterface && $entity->cover()->exists()) {
+        if ($entity instanceof HasCoverInterface && $entity->coverInfo()->exists()) {
             $imageService = app()->make(ImageService::class);
-            $imageService->destroy($entity->cover()->first());
+            $imageService->destroy($entity->coverInfo()->getImage());
         }
+
+        $entity->relatedData()->delete();
     }
 }
index 85ac7d2c9e979dee8c127ad6216eb45de108bbc5..ad489aba1cbb4cce1ee0b58dd2840827580a19ce 100644 (file)
@@ -284,7 +284,7 @@ class ExportFormatter
     public function bookToPlainText(Book $book): string
     {
         $bookTree = (new BookContents($book))->getTree(false, true);
-        $text = $book->name . "\n" . $book->description;
+        $text = $book->name . "\n" . $book->descriptionInfo()->getPlain();
         $text = rtrim($text) . "\n\n";
 
         $parts = [];
@@ -318,7 +318,7 @@ class ExportFormatter
     {
         $text = '# ' . $chapter->name . "\n\n";
 
-        $description = (new HtmlToMarkdown($chapter->descriptionHtml()))->convert();
+        $description = (new HtmlToMarkdown($chapter->descriptionInfo()->getHtml()))->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->descriptionHtml()))->convert();
+        $description = (new HtmlToMarkdown($book->descriptionInfo()->getHtml()))->convert();
         if ($description) {
             $text .= $description . "\n\n";
         }
index 6c51ea3379cabb1ae91a1b41b8cb8d53e2683581..ab3fd90ec1c32b9179e81e195ff925e5449d7ac2 100644 (file)
@@ -55,10 +55,10 @@ final class ZipExportBook extends ZipExportModel
         $instance = new self();
         $instance->id = $model->id;
         $instance->name = $model->name;
-        $instance->description_html = $model->descriptionHtml();
+        $instance->description_html = $model->descriptionInfo()->getHtml();
 
-        if ($model->cover) {
-            $instance->cover = $files->referenceForImage($model->cover);
+        if ($model->coverInfo()->exists()) {
+            $instance->cover = $files->referenceForImage($model->coverInfo()->getImage());
         }
 
         $instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all());
index 260191a3e78e231656cc3365a11f7bdcf165170f..906ce3d81857e84f63659914ae57b2bebc833cbe 100644 (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->descriptionHtml();
+        $instance->description_html = $model->descriptionInfo()->getHtml();
         $instance->priority = $model->priority;
         $instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all());
 
index eafb527e87ebe77c7d060a78e17aafa1c6422954..748acf43f74aab229bb6ce58e12d609e22ff5b27 100644 (file)
@@ -135,8 +135,8 @@ class ZipImportRunner
             'tags' => $this->exportTagsToInputArray($exportBook->tags ?? []),
         ]);
 
-        if ($book->cover) {
-            $this->references->addImage($book->cover, null);
+        if ($book->coverInfo()->getImage()) {
+            $this->references->addImage($book->coverInfo()->getImage(), null);
         }
 
         $children = [
@@ -197,8 +197,8 @@ class ZipImportRunner
 
         $this->pageRepo->publishDraft($page, [
             'name' => $exportPage->name,
-            'markdown' => $exportPage->markdown,
-            'html' => $exportPage->html,
+            'markdown' => $exportPage->markdown ?? '',
+            'html' => $exportPage->html ?? '',
             'tags' => $this->exportTagsToInputArray($exportPage->tags ?? []),
         ]);
 
index 23fdcfda9e563556d025b2594f577a1a108fea45..c44a18a4d53661d8f7d3bd1f9000ae31a540b068 100644 (file)
@@ -40,10 +40,6 @@ class PermissionApplicator
         $ownerField = $ownable->getOwnerFieldName();
         $ownableFieldVal = $ownable->getAttribute($ownerField);
 
-        if (is_null($ownableFieldVal)) {
-            throw new InvalidArgumentException("{$ownerField} field used but has not been loaded");
-        }
-
         $isOwner = $user->id === $ownableFieldVal;
         $hasRolePermission = $allRolePermission || ($isOwner && $ownRolePermission);
 
@@ -144,10 +140,10 @@ class PermissionApplicator
                 /** @var Builder $query */
                 $query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass)
                 ->orWhereExists(function (QueryBuilder $query) use ($tableDetails, $pageMorphClass) {
-                    $query->select('id')->from('pages')
-                        ->whereColumn('pages.id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
+                    $query->select('page_id')->from('entity_page_data')
+                        ->whereColumn('entity_page_data.page_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
                         ->where($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', $pageMorphClass)
-                        ->where('pages.draft', '=', false);
+                        ->where('entity_page_data.draft', '=', false);
                 });
             });
     }
@@ -197,18 +193,18 @@ class PermissionApplicator
     {
         $fullPageIdColumn = $tableName . '.' . $pageIdColumn;
         return $this->restrictEntityQuery($query)
-            ->where(function ($query) use ($fullPageIdColumn) {
-                /** @var Builder $query */
-                $query->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
-                    $query->select('id')->from('pages')
-                        ->whereColumn('pages.id', '=', $fullPageIdColumn)
-                        ->where('pages.draft', '=', false);
-                })->orWhereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
-                    $query->select('id')->from('pages')
-                        ->whereColumn('pages.id', '=', $fullPageIdColumn)
-                        ->where('pages.draft', '=', true)
-                        ->where('pages.created_by', '=', $this->currentUser()->id);
-                });
+            ->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
+                $query->select('id')->from('entities')
+                    ->leftJoin('entity_page_data', 'entities.id', '=', 'entity_page_data.page_id')
+                    ->whereColumn('entities.id', '=', $fullPageIdColumn)
+                    ->where('entities.type', '=', 'page')
+                    ->where(function (QueryBuilder $query) {
+                        $query->where('entity_page_data.draft', '=', false)
+                            ->orWhere(function (QueryBuilder $query) {
+                                $query->where('entity_page_data.draft', '=', true)
+                                    ->where('entities.created_by', '=', $this->currentUser()->id);
+                            });
+                    });
             });
     }
 
index 1c9664f45a9979d8c5e88627b4bb6eaa7547dd1a..8588c6e2c8ef3ab16dd8464f36dc42e44957f42d 100644 (file)
@@ -20,10 +20,10 @@ class ReferenceFetcher
      * Query and return the references pointing to the given entity.
      * Loads the commonly required relations while taking permissions into account.
      */
-    public function getReferencesToEntity(Entity $entity): Collection
+    public function getReferencesToEntity(Entity $entity, bool $withContents = false): Collection
     {
         $references = $this->queryReferencesToEntity($entity)->get();
-        $this->mixedEntityListLoader->loadIntoRelations($references->all(), 'from', true);
+        $this->mixedEntityListLoader->loadIntoRelations($references->all(), 'from', false, $withContents);
 
         return $references;
     }
index 5f1d711e9c6a97f5c23c6c3d68c693318b4f2ce8..06b3389bae527e342a36ad7197ff5cfea2dfed53 100644 (file)
@@ -3,9 +3,9 @@
 namespace BookStack\References;
 
 use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\HasDescriptionInterface;
 use BookStack\Entities\Models\Entity;
-use BookStack\Entities\Models\HtmlDescriptionInterface;
-use BookStack\Entities\Models\HtmlDescriptionTrait;
+use BookStack\Entities\Models\EntityContainerData;
 use BookStack\Entities\Models\Page;
 use BookStack\Entities\Repos\RevisionRepo;
 use BookStack\Util\HtmlDocument;
@@ -36,7 +36,7 @@ class ReferenceUpdater
     protected function getReferencesToUpdate(Entity $entity): array
     {
         /** @var Reference[] $references */
-        $references = $this->referenceFetcher->getReferencesToEntity($entity)->values()->all();
+        $references = $this->referenceFetcher->getReferencesToEntity($entity, true)->values()->all();
 
         if ($entity instanceof Book) {
             $pages = $entity->pages()->get(['id']);
@@ -44,7 +44,7 @@ class ReferenceUpdater
             $children = $pages->concat($chapters);
             foreach ($children as $bookChild) {
                 /** @var Reference[] $childRefs */
-                $childRefs = $this->referenceFetcher->getReferencesToEntity($bookChild)->values()->all();
+                $childRefs = $this->referenceFetcher->getReferencesToEntity($bookChild, true)->values()->all();
                 array_push($references, ...$childRefs);
             }
         }
@@ -64,16 +64,16 @@ class ReferenceUpdater
             $this->updateReferencesWithinPage($entity, $oldLink, $newLink);
         }
 
-        if ($entity instanceof HtmlDescriptionInterface) {
+        if ($entity instanceof HasDescriptionInterface) {
             $this->updateReferencesWithinDescription($entity, $oldLink, $newLink);
         }
     }
 
-    protected function updateReferencesWithinDescription(Entity&HtmlDescriptionInterface $entity, string $oldLink, string $newLink): void
+    protected function updateReferencesWithinDescription(Entity&HasDescriptionInterface $entity, string $oldLink, string $newLink): void
     {
-        $entity = (clone $entity)->refresh();
-        $html = $this->updateLinksInHtml($entity->descriptionHtml(true) ?: '', $oldLink, $newLink);
-        $entity->setDescriptionHtml($html);
+        $description = $entity->descriptionInfo();
+        $html = $this->updateLinksInHtml($description->getHtml(true) ?: '', $oldLink, $newLink);
+        $description->set($html);
         $entity->save();
     }
 
index 1152101d29388040cbad7a8652a99cd37dc5ac02..99e307e35cc94e3ea7ebcca349e6a26ff4c364d9 100644 (file)
@@ -33,22 +33,22 @@ class BookSorter
      */
     public function runBookAutoSort(Book $book): void
     {
-        $set = $book->sortRule;
-        if (!$set) {
+        $rule = $book->sortRule()->first();
+        if (!($rule instanceof SortRule)) {
             return;
         }
 
         $sortFunctions = array_map(function (SortRuleOperation $op) {
             return $op->getSortFunction();
-        }, $set->getOperations());
+        }, $rule->getOperations());
 
         $chapters = $book->chapters()
-            ->with('pages:id,name,priority,created_at,updated_at,chapter_id')
+            ->with('pages:id,name,book_id,chapter_id,priority,created_at,updated_at')
             ->get(['id', 'name', 'priority', 'created_at', 'updated_at']);
 
         /** @var (Chapter|Book)[] $topItems */
         $topItems = [
-            ...$book->directPages()->get(['id', 'name', 'priority', 'created_at', 'updated_at']),
+            ...$book->directPages()->get(['id', 'book_id', 'name', 'priority', 'created_at', 'updated_at']),
             ...$chapters,
         ];
 
@@ -155,11 +155,12 @@ class BookSorter
 
         // Action the required changes
         if ($bookChanged) {
-            $model->changeBook($newBook->id);
+            $model = $model->changeBook($newBook->id);
         }
 
         if ($model instanceof Page && $chapterChanged) {
             $model->chapter_id = $newChapter->id ?? 0;
+            $model->unsetRelation('chapter');
         }
 
         if ($priorityChanged) {
index 45e5514fd5f03dd03d5a71f4e3aba82a10796a73..bf53365a201b07a148cc82a052f60c503ae4e5a6 100644 (file)
@@ -50,7 +50,7 @@ class SortRule extends Model implements Loggable
 
     public function books(): HasMany
     {
-        return $this->hasMany(Book::class);
+        return $this->hasMany(Book::class, 'entity_container_data.sort_rule_id', 'id');
     }
 
     public static function allByName(): Collection
index bb5540a2a5039f3a11c0713000093db67f7bd7d0..65e1cba098a44c7ed7ca9f695196d2f5d554f330 100644 (file)
@@ -3,6 +3,7 @@
 namespace BookStack\Sorting;
 
 use BookStack\Activity\ActivityType;
+use BookStack\Entities\Models\EntityContainerData;
 use BookStack\Http\Controller;
 use BookStack\Permissions\Permission;
 use Illuminate\Http\Request;
@@ -88,7 +89,9 @@ class SortRuleController extends Controller
 
         if ($booksAssigned > 0) {
             if ($confirmed) {
-                $rule->books()->update(['sort_rule_id' => null]);
+                EntityContainerData::query()
+                    ->where('sort_rule_id', $rule->id)
+                    ->update(['sort_rule_id' => null]);
             } else {
                 $warnings[] = trans('settings.sort_rule_delete_warn_books', ['count' => $booksAssigned]);
             }
index b47d6ff8dd12df5f02febb4950529b7f919e81bf..ea3c4a962b315e5d220be46844d1b207c6a6d567 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace BookStack\Uploads\Controllers;
 
+use BookStack\Entities\EntityExistsRule;
 use BookStack\Entities\Queries\PageQueries;
 use BookStack\Exceptions\FileUploadException;
 use BookStack\Http\ApiController;
@@ -173,13 +174,13 @@ class AttachmentApiController extends ApiController
         return [
             'create' => [
                 'name'        => ['required', 'string', 'min:1', 'max:255'],
-                'uploaded_to' => ['required', 'integer', 'exists:pages,id'],
+                'uploaded_to' => ['required', 'integer', new EntityExistsRule('page')],
                 'file'        => array_merge(['required_without:link'], $this->attachmentService->getFileValidationRules()),
                 'link'        => ['required_without:file', 'string', 'min:1', 'max:2000', 'safe_url'],
             ],
             'update' => [
                 'name'        => ['string', 'min:1', 'max:255'],
-                'uploaded_to' => ['integer', 'exists:pages,id'],
+                'uploaded_to' => ['integer', new EntityExistsRule('page')],
                 'file'        => $this->attachmentService->getFileValidationRules(),
                 'link'        => ['string', 'min:1', 'max:2000', 'safe_url'],
             ],
index 0886193e46d6486ba068dc2f49c33b027efa4676..9c60fa415f83ae198ad9e4550f523677ada4f869 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace BookStack\Uploads\Controllers;
 
+use BookStack\Entities\EntityExistsRule;
 use BookStack\Entities\Queries\PageQueries;
 use BookStack\Entities\Repos\PageRepo;
 use BookStack\Exceptions\FileUploadException;
@@ -34,7 +35,7 @@ class AttachmentController extends Controller
     public function upload(Request $request)
     {
         $this->validate($request, [
-            'uploaded_to' => ['required', 'integer', 'exists:pages,id'],
+            'uploaded_to' => ['required', 'integer',  new EntityExistsRule('page')],
             'file'        => array_merge(['required'], $this->attachmentService->getFileValidationRules()),
         ]);
 
@@ -144,7 +145,7 @@ class AttachmentController extends Controller
 
         try {
             $this->validate($request, [
-                'attachment_link_uploaded_to' => ['required', 'integer', 'exists:pages,id'],
+                'attachment_link_uploaded_to' => ['required', 'integer',  new EntityExistsRule('page')],
                 'attachment_link_name'        => ['required', 'string', 'min:1', 'max:255'],
                 'attachment_link_url'         => ['required', 'string', 'min:1', 'max:2000', 'safe_url'],
             ]);
index 458f0102d772bb90c016c31c30f586f2b4ab7a65..402456e9791873605f03c46103fd8baae3fd7c4d 100644 (file)
@@ -184,7 +184,7 @@ class ImageService
                 /** @var Image $image */
                 foreach ($images as $image) {
                     $searchQuery = '%' . basename($image->path) . '%';
-                    $inPage = DB::table('pages')
+                    $inPage = DB::table('entity_page_data')
                             ->where('html', 'like', $searchQuery)->count() > 0;
 
                     $inRevision = false;
index 9134b3cc13b32768a00b25ec68dde42ed7398d1b..25753280f17179cd038112a610df71be1b037558 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace BookStack\Users\Controllers;
 
+use BookStack\Entities\EntityExistsRule;
 use BookStack\Exceptions\UserUpdateException;
 use BookStack\Http\ApiController;
 use BookStack\Permissions\Permission;
index d24f7002e710e678877263f1f3763222b29bc9ed..79d9e1b9eb23cecbdaa1dae42b90582fb4f06c41 100644 (file)
@@ -6,12 +6,14 @@ use BookStack\Access\UserInviteException;
 use BookStack\Access\UserInviteService;
 use BookStack\Activity\ActivityType;
 use BookStack\Entities\EntityProvider;
+use BookStack\Entities\Models\Entity;
 use BookStack\Exceptions\NotifyException;
 use BookStack\Exceptions\UserUpdateException;
 use BookStack\Facades\Activity;
 use BookStack\Uploads\UserAvatars;
 use BookStack\Users\Models\Role;
 use BookStack\Users\Models\User;
+use DB;
 use Exception;
 use Illuminate\Support\Facades\Hash;
 use Illuminate\Support\Facades\Log;
@@ -181,6 +183,7 @@ class UserRepo
             if (!is_null($newOwner)) {
                 $this->migrateOwnership($user, $newOwner);
             }
+            // TODO - Should be be nullifying ownership instead?
         }
 
         Activity::add(ActivityType::USER_DELETE, $user);
@@ -203,13 +206,11 @@ class UserRepo
     /**
      * Migrate ownership of items in the system from one user to another.
      */
-    protected function migrateOwnership(User $fromUser, User $toUser)
+    protected function migrateOwnership(User $fromUser, User $toUser): void
     {
-        $entities = (new EntityProvider())->all();
-        foreach ($entities as $instance) {
-            $instance->newQuery()->where('owned_by', '=', $fromUser->id)
-                ->update(['owned_by' => $toUser->id]);
-        }
+        DB::table('entities')
+            ->where('owned_by', '=', $fromUser->id)
+            ->update(['owned_by' => $toUser->id]);
     }
 
     /**
index 1fc49933ef766adcf5ff37639d9fd3a7424759b5..abf554ac894d784a141415827eb54b2427f60d42 100644 (file)
@@ -26,7 +26,8 @@ class ChapterFactory extends Factory
             'name'        => $this->faker->sentence(),
             'slug'        => Str::random(10),
             'description' => $description,
-            'description_html' => '<p>' . e($description) . '</p>'
+            'description_html' => '<p>' . e($description) . '</p>',
+            'priority' => 5,
         ];
     }
 }
index 8115700950ced18a76eaadf9620c4dd1b431b633..47e5aa5db2b154addb9431bdae4b8e8fbfa7be3f 100644 (file)
@@ -31,6 +31,7 @@ class PageFactory extends Factory
             'text'           => strip_tags($html),
             'revision_count' => 1,
             'editor'         => 'wysiwyg',
+            'priority'       => 1,
         ];
     }
 }
index 0e25c1d6052955d8dd8efa65edffd2c05453a443..a8f1843ed92f985997158c22e301cc31307ae17f 100644 (file)
@@ -25,9 +25,6 @@ return new class extends Migration
                 $table->unsignedInteger('owner_id')->nullable()->index();
             });
         }
-
-        // Rebuild permissions
-        app(JointPermissionBuilder::class)->rebuildForAll();
     }
 
     /**
diff --git a/database/migrations/2025_09_15_132850_create_entities_table.php b/database/migrations/2025_09_15_132850_create_entities_table.php
new file mode 100644 (file)
index 0000000..6c890d7
--- /dev/null
@@ -0,0 +1,71 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('entities', function (Blueprint $table) {
+            $table->bigIncrements('id');
+            $table->string('type', 10)->index();
+            $table->string('name');
+            $table->string('slug')->index();
+
+            $table->unsignedBigInteger('book_id')->nullable()->index();
+            $table->unsignedBigInteger('chapter_id')->nullable()->index();
+            $table->unsignedInteger('priority')->nullable();
+
+            $table->timestamp('created_at')->nullable();
+            $table->timestamp('updated_at')->nullable()->index();
+            $table->timestamp('deleted_at')->nullable()->index();
+
+            $table->unsignedInteger('created_by')->nullable();
+            $table->unsignedInteger('updated_by')->nullable();
+            $table->unsignedInteger('owned_by')->nullable()->index();
+
+            $table->primary(['id', 'type'], 'entities_pk');
+        });
+
+        Schema::create('entity_container_data', function (Blueprint $table) {
+            $table->unsignedBigInteger('entity_id');
+            $table->string('entity_type', 10);
+            $table->text('description');
+            $table->text('description_html');
+
+            $table->unsignedBigInteger('default_template_id')->nullable();
+            $table->unsignedInteger('image_id')->nullable();
+            $table->unsignedInteger('sort_rule_id')->nullable();
+
+            $table->primary(['entity_id', 'entity_type'], 'entity_container_data_pk');
+        });
+
+        Schema::create('entity_page_data', function (Blueprint $table) {
+            $table->unsignedBigInteger('page_id')->primary();
+
+            $table->boolean('draft')->index();
+            $table->boolean('template')->index();
+            $table->unsignedInteger('revision_count');
+            $table->string('editor', 50);
+
+            $table->longText('html');
+            $table->longText('text');
+            $table->longText('markdown');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('entities');
+        Schema::dropIfExists('entity_container_data');
+        Schema::dropIfExists('entity_page_data');
+    }
+};
diff --git a/database/migrations/2025_09_15_134701_migrate_entity_data.php b/database/migrations/2025_09_15_134701_migrate_entity_data.php
new file mode 100644 (file)
index 0000000..7b4beef
--- /dev/null
@@ -0,0 +1,90 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        // Start a transaction to avoid leaving a message DB on error
+        DB::beginTransaction();
+
+        // Migrate book/shelf data to entities
+        foreach (['books' => 'book', 'bookshelves' => 'bookshelf'] as $table => $type) {
+            DB::table('entities')->insertUsing([
+                'id', 'type', 'name', 'slug', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by',
+            ], DB::table($table)->select([
+                'id', DB::raw("'{$type}'"), 'name', 'slug', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by',
+            ]));
+        }
+
+        // Migrate chapter data to entities
+        DB::table('entities')->insertUsing([
+            'id', 'type', 'name', 'slug', 'book_id', 'priority', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by',
+        ], DB::table('chapters')->select([
+            'id', DB::raw("'chapter'"), 'name', 'slug', 'book_id', 'priority', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by',
+        ]));
+
+        DB::table('entities')->insertUsing([
+            'id', 'type', 'name', 'slug', 'book_id', 'chapter_id', 'priority', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by',
+        ], DB::table('pages')->select([
+            'id', DB::raw("'page'"), 'name', 'slug', 'book_id', 'chapter_id', 'priority', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by',
+        ]));
+
+        // Migrate shelf data to entity_container_data
+        DB::table('entity_container_data')->insertUsing([
+            'entity_id', 'entity_type', 'description', 'description_html', 'image_id',
+        ], DB::table('bookshelves')->select([
+            'id', DB::raw("'bookshelf'"), 'description', 'description_html', 'image_id',
+        ]));
+
+        // Migrate book data to entity_container_data
+        DB::table('entity_container_data')->insertUsing([
+            'entity_id', 'entity_type', 'description', 'description_html', 'default_template_id', 'image_id', 'sort_rule_id'
+        ], DB::table('books')->select([
+            'id', DB::raw("'book'"), 'description', 'description_html', 'default_template_id', 'image_id', 'sort_rule_id'
+        ]));
+
+        // Migrate chapter data to entity_container_data
+        DB::table('entity_container_data')->insertUsing([
+            'entity_id', 'entity_type', 'description', 'description_html', 'default_template_id',
+        ], DB::table('chapters')->select([
+            'id', DB::raw("'chapter'"), 'description', 'description_html', 'default_template_id',
+        ]));
+
+        // Migrate page data to entity_page_data
+        DB::table('entity_page_data')->insertUsing([
+            'page_id', 'draft', 'template', 'revision_count', 'editor', 'html', 'text', 'markdown',
+        ], DB::table('pages')->select([
+            'id', 'draft', 'template', 'revision_count', 'editor', 'html', 'text', 'markdown',
+        ]));
+
+        // Fix up data - Convert 0 id references to null
+        DB::table('entities')->where('created_by', '=', 0)->update(['created_by' => null]);
+        DB::table('entities')->where('updated_by', '=', 0)->update(['updated_by' => null]);
+        DB::table('entities')->where('owned_by', '=', 0)->update(['owned_by' => null]);
+        DB::table('entities')->where('chapter_id', '=', 0)->update(['chapter_id' => null]);
+
+        // Fix up data - Convert any missing id-based references to null
+        $userIdQuery = DB::table('users')->select('id');
+        DB::table('entities')->whereNotIn('created_by', $userIdQuery)->update(['created_by' => null]);
+        DB::table('entities')->whereNotIn('updated_by', $userIdQuery)->update(['updated_by' => null]);
+        DB::table('entities')->whereNotIn('owned_by', $userIdQuery)->update(['owned_by' => null]);
+        DB::table('entities')->whereNotIn('chapter_id', DB::table('chapters')->select('id'))->update(['chapter_id' => null]);
+
+        // Commit our changes within our transaction
+        DB::commit();
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        // No action here since the actual data remains in the database for the old tables,
+        // so data reversion actions are done in a later migration when the old tables are dropped.
+    }
+};
diff --git a/database/migrations/2025_09_15_134751_update_entity_relation_columns.php b/database/migrations/2025_09_15_134751_update_entity_relation_columns.php
new file mode 100644 (file)
index 0000000..267cd49
--- /dev/null
@@ -0,0 +1,114 @@
+<?php
+
+use BookStack\Permissions\JointPermissionBuilder;
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * @var array<string, string|array<string>> $columnByTable
+     */
+    protected static array $columnByTable = [
+        'activities' => 'loggable_id',
+        'attachments' => 'uploaded_to',
+        'bookshelves_books' => ['bookshelf_id', 'book_id'],
+        'comments' => 'entity_id',
+        'deletions' => 'deletable_id',
+        'entity_permissions' => 'entity_id',
+        'favourites' => 'favouritable_id',
+        'images' => 'uploaded_to',
+        'joint_permissions' => 'entity_id',
+        'page_revisions' => 'page_id',
+        'references' => ['from_id', 'to_id'],
+        'search_terms' => 'entity_id',
+        'tags' => 'entity_id',
+        'views' => 'viewable_id',
+        'watches' => 'watchable_id',
+    ];
+
+    protected static array $nullable = [
+        'activities.loggable_id',
+        'images.uploaded_to',
+    ];
+
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        // Drop foreign key constraints
+        Schema::table('bookshelves_books', function (Blueprint $table) {
+            $table->dropForeign(['book_id']);
+            $table->dropForeign(['bookshelf_id']);
+        });
+
+        // Update column types to unsigned big integers
+        foreach (static::$columnByTable as $table => $column) {
+            $tableName = $table;
+            Schema::table($table, function (Blueprint $table) use ($tableName, $column) {
+                if (is_string($column)) {
+                    $column = [$column];
+                }
+
+                foreach ($column as $col) {
+                    if (in_array($tableName . '.' . $col, static::$nullable)) {
+                        $table->unsignedBigInteger($col)->nullable()->change();
+                    } else {
+                        $table->unsignedBigInteger($col)->change();
+                    }
+                }
+            });
+        }
+
+        // Convert image zero values to null
+        DB::table('images')->where('uploaded_to', '=', 0)->update(['uploaded_to' => null]);
+
+        // Rebuild joint permissions if needed
+        // This was moved here from 2023_01_24_104625_refactor_joint_permissions_storage since the changes
+        // made for this release would mean our current logic would not be compatible with
+        // the database changes being made. This is based on a count since any joint permissions
+        // would have been truncated in the previous migration.
+        if (\Illuminate\Support\Facades\DB::table('joint_permissions')->count() === 0) {
+            app(JointPermissionBuilder::class)->rebuildForAll();
+        }
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        // Convert image null values back to zeros
+        DB::table('images')->whereNull('uploaded_to')->update(['uploaded_to' => '0']);
+
+        // Revert columns to standard integers
+        foreach (static::$columnByTable as $table => $column) {
+            $tableName = $table;
+            Schema::table($table, function (Blueprint $table) use ($tableName, $column) {
+                if (is_string($column)) {
+                    $column = [$column];
+                }
+
+                foreach ($column as $col) {
+                    if ($tableName . '.' . $col === 'activities.loggable_id') {
+                        $table->unsignedInteger($col)->nullable()->change();
+                    } else if ($tableName . '.' . $col === 'images.uploaded_to') {
+                        $table->unsignedInteger($col)->default(0)->change();
+                    } else {
+                        $table->unsignedInteger($col)->change();
+                    }
+                }
+            });
+        }
+
+        // Re-add foreign key constraints
+        Schema::table('bookshelves_books', function (Blueprint $table) {
+            $table->foreign('bookshelf_id')->references('id')->on('bookshelves')
+                ->onUpdate('cascade')->onDelete('cascade');
+            $table->foreign('book_id')->references('id')->on('books')
+                ->onUpdate('cascade')->onDelete('cascade');
+        });
+    }
+};
diff --git a/database/migrations/2025_09_15_134813_drop_old_entity_tables.php b/database/migrations/2025_09_15_134813_drop_old_entity_tables.php
new file mode 100644 (file)
index 0000000..d6360f7
--- /dev/null
@@ -0,0 +1,162 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Query\JoinClause;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::dropIfExists('pages');
+        Schema::dropIfExists('chapters');
+        Schema::dropIfExists('books');
+        Schema::dropIfExists('bookshelves');
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::create('pages', function (Blueprint $table) {
+            $table->unsignedInteger('id', true)->primary();
+            $table->integer('book_id')->index();
+            $table->integer('chapter_id')->index();
+            $table->string('name');
+            $table->string('slug')->index();
+            $table->longText('html');
+            $table->longText('text');
+            $table->integer('priority')->index();
+
+            $table->timestamp('created_at')->nullable();
+            $table->timestamp('updated_at')->nullable()->index();
+            $table->integer('created_by')->index();
+            $table->integer('updated_by')->index();
+
+            $table->boolean('draft')->default(0)->index();
+            $table->longText('markdown');
+            $table->integer('revision_count');
+            $table->boolean('template')->default(0)->index();
+            $table->timestamp('deleted_at')->nullable();
+
+            $table->unsignedInteger('owned_by')->index();
+            $table->string('editor', 50)->default('');
+        });
+
+        Schema::create('chapters', function (Blueprint $table) {
+            $table->unsignedInteger('id', true)->primary();
+            $table->integer('book_id')->index();
+            $table->string('slug')->index();
+            $table->text('name');
+            $table->text('description');
+            $table->integer('priority')->index();
+
+            $table->timestamp('created_at')->nullable();
+            $table->timestamp('updated_at')->nullable();
+            $table->integer('created_by')->index();
+            $table->integer('updated_by')->index();
+
+            $table->timestamp('deleted_at')->nullable();
+            $table->unsignedInteger('owned_by')->index();
+            $table->text('description_html');
+            $table->integer('default_template_id')->nullable();
+        });
+
+        Schema::create('books', function (Blueprint $table) {
+            $table->unsignedInteger('id', true)->primary();
+            $table->string('name');
+            $table->string('slug')->index();
+            $table->text('description');
+            $table->timestamp('created_at')->nullable();
+            $table->timestamp('updated_at')->nullable();
+
+            $table->integer('created_by')->index();
+            $table->integer('updated_by')->index();
+
+            $table->integer('image_id')->nullable();
+            $table->timestamp('deleted_at')->nullable();
+            $table->unsignedInteger('owned_by')->index();
+
+            $table->integer('default_template_id')->nullable();
+            $table->text('description_html');
+            $table->unsignedInteger('sort_rule_id')->nullable();
+        });
+
+        Schema::create('bookshelves', function (Blueprint $table) {
+            $table->unsignedInteger('id', true)->primary();
+            $table->string('name', 180);
+            $table->string('slug', 180)->index();
+            $table->text('description');
+
+            $table->integer('created_by')->index();
+            $table->integer('updated_by')->index();
+            $table->integer('image_id')->nullable();
+
+            $table->timestamp('created_at')->nullable();
+            $table->timestamp('updated_at')->nullable();
+            $table->timestamp('deleted_at')->nullable();
+
+            $table->unsignedInteger('owned_by')->index();
+            $table->text('description_html');
+        });
+
+        DB::beginTransaction();
+
+        // Revert nulls back to zeros
+        DB::table('entities')->whereNull('created_by')->update(['created_by' => 0]);
+        DB::table('entities')->whereNull('updated_by')->update(['updated_by' => 0]);
+        DB::table('entities')->whereNull('owned_by')->update(['owned_by' => 0]);
+        DB::table('entities')->whereNull('chapter_id')->update(['chapter_id' => 0]);
+
+        // Restore data back into pages table
+        $pageFields = [
+            'id', 'book_id', 'chapter_id', 'name', 'slug', 'html', 'text', 'priority', 'created_at', 'updated_at',
+            'created_by', 'updated_by', 'draft', 'markdown', 'revision_count', 'template', 'deleted_at', 'owned_by', 'editor'
+        ];
+        $pageQuery = DB::table('entities')->select($pageFields)
+            ->leftJoin('entity_page_data', 'entities.id', '=', 'entity_page_data.page_id')
+            ->where('type', '=', 'page');
+        DB::table('pages')->insertUsing($pageFields, $pageQuery);
+
+        // Restore data back into chapters table
+        $containerJoinClause = function (JoinClause $join) {
+            return $join->on('entities.id', '=', 'entity_container_data.entity_id')
+                ->on('entities.type', '=', 'entity_container_data.entity_type');
+        };
+        $chapterFields = [
+            'id', 'book_id', 'slug', 'name', 'description', 'priority', 'created_at', 'updated_at', 'created_by', 'updated_by',
+            'deleted_at', 'owned_by', 'description_html', 'default_template_id'
+        ];
+        $chapterQuery = DB::table('entities')->select($chapterFields)
+            ->leftJoin('entity_container_data', $containerJoinClause)
+            ->where('type', '=', 'chapter');
+        DB::table('chapters')->insertUsing($chapterFields, $chapterQuery);
+
+        // Restore data back into books table
+        $bookFields = [
+            'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'image_id',
+            'deleted_at', 'owned_by', 'default_template_id', 'description_html', 'sort_rule_id'
+        ];
+        $bookQuery = DB::table('entities')->select($bookFields)
+            ->leftJoin('entity_container_data', $containerJoinClause)
+            ->where('type', '=', 'book');
+        DB::table('books')->insertUsing($bookFields, $bookQuery);
+
+        // Restore data back into bookshelves table
+        $shelfFields = [
+            'id', 'name', 'slug', 'description',  'created_by', 'updated_by', 'image_id', 'created_at', 'updated_at',
+            'deleted_at', 'owned_by', 'description_html',
+        ];
+        $shelfQuery = DB::table('entities')->select($shelfFields)
+            ->leftJoin('entity_container_data', $containerJoinClause)
+            ->where('type', '=', 'bookshelf');
+        DB::table('bookshelves')->insertUsing($shelfFields, $shelfQuery);
+
+        DB::commit();
+    }
+};
index a4383be50a253b1ecf586c4f3b531398de2304b8..5f787259a1852d9f07ff16b643a4b0d965e1fb16 100644 (file)
@@ -12,7 +12,10 @@ use BookStack\Permissions\Models\RolePermission;
 use BookStack\Search\SearchIndex;
 use BookStack\Users\Models\Role;
 use BookStack\Users\Models\User;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Database\Eloquent\Relations\Relation;
 use Illuminate\Database\Seeder;
+use Illuminate\Support\Collection;
 use Illuminate\Support\Facades\Hash;
 use Illuminate\Support\Str;
 
@@ -39,40 +42,58 @@ class DummyContentSeeder extends Seeder
 
         $byData = ['created_by' => $editorUser->id, 'updated_by' => $editorUser->id, 'owned_by' => $editorUser->id];
 
-        Book::factory()->count(5)->create($byData)
+        Book::factory()->count(5)->make($byData)
             ->each(function ($book) use ($byData) {
+                $book->save();
                 $chapters = Chapter::factory()->count(3)->create($byData)
                     ->each(function ($chapter) use ($book, $byData) {
                         $pages = Page::factory()->count(3)->make(array_merge($byData, ['book_id' => $book->id]));
-                        $chapter->pages()->saveMany($pages);
+                        $this->saveManyOnRelation($pages, $chapter->pages());
                     });
                 $pages = Page::factory()->count(3)->make($byData);
-                $book->chapters()->saveMany($chapters);
-                $book->pages()->saveMany($pages);
+                $this->saveManyOnRelation($chapters, $book->chapters());
+                $this->saveManyOnRelation($pages, $book->pages());
             });
 
-        $largeBook = Book::factory()->create(array_merge($byData, ['name' => 'Large book' . Str::random(10)]));
+        $largeBook = Book::factory()->make(array_merge($byData, ['name' => 'Large book' . Str::random(10)]));
+        $largeBook->save();
+
         $pages = Page::factory()->count(200)->make($byData);
         $chapters = Chapter::factory()->count(50)->make($byData);
-        $largeBook->pages()->saveMany($pages);
-        $largeBook->chapters()->saveMany($chapters);
+        $this->saveManyOnRelation($pages, $largeBook->pages());
+        $this->saveManyOnRelation($chapters, $largeBook->chapters());
+
+        $shelves = Bookshelf::factory()->count(10)->make($byData);
+        foreach ($shelves as $shelf) {
+            $shelf->save();
+        }
 
-        $shelves = Bookshelf::factory()->count(10)->create($byData);
         $largeBook->shelves()->attach($shelves->pluck('id'));
 
         // Assign API permission to editor role and create an API key
         $apiPermission = RolePermission::getByName('access-api');
         $editorRole->attachPermission($apiPermission);
         $token = (new ApiToken())->forceFill([
-            'user_id'    => $editorUser->id,
-            'name'       => 'Testing API key',
+            'user_id' => $editorUser->id,
+            'name' => 'Testing API key',
             'expires_at' => ApiToken::defaultExpiry(),
-            'secret'     => Hash::make('password'),
-            'token_id'   => 'apitoken',
+            'secret' => Hash::make('password'),
+            'token_id' => 'apitoken',
         ]);
         $token->save();
 
         app(JointPermissionBuilder::class)->rebuildForAll();
         app(SearchIndex::class)->indexAllEntities();
     }
+
+    /**
+     * Inefficient workaround for saving many on a relation since we can't directly insert
+     * entities since we split them across tables.
+     */
+    protected function saveManyOnRelation(Collection $entities, HasMany $relation): void
+    {
+        foreach ($entities as $entity) {
+            $relation->save($entity);
+        }
+    }
 }
index afeebade619cc4a1f73e8fd8e0ebb06e8b7d32f1..582744f99a523de4645b8afcd10c3cdf8dc7a146 100644 (file)
@@ -52,7 +52,7 @@
       "name": "Cool Animals",
       "slug": "cool-animals",
       "book_id": 16,
-      "chapter_id": 0,
+      "chapter_id": null,
       "draft": false,
       "template": false,
       "created_at": "2021-12-19T18:22:11.000000Z",
index 11f5ab8c8a5e3bec5459a697fbf5d0941ef6e202..705dea6f61951decf0bb5b079af567a71cfa899e 100644 (file)
@@ -1,7 +1,7 @@
 {
        "id": 358,
        "book_id": 1,
-       "chapter_id": 0,
+       "chapter_id": null,
        "name": "My API Page",
        "slug": "my-api-page",
        "html": "<p id=\"bkmrk-my-new-api-page\">my new API page</p>",
index 2f3538964d46aea6f1b0d0016da0beb2023cd7eb..22ff2de84af5e7ab7b292c3759b685c2be69bc27 100644 (file)
@@ -1,7 +1,7 @@
 {
        "id": 306,
        "book_id": 1,
-       "chapter_id": 0,
+       "chapter_id": null,
        "name": "A page written in markdown",
        "slug": "a-page-written-in-markdown",
        "html": "<h1 id=\"bkmrk-this-is-my-cool-page\">This is my cool page! With some included text</h1>",
index 853070839e94ad7b9ce3d2cb83df5ae03bed803d..19167ec05fad22f11658fce6962c1cc3d651dc2c 100644 (file)
@@ -10,7 +10,7 @@
       "deletable": {
         "id": 2582,
         "book_id": 25,
-        "chapter_id": 0,
+        "chapter_id": null,
         "name": "A Wonderful Page",
         "slug": "a-wonderful-page",
         "priority": 9,
index 44d495c27770afe3b512222706f02c02ae5cf8fc..bb2cc936fe057b350c54e8a5b6078cde5f293347 100644 (file)
@@ -18,7 +18,7 @@
 
         @include('form.image-picker', [
             'defaultImage' => url('/book_default_cover.png'),
-            'currentImage' => (isset($model) && $model->cover) ? $model->getBookCover() : url('/book_default_cover.png') ,
+            'currentImage' => (($model ?? null)?->coverInfo()->getUrl(440, 250, null) ?? url('/book_default_cover.png')),
             'name' => 'image',
             'imageClass' => 'cover'
         ])
index a3ff0971f117cd6a8d7f748718d7176ba2af1b8f..0852670fe6d81d8071df8aed45395a636a5305a6 100644 (file)
@@ -1,11 +1,16 @@
+@php
+    /**
+     * @var \BookStack\Entities\Models\Book $book
+     */
+@endphp
 <a href="{{ $book->getUrl() }}" class="book entity-list-item" data-entity-type="book" data-entity-id="{{$book->id}}">
-    <div class="entity-list-item-image bg-book" style="background-image: url('{{ $book->getBookCover() }}')">
+    <div class="entity-list-item-image bg-book" style="background-image: url('{{ $book->coverInfo()->getUrl() }}')">
         @icon('book')
     </div>
     <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->description }}</p>
+            <p class="text-muted break-text mb-s text-limit-lines-1">{{ $book->descriptionInfo()->getPlain() }}</p>
         </div>
     </div>
 </a>
\ No newline at end of file
index e28d9564829d1d3d98dd52bc0336b123b286b967..d510c8fd560ac0a5c3b57e5a546b842143030d72 100644 (file)
@@ -8,8 +8,8 @@
 
 @push('social-meta')
     <meta property="og:description" content="{{ Str::limit($book->description, 100, '...') }}">
-    @if($book->cover)
-        <meta property="og:image" content="{{ $book->getBookCover() }}">
+    @if($book->coverInfo()->exists())
+        <meta property="og:image" content="{{ $book->coverInfo()->getUrl() }}">
     @endif
 @endpush
 
@@ -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->descriptionHtml() !!}</div>
+            <div class="text-muted break-text">{!! $book->descriptionInfo()->getHtml() !!}</div>
             @if(count($bookChildren) > 0)
                 <div class="entity-list book-contents">
                     @foreach($bookChildren as $childElement)
index da914b32d2139acb6aedd202aa597d68e7c9057d..585bf8a3b8dc0bb085b8cad9c7f154ebf7208722 100644 (file)
@@ -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->descriptionHtml() !!}</div>
+            <div class="text-muted break-text">{!! $chapter->descriptionInfo()->getHtml() !!}</div>
             @if(count($pages) > 0)
                 <div class="entity-list book-contents">
                     @foreach($pages as $page)
index 17c54e26353608d25d535d5701b3152462468562..e64d0f42cd859b959d82a7fe631f5bbc256d2df2 100644 (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->cover) style="background-image: url('{{ $entity->getBookCover() }}')"@endif>
+        <div class="featured-image-container" @if($entity->coverInfo()->exists()) style="background-image: url('{{ $entity->coverInfo()->getUrl() }}')"@endif>
         </div>
         @icon($entity->getType())
     </div>
index 9de7b8eba6c1be4e9be19d1e2ba931343bdf5cb4..8935c5151849adef84ed4f4be3e3b91b9975d89f 100644 (file)
@@ -5,7 +5,7 @@
 @section('content')
 
     <h1 style="font-size: 4.8em">{{$book->name}}</h1>
-    <div>{!! $book->descriptionHtml() !!}</div>
+    <div>{!! $book->descriptionInfo()->getHtml() !!}</div>
 
     @include('exports.parts.book-contents-menu', ['children' => $bookChildren])
 
index 515366d60e33f9bebcb2e8abdf404f50c74aabb5..46a0738874965f6612e4825f7c5288a7b6b26a8b 100644 (file)
@@ -5,7 +5,7 @@
 @section('content')
 
     <h1 style="font-size: 4.8em">{{$chapter->name}}</h1>
-    <div>{!! $chapter->descriptionHtml() !!}</div>
+    <div>{!! $chapter->descriptionInfo()->getHtml() !!}</div>
 
     @include('exports.parts.chapter-contents-menu', ['pages' => $pages])
 
index fa0b1f22884b6eeb7ecef0612899579f01bcf782..beb3b8abbe97e3390d8a956e595d774988eda112 100644 (file)
@@ -1,7 +1,7 @@
 <div class="page-break"></div>
 <h1 id="chapter-{{$chapter->id}}">{{ $chapter->name }}</h1>
 
-<div>{!! $chapter->descriptionHtml() !!}</div>
+<div>{!! $chapter->descriptionInfo()->getHtml() !!}</div>
 
 @if(count($chapter->visible_pages) > 0)
     @foreach($chapter->visible_pages as $page)
index 52244eda6f9f4cd2369cdab3ce295108532066e7..983d2fb837137e3c3bb2419532153e10126b4cc6 100644 (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->descriptionHtml()}}@endif</textarea>
+          @if($errors->has('description_html')) class="text-neg" @endif>@if(isset($model) || old('description_html')){{ old('description_html') ?? $model->descriptionInfo()->getHtml() }}@endif</textarea>
 @if($errors->has('description_html'))
     <div class="text-neg text-small">{{ $errors->first('description_html') }}</div>
 @endif
\ No newline at end of file
index 0207d72780be1aeb32e39d899bdb4244477fcb88..0a5343b16ebe8c7ff2171a520b0afd2fe541ffed 100644 (file)
                     @icon('more')
                 </button>
                 <div refs="dropdown@menu shelf-sort@sort-button-container" class="dropdown-menu" role="menu">
-                    <button type="button" class="text-item" data-sort="name">{{ trans('entities.books_sort_name') }}</button>
-                    <button type="button" class="text-item" data-sort="created">{{ trans('entities.books_sort_created') }}</button>
-                    <button type="button" class="text-item" data-sort="updated">{{ trans('entities.books_sort_updated') }}</button>
+                    <button type="button" class="text-item"
+                            data-sort="name">{{ trans('entities.books_sort_name') }}</button>
+                    <button type="button" class="text-item"
+                            data-sort="created">{{ trans('entities.books_sort_created') }}</button>
+                    <button type="button" class="text-item"
+                            data-sort="updated">{{ trans('entities.books_sort_updated') }}</button>
                 </div>
             </div>
         </div>
@@ -42,7 +45,8 @@
     </div>
     <div class="form-group">
         <label for="books" id="shelf-sort-all-books-label">{{ trans('entities.shelves_add_books') }}</label>
-        <input type="text" refs="shelf-sort@book-search" class="scroll-box-search" placeholder="{{ trans('common.search') }}">
+        <input type="text" refs="shelf-sort@book-search" class="scroll-box-search"
+               placeholder="{{ trans('common.search') }}">
         <ul refs="shelf-sort@all-book-list"
             aria-labelledby="shelf-sort-all-books-label"
             class="scroll-box available-option-list">
@@ -54,7 +58,6 @@
 </div>
 
 
-
 <div class="form-group collapsible" component="collapsible" id="logo-control">
     <button refs="collapsible@trigger" type="button" class="collapse-title text-link" aria-expanded="false">
         <label>{{ trans('common.cover_image') }}</label>
@@ -64,7 +67,7 @@
 
         @include('form.image-picker', [
             'defaultImage' => url('/book_default_cover.png'),
-            'currentImage' => (isset($shelf) && $shelf->cover) ? $shelf->getBookCover() : url('/book_default_cover.png') ,
+            'currentImage' => (($shelf ?? null)?->coverInfo()->getUrl(440, 250, null) ?? url('/book_default_cover.png')),
             'name' => 'image',
             'imageClass' => 'cover'
         ])
@@ -81,7 +84,8 @@
 </div>
 
 <div class="form-group text-right">
-    <a href="{{ isset($shelf) ? $shelf->getUrl() : url('/shelves') }}" class="button outline">{{ trans('common.cancel') }}</a>
+    <a href="{{ isset($shelf) ? $shelf->getUrl() : url('/shelves') }}"
+       class="button outline">{{ trans('common.cancel') }}</a>
     <button type="submit" class="button">{{ trans('entities.shelves_save') }}</button>
 </div>
 
index 00cacfa707c3c28f927695e7cf7fc71c3f629ba1..5fc8a362b3227e6f33dffe30c5e98f84c068d958 100644 (file)
@@ -1,5 +1,5 @@
 <a href="{{ $shelf->getUrl() }}" class="shelf entity-list-item" data-entity-type="bookshelf" data-entity-id="{{$shelf->id}}">
-    <div class="entity-list-item-image bg-bookshelf @if($shelf->image_id) has-image @endif" style="background-image: url('{{ $shelf->getBookCover() }}')">
+    <div class="entity-list-item-image bg-bookshelf @if($shelf->coverInfo()->exists()) has-image @endif" style="background-image: url('{{ $shelf->coverInfo()->getUrl() }}')">
         @icon('bookshelf')
     </div>
     <div class="content py-xs">
index 633f959f3c9108b202f45aa632932eae5eab35ee..9ee14f1bf4e830f1dd05de80e188f681b299d9cc 100644 (file)
@@ -2,8 +2,8 @@
 
 @push('social-meta')
     <meta property="og:description" content="{{ Str::limit($shelf->description, 100, '...') }}">
-    @if($shelf->cover)
-        <meta property="og:image" content="{{ $shelf->getBookCover() }}">
+    @if($shelf->coverInfo()->exists())
+        <meta property="og:image" content="{{ $shelf->coverInfo()->getUrl() }}">
     @endif
 @endpush
 
@@ -28,7 +28,7 @@
         </div>
 
         <div class="book-content">
-            <div class="text-muted break-text">{!! $shelf->descriptionHtml() !!}</div>
+            <div class="text-muted break-text">{!! $shelf->descriptionInfo()->getHtml() !!}</div>
             @if(count($sortedVisibleShelfBooks) > 0)
                 @if($view === 'list')
                     <div class="entity-list">
index 93e4b02e423178a514a2ab9fe9b2c52a6eaa474f..4e446bf5d1a2f6867083cdc5b588836c5c3bc6b5 100644 (file)
@@ -12,7 +12,7 @@ class ApiAuthTest extends TestCase
 {
     use TestsApi;
 
-    protected $endpoint = '/api/books';
+    protected string $endpoint = '/api/books';
 
     public function test_requests_succeed_with_default_auth()
     {
index 22ccfb482c9c29a450c2dc27c286bfc580b3f9a6..e5bd77b67d1f826ce53a417240914e36345e5ca3 100644 (file)
@@ -47,8 +47,8 @@ class BooksApiTest extends TestCase
             [
                 'id'   => $book->id,
                 'cover' => [
-                    'id' => $book->cover->id,
-                    'url' => $book->cover->url,
+                    'id' => $book->coverInfo()->getImage()->id,
+                    'url' => $book->coverInfo()->getImage()->url,
                 ],
             ],
         ]]);
@@ -94,7 +94,7 @@ class BooksApiTest extends TestCase
         ]);
 
         $resp->assertJson($expectedDetails);
-        $this->assertDatabaseHas('books', $expectedDetails);
+        $this->assertDatabaseHasEntityData('book', $expectedDetails);
     }
 
     public function test_book_name_needed_to_create()
@@ -153,23 +153,23 @@ class BooksApiTest extends TestCase
         $directChildCount = $book->directPages()->count() + $book->chapters()->count();
         $resp->assertStatus(200);
         $resp->assertJsonCount($directChildCount, 'contents');
-        $resp->assertJson([
-            'contents' => [
-                [
-                    'type' => 'chapter',
-                    'id' => $chapter->id,
-                    'name' => $chapter->name,
-                    'slug' => $chapter->slug,
-                    'pages' => [
-                        [
-                            'id' => $chapterPage->id,
-                            'name' => $chapterPage->name,
-                            'slug' => $chapterPage->slug,
-                        ]
-                    ]
-                ]
-            ]
-        ]);
+
+        $contents = $resp->json('contents');
+        $respChapter = array_values(array_filter($contents, fn ($item) =>  ($item['id'] === $chapter->id && $item['type'] === 'chapter')))[0];
+        $this->assertArrayMapIncludes([
+            'id' => $chapter->id,
+            'type' => 'chapter',
+            'name' => $chapter->name,
+            'slug' => $chapter->slug,
+        ], $respChapter);
+
+        $respPage = array_values(array_filter($respChapter['pages'], fn ($item) =>  ($item['id'] === $chapterPage->id)))[0];
+
+        $this->assertArrayMapIncludes([
+            'id' => $chapterPage->id,
+            'name' => $chapterPage->name,
+            'slug' => $chapterPage->slug,
+        ], $respPage);
     }
 
     public function test_read_endpoint_contents_nested_pages_has_permissions_applied()
@@ -224,14 +224,14 @@ class BooksApiTest extends TestCase
         $resp = $this->putJson($this->baseEndpoint . "/{$book->id}", $details);
         $resp->assertStatus(200);
 
-        $this->assertDatabaseHas('books', array_merge($details, ['id' => $book->id, 'description' => 'A book updated via the API']));
+        $this->assertDatabaseHasEntityData('book', array_merge($details, ['id' => $book->id, 'description' => 'A book updated via the API']));
     }
 
     public function test_update_increments_updated_date_if_only_tags_are_sent()
     {
         $this->actingAsApiEditor();
         $book = $this->entities->book();
-        DB::table('books')->where('id', '=', $book->id)->update(['updated_at' => Carbon::now()->subWeek()]);
+        Book::query()->where('id', '=', $book->id)->update(['updated_at' => Carbon::now()->subWeek()]);
 
         $details = [
             'tags' => [['name' => 'Category', 'value' => 'Testing']],
@@ -247,7 +247,7 @@ class BooksApiTest extends TestCase
         $this->actingAsApiEditor();
         /** @var Book $book */
         $book = $this->entities->book();
-        $this->assertNull($book->cover);
+        $this->assertNull($book->coverInfo()->getImage());
         $file = $this->files->uploadedImage('image.png');
 
         // Ensure cover image can be set via API
@@ -257,7 +257,7 @@ class BooksApiTest extends TestCase
         $book->refresh();
 
         $resp->assertStatus(200);
-        $this->assertNotNull($book->cover);
+        $this->assertNotNull($book->coverInfo()->getImage());
 
         // Ensure further updates without image do not clear cover image
         $resp = $this->put($this->baseEndpoint . "/{$book->id}", [
@@ -266,7 +266,7 @@ class BooksApiTest extends TestCase
         $book->refresh();
 
         $resp->assertStatus(200);
-        $this->assertNotNull($book->cover);
+        $this->assertNotNull($book->coverInfo()->getImage());
 
         // Ensure update with null image property clears image
         $resp = $this->put($this->baseEndpoint . "/{$book->id}", [
@@ -275,7 +275,7 @@ class BooksApiTest extends TestCase
         $book->refresh();
 
         $resp->assertStatus(200);
-        $this->assertNull($book->cover);
+        $this->assertNull($book->coverInfo()->getImage());
     }
 
     public function test_delete_endpoint()
index 5d7b0530891a3ea6415b31473e130f60c226d7cc..194140a569afd08a3264d0fcdaf6657f761cf5d8 100644 (file)
@@ -91,7 +91,7 @@ class ChaptersApiTest extends TestCase
             'description' => 'A chapter created via the API',
         ]);
         $resp->assertJson($expectedDetails);
-        $this->assertDatabaseHas('chapters', $expectedDetails);
+        $this->assertDatabaseHasEntityData('chapter', $expectedDetails);
     }
 
     public function test_chapter_name_needed_to_create()
@@ -155,7 +155,7 @@ class ChaptersApiTest extends TestCase
                     'owned_by' => $page->owned_by,
                     'created_by' => $page->created_by,
                     'updated_by' => $page->updated_by,
-                    'book_id' => $page->id,
+                    'book_id' => $page->book->id,
                     'chapter_id' => $chapter->id,
                     'priority' => $page->priority,
                     'book_slug' => $chapter->book->slug,
@@ -213,7 +213,7 @@ class ChaptersApiTest extends TestCase
         $resp = $this->putJson($this->baseEndpoint . "/{$chapter->id}", $details);
         $resp->assertStatus(200);
 
-        $this->assertDatabaseHas('chapters', array_merge($details, [
+        $this->assertDatabaseHasEntityData('chapter', array_merge($details, [
             'id' => $chapter->id, 'description' => 'A chapter updated via the API'
         ]));
     }
@@ -222,7 +222,7 @@ class ChaptersApiTest extends TestCase
     {
         $this->actingAsApiEditor();
         $chapter = $this->entities->chapter();
-        DB::table('chapters')->where('id', '=', $chapter->id)->update(['updated_at' => Carbon::now()->subWeek()]);
+        $chapter->newQuery()->where('id', '=', $chapter->id)->update(['updated_at' => Carbon::now()->subWeek()]);
 
         $details = [
             'tags' => [['name' => 'Category', 'value' => 'Testing']],
@@ -244,8 +244,8 @@ class ChaptersApiTest extends TestCase
         $resp->assertOk();
         $chapter->refresh();
 
-        $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'book_id' => $newBook->id]);
-        $this->assertDatabaseHas('pages', ['id' => $page->id, 'book_id' => $newBook->id, 'chapter_id' => $chapter->id]);
+        $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'book_id' => $newBook->id]);
+        $this->assertDatabaseHasEntityData('page', ['id' => $page->id, 'book_id' => $newBook->id, 'chapter_id' => $chapter->id]);
     }
 
     public function test_update_with_new_book_id_requires_delete_permission()
index a62abacc75e56ba9b2e223330d5a3c04cde1e895..464d62683ad82a6ebfdb6735c5daa8f078931c9e 100644 (file)
@@ -280,7 +280,7 @@ class ContentPermissionsApiTest extends TestCase
         ]);
 
         $resp->assertOk();
-        $this->assertDatabaseHas('pages', ['id' => $page->id, 'owned_by' => $user->id]);
+        $this->assertDatabaseHasEntityData('page', ['id' => $page->id, 'owned_by' => $user->id]);
         $this->assertDatabaseHas('entity_permissions', [
             'entity_id' => $page->id,
             'entity_type' => 'page',
index ced8954eb1149490c9a88ac01be2a2d3c664cf45..8caf85affbee2bc6e492142466b3251f98ee87c7 100644 (file)
@@ -286,7 +286,7 @@ class PagesApiTest extends TestCase
     {
         $this->actingAsApiEditor();
         $page = $this->entities->page();
-        DB::table('pages')->where('id', '=', $page->id)->update(['updated_at' => Carbon::now()->subWeek()]);
+        $page->newQuery()->where('id', '=', $page->id)->update(['updated_at' => Carbon::now()->subWeek()]);
 
         $details = [
             'tags' => [['name' => 'Category', 'value' => 'Testing']],
index d174838c27d0db38d5346cae9478ba1c0b3d09bd..6ccc69c3545c7d4e8973673a3947afed74b61822 100644 (file)
@@ -144,7 +144,7 @@ class RecycleBinApiTest extends TestCase
 
         $deletion = Deletion::query()->orderBy('id')->first();
 
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'id'            => $page->id,
             'deleted_at'    => $page->deleted_at,
         ]);
@@ -154,7 +154,7 @@ class RecycleBinApiTest extends TestCase
             'restore_count' => 1,
         ]);
 
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'id'            => $page->id,
             'deleted_at'    => null,
         ]);
@@ -168,7 +168,7 @@ class RecycleBinApiTest extends TestCase
 
         $deletion = Deletion::query()->orderBy('id')->first();
 
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'id'            => $page->id,
             'deleted_at'    => $page->deleted_at,
         ]);
@@ -178,6 +178,6 @@ class RecycleBinApiTest extends TestCase
             'delete_count' => 1,
         ]);
 
-        $this->assertDatabaseMissing('pages', ['id' => $page->id]);
+        $this->assertDatabaseMissing('entities', ['id' => $page->id, 'type' => 'page']);
     }
 }
index ba13c0153b14519023a470f005d3bcbb16cf347f..34ce0e4e5b2cca904f7bd2f970e7b3585c0f1570 100644 (file)
@@ -48,8 +48,8 @@ class ShelvesApiTest extends TestCase
             [
                 'id'   => $shelf->id,
                 'cover' => [
-                    'id' => $shelf->cover->id,
-                    'url' => $shelf->cover->url,
+                    'id' => $shelf->coverInfo()->getImage()->id,
+                    'url' => $shelf->coverInfo()->getImage()->url,
                 ],
             ],
         ]]);
@@ -102,7 +102,7 @@ class ShelvesApiTest extends TestCase
         ]);
 
         $resp->assertJson($expectedDetails);
-        $this->assertDatabaseHas('bookshelves', $expectedDetails);
+        $this->assertDatabaseHasEntityData('bookshelf', $expectedDetails);
     }
 
     public function test_shelf_name_needed_to_create()
@@ -181,14 +181,14 @@ class ShelvesApiTest extends TestCase
         $resp = $this->putJson($this->baseEndpoint . "/{$shelf->id}", $details);
         $resp->assertStatus(200);
 
-        $this->assertDatabaseHas('bookshelves', array_merge($details, ['id' => $shelf->id, 'description' => 'A shelf updated via the API']));
+        $this->assertDatabaseHasEntityData('bookshelf', array_merge($details, ['id' => $shelf->id, 'description' => 'A shelf updated via the API']));
     }
 
     public function test_update_increments_updated_date_if_only_tags_are_sent()
     {
         $this->actingAsApiEditor();
         $shelf = Bookshelf::visible()->first();
-        DB::table('bookshelves')->where('id', '=', $shelf->id)->update(['updated_at' => Carbon::now()->subWeek()]);
+        $shelf->newQuery()->where('id', '=', $shelf->id)->update(['updated_at' => Carbon::now()->subWeek()]);
 
         $details = [
             'tags' => [['name' => 'Category', 'value' => 'Testing']],
@@ -222,7 +222,7 @@ class ShelvesApiTest extends TestCase
         $this->actingAsApiEditor();
         /** @var Book $shelf */
         $shelf = Bookshelf::visible()->first();
-        $this->assertNull($shelf->cover);
+        $this->assertNull($shelf->coverInfo()->getImage());
         $file = $this->files->uploadedImage('image.png');
 
         // Ensure cover image can be set via API
@@ -232,7 +232,7 @@ class ShelvesApiTest extends TestCase
         $shelf->refresh();
 
         $resp->assertStatus(200);
-        $this->assertNotNull($shelf->cover);
+        $this->assertNotNull($shelf->coverInfo()->getImage());
 
         // Ensure further updates without image do not clear cover image
         $resp = $this->put($this->baseEndpoint . "/{$shelf->id}", [
@@ -241,7 +241,7 @@ class ShelvesApiTest extends TestCase
         $shelf->refresh();
 
         $resp->assertStatus(200);
-        $this->assertNotNull($shelf->cover);
+        $this->assertNotNull($shelf->coverInfo()->getImage());
 
         // Ensure update with null image property clears image
         $resp = $this->put($this->baseEndpoint . "/{$shelf->id}", [
@@ -250,7 +250,7 @@ class ShelvesApiTest extends TestCase
         $shelf->refresh();
 
         $resp->assertStatus(200);
-        $this->assertNull($shelf->cover);
+        $this->assertNull($shelf->coverInfo()->getImage());
     }
 
     public function test_delete_endpoint()
index 1f359b41a103622ea2e807871ed2b84539f84713..5184bf9843c7aa594af498e254d80ec68ba87727 100644 (file)
@@ -6,6 +6,7 @@ use BookStack\Access\Mfa\MfaValue;
 use BookStack\Activity\ActivityType;
 use BookStack\Users\Models\Role;
 use BookStack\Users\Models\User;
+use Illuminate\Support\Facades\Hash;
 use PragmaRX\Google2FA\Google2FA;
 use Tests\TestCase;
 
@@ -166,6 +167,36 @@ class MfaConfigurationTest extends TestCase
         $this->assertEquals(0, $admin->mfaValues()->count());
     }
 
+    public function test_mfa_required_if_set_on_role()
+    {
+        $user = $this->users->viewer();
+        $user->password = Hash::make('password');
+        $user->save();
+        /** @var Role $role */
+        $role = $user->roles()->first();
+        $role->mfa_enforced = true;
+        $role->save();
+
+        $resp = $this->post('/login', ['email' => $user->email, 'password' => 'password']);
+        $this->assertFalse(auth()->check());
+        $resp->assertRedirect('/mfa/verify');
+    }
+
+    public function test_mfa_required_if_mfa_option_configured()
+    {
+        $user = $this->users->viewer();
+        $user->password = Hash::make('password');
+        $user->save();
+        $user->mfaValues()->create([
+            'method' => MfaValue::METHOD_TOTP,
+            'value'  => 'test',
+        ]);
+
+        $resp = $this->post('/login', ['email' => $user->email, 'password' => 'password']);
+        $this->assertFalse(auth()->check());
+        $resp->assertRedirect('/mfa/verify');
+    }
+
     public function test_totp_setup_url_shows_correct_user_when_setup_forced_upon_login()
     {
         $admin = $this->users->admin();
index d336e05a2403b0317f533c7e806f566850a8ea74..356a026a849b4fb4b767ec322544752943b04cfa 100644 (file)
@@ -19,7 +19,7 @@ class UpdateUrlCommandTest extends TestCase
             ->expectsQuestion("This will search for \"https://example.com\" in your database and replace it with  \"https://cats.example.com\".\nAre you sure you want to proceed?", 'y')
             ->expectsQuestion('This operation could cause issues if used incorrectly. Have you made a backup of your existing database?', 'y');
 
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'id'   => $page->id,
             'html' => '<a href="https://cats.example.com/donkeys"></a>',
         ]);
@@ -40,7 +40,7 @@ class UpdateUrlCommandTest extends TestCase
             ->expectsQuestion('This operation could cause issues if used incorrectly. Have you made a backup of your existing database?', 'y');
 
         foreach ($models as $model) {
-            $this->assertDatabaseHas($model->getTable(), [
+            $this->assertDatabaseHasEntityData($model->getMorphClass(), [
                 'id'               => $model->id,
                 'description_html' => '<a href="https://cats.example.com/donkeys"></a>',
             ]);
index ad1d64e7126570c7e3388a0201e39c2adef66edd..3ba2c3e99c8839d91ad60bdddceaee85efe9dd4d 100644 (file)
@@ -91,7 +91,7 @@ class BookShelfTest extends TestCase
         ]));
         $resp->assertRedirect();
         $editorId = $this->users->editor()->id;
-        $this->assertDatabaseHas('bookshelves', array_merge($shelfInfo, ['created_by' => $editorId, 'updated_by' => $editorId]));
+        $this->assertDatabaseHasEntityData('bookshelf', array_merge($shelfInfo, ['created_by' => $editorId, 'updated_by' => $editorId]));
 
         $shelf = Bookshelf::where('name', '=', $shelfInfo['name'])->first();
         $shelfPage = $this->get($shelf->getUrl());
@@ -117,11 +117,12 @@ class BookShelfTest extends TestCase
 
         $lastImage = Image::query()->orderByDesc('id')->firstOrFail();
         $shelf = Bookshelf::query()->where('name', '=', $shelfInfo['name'])->first();
-        $this->assertDatabaseHas('bookshelves', [
-            'id'       => $shelf->id,
+        $this->assertDatabaseHas('entity_container_data', [
+            'entity_id'       => $shelf->id,
+            'entity_type' => 'bookshelf',
             'image_id' => $lastImage->id,
         ]);
-        $this->assertEquals($lastImage->id, $shelf->cover->id);
+        $this->assertEquals($lastImage->id, $shelf->coverInfo()->getImage()->id);
         $this->assertEquals('cover_bookshelf', $lastImage->type);
     }
 
@@ -247,7 +248,7 @@ class BookShelfTest extends TestCase
         $this->assertSessionHas('success');
 
         $editorId = $this->users->editor()->id;
-        $this->assertDatabaseHas('bookshelves', array_merge($shelfInfo, ['id' => $shelf->id, 'created_by' => $editorId, 'updated_by' => $editorId]));
+        $this->assertDatabaseHasEntityData('bookshelf', array_merge($shelfInfo, ['id' => $shelf->id, 'created_by' => $editorId, 'updated_by' => $editorId]));
 
         $shelfPage = $this->get($shelf->getUrl());
         $shelfPage->assertSee($shelfInfo['name']);
index 51bf65d10bb29d92668fd80bfb1614688cf73c83..543c4e8bbdb032d910577b103d06a20e0b7c8b1b 100644 (file)
@@ -27,7 +27,7 @@ class BookTest extends TestCase
 
         $resp = $this->get('/books/my-first-book');
         $resp->assertSee($book->name);
-        $resp->assertSee($book->description);
+        $resp->assertSee($book->descriptionInfo()->getPlain());
     }
 
     public function test_create_uses_different_slugs_when_name_reused()
@@ -362,12 +362,12 @@ class BookTest extends TestCase
         $coverImageFile = $this->files->uploadedImage('cover.png');
         $bookRepo->updateCoverImage($book, $coverImageFile);
 
-        $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']);
+        $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book'])->assertRedirect();
         /** @var Book $copy */
         $copy = Book::query()->where('name', '=', 'My copy book')->first();
 
-        $this->assertNotNull($copy->cover);
-        $this->assertNotEquals($book->cover->id, $copy->cover->id);
+        $this->assertNotNull($copy->coverInfo()->getImage());
+        $this->assertNotEquals($book->coverInfo()->getImage()->id, $copy->coverInfo()->getImage()->id);
     }
 
     public function test_copy_adds_book_to_shelves_if_edit_permissions_allows()
index d9b1ee466cf1db9656cfd922d7bbdefa585c613d..8658e76998b35587cd51733157413515e99883d1 100644 (file)
@@ -35,8 +35,8 @@ class ConvertTest extends TestCase
         /** @var Book $newBook */
         $newBook = Book::query()->orderBy('id', 'desc')->first();
 
-        $this->assertDatabaseMissing('chapters', ['id' => $chapter->id]);
-        $this->assertDatabaseHas('pages', ['id' => $childPage->id, 'book_id' => $newBook->id, 'chapter_id' => 0]);
+        $this->assertDatabaseMissing('entities', ['id' => $chapter->id, 'type' => 'chapter']);
+        $this->assertDatabaseHasEntityData('page', ['id' => $childPage->id, 'book_id' => $newBook->id, 'chapter_id' => 0]);
         $this->assertCount(1, $newBook->tags);
         $this->assertEquals('Category', $newBook->tags->first()->name);
         $this->assertEquals('Penguins', $newBook->tags->first()->value);
@@ -100,7 +100,7 @@ class ConvertTest extends TestCase
 
         // Checks for new shelf
         $resp->assertRedirectContains('/shelves/');
-        $this->assertDatabaseMissing('chapters', ['id' => $childChapter->id]);
+        $this->assertDatabaseMissing('entities', ['id' => $childChapter->id, 'type' => 'chapter']);
         $this->assertCount(1, $newShelf->tags);
         $this->assertEquals('Category', $newShelf->tags->first()->name);
         $this->assertEquals('Ducks', $newShelf->tags->first()->value);
@@ -112,8 +112,8 @@ class ConvertTest extends TestCase
         $this->assertActivityExists(ActivityType::BOOKSHELF_CREATE_FROM_BOOK, $newShelf);
 
         // Checks for old book to contain child pages
-        $this->assertDatabaseHas('books', ['id' => $book->id, 'name' => $book->name . ' Pages']);
-        $this->assertDatabaseHas('pages', ['id' => $childPage->id, 'book_id' => $book->id, 'chapter_id' => 0]);
+        $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'name' => $book->name . ' Pages']);
+        $this->assertDatabaseHasEntityData('page', ['id' => $childPage->id, 'book_id' => $book->id, 'chapter_id' => null]);
 
         // Checks for nested page
         $chapterChildPage->refresh();
index 5369a5430bcffd6125d15765ab2aac2ab4ecaf08..d3109c8a2fe1ed94efc3645edd3374a2d19456bc 100644 (file)
@@ -18,7 +18,7 @@ class DefaultTemplateTest extends TestCase
         ];
 
         $this->asEditor()->post('/books', $details);
-        $this->assertDatabaseHas('books', $details);
+        $this->assertDatabaseHasEntityData('book', $details);
     }
 
     public function test_creating_chapter_with_default_template()
@@ -31,7 +31,7 @@ class DefaultTemplateTest extends TestCase
         ];
 
         $this->asEditor()->post($book->getUrl('/create-chapter'), $details);
-        $this->assertDatabaseHas('chapters', $details);
+        $this->assertDatabaseHasEntityData('chapter', $details);
     }
 
     public function test_updating_book_with_default_template()
@@ -40,10 +40,10 @@ class DefaultTemplateTest extends TestCase
         $templatePage = $this->entities->templatePage();
 
         $this->asEditor()->put($book->getUrl(), ['name' => $book->name, 'default_template_id' => strval($templatePage->id)]);
-        $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => $templatePage->id]);
+        $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => $templatePage->id]);
 
         $this->asEditor()->put($book->getUrl(), ['name' => $book->name, 'default_template_id' => '']);
-        $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => null]);
+        $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => null]);
     }
 
     public function test_updating_chapter_with_default_template()
@@ -52,10 +52,10 @@ class DefaultTemplateTest extends TestCase
         $templatePage = $this->entities->templatePage();
 
         $this->asEditor()->put($chapter->getUrl(), ['name' => $chapter->name, 'default_template_id' => strval($templatePage->id)]);
-        $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => $templatePage->id]);
+        $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => $templatePage->id]);
 
         $this->asEditor()->put($chapter->getUrl(), ['name' => $chapter->name, 'default_template_id' => '']);
-        $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => null]);
+        $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => null]);
     }
 
     public function test_default_book_template_cannot_be_set_if_not_a_template()
@@ -65,7 +65,7 @@ class DefaultTemplateTest extends TestCase
         $this->assertFalse($page->template);
 
         $this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => $page->id]);
-        $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => null]);
+        $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => null]);
     }
 
     public function test_default_chapter_template_cannot_be_set_if_not_a_template()
@@ -75,7 +75,7 @@ class DefaultTemplateTest extends TestCase
         $this->assertFalse($page->template);
 
         $this->asEditor()->put("/chapters/{$chapter->slug}", ['name' => $chapter->name, 'default_template_id' => $page->id]);
-        $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => null]);
+        $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => null]);
     }
 
 
@@ -86,7 +86,7 @@ class DefaultTemplateTest extends TestCase
         $this->permissions->disableEntityInheritedPermissions($templatePage);
 
         $this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => $templatePage->id]);
-        $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => null]);
+        $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => null]);
     }
 
     public function test_default_chapter_template_cannot_be_set_if_not_have_access()
@@ -96,7 +96,7 @@ class DefaultTemplateTest extends TestCase
         $this->permissions->disableEntityInheritedPermissions($templatePage);
 
         $this->asEditor()->put("/chapters/{$chapter->slug}", ['name' => $chapter->name, 'default_template_id' => $templatePage->id]);
-        $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => null]);
+        $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => null]);
     }
 
     public function test_inaccessible_book_default_template_can_be_set_if_unchanged()
@@ -106,7 +106,7 @@ class DefaultTemplateTest extends TestCase
         $this->permissions->disableEntityInheritedPermissions($templatePage);
 
         $this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => $templatePage->id]);
-        $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => $templatePage->id]);
+        $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => $templatePage->id]);
     }
 
     public function test_inaccessible_chapter_default_template_can_be_set_if_unchanged()
@@ -116,7 +116,7 @@ class DefaultTemplateTest extends TestCase
         $this->permissions->disableEntityInheritedPermissions($templatePage);
 
         $this->asEditor()->put("/chapters/{$chapter->slug}", ['name' => $chapter->name, 'default_template_id' => $templatePage->id]);
-        $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => $templatePage->id]);
+        $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => $templatePage->id]);
     }
 
     public function test_default_page_template_option_shows_on_book_form()
@@ -173,7 +173,7 @@ class DefaultTemplateTest extends TestCase
         $templatePage->forceFill(['html' => '<p>My template page</p>', 'markdown' => '# My template page'])->save();
         $book = $this->bookUsingDefaultTemplate($templatePage);
 
-        $this->asEditor()->get($book->getUrl('/create-page'));
+        $this->asEditor()->get($book->getUrl('/create-page'))->assertRedirect();
         $latestPage = $book->pages()
             ->where('draft', '=', true)
             ->where('template', '=', false)
@@ -251,7 +251,7 @@ class DefaultTemplateTest extends TestCase
 
         $this->post($book->getUrl('/create-guest-page'), [
             'name' => 'My guest page with template'
-        ]);
+        ])->assertRedirect();
         $latestBookPage = $book->pages()
             ->where('draft', '=', false)
             ->where('template', '=', false)
index e99ba9b8189423f85922e0fe066550f25e467ee5..2623acd3f42fff2b329dad7f219d22195c3200a8 100644 (file)
@@ -204,7 +204,7 @@ class PageDraftTest extends TestCase
         ]);
         $resp->assertOk();
 
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'id'       => $draft->id,
             'draft'    => true,
             'name'     => 'My updated draft',
@@ -235,7 +235,7 @@ class PageDraftTest extends TestCase
             'markdown' => '# My markdown page',
         ]);
 
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'id'    => $draft->id,
             'draft' => false,
             'slug'  => 'my-page',
index ad753c9664759c9d891d5a7050c07b8bbc97ac13..d98b1f998df99d024a1b42e582121396bb2e0f4a 100644 (file)
@@ -85,7 +85,7 @@ class PageEditorTest extends TestCase
         $resp = $this->post($book->getUrl("/draft/{$draft->id}"), $details);
         $resp->assertRedirect();
 
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'markdown' => $details['markdown'],
             'id'       => $draft->id,
             'draft'    => false,
index 9040254f78fdaaa3add672531032548edb8e7e1c..3828bd06e4a66278c5a752882d51b7a992548715 100644 (file)
@@ -91,7 +91,7 @@ class PageRevisionTest extends TestCase
         $restoreReq->assertRedirect($page->getUrl());
 
         $pageView = $this->get($page->getUrl());
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'id'       => $page->id,
             'markdown' => '## New Content def456',
         ]);
index 6a68c3ab18ff4d80852e632cc4f036dc8ba0d075..9c867a534fa533669d95a33db4ac81d51c963f58 100644 (file)
@@ -35,7 +35,7 @@ class PageTemplateTest extends TestCase
         ];
 
         $this->put($page->getUrl(), $pageUpdateData);
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'id'       => $page->id,
             'template' => false,
         ]);
@@ -43,7 +43,7 @@ class PageTemplateTest extends TestCase
         $this->permissions->grantUserRolePermissions($editor, ['templates-manage']);
 
         $this->put($page->getUrl(), $pageUpdateData);
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'id'       => $page->id,
             'template' => true,
         ]);
index d2c448bf4b77bfdfae0f3507550cb16de98bff32..6994144626e985651d1ae79e1b6151674dd6fed3 100644 (file)
@@ -74,7 +74,7 @@ class PageTest extends TestCase
         $resp = $this->post($book->getUrl("/draft/{$draft->id}"), $details);
         $resp->assertRedirect();
 
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'markdown' => $details['markdown'],
             'name'     => $details['name'],
             'id'       => $draft->id,
@@ -242,7 +242,7 @@ class PageTest extends TestCase
         ]);
         $movePageResp->assertRedirect();
 
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'name'       => 'My copied test page',
             'created_by' => $viewer->id,
             'book_id'    => $newBook->id,
index 3bccd46827095dc52aa6ad0979f023681b5963a8..6bf585d5903cbae5a1db4922d34b5bb21a570972 100644 (file)
@@ -84,7 +84,7 @@ class MarkdownExportTest extends TestCase
         $this->asEditor()->get($book->getUrl('/create-page'));
         $this->get($book->getUrl('/create-page'));
 
-        [$pageA, $pageB] = $book->pages()->where('chapter_id', '=', 0)->get();
+        [$pageA, $pageB] = $book->pages()->whereNull('chapter_id')->get();
         $pageA->html = '<p>hello tester</p>';
         $pageA->save();
         $pageB->name = 'The second page in this test';
index 1310dcc24566b72bddd9bdc96d7027b665ae6ab9..692a5910f3c2c05bc067679fc6f8e856d3655352 100644 (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->descriptionHtml(), $bookData['description_html']);
+        $this->assertEquals($book->descriptionInfo()->getHtml(), $bookData['description_html']);
         $this->assertCount(2, $bookData['tags']);
         $this->assertCount($book->directPages()->count(), $bookData['pages']);
         $this->assertCount($book->chapters()->count(), $bookData['chapters']);
@@ -240,7 +240,7 @@ class ZipExportTest extends TestCase
         $bookRepo = $this->app->make(BookRepo::class);
         $coverImageFile = $this->files->uploadedImage('cover.png');
         $bookRepo->updateCoverImage($book, $coverImageFile);
-        $coverImage = $book->cover()->first();
+        $coverImage = $book->coverInfo()->getImage();
 
         $zipResp = $this->asEditor()->get($book->getUrl("/export/zip"));
         $zip = ZipTestHelper::extractFromZipResponse($zipResp);
@@ -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->descriptionHtml(), $chapterData['description_html']);
+        $this->assertEquals($chapter->descriptionInfo()->getHtml(), $chapterData['description_html']);
         $this->assertCount(2, $chapterData['tags']);
         $this->assertEquals($chapter->priority, $chapterData['priority']);
         $this->assertCount($chapter->pages()->count(), $chapterData['pages']);
index 68ffb4231bd2493de2e47222daa0e2e2e0b4b33a..2255e16c393bdb0548a4e397d1b17219df192d06 100644 (file)
@@ -109,7 +109,7 @@ class ZipImportRunnerTest extends TestCase
 
         // Book checks
         $this->assertEquals('Import test', $book->name);
-        $this->assertFileExists(public_path($book->cover->path));
+        $this->assertFileExists(public_path($book->coverInfo()->getImage()->path));
         $this->assertCount(2, $book->tags);
         $this->assertEquals('Cat', $book->tags()->first()->value);
         $this->assertCount(2, $book->chapters);
index c794f9478ab317cef7130efe3787ffcde4ece3a7..5163cef14a0b9dc1acd514797019f0916f27f12c 100644 (file)
@@ -50,7 +50,7 @@ class EntityProvider
 
     public function pageNotWithinChapter(): Page
     {
-        return $this->page(fn(Builder $query) => $query->where('chapter_id', '=', 0));
+        return $this->page(fn(Builder $query) => $query->whereNull('chapter_id'));
     }
 
     public function templatePage(): Page
index 96e622da052c25a1794f86ff7e4def3a5f58c595..b35716359961e1f1daa20ab4e77bfdae1ff3e61d 100644 (file)
@@ -49,7 +49,7 @@ class OpenGraphTest extends TestCase
         $resp = $this->asEditor()->get($book->getUrl());
         $tags = $this->getOpenGraphTags($resp);
 
-        $this->assertEquals($book->getBookCover(), $tags['image']);
+        $this->assertEquals($book->coverInfo()->getUrl(), $tags['image']);
     }
 
     public function test_shelf_tags()
@@ -69,7 +69,7 @@ class OpenGraphTest extends TestCase
         $resp = $this->asEditor()->get($shelf->getUrl());
         $tags = $this->getOpenGraphTags($resp);
 
-        $this->assertEquals($shelf->getBookCover(), $tags['image']);
+        $this->assertEquals($shelf->coverInfo()->getUrl(), $tags['image']);
     }
 
     /**
index f002549220b58558b14b1bfcf0db0f11bc6c22a0..fd3f27972564762006e3bbe479285e0842261f36 100644 (file)
@@ -13,7 +13,7 @@ class EntityOwnerChangeTest extends TestCase
         $user = User::query()->where('id', '!=', $page->owned_by)->first();
 
         $this->asAdmin()->put($page->getUrl('permissions'), ['owned_by' => $user->id]);
-        $this->assertDatabaseHas('pages', ['owned_by' => $user->id, 'id' => $page->id]);
+        $this->assertDatabaseHasEntityData('page', ['owned_by' => $user->id, 'id' => $page->id]);
     }
 
     public function test_changing_chapter_owner()
@@ -22,7 +22,7 @@ class EntityOwnerChangeTest extends TestCase
         $user = User::query()->where('id', '!=', $chapter->owned_by)->first();
 
         $this->asAdmin()->put($chapter->getUrl('permissions'), ['owned_by' => $user->id]);
-        $this->assertDatabaseHas('chapters', ['owned_by' => $user->id, 'id' => $chapter->id]);
+        $this->assertDatabaseHasEntityData('chapter', ['owned_by' => $user->id, 'id' => $chapter->id]);
     }
 
     public function test_changing_book_owner()
@@ -31,7 +31,7 @@ class EntityOwnerChangeTest extends TestCase
         $user = User::query()->where('id', '!=', $book->owned_by)->first();
 
         $this->asAdmin()->put($book->getUrl('permissions'), ['owned_by' => $user->id]);
-        $this->assertDatabaseHas('books', ['owned_by' => $user->id, 'id' => $book->id]);
+        $this->assertDatabaseHasEntityData('book', ['owned_by' => $user->id, 'id' => $book->id]);
     }
 
     public function test_changing_shelf_owner()
@@ -40,6 +40,6 @@ class EntityOwnerChangeTest extends TestCase
         $user = User::query()->where('id', '!=', $shelf->owned_by)->first();
 
         $this->asAdmin()->put($shelf->getUrl('permissions'), ['owned_by' => $user->id]);
-        $this->assertDatabaseHas('bookshelves', ['owned_by' => $user->id, 'id' => $shelf->id]);
+        $this->assertDatabaseHasEntityData('bookshelf', ['owned_by' => $user->id, 'id' => $shelf->id]);
     }
 }
index ec2756b12137d8279494d347ed3c99dcad5586ba..d399e0c3463045e9f7dc18edf21f2c97078197fb 100644 (file)
@@ -629,10 +629,8 @@ class EntityPermissionsTest extends TestCase
 
     public function test_book_sort_view_permission()
     {
-        /** @var Book $firstBook */
-        $firstBook = Book::query()->first();
-        /** @var Book $secondBook */
-        $secondBook = Book::query()->find(2);
+        $firstBook = $this->entities->book();
+        $secondBook = $this->entities->book();
 
         $this->setRestrictionsForTestRoles($firstBook, ['view', 'update']);
         $this->setRestrictionsForTestRoles($secondBook, ['view']);
index 76745aaac72c999ddfa78eb22284788253c5dd34..e6fc7a6a3c5c5a261d88f7c3a70f87af42e6d784 100644 (file)
@@ -104,7 +104,7 @@ class PublicActionTest extends TestCase
         $resp->assertRedirect($chapter->book->getUrl('/page/my-guest-page/edit'));
 
         $user = $this->users->guest();
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'name'       => 'My guest page',
             'chapter_id' => $chapter->id,
             'created_by' => $user->id,
index f8698d028858145deba511a35784839d5d7de9df..389f164a9efc74ad0286866d31636f668a6b0811 100644 (file)
@@ -259,7 +259,7 @@ class ReferencesTest extends TestCase
         }
 
         $oldUrl = $shelf->getUrl();
-        $this->put($shelf->getUrl(), ['name' => 'My updated shelf link']);
+        $this->put($shelf->getUrl(), ['name' => 'My updated shelf link'])->assertRedirect();
         $shelf->refresh();
         $this->assertNotEquals($oldUrl, $shelf->getUrl());
 
index 33284b4b3ff85f2297c024873e5851c2e8544c7f..c17cfed97ea3b8f82d164fb7cecd2e7196314f21 100644 (file)
@@ -3,6 +3,7 @@
 namespace Tests\Settings;
 
 use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Deletion;
 use BookStack\Entities\Models\Page;
 use Illuminate\Support\Carbon;
@@ -82,10 +83,12 @@ class RecycleBinTest extends TestCase
         $emptyReq->assertRedirect('/settings/recycle-bin');
 
         $this->assertTrue(Deletion::query()->count() === 0);
-        $this->assertDatabaseMissing('books', ['id' => $book->id]);
-        $this->assertDatabaseMissing('pages', ['id' => $page->id]);
-        $this->assertDatabaseMissing('pages', ['id' => $book->pages->first()->id]);
-        $this->assertDatabaseMissing('chapters', ['id' => $book->chapters->first()->id]);
+        $this->assertDatabaseMissing('entities', ['id' => $book->id, 'type' => 'book']);
+        $this->assertDatabaseMissing('entity_container_data', ['entity_id' => $book->id, 'entity_type' => 'book']);
+        $this->assertDatabaseMissing('entities', ['id' => $book->pages->first()->id, 'type' => 'page']);
+        $this->assertDatabaseMissing('entity_page_data', ['page_id' => $book->pages->first()->id]);
+        $this->assertDatabaseMissing('entities', ['id' => $book->chapters->first()->id, 'type' => 'chapter']);
+        $this->assertDatabaseMissing('entity_container_data', ['entity_id' => $book->chapters->first()->id, 'entity_type' => 'chapter']);
 
         $itemCount = 2 + $book->pages->count() + $book->chapters->count();
         $redirectReq = $this->get('/settings/recycle-bin');
@@ -95,18 +98,18 @@ class RecycleBinTest extends TestCase
     public function test_entity_restore()
     {
         $book = $this->entities->bookHasChaptersAndPages();
-        $this->asEditor()->delete($book->getUrl());
+        $this->asEditor()->delete($book->getUrl())->assertRedirect();
         $deletion = Deletion::query()->firstOrFail();
 
-        $this->assertEquals($book->pages->count(), DB::table('pages')->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count());
-        $this->assertEquals($book->chapters->count(), DB::table('chapters')->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count());
+        $this->assertEquals($book->pages->count(), Page::query()->withTrashed()->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count());
+        $this->assertEquals($book->chapters->count(), Chapter::query()->withTrashed()->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count());
 
         $restoreReq = $this->asAdmin()->post("/settings/recycle-bin/{$deletion->id}/restore");
         $restoreReq->assertRedirect('/settings/recycle-bin');
         $this->assertTrue(Deletion::query()->count() === 0);
 
-        $this->assertEquals($book->pages->count(), DB::table('pages')->where('book_id', '=', $book->id)->whereNull('deleted_at')->count());
-        $this->assertEquals($book->chapters->count(), DB::table('chapters')->where('book_id', '=', $book->id)->whereNull('deleted_at')->count());
+        $this->assertEquals($book->pages->count(), Page::query()->where('book_id', '=', $book->id)->whereNull('deleted_at')->count());
+        $this->assertEquals($book->chapters->count(), Chapter::query()->where('book_id', '=', $book->id)->whereNull('deleted_at')->count());
 
         $itemCount = 1 + $book->pages->count() + $book->chapters->count();
         $redirectReq = $this->get('/settings/recycle-bin');
@@ -123,9 +126,12 @@ class RecycleBinTest extends TestCase
         $deleteReq->assertRedirect('/settings/recycle-bin');
         $this->assertTrue(Deletion::query()->count() === 0);
 
-        $this->assertDatabaseMissing('books', ['id' => $book->id]);
-        $this->assertDatabaseMissing('pages', ['id' => $book->pages->first()->id]);
-        $this->assertDatabaseMissing('chapters', ['id' => $book->chapters->first()->id]);
+        $this->assertDatabaseMissing('entities', ['id' => $book->id, 'type' => 'book']);
+        $this->assertDatabaseMissing('entity_container_data', ['entity_id' => $book->id, 'entity_type' => 'book']);
+        $this->assertDatabaseMissing('entities', ['id' => $book->pages->first()->id, 'type' => 'page']);
+        $this->assertDatabaseMissing('entity_page_data', ['page_id' => $book->pages->first()->id]);
+        $this->assertDatabaseMissing('entities', ['id' => $book->chapters->first()->id, 'type' => 'chapter']);
+        $this->assertDatabaseMissing('entity_container_data', ['entity_id' => $book->chapters->first()->id, 'entity_type' => 'chapter']);
 
         $itemCount = 1 + $book->pages->count() + $book->chapters->count();
         $redirectReq = $this->get('/settings/recycle-bin');
@@ -173,6 +179,34 @@ class RecycleBinTest extends TestCase
         ]);
     }
 
+    public function test_permanent_book_delete_removes_shelf_relation_data()
+    {
+        $book = $this->entities->book();
+        $shelf = $this->entities->shelf();
+        $shelf->books()->attach($book);
+        $this->assertDatabaseHas('bookshelves_books', ['book_id' => $book->id]);
+
+        $this->asEditor()->delete($book->getUrl());
+        $deletion = $book->deletions()->firstOrFail();
+        $this->asAdmin()->delete("/settings/recycle-bin/{$deletion->id}")->assertRedirect();
+
+        $this->assertDatabaseMissing('bookshelves_books', ['book_id' => $book->id]);
+    }
+
+    public function test_permanent_shelf_delete_removes_book_relation_data()
+    {
+        $book = $this->entities->book();
+        $shelf = $this->entities->shelf();
+        $shelf->books()->attach($book);
+        $this->assertDatabaseHas('bookshelves_books', ['bookshelf_id' => $shelf->id]);
+
+        $this->asEditor()->delete($shelf->getUrl());
+        $deletion = $shelf->deletions()->firstOrFail();
+        $this->asAdmin()->delete("/settings/recycle-bin/{$deletion->id}")->assertRedirect();
+
+        $this->assertDatabaseMissing('bookshelves_books', ['bookshelf_id' => $shelf->id]);
+    }
+
     public function test_auto_clear_functionality_works()
     {
         config()->set('app.recycle_bin_lifetime', 5);
@@ -180,14 +214,14 @@ class RecycleBinTest extends TestCase
         $otherPage = $this->entities->page();
 
         $this->asEditor()->delete($page->getUrl());
-        $this->assertDatabaseHas('pages', ['id' => $page->id]);
+        $this->assertDatabaseHasEntityData('page', ['id' => $page->id]);
         $this->assertEquals(1, Deletion::query()->count());
 
         Carbon::setTestNow(Carbon::now()->addDays(6));
         $this->asEditor()->delete($otherPage->getUrl());
         $this->assertEquals(1, Deletion::query()->count());
 
-        $this->assertDatabaseMissing('pages', ['id' => $page->id]);
+        $this->assertDatabaseMissing('entities', ['id' => $page->id, 'type' => 'page']);
     }
 
     public function test_auto_clear_functionality_with_negative_time_keeps_forever()
@@ -203,7 +237,7 @@ class RecycleBinTest extends TestCase
         $this->asEditor()->delete($otherPage->getUrl());
         $this->assertEquals(2, Deletion::query()->count());
 
-        $this->assertDatabaseHas('pages', ['id' => $page->id]);
+        $this->assertDatabaseHasEntityData('page', ['id' => $page->id]);
     }
 
     public function test_auto_clear_functionality_with_zero_time_deletes_instantly()
@@ -212,7 +246,7 @@ class RecycleBinTest extends TestCase
         $page = $this->entities->page();
 
         $this->asEditor()->delete($page->getUrl());
-        $this->assertDatabaseMissing('pages', ['id' => $page->id]);
+        $this->assertDatabaseMissing('entities', ['id' => $page->id, 'type' => 'page']);
         $this->assertEquals(0, Deletion::query()->count());
     }
 
index 4737ec231b62597bf05e605fdb1852f333c67635..7f31f9c27399fdb23db8bada623580a5b89613cd 100644 (file)
@@ -66,7 +66,7 @@ class BookSortTest extends TestCase
         $sortResp = $this->asEditor()->put($newBook->getUrl() . '/sort', ['sort-tree' => json_encode($reqData)]);
         $sortResp->assertRedirect($newBook->getUrl());
         $sortResp->assertStatus(302);
-        $this->assertDatabaseHas('chapters', [
+        $this->assertDatabaseHasEntityData('chapter', [
             'id'       => $chapterToMove->id,
             'book_id'  => $newBook->id,
             'priority' => 0,
@@ -93,7 +93,7 @@ class BookSortTest extends TestCase
         ];
         $this->asEditor()->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect();
 
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id,
         ]);
     }
@@ -114,7 +114,7 @@ class BookSortTest extends TestCase
         ];
         $this->asEditor()->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect();
 
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id,
         ]);
     }
@@ -136,7 +136,7 @@ class BookSortTest extends TestCase
         ];
         $this->actingAs($editor)->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect();
 
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id,
         ]);
     }
@@ -158,7 +158,7 @@ class BookSortTest extends TestCase
         ];
         $this->actingAs($editor)->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect();
 
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id,
         ]);
     }
@@ -180,7 +180,7 @@ class BookSortTest extends TestCase
         ];
         $this->actingAs($editor)->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect();
 
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id,
         ]);
     }
@@ -202,7 +202,7 @@ class BookSortTest extends TestCase
         ];
         $this->actingAs($editor)->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect();
 
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id,
         ]);
     }
@@ -211,7 +211,7 @@ class BookSortTest extends TestCase
     {
         $book = $this->entities->bookHasChaptersAndPages();
         $chapter = $book->chapters()->first();
-        \DB::table('chapters')->where('id', '=', $chapter->id)->update([
+        Chapter::query()->where('id', '=', $chapter->id)->update([
             'priority' => 10001,
             'updated_at' => \Carbon\Carbon::now()->subYear(5),
         ]);
@@ -299,7 +299,7 @@ class BookSortTest extends TestCase
         $book = $this->entities->bookHasChaptersAndPages();
         $book->chapters()->forceDelete();
         /** @var Page[] $pages */
-        $pages = $book->pages()->where('chapter_id', '=', 0)->take(2)->get();
+        $pages = $book->pages()->whereNull('chapter_id')->take(2)->get();
         $book->pages()->whereNotIn('id', $pages->pluck('id'))->delete();
 
         $resp = $this->asEditor()->get($book->getUrl());
index 606b23c680fe15c500417932bc0fc2b2e4d69b61..5a341026bf2cd8ed832860f6c895208cda51b704 100644 (file)
@@ -20,7 +20,7 @@ class MoveTest extends TestCase
 
         $movePageResp = $this->put($page->getUrl('/move'), [
             'entity_selection' => 'book:' . $newBook->id,
-        ]);
+        ])->assertRedirect();
         $page->refresh();
 
         $movePageResp->assertRedirect($page->getUrl());
index 4a9d3a7b33f2365754380b24fd1be9d30189e2b4..a6be9beef1ff3b4bfc3e0567a51b50a8c192df62 100644 (file)
@@ -142,7 +142,7 @@ class SortRuleTest extends TestCase
         $resp = $this->delete("settings/sorting/rules/{$rule->id}", ['confirm' => 'true']);
         $resp->assertRedirect('/settings/sorting');
         $this->assertDatabaseMissing('sort_rules', ['id' => $rule->id]);
-        $this->assertDatabaseMissing('books', ['sort_rule_id' => $rule->id]);
+        $this->assertDatabaseMissing('entity_container_data', ['sort_rule_id' => $rule->id]);
     }
 
     public function test_page_create_triggers_book_sort()
@@ -159,7 +159,7 @@ class SortRuleTest extends TestCase
         ]);
         $resp->assertOk();
 
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'book_id' => $book->id,
             'name' => '1111 page',
             'priority' => $book->chapters()->count() + 1,
@@ -217,7 +217,7 @@ class SortRuleTest extends TestCase
         }
 
         foreach ($namesToAdd as $index => $name) {
-            $this->assertDatabaseHas('pages', [
+            $this->assertDatabaseHasEntityData('page', [
                 'book_id' => $book->id,
                 'name' => $name,
                 'priority' => $index + 1,
@@ -251,7 +251,7 @@ class SortRuleTest extends TestCase
         }
 
         foreach ($namesToAdd as $index => $name) {
-            $this->assertDatabaseHas('pages', [
+            $this->assertDatabaseHasEntityData('page', [
                 'book_id' => $book->id,
                 'name' => $name,
                 'priority' => $index + 1,
index a8636fb158e2b37abd34c8b8e18b15809f76899b..2395317482eb856ae389d91eaeac587b7949b133 100644 (file)
@@ -6,7 +6,7 @@ use BookStack\Entities\Models\Entity;
 use BookStack\Http\HttpClientHistory;
 use BookStack\Http\HttpRequestService;
 use BookStack\Settings\SettingService;
-use BookStack\Users\Models\User;
+use Exception;
 use Illuminate\Contracts\Console\Kernel;
 use Illuminate\Foundation\Testing\DatabaseTransactions;
 use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
@@ -15,6 +15,7 @@ use Illuminate\Support\Env;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Testing\Assert as PHPUnit;
+use Illuminate\Testing\Constraints\HasInDatabase;
 use Monolog\Handler\TestHandler;
 use Monolog\Logger;
 use Ssddanbrown\AssertHtml\TestsHtml;
@@ -267,4 +268,42 @@ abstract class TestCase extends BaseTestCase
 
         $this->assertDatabaseHas('activities', $detailsToCheck);
     }
+
+    /**
+     * Assert the database has the given data for an entity type.
+     */
+    protected function assertDatabaseHasEntityData(string $type, array $data = []): self
+    {
+        $entityFields = array_intersect_key($data, array_flip(Entity::$commonFields));
+        $extraFields = array_diff_key($data, $entityFields);
+        $extraTable = $type === 'page' ? 'entity_page_data' : 'entity_container_data';
+        $entityFields['type'] = $type;
+
+        $this->assertThat(
+            $this->getTable('entities'),
+            new HasInDatabase($this->getConnection(null, 'entities'), $entityFields)
+        );
+
+        if (!empty($extraFields)) {
+            $id = $entityFields['id'] ?? DB::table($this->getTable('entities'))
+                ->where($entityFields)->orderByDesc('id')->first()->id ?? null;
+            if (is_null($id)) {
+                throw new Exception('Failed to find entity id for asserting database data');
+            }
+
+            if ($type !== 'page') {
+                $extraFields['entity_id'] = $id;
+                $extraFields['entity_type'] = $type;
+            } else {
+                $extraFields['page_id'] = $id;
+            }
+
+            $this->assertThat(
+                $this->getTable($extraTable),
+                new HasInDatabase($this->getConnection(null, $extraTable), $extraFields)
+            );
+        }
+
+        return $this;
+    }
 }
index d92f13f0b3ff7624a58ba95298412a19bf6da152..6d8b4d75a5336bd4c2764db4c1c9b2bef4b41a3a 100644 (file)
@@ -165,8 +165,8 @@ class UserManagementTest extends TestCase
         $owner = $page->ownedBy;
         $newOwner = User::query()->where('id', '!=', $owner->id)->first();
 
-        $this->asAdmin()->delete("settings/users/{$owner->id}", ['new_owner_id' => $newOwner->id]);
-        $this->assertDatabaseHas('pages', [
+        $this->asAdmin()->delete("settings/users/{$owner->id}", ['new_owner_id' => $newOwner->id])->assertRedirect();
+        $this->assertDatabaseHasEntityData('page', [
             'id'       => $page->id,
             'owned_by' => $newOwner->id,
         ]);