]> BookStack Code Mirror - bookstack/commitdiff
Merge pull request #4729 from BookStackApp/description_wysiwyg
authorDan Brown <redacted>
Fri, 22 Dec 2023 15:28:13 +0000 (15:28 +0000)
committerGitHub <redacted>
Fri, 22 Dec 2023 15:28:13 +0000 (15:28 +0000)
Simple WYSIWYG for description fields and comments

85 files changed:
app/Console/Commands/RegenerateReferencesCommand.php
app/Console/Commands/UpdateUrlCommand.php
app/Entities/Controllers/BookApiController.php
app/Entities/Controllers/BookController.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/Models/Book.php
app/Entities/Models/BookChild.php
app/Entities/Models/Bookshelf.php
app/Entities/Models/Chapter.php
app/Entities/Models/Entity.php
app/Entities/Models/HasHtmlDescription.php [new file with mode: 0644]
app/Entities/Models/Page.php
app/Entities/Repos/BaseRepo.php
app/Entities/Repos/PageRepo.php
app/Entities/Tools/MixedEntityListLoader.php [new file with mode: 0644]
app/References/ReferenceController.php
app/References/ReferenceFetcher.php
app/References/ReferenceStore.php
app/References/ReferenceUpdater.php
app/Settings/MaintenanceController.php
app/Util/HtmlDescriptionFilter.php [new file with mode: 0644]
database/factories/Entities/Models/BookFactory.php
database/factories/Entities/Models/BookshelfFactory.php
database/factories/Entities/Models/ChapterFactory.php
database/migrations/2023_12_17_140913_add_description_html_to_entities.php [new file with mode: 0644]
database/seeders/DummyContentSeeder.php
dev/api/requests/books-create.json
dev/api/requests/books-update.json
dev/api/requests/chapters-create.json
dev/api/requests/chapters-update.json
dev/api/requests/shelves-create.json
dev/api/requests/shelves-update.json
dev/api/responses/books-create.json
dev/api/responses/books-read.json
dev/api/responses/books-update.json
dev/api/responses/chapters-create.json
dev/api/responses/chapters-read.json
dev/api/responses/chapters-update.json
dev/api/responses/shelves-create.json
dev/api/responses/shelves-read.json
dev/api/responses/shelves-update.json
lang/en/entities.php
resources/js/components/entity-selector-popup.js
resources/js/components/entity-selector.js
resources/js/components/index.js
resources/js/components/page-picker.js
resources/js/components/wysiwyg-editor.js
resources/js/components/wysiwyg-input.js [new file with mode: 0644]
resources/js/markdown/actions.js
resources/js/wysiwyg/config.js
resources/js/wysiwyg/shortcuts.js
resources/sass/_forms.scss
resources/sass/_tinymce.scss
resources/views/books/parts/form.blade.php
resources/views/books/show.blade.php
resources/views/chapters/parts/form.blade.php
resources/views/chapters/show.blade.php
resources/views/entities/meta.blade.php
resources/views/entities/selector-popup.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 [new file with mode: 0644]
resources/views/form/editor-translations.blade.php [moved from resources/views/pages/parts/editor-translations.blade.php with 100% similarity]
resources/views/form/page-picker.blade.php
resources/views/pages/parts/wysiwyg-editor.blade.php
resources/views/settings/customization.blade.php
resources/views/shelves/parts/form.blade.php
resources/views/shelves/show.blade.php
tests/Api/BooksApiTest.php
tests/Api/ChaptersApiTest.php
tests/Api/SearchApiTest.php
tests/Api/ShelvesApiTest.php
tests/Commands/UpdateUrlCommandTest.php
tests/Entity/BookShelfTest.php
tests/Entity/BookTest.php
tests/Entity/ChapterTest.php
tests/Entity/ConvertTest.php
tests/Entity/ExportTest.php
tests/References/ReferencesTest.php
tests/Settings/RegenerateReferencesTest.php

index ea8ff8e00e4b9fee837bf139d048fc22fe03b2bc..563da100a79a6452d3e2d775ce9e9676035eb853 100644 (file)
@@ -34,7 +34,7 @@ class RegenerateReferencesCommand extends Command
             DB::setDefaultConnection($this->option('database'));
         }
 
-        $references->updateForAllPages();
+        $references->updateForAll();
 
         DB::setDefaultConnection($connection);
 
index 27f84cc89f30689f40a87e80aaafa9181bcfb432..0c95b0a3c8a0e66e5cebf5a37969266ab37774b5 100644 (file)
@@ -46,6 +46,9 @@ class UpdateUrlCommand extends Command
         $columnsToUpdateByTable = [
             'attachments' => ['path'],
             'pages'       => ['html', 'text', 'markdown'],
+            'chapters'    => ['description_html'],
+            'books'       => ['description_html'],
+            'bookshelves' => ['description_html'],
             'images'      => ['url'],
             'settings'    => ['value'],
             'comments'    => ['html', 'text'],
index 41ff11ddec3f2db68bee541c3a0aefa3eecb03b7..aa21aea472a8d044d718cb1d04d0e4745e0a7115 100644 (file)
@@ -45,7 +45,7 @@ class BookApiController extends ApiController
 
         $book = $this->bookRepo->create($requestData);
 
-        return response()->json($book);
+        return response()->json($this->forJsonDisplay($book));
     }
 
     /**
@@ -56,9 +56,9 @@ class BookApiController extends ApiController
      */
     public function read(string $id)
     {
-        $book = Book::visible()
-            ->with(['tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy'])
-            ->findOrFail($id);
+        $book = Book::visible()->findOrFail($id);
+        $book = $this->forJsonDisplay($book);
+        $book->load(['createdBy', 'updatedBy', 'ownedBy']);
 
         $contents = (new BookContents($book))->getTree(true, false)->all();
         $contentsApiData = (new ApiEntityListFormatter($contents))
@@ -89,7 +89,7 @@ class BookApiController extends ApiController
         $requestData = $this->validate($request, $this->rules()['update']);
         $book = $this->bookRepo->update($book, $requestData);
 
-        return response()->json($book);
+        return response()->json($this->forJsonDisplay($book));
     }
 
     /**
@@ -108,21 +108,35 @@ class BookApiController extends ApiController
         return response('', 204);
     }
 
+    protected function forJsonDisplay(Book $book): Book
+    {
+        $book = clone $book;
+        $book->unsetRelations()->refresh();
+
+        $book->load(['tags', 'cover']);
+        $book->makeVisible('description_html')
+            ->setAttribute('description_html', $book->descriptionHtml());
+
+        return $book;
+    }
+
     protected function rules(): array
     {
         return [
             'create' => [
-                'name'        => ['required', 'string', 'max:255'],
-                'description' => ['string', 'max:1000'],
-                'tags'        => ['array'],
-                'image'       => array_merge(['nullable'], $this->getImageValidationRules()),
+                'name'                => ['required', 'string', 'max:255'],
+                'description'         => ['string', 'max:1900'],
+                'description_html'    => ['string', 'max:2000'],
+                'tags'                => ['array'],
+                'image'               => array_merge(['nullable'], $this->getImageValidationRules()),
                 'default_template_id' => ['nullable', 'integer'],
             ],
             'update' => [
-                'name'        => ['string', 'min:1', 'max:255'],
-                'description' => ['string', 'max:1000'],
-                'tags'        => ['array'],
-                'image'       => array_merge(['nullable'], $this->getImageValidationRules()),
+                'name'                => ['string', 'min:1', 'max:255'],
+                'description'         => ['string', 'max:1900'],
+                'description_html'    => ['string', 'max:2000'],
+                'tags'                => ['array'],
+                'image'               => array_merge(['nullable'], $this->getImageValidationRules()),
                 'default_template_id' => ['nullable', 'integer'],
             ],
         ];
index faa5788938e979eab0946d440608e0417fc3fee4..412feca2fe5b24fee76cc1068ca3583dbcccae04 100644 (file)
@@ -93,7 +93,7 @@ class BookController extends Controller
         $this->checkPermission('book-create-all');
         $validated = $this->validate($request, [
             'name'                => ['required', 'string', 'max:255'],
-            'description'         => ['string', 'max:1000'],
+            'description_html'    => ['string', 'max:2000'],
             'image'               => array_merge(['nullable'], $this->getImageValidationRules()),
             'tags'                => ['array'],
             'default_template_id' => ['nullable', 'integer'],
@@ -138,7 +138,7 @@ class BookController extends Controller
             'bookParentShelves' => $bookParentShelves,
             'watchOptions'      => new UserEntityWatchOptions(user(), $book),
             'activity'          => $activities->entityActivity($book, 20, 1),
-            'referenceCount'    => $this->referenceFetcher->getPageReferenceCountToEntity($book),
+            'referenceCount'    => $this->referenceFetcher->getReferenceCountToEntity($book),
         ]);
     }
 
@@ -168,7 +168,7 @@ class BookController extends Controller
 
         $validated = $this->validate($request, [
             'name'                => ['required', 'string', 'max:255'],
-            'description'         => ['string', 'max:1000'],
+            'description_html'    => ['string', 'max:2000'],
             'image'               => array_merge(['nullable'], $this->getImageValidationRules()),
             'tags'                => ['array'],
             'default_template_id' => ['nullable', 'integer'],
index 9bdb8256df374367bbaf1c0a9cb1330ad4e004c7..a12dc90ac62cf654d34521fe8579ee38072d031b 100644 (file)
@@ -12,11 +12,9 @@ use Illuminate\Validation\ValidationException;
 
 class BookshelfApiController extends ApiController
 {
-    protected BookshelfRepo $bookshelfRepo;
-
-    public function __construct(BookshelfRepo $bookshelfRepo)
-    {
-        $this->bookshelfRepo = $bookshelfRepo;
+    public function __construct(
+        protected BookshelfRepo $bookshelfRepo
+    ) {
     }
 
     /**
@@ -48,7 +46,7 @@ class BookshelfApiController extends ApiController
         $bookIds = $request->get('books', []);
         $shelf = $this->bookshelfRepo->create($requestData, $bookIds);
 
-        return response()->json($shelf);
+        return response()->json($this->forJsonDisplay($shelf));
     }
 
     /**
@@ -56,12 +54,14 @@ class BookshelfApiController extends ApiController
      */
     public function read(string $id)
     {
-        $shelf = Bookshelf::visible()->with([
-            'tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy',
+        $shelf = Bookshelf::visible()->findOrFail($id);
+        $shelf = $this->forJsonDisplay($shelf);
+        $shelf->load([
+            'createdBy', 'updatedBy', 'ownedBy',
             'books' => function (BelongsToMany $query) {
                 $query->scopes('visible')->get(['id', 'name', 'slug']);
             },
-        ])->findOrFail($id);
+        ]);
 
         return response()->json($shelf);
     }
@@ -86,7 +86,7 @@ class BookshelfApiController extends ApiController
 
         $shelf = $this->bookshelfRepo->update($shelf, $requestData, $bookIds);
 
-        return response()->json($shelf);
+        return response()->json($this->forJsonDisplay($shelf));
     }
 
     /**
@@ -105,22 +105,36 @@ class BookshelfApiController extends ApiController
         return response('', 204);
     }
 
+    protected function forJsonDisplay(Bookshelf $shelf): Bookshelf
+    {
+        $shelf = clone $shelf;
+        $shelf->unsetRelations()->refresh();
+
+        $shelf->load(['tags', 'cover']);
+        $shelf->makeVisible('description_html')
+            ->setAttribute('description_html', $shelf->descriptionHtml());
+
+        return $shelf;
+    }
+
     protected function rules(): array
     {
         return [
             'create' => [
-                'name'        => ['required', 'string', 'max:255'],
-                'description' => ['string', 'max:1000'],
-                'books'       => ['array'],
-                'tags'        => ['array'],
-                'image'       => array_merge(['nullable'], $this->getImageValidationRules()),
+                'name'             => ['required', 'string', 'max:255'],
+                'description'      => ['string', 'max:1900'],
+                'description_html' => ['string', 'max:2000'],
+                'books'            => ['array'],
+                'tags'             => ['array'],
+                'image'            => array_merge(['nullable'], $this->getImageValidationRules()),
             ],
             'update' => [
-                'name'        => ['string', 'min:1', 'max:255'],
-                'description' => ['string', 'max:1000'],
-                'books'       => ['array'],
-                'tags'        => ['array'],
-                'image'       => array_merge(['nullable'], $this->getImageValidationRules()),
+                'name'             => ['string', 'min:1', 'max:255'],
+                'description'      => ['string', 'max:1900'],
+                'description_html' => ['string', 'max:2000'],
+                'books'            => ['array'],
+                'tags'             => ['array'],
+                'image'            => array_merge(['nullable'], $this->getImageValidationRules()),
             ],
         ];
     }
index fcfd37538724a8c653e9997e3df732011cd30243..2f5461cdb0bbdeaf3eb0a2e640e80fccf4f86dc5 100644 (file)
@@ -18,15 +18,11 @@ use Illuminate\Validation\ValidationException;
 
 class BookshelfController extends Controller
 {
-    protected BookshelfRepo $shelfRepo;
-    protected ShelfContext $shelfContext;
-    protected ReferenceFetcher $referenceFetcher;
-
-    public function __construct(BookshelfRepo $shelfRepo, ShelfContext $shelfContext, ReferenceFetcher $referenceFetcher)
-    {
-        $this->shelfRepo = $shelfRepo;
-        $this->shelfContext = $shelfContext;
-        $this->referenceFetcher = $referenceFetcher;
+    public function __construct(
+        protected BookshelfRepo $shelfRepo,
+        protected ShelfContext $shelfContext,
+        protected ReferenceFetcher $referenceFetcher
+    ) {
     }
 
     /**
@@ -81,10 +77,10 @@ class BookshelfController extends Controller
     {
         $this->checkPermission('bookshelf-create-all');
         $validated = $this->validate($request, [
-            'name'        => ['required', 'string', 'max:255'],
-            'description' => ['string', 'max:1000'],
-            'image'       => array_merge(['nullable'], $this->getImageValidationRules()),
-            'tags'        => ['array'],
+            'name'             => ['required', 'string', 'max:255'],
+            'description_html' => ['string', 'max:2000'],
+            'image'            => array_merge(['nullable'], $this->getImageValidationRules()),
+            'tags'             => ['array'],
         ]);
 
         $bookIds = explode(',', $request->get('books', ''));
@@ -129,7 +125,7 @@ class BookshelfController extends Controller
             'view'                    => $view,
             'activity'                => $activities->entityActivity($shelf, 20, 1),
             'listOptions'             => $listOptions,
-            'referenceCount'          => $this->referenceFetcher->getPageReferenceCountToEntity($shelf),
+            'referenceCount'          => $this->referenceFetcher->getReferenceCountToEntity($shelf),
         ]);
     }
 
@@ -164,10 +160,10 @@ class BookshelfController extends Controller
         $shelf = $this->shelfRepo->getBySlug($slug);
         $this->checkOwnablePermission('bookshelf-update', $shelf);
         $validated = $this->validate($request, [
-            'name'        => ['required', 'string', 'max:255'],
-            'description' => ['string', 'max:1000'],
-            'image'       => array_merge(['nullable'], $this->getImageValidationRules()),
-            'tags'        => ['array'],
+            'name'             => ['required', 'string', 'max:255'],
+            'description_html' => ['string', 'max:2000'],
+            'image'            => array_merge(['nullable'], $this->getImageValidationRules()),
+            'tags'             => ['array'],
         ]);
 
         if ($request->has('image_reset')) {
index 7f01e445a576d3d86cbbf097da50a09014472c44..c2132326200c5f186fdcfb82d680103a985ad20f 100644 (file)
@@ -15,18 +15,20 @@ class ChapterApiController extends ApiController
 {
     protected $rules = [
         'create' => [
-            'book_id'     => ['required', 'integer'],
-            'name'        => ['required', 'string', 'max:255'],
-            'description' => ['string', 'max:1000'],
-            'tags'        => ['array'],
-            'priority'    => ['integer'],
+            'book_id'          => ['required', 'integer'],
+            'name'             => ['required', 'string', 'max:255'],
+            'description'      => ['string', 'max:1900'],
+            'description_html' => ['string', 'max:2000'],
+            'tags'             => ['array'],
+            'priority'         => ['integer'],
         ],
         'update' => [
-            'book_id'     => ['integer'],
-            'name'        => ['string', 'min:1', 'max:255'],
-            'description' => ['string', 'max:1000'],
-            'tags'        => ['array'],
-            'priority'    => ['integer'],
+            'book_id'          => ['integer'],
+            'name'             => ['string', 'min:1', 'max:255'],
+            'description'      => ['string', 'max:1900'],
+            'description_html' => ['string', 'max:2000'],
+            'tags'             => ['array'],
+            'priority'         => ['integer'],
         ],
     ];
 
@@ -61,7 +63,7 @@ class ChapterApiController extends ApiController
 
         $chapter = $this->chapterRepo->create($requestData, $book);
 
-        return response()->json($chapter->load(['tags']));
+        return response()->json($this->forJsonDisplay($chapter));
     }
 
     /**
@@ -69,9 +71,15 @@ class ChapterApiController extends ApiController
      */
     public function read(string $id)
     {
-        $chapter = Chapter::visible()->with(['tags', 'createdBy', 'updatedBy', 'ownedBy', 'pages' => function (HasMany $query) {
-            $query->scopes('visible')->get(['id', 'name', 'slug']);
-        }])->findOrFail($id);
+        $chapter = Chapter::visible()->findOrFail($id);
+        $chapter = $this->forJsonDisplay($chapter);
+
+        $chapter->load([
+            'createdBy', 'updatedBy', 'ownedBy',
+            'pages' => function (HasMany $query) {
+                $query->scopes('visible')->get(['id', 'name', 'slug']);
+            }
+        ]);
 
         return response()->json($chapter);
     }
@@ -93,7 +101,7 @@ class ChapterApiController extends ApiController
             try {
                 $this->chapterRepo->move($chapter, "book:{$requestData['book_id']}");
             } catch (Exception $exception) {
-                if ($exception instanceof  PermissionsException) {
+                if ($exception instanceof PermissionsException) {
                     $this->showPermissionError();
                 }
 
@@ -103,7 +111,7 @@ class ChapterApiController extends ApiController
 
         $updatedChapter = $this->chapterRepo->update($chapter, $requestData);
 
-        return response()->json($updatedChapter->load(['tags']));
+        return response()->json($this->forJsonDisplay($updatedChapter));
     }
 
     /**
@@ -119,4 +127,16 @@ class ChapterApiController extends ApiController
 
         return response('', 204);
     }
+
+    protected function forJsonDisplay(Chapter $chapter): Chapter
+    {
+        $chapter = clone $chapter;
+        $chapter->unsetRelations()->refresh();
+
+        $chapter->load(['tags']);
+        $chapter->makeVisible('description_html')
+            ->setAttribute('description_html', $chapter->descriptionHtml());
+
+        return $chapter;
+    }
 }
index 40a5373031733aa0b4bf1acfbbe95053afe01e72..28ad35fa4b37e1b949ee942cd0f05475427baada 100644 (file)
@@ -22,13 +22,10 @@ use Throwable;
 
 class ChapterController extends Controller
 {
-    protected ChapterRepo $chapterRepo;
-    protected ReferenceFetcher $referenceFetcher;
-
-    public function __construct(ChapterRepo $chapterRepo, ReferenceFetcher $referenceFetcher)
-    {
-        $this->chapterRepo = $chapterRepo;
-        $this->referenceFetcher = $referenceFetcher;
+    public function __construct(
+        protected ChapterRepo $chapterRepo,
+        protected ReferenceFetcher $referenceFetcher
+    ) {
     }
 
     /**
@@ -51,14 +48,16 @@ class ChapterController extends Controller
      */
     public function store(Request $request, string $bookSlug)
     {
-        $this->validate($request, [
-            'name' => ['required', 'string', 'max:255'],
+        $validated = $this->validate($request, [
+            'name'             => ['required', 'string', 'max:255'],
+            'description_html' => ['string', 'max:2000'],
+            'tags'             => ['array'],
         ]);
 
         $book = Book::visible()->where('slug', '=', $bookSlug)->firstOrFail();
         $this->checkOwnablePermission('chapter-create', $book);
 
-        $chapter = $this->chapterRepo->create($request->all(), $book);
+        $chapter = $this->chapterRepo->create($validated, $book);
 
         return redirect($chapter->getUrl());
     }
@@ -87,7 +86,7 @@ class ChapterController extends Controller
             'pages'          => $pages,
             'next'           => $nextPreviousLocator->getNext(),
             'previous'       => $nextPreviousLocator->getPrevious(),
-            'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($chapter),
+            'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($chapter),
         ]);
     }
 
@@ -111,10 +110,16 @@ class ChapterController extends Controller
      */
     public function update(Request $request, string $bookSlug, string $chapterSlug)
     {
+        $validated = $this->validate($request, [
+            'name'             => ['required', 'string', 'max:255'],
+            'description_html' => ['string', 'max:2000'],
+            'tags'             => ['array'],
+        ]);
+
         $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
         $this->checkOwnablePermission('chapter-update', $chapter);
 
-        $this->chapterRepo->update($chapter, $request->all());
+        $this->chapterRepo->update($chapter, $validated);
 
         return redirect($chapter->getUrl());
     }
index 0a3e76daa42bdbc69a2ce52d4b48736aa05586b1..adafcdc7bd919a26ca5a57c735f0f3440e1fcdbe 100644 (file)
@@ -155,7 +155,7 @@ class PageController extends Controller
             'watchOptions'    => new UserEntityWatchOptions(user(), $page),
             'next'            => $nextPreviousLocator->getNext(),
             'previous'        => $nextPreviousLocator->getPrevious(),
-            'referenceCount'  => $this->referenceFetcher->getPageReferenceCountToEntity($page),
+            'referenceCount'  => $this->referenceFetcher->getReferenceCountToEntity($page),
         ]);
     }
 
index ee9a7f44722538cf3a421add4e347d33612dd7c9..14cb790c5c509c9283713aaa6a1b69fce4f22b8b 100644 (file)
@@ -26,11 +26,12 @@ use Illuminate\Support\Collection;
 class Book extends Entity implements HasCoverImage
 {
     use HasFactory;
+    use HasHtmlDescription;
 
-    public $searchFactor = 1.2;
+    public float $searchFactor = 1.2;
 
-    protected $fillable = ['name', 'description'];
-    protected $hidden = ['pivot', 'image_id', 'deleted_at'];
+    protected $fillable = ['name'];
+    protected $hidden = ['pivot', 'image_id', 'deleted_at', 'description_html'];
 
     /**
      * Get the url for this book.
index ed08f16e6ac9783be4581e87b33a1af651c5cffa..18735e56b72e74cdf08c7a774f0aadeae1bd7d4c 100644 (file)
@@ -65,7 +65,7 @@ abstract class BookChild extends Entity
         $this->refresh();
 
         if ($oldUrl !== $this->getUrl()) {
-            app()->make(ReferenceUpdater::class)->updateEntityPageReferences($this, $oldUrl);
+            app()->make(ReferenceUpdater::class)->updateEntityReferences($this, $oldUrl);
         }
 
         // Update all child pages if a chapter
index 4b44025a4c3e84eb3b00838cb4c32868e19d3b13..9ffa0ea9cabd9a9b3beed43ea65cc4c21f2f0aad 100644 (file)
@@ -11,14 +11,15 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 class Bookshelf extends Entity implements HasCoverImage
 {
     use HasFactory;
+    use HasHtmlDescription;
 
     protected $table = 'bookshelves';
 
-    public $searchFactor = 1.2;
+    public float $searchFactor = 1.2;
 
     protected $fillable = ['name', 'description', 'image_id'];
 
-    protected $hidden = ['image_id', 'deleted_at'];
+    protected $hidden = ['image_id', 'deleted_at', 'description_html'];
 
     /**
      * Get the books in this shelf.
index 98889ce3f38a430c15d281d64b20b05491fc9bb9..f30d77b5c5c33d86d38554e431c4dbe64028d859 100644 (file)
@@ -15,11 +15,12 @@ use Illuminate\Support\Collection;
 class Chapter extends BookChild
 {
     use HasFactory;
+    use HasHtmlDescription;
 
-    public $searchFactor = 1.2;
+    public float $searchFactor = 1.2;
 
     protected $fillable = ['name', 'description', 'priority'];
-    protected $hidden = ['pivot', 'deleted_at'];
+    protected $hidden = ['pivot', 'deleted_at', 'description_html'];
 
     /**
      * Get the pages that this chapter contains.
index 33251067297de3ace16de5939382a7dbf3ba30fd..f07d372c3e9cdca77bc1571530f13e357b5bf266 100644 (file)
@@ -57,12 +57,17 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
     /**
      * @var string - Name of property where the main text content is found
      */
-    public $textField = 'description';
+    public string $textField = 'description';
+
+    /**
+     * @var string - Name of the property where the main HTML content is found
+     */
+    public string $htmlField = 'description_html';
 
     /**
      * @var float - Multiplier for search indexing.
      */
-    public $searchFactor = 1.0;
+    public float $searchFactor = 1.0;
 
     /**
      * Get the entities that are visible to the current user.
diff --git a/app/Entities/Models/HasHtmlDescription.php b/app/Entities/Models/HasHtmlDescription.php
new file mode 100644 (file)
index 0000000..c9f0861
--- /dev/null
@@ -0,0 +1,21 @@
+<?php
+
+namespace BookStack\Entities\Models;
+
+use BookStack\Util\HtmlContentFilter;
+
+/**
+ * @property string $description
+ * @property string $description_html
+ */
+trait HasHtmlDescription
+{
+    /**
+     * Get the HTML description for this book.
+     */
+    public function descriptionHtml(): string
+    {
+        $html = $this->description_html ?: '<p>' . nl2br(e($this->description)) . '</p>';
+        return HtmlContentFilter::removeScriptsFromHtmlString($html);
+    }
+}
index 7e2c12c2048d94f93745f803a8927942b79ad120..17d6f9a016f59e7805f7743a98fb41f38e140e75 100644 (file)
@@ -37,7 +37,8 @@ class Page extends BookChild
 
     protected $fillable = ['name', 'priority'];
 
-    public $textField = 'text';
+    public string $textField = 'text';
+    public string $htmlField = 'html';
 
     protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at'];
 
index 2894a04e36ee6388ea29c41ef133a74466288003..27bf00161867b1c7d530005a49a6b3d5281045b5 100644 (file)
@@ -5,22 +5,22 @@ namespace BookStack\Entities\Repos;
 use BookStack\Activity\TagRepo;
 use BookStack\Entities\Models\Entity;
 use BookStack\Entities\Models\HasCoverImage;
+use BookStack\Entities\Models\HasHtmlDescription;
 use BookStack\Exceptions\ImageUploadException;
+use BookStack\References\ReferenceStore;
 use BookStack\References\ReferenceUpdater;
 use BookStack\Uploads\ImageRepo;
+use BookStack\Util\HtmlDescriptionFilter;
 use Illuminate\Http\UploadedFile;
 
 class BaseRepo
 {
-    protected TagRepo $tagRepo;
-    protected ImageRepo $imageRepo;
-    protected ReferenceUpdater $referenceUpdater;
-
-    public function __construct(TagRepo $tagRepo, ImageRepo $imageRepo, ReferenceUpdater $referenceUpdater)
-    {
-        $this->tagRepo = $tagRepo;
-        $this->imageRepo = $imageRepo;
-        $this->referenceUpdater = $referenceUpdater;
+    public function __construct(
+        protected TagRepo $tagRepo,
+        protected ImageRepo $imageRepo,
+        protected ReferenceUpdater $referenceUpdater,
+        protected ReferenceStore $referenceStore,
+    ) {
     }
 
     /**
@@ -29,6 +29,7 @@ class BaseRepo
     public function create(Entity $entity, array $input)
     {
         $entity->fill($input);
+        $this->updateDescription($entity, $input);
         $entity->forceFill([
             'created_by' => user()->id,
             'updated_by' => user()->id,
@@ -44,6 +45,7 @@ class BaseRepo
         $entity->refresh();
         $entity->rebuildPermissions();
         $entity->indexForSearch();
+        $this->referenceStore->updateForEntity($entity);
     }
 
     /**
@@ -54,6 +56,7 @@ class BaseRepo
         $oldUrl = $entity->getUrl();
 
         $entity->fill($input);
+        $this->updateDescription($entity, $input);
         $entity->updated_by = user()->id;
 
         if ($entity->isDirty('name') || empty($entity->slug)) {
@@ -69,9 +72,10 @@ class BaseRepo
 
         $entity->rebuildPermissions();
         $entity->indexForSearch();
+        $this->referenceStore->updateForEntity($entity);
 
         if ($oldUrl !== $entity->getUrl()) {
-            $this->referenceUpdater->updateEntityPageReferences($entity, $oldUrl);
+            $this->referenceUpdater->updateEntityReferences($entity, $oldUrl);
         }
     }
 
@@ -99,4 +103,21 @@ class BaseRepo
             $entity->save();
         }
     }
+
+    protected function updateDescription(Entity $entity, array $input): void
+    {
+        if (!in_array(HasHtmlDescription::class, class_uses($entity))) {
+            return;
+        }
+
+        /** @var HasHtmlDescription $entity */
+        if (isset($input['description_html'])) {
+            $entity->description_html = HtmlDescriptionFilter::filterFromString($input['description_html']);
+            $entity->description = html_entity_decode(strip_tags($input['description_html']));
+        } else if (isset($input['description'])) {
+            $entity->description = $input['description'];
+            $entity->description_html = '';
+            $entity->description_html = $entity->descriptionHtml();
+        }
+    }
 }
index 9a183469b4a9bdf8d4b7a9f0d2a674f51a4a8b59..7b14ea7d278c93b45bb0599251e8bbab6abfa337 100644 (file)
@@ -162,7 +162,6 @@ class PageRepo
         $this->baseRepo->update($draft, $input);
 
         $this->revisionRepo->storeNewForPage($draft, trans('entities.pages_initial_revision'));
-        $this->referenceStore->updateForPage($draft);
         $draft->refresh();
 
         Activity::add(ActivityType::PAGE_CREATE, $draft);
@@ -182,7 +181,6 @@ class PageRepo
 
         $this->updateTemplateStatusAndContentFromInput($page, $input);
         $this->baseRepo->update($page, $input);
-        $this->referenceStore->updateForPage($page);
 
         // Update with new details
         $page->revision_count++;
@@ -301,13 +299,13 @@ class PageRepo
         $page->refreshSlug();
         $page->save();
         $page->indexForSearch();
-        $this->referenceStore->updateForPage($page);
+        $this->referenceStore->updateForEntity($page);
 
         $summary = trans('entities.pages_revision_restored_from', ['id' => strval($revisionId), 'summary' => $revision->summary]);
         $this->revisionRepo->storeNewForPage($page, $summary);
 
         if ($oldUrl !== $page->getUrl()) {
-            $this->referenceUpdater->updateEntityPageReferences($page, $oldUrl);
+            $this->referenceUpdater->updateEntityReferences($page, $oldUrl);
         }
 
         Activity::add(ActivityType::PAGE_RESTORE, $page);
diff --git a/app/Entities/Tools/MixedEntityListLoader.php b/app/Entities/Tools/MixedEntityListLoader.php
new file mode 100644 (file)
index 0000000..50079e3
--- /dev/null
@@ -0,0 +1,103 @@
+<?php
+
+namespace BookStack\Entities\Tools;
+
+use BookStack\App\Model;
+use BookStack\Entities\EntityProvider;
+use Illuminate\Database\Eloquent\Relations\Relation;
+
+class MixedEntityListLoader
+{
+    protected array $listAttributes = [
+        'page'      => ['id', 'name', 'slug', 'book_id', 'chapter_id', 'text', 'draft'],
+        'chapter'   => ['id', 'name', 'slug', 'book_id', 'description'],
+        'book'      => ['id', 'name', 'slug', 'description'],
+        'bookshelf' => ['id', 'name', 'slug', 'description'],
+    ];
+
+    public function __construct(
+        protected EntityProvider $entityProvider
+    ) {
+    }
+
+    /**
+     * Efficiently load in entities for listing onto the given list
+     * where entities are set as a relation via the given name.
+     * 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): void
+    {
+        $idsByType = [];
+        foreach ($relations as $relation) {
+            $type = $relation->getAttribute($relationName . '_type');
+            $id = $relation->getAttribute($relationName . '_id');
+
+            if (!isset($idsByType[$type])) {
+                $idsByType[$type] = [];
+            }
+
+            $idsByType[$type][] = $id;
+        }
+
+        $modelMap = $this->idsByTypeToModelMap($idsByType);
+
+        foreach ($relations as $relation) {
+            $type = $relation->getAttribute($relationName . '_type');
+            $id = $relation->getAttribute($relationName . '_id');
+            $related = $modelMap[$type][strval($id)] ?? null;
+            if ($related) {
+                $relation->setRelation($relationName, $related);
+            }
+        }
+    }
+
+    /**
+     * @param array<string, int[]> $idsByType
+     * @return array<string, array<int, Model>>
+     */
+    protected function idsByTypeToModelMap(array $idsByType): array
+    {
+        $modelMap = [];
+
+        foreach ($idsByType as $type => $ids) {
+            if (!isset($this->listAttributes[$type])) {
+                continue;
+            }
+
+            $instance = $this->entityProvider->get($type);
+            $models = $instance->newQuery()
+                ->select($this->listAttributes[$type])
+                ->scopes('visible')
+                ->whereIn('id', $ids)
+                ->with($this->getRelationsToEagerLoad($type))
+                ->get();
+
+            if (count($models) > 0) {
+                $modelMap[$type] = [];
+            }
+
+            foreach ($models as $model) {
+                $modelMap[$type][strval($model->id)] = $model;
+            }
+        }
+
+        return $modelMap;
+    }
+
+    protected function getRelationsToEagerLoad(string $type): array
+    {
+        $toLoad = [];
+        $loadVisible = fn (Relation $query) => $query->scopes('visible');
+
+        if ($type === 'chapter' || $type === 'page') {
+            $toLoad['book'] = $loadVisible;
+        }
+
+        if ($type === 'page') {
+            $toLoad['chapter'] = $loadVisible;
+        }
+
+        return $toLoad;
+    }
+}
index d6978dd5b23deedb9506b7d616730d3cac7c24f8..991f47225b8e044094ba31e3ebf4b3fdd09cd036 100644 (file)
@@ -10,11 +10,9 @@ use BookStack\Http\Controller;
 
 class ReferenceController extends Controller
 {
-    protected ReferenceFetcher $referenceFetcher;
-
-    public function __construct(ReferenceFetcher $referenceFetcher)
-    {
-        $this->referenceFetcher = $referenceFetcher;
+    public function __construct(
+        protected ReferenceFetcher $referenceFetcher
+    ) {
     }
 
     /**
@@ -23,7 +21,7 @@ class ReferenceController extends Controller
     public function page(string $bookSlug, string $pageSlug)
     {
         $page = Page::getBySlugs($bookSlug, $pageSlug);
-        $references = $this->referenceFetcher->getPageReferencesToEntity($page);
+        $references = $this->referenceFetcher->getReferencesToEntity($page);
 
         return view('pages.references', [
             'page'       => $page,
@@ -37,7 +35,7 @@ class ReferenceController extends Controller
     public function chapter(string $bookSlug, string $chapterSlug)
     {
         $chapter = Chapter::getBySlugs($bookSlug, $chapterSlug);
-        $references = $this->referenceFetcher->getPageReferencesToEntity($chapter);
+        $references = $this->referenceFetcher->getReferencesToEntity($chapter);
 
         return view('chapters.references', [
             'chapter'    => $chapter,
@@ -51,7 +49,7 @@ class ReferenceController extends Controller
     public function book(string $slug)
     {
         $book = Book::getBySlug($slug);
-        $references = $this->referenceFetcher->getPageReferencesToEntity($book);
+        $references = $this->referenceFetcher->getReferencesToEntity($book);
 
         return view('books.references', [
             'book'       => $book,
@@ -65,7 +63,7 @@ class ReferenceController extends Controller
     public function shelf(string $slug)
     {
         $shelf = Bookshelf::getBySlug($slug);
-        $references = $this->referenceFetcher->getPageReferencesToEntity($shelf);
+        $references = $this->referenceFetcher->getReferencesToEntity($shelf);
 
         return view('shelves.references', [
             'shelf'      => $shelf,
index c4a7d31b60b451632de5ecdb5756d460da1c08ec..0d9883a3edcbcc057136a3273cd5433055286f55 100644 (file)
@@ -3,65 +3,51 @@
 namespace BookStack\References;
 
 use BookStack\Entities\Models\Entity;
-use BookStack\Entities\Models\Page;
+use BookStack\Entities\Tools\MixedEntityListLoader;
 use BookStack\Permissions\PermissionApplicator;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Database\Eloquent\Collection;
-use Illuminate\Database\Eloquent\Relations\Relation;
 
 class ReferenceFetcher
 {
-    protected PermissionApplicator $permissions;
-
-    public function __construct(PermissionApplicator $permissions)
-    {
-        $this->permissions = $permissions;
+    public function __construct(
+        protected PermissionApplicator $permissions,
+        protected MixedEntityListLoader $mixedEntityListLoader,
+    ) {
     }
 
     /**
-     * Query and return the page references pointing to the given entity.
+     * Query and return the references pointing to the given entity.
      * Loads the commonly required relations while taking permissions into account.
      */
-    public function getPageReferencesToEntity(Entity $entity): Collection
+    public function getReferencesToEntity(Entity $entity): Collection
     {
-        $baseQuery = $this->queryPageReferencesToEntity($entity)
-            ->with([
-                'from'         => fn (Relation $query) => $query->select(Page::$listAttributes),
-                'from.book'    => fn (Relation $query) => $query->scopes('visible'),
-                'from.chapter' => fn (Relation $query) => $query->scopes('visible'),
-            ]);
-
-        $references = $this->permissions->restrictEntityRelationQuery(
-            $baseQuery,
-            'references',
-            'from_id',
-            'from_type'
-        )->get();
+        $references = $this->queryReferencesToEntity($entity)->get();
+        $this->mixedEntityListLoader->loadIntoRelations($references->all(), 'from');
 
         return $references;
     }
 
     /**
-     * Returns the count of page references pointing to the given entity.
+     * Returns the count of references pointing to the given entity.
      * Takes permissions into account.
      */
-    public function getPageReferenceCountToEntity(Entity $entity): int
+    public function getReferenceCountToEntity(Entity $entity): int
     {
-        $count = $this->permissions->restrictEntityRelationQuery(
-            $this->queryPageReferencesToEntity($entity),
-            'references',
-            'from_id',
-            'from_type'
-        )->count();
-
-        return $count;
+        return $this->queryReferencesToEntity($entity)->count();
     }
 
-    protected function queryPageReferencesToEntity(Entity $entity): Builder
+    protected function queryReferencesToEntity(Entity $entity): Builder
     {
-        return Reference::query()
+        $baseQuery = Reference::query()
             ->where('to_type', '=', $entity->getMorphClass())
-            ->where('to_id', '=', $entity->id)
-            ->where('from_type', '=', (new Page())->getMorphClass());
+            ->where('to_id', '=', $entity->id);
+
+        return $this->permissions->restrictEntityRelationQuery(
+            $baseQuery,
+            'references',
+            'from_id',
+            'from_type'
+        );
     }
 }
index 4c6db35c5a3d7e9236e804ada1f97d945c85c46e..78595084b097a26cc012fac553297b84133d2d9c 100644 (file)
@@ -2,60 +2,62 @@
 
 namespace BookStack\References;
 
-use BookStack\Entities\Models\Page;
+use BookStack\Entities\EntityProvider;
+use BookStack\Entities\Models\Entity;
 use Illuminate\Database\Eloquent\Collection;
 
 class ReferenceStore
 {
+    public function __construct(
+        protected EntityProvider $entityProvider
+    ) {
+    }
+
     /**
-     * Update the outgoing references for the given page.
+     * Update the outgoing references for the given entity.
      */
-    public function updateForPage(Page $page): void
+    public function updateForEntity(Entity $entity): void
     {
-        $this->updateForPages([$page]);
+        $this->updateForEntities([$entity]);
     }
 
     /**
-     * Update the outgoing references for all pages in the system.
+     * Update the outgoing references for all entities in the system.
      */
-    public function updateForAllPages(): void
+    public function updateForAll(): void
     {
-        Reference::query()
-            ->where('from_type', '=', (new Page())->getMorphClass())
-            ->delete();
+        Reference::query()->delete();
 
-        Page::query()->select(['id', 'html'])->chunk(100, function (Collection $pages) {
-            $this->updateForPages($pages->all());
-        });
+        foreach ($this->entityProvider->all() as $entity) {
+            $entity->newQuery()->select(['id', $entity->htmlField])->chunk(100, function (Collection $entities) {
+                $this->updateForEntities($entities->all());
+            });
+        }
     }
 
     /**
-     * Update the outgoing references for the pages in the given array.
+     * Update the outgoing references for the entities in the given array.
      *
-     * @param Page[] $pages
+     * @param Entity[] $entities
      */
-    protected function updateForPages(array $pages): void
+    protected function updateForEntities(array $entities): void
     {
-        if (count($pages) === 0) {
+        if (count($entities) === 0) {
             return;
         }
 
         $parser = CrossLinkParser::createWithEntityResolvers();
         $references = [];
 
-        $pageIds = array_map(fn (Page $page) => $page->id, $pages);
-        Reference::query()
-            ->where('from_type', '=', $pages[0]->getMorphClass())
-            ->whereIn('from_id', $pageIds)
-            ->delete();
+        $this->dropReferencesFromEntities($entities);
 
-        foreach ($pages as $page) {
-            $models = $parser->extractLinkedModels($page->html);
+        foreach ($entities as $entity) {
+            $models = $parser->extractLinkedModels($entity->getAttribute($entity->htmlField));
 
             foreach ($models as $model) {
                 $references[] = [
-                    'from_id'   => $page->id,
-                    'from_type' => $page->getMorphClass(),
+                    'from_id'   => $entity->id,
+                    'from_type' => $entity->getMorphClass(),
                     'to_id'     => $model->id,
                     'to_type'   => $model->getMorphClass(),
                 ];
@@ -66,4 +68,29 @@ class ReferenceStore
             Reference::query()->insert($referenceDataChunk);
         }
     }
+
+    /**
+     * Delete all the existing references originating from the given entities.
+     * @param Entity[] $entities
+     */
+    protected function dropReferencesFromEntities(array $entities): void
+    {
+        $IdsByType = [];
+
+        foreach ($entities as $entity) {
+            $type = $entity->getMorphClass();
+            if (!isset($IdsByType[$type])) {
+                $IdsByType[$type] = [];
+            }
+
+            $IdsByType[$type][] = $entity->id;
+        }
+
+        foreach ($IdsByType as $type => $entityIds) {
+            Reference::query()
+                ->where('from_type', '=', $type)
+                ->whereIn('from_id', $entityIds)
+                ->delete();
+        }
+    }
 }
index 248937339d9335f24346e05c5840bf6ec7d710bb..db355f211307d0381070a066cdfd40b7b50b4890 100644 (file)
@@ -4,6 +4,7 @@ namespace BookStack\References;
 
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\HasHtmlDescription;
 use BookStack\Entities\Models\Page;
 use BookStack\Entities\Repos\RevisionRepo;
 use BookStack\Util\HtmlDocument;
@@ -12,20 +13,19 @@ class ReferenceUpdater
 {
     public function __construct(
         protected ReferenceFetcher $referenceFetcher,
-        protected RevisionRepo $revisionRepo
+        protected RevisionRepo $revisionRepo,
     ) {
     }
 
-    public function updateEntityPageReferences(Entity $entity, string $oldLink)
+    public function updateEntityReferences(Entity $entity, string $oldLink): void
     {
         $references = $this->getReferencesToUpdate($entity);
         $newLink = $entity->getUrl();
 
-        /** @var Reference $reference */
         foreach ($references as $reference) {
-            /** @var Page $page */
-            $page = $reference->from;
-            $this->updateReferencesWithinPage($page, $oldLink, $newLink);
+            /** @var Entity $entity */
+            $entity = $reference->from;
+            $this->updateReferencesWithinEntity($entity, $oldLink, $newLink);
         }
     }
 
@@ -35,7 +35,7 @@ class ReferenceUpdater
     protected function getReferencesToUpdate(Entity $entity): array
     {
         /** @var Reference[] $references */
-        $references = $this->referenceFetcher->getPageReferencesToEntity($entity)->values()->all();
+        $references = $this->referenceFetcher->getReferencesToEntity($entity)->values()->all();
 
         if ($entity instanceof Book) {
             $pages = $entity->pages()->get(['id']);
@@ -43,7 +43,7 @@ class ReferenceUpdater
             $children = $pages->concat($chapters);
             foreach ($children as $bookChild) {
                 /** @var Reference[] $childRefs */
-                $childRefs = $this->referenceFetcher->getPageReferencesToEntity($bookChild)->values()->all();
+                $childRefs = $this->referenceFetcher->getReferencesToEntity($bookChild)->values()->all();
                 array_push($references, ...$childRefs);
             }
         }
@@ -57,7 +57,28 @@ class ReferenceUpdater
         return array_values($deduped);
     }
 
-    protected function updateReferencesWithinPage(Page $page, string $oldLink, string $newLink)
+    protected function updateReferencesWithinEntity(Entity $entity, string $oldLink, string $newLink): void
+    {
+        if ($entity instanceof Page) {
+            $this->updateReferencesWithinPage($entity, $oldLink, $newLink);
+            return;
+        }
+
+        if (in_array(HasHtmlDescription::class, class_uses($entity))) {
+            $this->updateReferencesWithinDescription($entity, $oldLink, $newLink);
+        }
+    }
+
+    protected function updateReferencesWithinDescription(Entity $entity, string $oldLink, string $newLink): void
+    {
+        /** @var HasHtmlDescription&Entity $entity */
+        $entity = (clone $entity)->refresh();
+        $html = $this->updateLinksInHtml($entity->description_html ?: '', $oldLink, $newLink);
+        $entity->description_html = $html;
+        $entity->save();
+    }
+
+    protected function updateReferencesWithinPage(Page $page, string $oldLink, string $newLink): void
     {
         $page = (clone $page)->refresh();
         $html = $this->updateLinksInHtml($page->html, $oldLink, $newLink);
index 60e5fee283ffee34f9e968c2cd6cf15b9552e425..62eeecf39a9ca961ec53c4b18d854190de5ea601 100644 (file)
@@ -87,7 +87,7 @@ class MaintenanceController extends Controller
         $this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'regenerate-references');
 
         try {
-            $referenceStore->updateForAllPages();
+            $referenceStore->updateForAll();
             $this->showSuccessNotification(trans('settings.maint_regen_references_success'));
         } catch (\Exception $exception) {
             $this->showErrorNotification($exception->getMessage());
diff --git a/app/Util/HtmlDescriptionFilter.php b/app/Util/HtmlDescriptionFilter.php
new file mode 100644 (file)
index 0000000..7287586
--- /dev/null
@@ -0,0 +1,79 @@
+<?php
+
+namespace BookStack\Util;
+
+use DOMAttr;
+use DOMElement;
+use DOMNamedNodeMap;
+use DOMNode;
+
+/**
+ * Filter to ensure HTML input for description content remains simple and
+ * to a limited allow-list of elements and attributes.
+ * More for consistency and to prevent nuisance rather than for security
+ * (which would be done via a separate content filter and CSP).
+ */
+class HtmlDescriptionFilter
+{
+    /**
+     * @var array<string, string[]>
+     */
+    protected static array $allowedAttrsByElements = [
+        'p' => [],
+        'a' => ['href', 'title'],
+        'ol' => [],
+        'ul' => [],
+        'li' => [],
+        'strong' => [],
+        'em' => [],
+        'br' => [],
+    ];
+
+    public static function filterFromString(string $html): string
+    {
+        if (empty(trim($html))) {
+            return '';
+        }
+
+        $doc = new HtmlDocument($html);
+
+        $topLevel = [...$doc->getBodyChildren()];
+        foreach ($topLevel as $child) {
+            /** @var DOMNode $child */
+            if ($child instanceof DOMElement) {
+                static::filterElement($child);
+            } else {
+                $child->parentNode->removeChild($child);
+            }
+        }
+
+        return $doc->getBodyInnerHtml();
+    }
+
+    protected static function filterElement(DOMElement $element): void
+    {
+        $elType = strtolower($element->tagName);
+        $allowedAttrs = static::$allowedAttrsByElements[$elType] ?? null;
+        if (is_null($allowedAttrs)) {
+            $element->remove();
+            return;
+        }
+
+        /** @var DOMNamedNodeMap $attrs */
+        $attrs = $element->attributes;
+        for ($i = $attrs->length - 1; $i >= 0; $i--) {
+            /** @var DOMAttr $attr */
+            $attr = $attrs->item($i);
+            $name = strtolower($attr->name);
+            if (!in_array($name, $allowedAttrs)) {
+                $element->removeAttribute($attr->name);
+            }
+        }
+
+        foreach ($element->childNodes as $child) {
+            if ($child instanceof DOMElement) {
+                static::filterElement($child);
+            }
+        }
+    }
+}
index 3bf15778670387554fd7f655147610a200103a32..9cb8e971c6e14e16542ea4d203da45a4d3511ff5 100644 (file)
@@ -21,10 +21,12 @@ class BookFactory extends Factory
      */
     public function definition()
     {
+        $description = $this->faker->paragraph();
         return [
             'name'        => $this->faker->sentence(),
             'slug'        => Str::random(10),
-            'description' => $this->faker->paragraph(),
+            'description' => $description,
+            'description_html' => '<p>' . e($description) . '</p>'
         ];
     }
 }
index 66dd1c111851419004761b24dc9377e2cf8c0dd6..edbefc3e797b721c50ebd90b38a7acfc088f1209 100644 (file)
@@ -21,10 +21,12 @@ class BookshelfFactory extends Factory
      */
     public function definition()
     {
+        $description = $this->faker->paragraph();
         return [
             'name'        => $this->faker->sentence,
             'slug'        => Str::random(10),
-            'description' => $this->faker->paragraph,
+            'description' => $description,
+            'description_html' => '<p>' . e($description) . '</p>'
         ];
     }
 }
index 36379866ed85bfafe7822413ed99510ba751ab4b..1fc49933ef766adcf5ff37639d9fd3a7424759b5 100644 (file)
@@ -21,10 +21,12 @@ class ChapterFactory extends Factory
      */
     public function definition()
     {
+        $description = $this->faker->paragraph();
         return [
             'name'        => $this->faker->sentence(),
             'slug'        => Str::random(10),
-            'description' => $this->faker->paragraph(),
+            'description' => $description,
+            'description_html' => '<p>' . e($description) . '</p>'
         ];
     }
 }
diff --git a/database/migrations/2023_12_17_140913_add_description_html_to_entities.php b/database/migrations/2023_12_17_140913_add_description_html_to_entities.php
new file mode 100644 (file)
index 0000000..68c52e8
--- /dev/null
@@ -0,0 +1,36 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        $addColumn = fn(Blueprint $table) => $table->text('description_html');
+
+        Schema::table('books', $addColumn);
+        Schema::table('chapters', $addColumn);
+        Schema::table('bookshelves', $addColumn);
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        $removeColumn = fn(Blueprint $table) => $table->removeColumn('description_html');
+
+        Schema::table('books', $removeColumn);
+        Schema::table('chapters', $removeColumn);
+        Schema::table('bookshelves', $removeColumn);
+    }
+};
index 47e8d1d7c1d32e4ed1c14b86601edfa7df736a77..a4383be50a253b1ecf586c4f3b531398de2304b8 100644 (file)
@@ -3,6 +3,7 @@
 namespace Database\Seeders;
 
 use BookStack\Api\ApiToken;
+use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Bookshelf;
 use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Page;
@@ -38,7 +39,7 @@ class DummyContentSeeder extends Seeder
 
         $byData = ['created_by' => $editorUser->id, 'updated_by' => $editorUser->id, 'owned_by' => $editorUser->id];
 
-        \BookStack\Entities\Models\Book::factory()->count(5)->create($byData)
+        Book::factory()->count(5)->create($byData)
             ->each(function ($book) use ($byData) {
                 $chapters = Chapter::factory()->count(3)->create($byData)
                     ->each(function ($chapter) use ($book, $byData) {
@@ -50,7 +51,7 @@ class DummyContentSeeder extends Seeder
                 $book->pages()->saveMany($pages);
             });
 
-        $largeBook = \BookStack\Entities\Models\Book::factory()->create(array_merge($byData, ['name' => 'Large book' . Str::random(10)]));
+        $largeBook = Book::factory()->create(array_merge($byData, ['name' => 'Large book' . Str::random(10)]));
         $pages = Page::factory()->count(200)->make($byData);
         $chapters = Chapter::factory()->count(50)->make($byData);
         $largeBook->pages()->saveMany($pages);
index 2a38dba8392fda308cdf460a45e159bd74853bce..71dbdcc658d8577ff97589b1c02560d5959ae643 100644 (file)
@@ -1,7 +1,7 @@
 {
   "name": "My own book",
-  "description": "This is my own little book",
-  "default_template_id": 12,
+  "description_html": "<p>This is <strong>my</strong> own little book created via the API</p>",
+  "default_template_id": 2427,
   "tags": [
     {"name": "Category", "value": "Top Content"},
     {"name": "Rating", "value": "Highest"}
index c026b7b4943f549534f224732a01f0fec26e6c6a..30ce7e95af4d50eecb5a4e2b9d9fe48bebea1478 100644 (file)
@@ -1,7 +1,7 @@
 {
   "name": "My updated book",
-  "description": "This is my book with updated details",
-  "default_template_id": 12,
+  "description_html": "<p>This is my book with <em>updated</em> details</p>",
+  "default_template_id": 2427,
   "tags": [
     {"name": "Subject", "value": "Updates"}
   ]
index a7a0e072c6c6db48a8727400399edd2907c37999..e9d9033874e91ae33feac193fad1ed8025d128bb 100644 (file)
@@ -1,7 +1,7 @@
 {
   "book_id": 1,
   "name": "My fantastic new chapter",
-  "description": "This is a great new chapter that I've created via the API",
+  "description_html": "<p>This is a <strong>great new chapter</strong> that I've created via the API</p>",
   "priority": 15,
   "tags": [
     {"name": "Category", "value": "Top Content"},
index 18c40301b6d7a6784b4ee3c88ddcf1b1967d3b1d..be675772bc6d26ac5a1dfc055cd10d98d414b358 100644 (file)
@@ -1,7 +1,7 @@
 {
   "book_id": 1,
   "name": "My fantastic updated chapter",
-  "description": "This is an updated chapter that I've altered via the API",
+  "description_html": "<p>This is an <strong>updated chapter</strong> that I've altered via the API</p>",
   "priority": 16,
   "tags": [
     {"name": "Category", "value": "Kinda Good Content"},
index 39b88af7e009969664d226b7537478582cb685a7..8f35340f65dd16cad2b8aa95b8f76f4dfc3ba4f6 100644 (file)
@@ -1,5 +1,8 @@
 {
   "name": "My shelf",
-  "description": "This is my shelf with some books",
-  "books": [5,1,3]
+  "description_html": "<p>This is <strong>my shelf</strong> with some books</p>",
+  "books": [5,1,3],
+  "tags": [
+    {"name": "Category", "value": "Learning"}
+  ]
 }
\ No newline at end of file
index df5f5735de0cb97098d598c7f92d6d2651e3e3c2..081c8f4c1f0ec7ce449720c8eb62234e2395e5d2 100644 (file)
@@ -1,5 +1,5 @@
 {
   "name": "My updated shelf",
-  "description": "This is my update shelf with some books",
+  "description_html": "<p>This is my <em>updated shelf</em> with some books</p>",
   "books": [5,1,3]
 }
\ No newline at end of file
index 773879125356cfca262a340ce4d42325a2ce98ca..8895fb854d43f2f8fd1e5b863928c6493af5c610 100644 (file)
@@ -1,12 +1,26 @@
 {
-  "id": 15,
-  "name": "My new book",
-  "slug": "my-new-book",
-  "description": "This is a book created via the API",
+  "id": 226,
+  "name": "My own book",
+  "slug": "my-own-book",
+  "description": "This is my own little book created via the API",
+  "created_at": "2023-12-22T14:22:28.000000Z",
+  "updated_at": "2023-12-22T14:22:28.000000Z",
   "created_by": 1,
   "updated_by": 1,
   "owned_by": 1,
-  "default_template_id": 12,
-  "updated_at": "2020-01-12T14:05:11.000000Z",
-  "created_at": "2020-01-12T14:05:11.000000Z"
+  "default_template_id": 2427,
+  "description_html": "<p>This is <strong>my<\/strong> own little book created via the API<\/p>",
+  "tags": [
+    {
+      "name": "Category",
+      "value": "Top Content",
+      "order": 0
+    },
+    {
+      "name": "Rating",
+      "value": "Highest",
+      "order": 0
+    }
+  ],
+  "cover": null
 }
\ No newline at end of file
index 21e1829b8eb4215eda4d046156b4ea5a637a6b78..afeebade619cc4a1f73e8fd8e0ebb06e8b7d32f1 100644 (file)
@@ -3,6 +3,7 @@
   "name": "My own book",
   "slug": "my-own-book",
   "description": "This is my own little book",
+  "description_html": "<p>This is my own <em>little</em> book</p>",
   "created_at": "2020-01-12T14:09:59.000000Z",
   "updated_at": "2020-01-12T14:11:51.000000Z",
   "created_by": {
index f69677c4ad79d3f75d89a4cedd5c3e415d3f9685..dafa2feb032315862c263f66034b55a8c27cb1c0 100644 (file)
@@ -1,12 +1,21 @@
 {
-  "id": 16,
+  "id": 226,
   "name": "My updated book",
   "slug": "my-updated-book",
   "description": "This is my book with updated details",
-  "created_at": "2020-01-12T14:09:59.000000Z",
-  "updated_at": "2020-01-12T14:16:10.000000Z",
+  "created_at": "2023-12-22T14:22:28.000000Z",
+  "updated_at": "2023-12-22T14:24:07.000000Z",
   "created_by": 1,
   "updated_by": 1,
   "owned_by": 1,
-  "default_template_id": 12
+  "default_template_id": 2427,
+  "description_html": "<p>This is my book with <em>updated<\/em> details<\/p>",
+  "tags": [
+    {
+      "name": "Subject",
+      "value": "Updates",
+      "order": 0
+    }
+  ],
+  "cover": null
 }
\ No newline at end of file
index cf47b123df7bb1027a2a5a5fe96bb61afd9d667a..183186b0b4295d1a895254a11cb097dd7c082cd4 100644 (file)
@@ -1,15 +1,16 @@
 {
-  "id": 74,
+  "id": 668,
   "book_id": 1,
   "slug": "my-fantastic-new-chapter",
   "name": "My fantastic new chapter",
   "description": "This is a great new chapter that I've created via the API",
   "priority": 15,
+  "created_at": "2023-12-22T14:26:28.000000Z",
+  "updated_at": "2023-12-22T14:26:28.000000Z",
   "created_by": 1,
   "updated_by": 1,
   "owned_by": 1,
-  "updated_at": "2020-05-22T22:59:55.000000Z",
-  "created_at": "2020-05-22T22:59:55.000000Z",
+  "description_html": "<p>This is a <strong>great new chapter<\/strong> that I've created via the API<\/p>",
   "tags": [
     {
       "name": "Category",
@@ -19,7 +20,7 @@
     {
       "name": "Rating",
       "value": "Highest",
-      "order": 1
+      "order": 0
     }
   ]
 }
\ No newline at end of file
index 5f4de85f107ca6196d6fbedb203b7238bb40bdb9..192ffce7cd25cbe16f1a0488d7554431551065fe 100644 (file)
@@ -4,6 +4,7 @@
   "slug": "content-creation",
   "name": "Content Creation",
   "description": "How to create documentation on whatever subject you need to write about.",
+  "description_html": "<p>How to create <strong>documentation</strong> on whatever subject you need to write about.</p>",
   "priority": 3,
   "created_at": "2019-05-05T21:49:56.000000Z",
   "updated_at": "2019-09-28T11:24:23.000000Z",
index a4940af2df535d35e5cb65682984cdb821a76449..5ac3c64c12fdfa0684b0e9046c93dc483215725c 100644 (file)
@@ -1,16 +1,16 @@
 {
-  "id": 75,
+  "id": 668,
   "book_id": 1,
   "slug": "my-fantastic-updated-chapter",
   "name": "My fantastic updated chapter",
   "description": "This is an updated chapter that I've altered via the API",
   "priority": 16,
-  "created_at": "2020-05-22T23:03:35.000000Z",
-  "updated_at": "2020-05-22T23:07:20.000000Z",
+  "created_at": "2023-12-22T14:26:28.000000Z",
+  "updated_at": "2023-12-22T14:27:59.000000Z",
   "created_by": 1,
   "updated_by": 1,
   "owned_by": 1,
-  "book_slug": "bookstack-demo-site",
+  "description_html": "<p>This is an <strong>updated chapter<\/strong> that I've altered via the API<\/p>",
   "tags": [
     {
       "name": "Category",
@@ -20,7 +20,7 @@
     {
       "name": "Rating",
       "value": "Medium",
-      "order": 1
+      "order": 0
     }
   ]
 }
\ No newline at end of file
index 84caf8bdc869a8e0356f6b8f0b7acfef1ece47f2..235557834704ff587fb70599b1101145b62bfe45 100644 (file)
@@ -1,11 +1,20 @@
 {
-  "id": 14,
+  "id": 20,
   "name": "My shelf",
   "slug": "my-shelf",
   "description": "This is my shelf with some books",
   "created_by": 1,
   "updated_by": 1,
+  "created_at": "2023-12-22T14:33:52.000000Z",
+  "updated_at": "2023-12-22T14:33:52.000000Z",
   "owned_by": 1,
-  "created_at": "2020-04-10T13:24:09.000000Z",
-  "updated_at": "2020-04-10T13:24:09.000000Z"
+  "description_html": "<p>This is <strong>my shelf<\/strong> with some books<\/p>",
+  "tags": [
+    {
+      "name": "Category",
+      "value": "Learning",
+      "order": 0
+    }
+  ],
+  "cover": null
 }
\ No newline at end of file
index 802045bd8322f378fe823cadb2515b0e852c7f3f..eca06a46b329b617c4f3bcf813e82b9c48ea8137 100644 (file)
@@ -3,6 +3,7 @@
   "name": "My shelf",
   "slug": "my-shelf",
   "description": "This is my shelf with some books",
+  "description_html": "<p>This is my shelf with some books</p>",
   "created_by": {
     "id": 1,
     "name": "Admin",
index e199d8d68a552cfc96664ff6aa7ad09f33b20c84..3b3f0538e689017fe5bc3e7a271294dbee27689a 100644 (file)
@@ -1,11 +1,20 @@
 {
-  "id": 14,
+  "id": 20,
   "name": "My updated shelf",
   "slug": "my-updated-shelf",
-  "description": "This is my update shelf with some books",
+  "description": "This is my updated shelf with some books",
   "created_by": 1,
   "updated_by": 1,
+  "created_at": "2023-12-22T14:33:52.000000Z",
+  "updated_at": "2023-12-22T14:35:00.000000Z",
   "owned_by": 1,
-  "created_at": "2020-04-10T13:24:09.000000Z",
-  "updated_at": "2020-04-10T13:48:22.000000Z"
+  "description_html": "<p>This is my <em>updated shelf<\/em> with some books<\/p>",
+  "tags": [
+    {
+      "name": "Category",
+      "value": "Learning",
+      "order": 0
+    }
+  ],
+  "cover": null
 }
\ No newline at end of file
index 354eee42e7983a1304cc21385264be0ac98eea47..f1f915544d18db285e63110cb743a2004089e86c 100644 (file)
@@ -23,7 +23,7 @@ return [
     'meta_updated' => 'Updated :timeLength',
     'meta_updated_name' => 'Updated :timeLength by :user',
     'meta_owned_name' => 'Owned by :user',
-    'meta_reference_page_count' => 'Referenced on :count page|Referenced on :count pages',
+    'meta_reference_count' => 'Referenced by :count item|Referenced by :count items',
     'entity_select' => 'Entity Select',
     'entity_select_lack_permission' => 'You don\'t have the required permissions to select this item',
     'images' => 'Images',
@@ -409,7 +409,7 @@ return [
     // References
     'references' => 'References',
     'references_none' => 'There are no tracked references to this item.',
-    'references_to_desc' => 'Shown below are all the known pages in the system that link to this item.',
+    'references_to_desc' => 'Listed below is all the known content in the system that links to this item.',
 
     // Watch Options
     'watch' => 'Watch',
index 9ff67d53efbe49637ddabd87f9fc50f333e9f2ac..6fb461968859ece0ad0f8934743c42e4f97c13d0 100644 (file)
@@ -15,8 +15,15 @@ export class EntitySelectorPopup extends Component {
         window.$events.listen('entity-select-confirm', this.handleConfirmedSelection.bind(this));
     }
 
-    show(callback, searchText = '') {
+    /**
+     * Show the selector popup.
+     * @param {Function} callback
+     * @param {String} searchText
+     * @param {EntitySelectorSearchOptions} searchOptions
+     */
+    show(callback, searchText = '', searchOptions = {}) {
         this.callback = callback;
+        this.getSelector().configureSearchOptions(searchOptions);
         this.getPopup().show();
 
         if (searchText) {
index b12eeb402ba14768d8b4ae573b9864ad4b15c844..5ad9914378e2632e446ffbcaff7728a5bc42fdc1 100644 (file)
@@ -1,6 +1,13 @@
 import {onChildEvent} from '../services/dom';
 import {Component} from './component';
 
+/**
+ * @typedef EntitySelectorSearchOptions
+ * @property entityTypes string
+ * @property entityPermission string
+ * @property searchEndpoint string
+ */
+
 /**
  * Entity Selector
  */
@@ -8,21 +15,35 @@ export class EntitySelector extends Component {
 
     setup() {
         this.elem = this.$el;
-        this.entityTypes = this.$opts.entityTypes || 'page,book,chapter';
-        this.entityPermission = this.$opts.entityPermission || 'view';
-        this.searchEndpoint = this.$opts.searchEndpoint || '/search/entity-selector';
 
         this.input = this.$refs.input;
         this.searchInput = this.$refs.search;
         this.loading = this.$refs.loading;
         this.resultsContainer = this.$refs.results;
 
+        this.searchOptions = {
+            entityTypes: this.$opts.entityTypes || 'page,book,chapter',
+            entityPermission: this.$opts.entityPermission || 'view',
+            searchEndpoint: this.$opts.searchEndpoint || '',
+        };
+
         this.search = '';
         this.lastClick = 0;
 
         this.setupListeners();
         this.showLoading();
-        this.initialLoad();
+
+        if (this.searchOptions.searchEndpoint) {
+            this.initialLoad();
+        }
+    }
+
+    /**
+     * @param {EntitySelectorSearchOptions} options
+     */
+    configureSearchOptions(options) {
+        Object.assign(this.searchOptions, options);
+        this.reset();
     }
 
     setupListeners() {
@@ -103,6 +124,10 @@ export class EntitySelector extends Component {
     }
 
     initialLoad() {
+        if (!this.searchOptions.searchEndpoint) {
+            throw new Error('Search endpoint not set for entity-selector load');
+        }
+
         window.$http.get(this.searchUrl()).then(resp => {
             this.resultsContainer.innerHTML = resp.data;
             this.hideLoading();
@@ -110,10 +135,15 @@ export class EntitySelector extends Component {
     }
 
     searchUrl() {
-        return `${this.searchEndpoint}?types=${encodeURIComponent(this.entityTypes)}&permission=${encodeURIComponent(this.entityPermission)}`;
+        const query = `types=${encodeURIComponent(this.searchOptions.entityTypes)}&permission=${encodeURIComponent(this.searchOptions.entityPermission)}`;
+        return `${this.searchOptions.searchEndpoint}?${query}`;
     }
 
     searchEntities(searchTerm) {
+        if (!this.searchOptions.searchEndpoint) {
+            throw new Error('Search endpoint not set for entity-selector load');
+        }
+
         this.input.value = '';
         const url = `${this.searchUrl()}&term=${encodeURIComponent(searchTerm)}`;
         window.$http.get(url).then(resp => {
index a56f18a5aff728f7f7aba88aeb9b6f42c645ad9a..3a66079d7f8345d81cf4cd543fe55ef5c5745472 100644 (file)
@@ -58,3 +58,4 @@ export {TriLayout} from './tri-layout';
 export {UserSelect} from './user-select';
 export {WebhookEvents} from './webhook-events';
 export {WysiwygEditor} from './wysiwyg-editor';
+export {WysiwygInput} from './wysiwyg-input';
index 9bb0bee04a3fb6be13e798d01352e0b59b6fa2bf..39af67229932910b3e283ed6859a53ffc67ef937 100644 (file)
@@ -14,6 +14,8 @@ export class PagePicker extends Component {
         this.defaultDisplay = this.$refs.defaultDisplay;
         this.buttonSep = this.$refs.buttonSeperator;
 
+        this.selectorEndpoint = this.$opts.selectorEndpoint;
+
         this.value = this.input.value;
         this.setupListeners();
     }
@@ -33,6 +35,10 @@ export class PagePicker extends Component {
         const selectorPopup = window.$components.first('entity-selector-popup');
         selectorPopup.show(entity => {
             this.setValue(entity.id, entity.name);
+        }, '', {
+            searchEndpoint: this.selectorEndpoint,
+            entityTypes: 'page',
+            entityPermission: 'view',
         });
     }
 
index 21db207e6705451daf45d7a241210b63c1aa00be..82f60827d7af668637d3624d7bbd38c5658ce311 100644 (file)
@@ -1,4 +1,4 @@
-import {build as buildEditorConfig} from '../wysiwyg/config';
+import {buildForEditor as buildEditorConfig} from '../wysiwyg/config';
 import {Component} from './component';
 
 export class WysiwygEditor extends Component {
@@ -6,17 +6,13 @@ export class WysiwygEditor extends Component {
     setup() {
         this.elem = this.$el;
 
-        this.pageId = this.$opts.pageId;
-        this.textDirection = this.$opts.textDirection;
-        this.isDarkMode = document.documentElement.classList.contains('dark-mode');
-
         this.tinyMceConfig = buildEditorConfig({
             language: this.$opts.language,
             containerElement: this.elem,
-            darkMode: this.isDarkMode,
-            textDirection: this.textDirection,
+            darkMode: document.documentElement.classList.contains('dark-mode'),
+            textDirection: this.$opts.textDirection,
             drawioUrl: this.getDrawIoUrl(),
-            pageId: Number(this.pageId),
+            pageId: Number(this.$opts.pageId),
             translations: {
                 imageUploadErrorText: this.$opts.imageUploadErrorText,
                 serverUploadLimitText: this.$opts.serverUploadLimitText,
diff --git a/resources/js/components/wysiwyg-input.js b/resources/js/components/wysiwyg-input.js
new file mode 100644 (file)
index 0000000..88c06a3
--- /dev/null
@@ -0,0 +1,26 @@
+import {Component} from './component';
+import {buildForInput} from '../wysiwyg/config';
+
+export class WysiwygInput extends Component {
+
+    setup() {
+        this.elem = this.$el;
+
+        const config = buildForInput({
+            language: this.$opts.language,
+            containerElement: this.elem,
+            darkMode: document.documentElement.classList.contains('dark-mode'),
+            textDirection: this.textDirection,
+            translations: {
+                imageUploadErrorText: this.$opts.imageUploadErrorText,
+                serverUploadLimitText: this.$opts.serverUploadLimitText,
+            },
+            translationMap: window.editor_translations,
+        });
+
+        window.tinymce.init(config).then(editors => {
+            this.editor = editors[0];
+        });
+    }
+
+}
index 4909a59d066f9627b3756e951be2079940369083..511f1ebdae6a38caa7fa31b3d965cc9890e4694e 100644 (file)
@@ -73,7 +73,11 @@ export class Actions {
             const selectedText = selectionText || entity.name;
             const newText = `[${selectedText}](${entity.link})`;
             this.#replaceSelection(newText, newText.length, selectionRange);
-        }, selectionText);
+        }, selectionText, {
+            searchEndpoint: '/search/entity-selector',
+            entityTypes: 'page,book,chapter,bookshelf',
+            entityPermission: 'view',
+        });
     }
 
     // Show draw.io if enabled and handle save.
index 6973db8c86d606aab1cb628d8d3f1f36ca951f81..963e2970d341356d83f685994026ab9091790c8c 100644 (file)
@@ -85,7 +85,11 @@ function filePickerCallback(callback, value, meta) {
                 text: entity.name,
                 title: entity.name,
             });
-        }, selectionText);
+        }, selectionText, {
+            searchEndpoint: '/search/entity-selector',
+            entityTypes: 'page,book,chapter,bookshelf',
+            entityPermission: 'view',
+        });
     }
 
     if (meta.filetype === 'image') {
@@ -217,7 +221,7 @@ body {
  * @param {WysiwygConfigOptions} options
  * @return {Object}
  */
-export function build(options) {
+export function buildForEditor(options) {
     // Set language
     window.tinymce.addI18n(options.language, options.translationMap);
 
@@ -290,6 +294,54 @@ export function build(options) {
     };
 }
 
+/**
+ * @param {WysiwygConfigOptions} options
+ * @return {RawEditorOptions}
+ */
+export function buildForInput(options) {
+    // Set language
+    window.tinymce.addI18n(options.language, options.translationMap);
+
+    // BookStack Version
+    const version = document.querySelector('script[src*="/dist/app.js"]').getAttribute('src').split('?version=')[1];
+
+    // Return config object
+    return {
+        width: '100%',
+        height: '185px',
+        target: options.containerElement,
+        cache_suffix: `?version=${version}`,
+        content_css: [
+            window.baseUrl('/dist/styles.css'),
+        ],
+        branding: false,
+        skin: options.darkMode ? 'tinymce-5-dark' : 'tinymce-5',
+        body_class: 'wysiwyg-input',
+        browser_spellcheck: true,
+        relative_urls: false,
+        language: options.language,
+        directionality: options.textDirection,
+        remove_script_host: false,
+        document_base_url: window.baseUrl('/'),
+        end_container_on_empty_block: true,
+        remove_trailing_brs: false,
+        statusbar: false,
+        menubar: false,
+        plugins: 'link autolink lists',
+        contextmenu: false,
+        toolbar: 'bold italic link bullist numlist',
+        content_style: getContentStyle(options),
+        file_picker_types: 'file',
+        file_picker_callback: filePickerCallback,
+        init_instance_callback(editor) {
+            const head = editor.getDoc().querySelector('head');
+            head.innerHTML += fetchCustomHeadContent();
+
+            editor.contentDocument.documentElement.classList.toggle('dark-mode', options.darkMode);
+        },
+    };
+}
+
 /**
  * @typedef {Object} WysiwygConfigOptions
  * @property {Element} containerElement
index 147e3c2d5c9aceb2d4c573a6aef5d8e4f74335c7..da9e0227099aaa9e6adad45398884987c1c9435a 100644 (file)
@@ -58,6 +58,10 @@ export function register(editor) {
 
             editor.selection.collapse(false);
             editor.focus();
-        }, selectionText);
+        }, selectionText, {
+            searchEndpoint: '/search/entity-selector',
+            entityTypes: 'page,book,chapter,bookshelf',
+            entityPermission: 'view',
+        });
     });
 }
index cd5d929f4b57835e5e8da3629d2988645f2ed5e9..8c277c2b5010c755eede2124fa88d59cc45a259c 100644 (file)
@@ -406,6 +406,15 @@ input[type=color] {
   height: auto;
 }
 
+.description-input > .tox-tinymce {
+  border: 1px solid #DDD !important;
+  @include lightDark(border-color, #DDD !important, #000 !important);
+  border-radius: 3px;
+  .tox-toolbar__primary {
+    justify-content: end;
+  }
+}
+
 .search-box {
   max-width: 100%;
   position: relative;
index 8e036fc462069c95834b4f095fa8824694a38fd2..c4336da7cb7efd02cdd3e96f171b8fe84181aa7f 100644 (file)
   display: block;
 }
 
+.wysiwyg-input.mce-content-body {
+  padding-block-start: 1rem;
+  padding-block-end: 1rem;
+  outline: 0;
+  display: block;
+}
+
 // Default styles for our custom root nodes
 .page-content.mce-content-body doc-root {
   display: block;
index e22be619d66dd107eb54fd6b529ce292e1fb1af7..fa8f16e52f4a5d5d17273025900d9c08bd6a5682 100644 (file)
@@ -1,3 +1,6 @@
+@push('head')
+    <script src="{{ versioned_asset('libs/tinymce/tinymce.min.js') }}" nonce="{{ $cspNonce }}"></script>
+@endpush
 
 {{ csrf_field() }}
 <div class="form-group title-input">
@@ -6,8 +9,8 @@
 </div>
 
 <div class="form-group description-input">
-    <label for="description">{{ trans('common.description') }}</label>
-    @include('form.textarea', ['name' => 'description'])
+    <label for="description_html">{{ trans('common.description') }}</label>
+    @include('form.description-html-input')
 </div>
 
 <div class="form-group collapsible" component="collapsible" id="logo-control">
@@ -36,7 +39,7 @@
 </div>
 
 <div class="form-group collapsible" component="collapsible" id="template-control">
-    <button refs="collapsible@trigger" type="button" class="collapse-title text-primary" aria-expanded="false">
+    <button refs="collapsible@trigger" type="button" class="collapse-title text-link" aria-expanded="false">
         <label for="template-manager">{{ trans('entities.books_default_template') }}</label>
     </button>
     <div refs="collapsible@content" class="collapse-content">
@@ -50,6 +53,7 @@
                     'name' => 'default_template_id',
                     'placeholder' => trans('entities.books_default_template_select'),
                     'value' => $book->default_template_id ?? null,
+                    'selectorEndpoint' => '/search/entity-selector-templates',
                 ])
             </div>
         </div>
@@ -62,4 +66,5 @@
     <button type="submit" class="button">{{ trans('entities.books_save') }}</button>
 </div>
 
-@include('entities.selector-popup', ['entityTypes' => 'page', 'selectorEndpoint' => '/search/entity-selector-templates'])
\ No newline at end of file
+@include('entities.selector-popup')
+@include('form.editor-translations')
\ No newline at end of file
index 8f7c3f6cf1133c08546be55dcce3e962774cea06..dbb09fc9e877eb1e51513dd0640087dcb4c40947 100644 (file)
@@ -26,7 +26,7 @@
     <main class="content-wrap card">
         <h1 class="break-text">{{$book->name}}</h1>
         <div refs="entity-search@contentView" class="book-content">
-            <p class="text-muted">{!! nl2br(e($book->description)) !!}</p>
+            <div class="text-muted break-text">{!! $book->descriptionHtml() !!}</div>
             @if(count($bookChildren) > 0)
                 <div class="entity-list book-contents">
                     @foreach($bookChildren as $childElement)
index 8abcebe133aef9bb520f3033b72067ef2b7d223d..c6052c93af415b82d523f3163d3ceeaf0a45e108 100644 (file)
@@ -1,14 +1,16 @@
+@push('head')
+    <script src="{{ versioned_asset('libs/tinymce/tinymce.min.js') }}" nonce="{{ $cspNonce }}"></script>
+@endpush
 
-{!! csrf_field() !!}
-
+{{ csrf_field() }}
 <div class="form-group title-input">
     <label for="name">{{ trans('common.name') }}</label>
     @include('form.text', ['name' => 'name', 'autofocus' => true])
 </div>
 
 <div class="form-group description-input">
-    <label for="description">{{ trans('common.description') }}</label>
-    @include('form.textarea', ['name' => 'description'])
+    <label for="description_html">{{ trans('common.description') }}</label>
+    @include('form.description-html-input')
 </div>
 
 <div class="form-group collapsible" component="collapsible" id="logo-control">
@@ -24,3 +26,6 @@
     <a href="{{ isset($chapter) ? $chapter->getUrl() : $book->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
     <button type="submit" class="button">{{ trans('entities.chapters_save') }}</button>
 </div>
+
+@include('entities.selector-popup')
+@include('form.editor-translations')
\ No newline at end of file
index 0e5224d54783294da51558a0e3d231bdcb8919d1..45e43ad96a91dda5862e8f26d422e0d129ed5c6d 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">
-            <p class="text-muted break-text">{!! nl2br(e($chapter->description)) !!}</p>
+            <div class="text-muted break-text">{!! $chapter->descriptionHtml() !!}</div>
             @if(count($pages) > 0)
                 <div class="entity-list book-contents">
                     @foreach($pages as $page)
index 2298be8bb27689ec2435828f7457ff2048942b51..9d3c4b956a7d784feecb7f38abdc8f7d3a668d57 100644 (file)
@@ -64,7 +64,7 @@
         <a href="{{ $entity->getUrl('/references') }}" class="entity-meta-item">
             @icon('reference')
             <div>
-                {!! trans_choice('entities.meta_reference_page_count', $referenceCount, ['count' => $referenceCount]) !!}
+                {{ trans_choice('entities.meta_reference_count', $referenceCount, ['count' => $referenceCount]) }}
             </div>
         </a>
     @endif
index d4c941e9a3358fa82e392d81639148c5a3019244..ac91725d63dc53d5f9f089c37bad8c1095e0c664 100644 (file)
@@ -5,7 +5,7 @@
                 <div class="popup-title">{{ trans('entities.entity_select') }}</div>
                 <button refs="popup@hide" type="button" class="popup-header-close">@icon('close')</button>
             </div>
-            @include('entities.selector', ['name' => 'entity-selector'])
+            @include('entities.selector', ['name' => 'entity-selector', 'selectorEndpoint' => ''])
             <div class="popup-footer">
                 <button refs="entity-selector-popup@select" type="button" disabled class="button">{{ trans('common.select') }}</button>
             </div>
index 42e03ea01bb581366916bfb3134eadc76c92d7e2..9de7b8eba6c1be4e9be19d1e2ba931343bdf5cb4 100644 (file)
@@ -5,7 +5,7 @@
 @section('content')
 
     <h1 style="font-size: 4.8em">{{$book->name}}</h1>
-    <p>{{ $book->description }}</p>
+    <div>{!! $book->descriptionHtml() !!}</div>
 
     @include('exports.parts.book-contents-menu', ['children' => $bookChildren])
 
index ae49fa918288d70e7649861fd643aee762e8f4a7..515366d60e33f9bebcb2e8abdf404f50c74aabb5 100644 (file)
@@ -5,7 +5,7 @@
 @section('content')
 
     <h1 style="font-size: 4.8em">{{$chapter->name}}</h1>
-    <p>{{ $chapter->description }}</p>
+    <div>{!! $chapter->descriptionHtml() !!}</div>
 
     @include('exports.parts.chapter-contents-menu', ['pages' => $pages])
 
index f58068b5e4cf3dcd89055f8a69df90d761d39bda..fa0b1f22884b6eeb7ecef0612899579f01bcf782 100644 (file)
@@ -1,7 +1,7 @@
 <div class="page-break"></div>
 <h1 id="chapter-{{$chapter->id}}">{{ $chapter->name }}</h1>
 
-<p>{{ $chapter->description }}</p>
+<div>{!! $chapter->descriptionHtml() !!}</div>
 
 @if(count($chapter->visible_pages) > 0)
     @foreach($chapter->visible_pages as $page)
diff --git a/resources/views/form/description-html-input.blade.php b/resources/views/form/description-html-input.blade.php
new file mode 100644 (file)
index 0000000..3cf726b
--- /dev/null
@@ -0,0 +1,8 @@
+<textarea component="wysiwyg-input"
+          option:wysiwyg-input:language="{{ $locale->htmlLang() }}"
+          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'))
+    <div class="text-neg text-small">{{ $errors->first('description_html') }}</div>
+@endif
\ No newline at end of file
index d9810d575df311b0d0b5955c0afa231c570f74ca..ad0a9d516710b6def674dff1a417bfda2a06e877 100644 (file)
@@ -1,6 +1,7 @@
 
 {{--Depends on entity selector popup--}}
-<div component="page-picker">
+<div component="page-picker"
+     option:page-picker:selector-endpoint="{{ $selectorEndpoint }}">
     <div class="input-base overflow-hidden height-auto">
         <span @if($value) hidden @endif refs="page-picker@default-display" class="text-muted italic">{{ $placeholder }}</span>
         <a @if(!$value) hidden @endif href="{{ url('/link/' . $value) }}" target="_blank" rel="noopener" class="text-page" refs="page-picker@display">#{{$value}}, {{$value ? \BookStack\Entities\Models\Page::query()->visible()->find($value)->name ?? '' : '' }}</a>
index ca6b6da8a2809f74ea32060fb5d29fceee6225ab..84a267b681b9b6a257d664eaaf460669d383ec93 100644 (file)
@@ -18,4 +18,4 @@
     <div class="text-neg text-small">{{ $errors->first('html') }}</div>
 @endif
 
-@include('pages.parts.editor-translations')
\ No newline at end of file
+@include('form.editor-translations')
\ No newline at end of file
index 7112ebcff64d44fb9f5daee60da23a5f9a8cd4c2..4845e2055fc38f3d75248910666cf55978a89962 100644 (file)
@@ -3,7 +3,7 @@
 @section('card')
     <h1 id="customization" class="list-heading">{{ trans('settings.app_customization') }}</h1>
     <form action="{{ url("/settings/customization") }}" method="POST" enctype="multipart/form-data">
-        {!! csrf_field() !!}
+        {{ csrf_field() }}
         <input type="hidden" name="section" value="customization">
 
         <div class="setting-list">
                     </select>
 
                     <div refs="setting-homepage-control@page-picker-container" style="display: none;" class="mt-m">
-                        @include('form.page-picker', ['name' => 'setting-app-homepage', 'placeholder' => trans('settings.app_homepage_select'), 'value' => setting('app-homepage')])
+                        @include('form.page-picker', [
+                            'name' => 'setting-app-homepage',
+                            'placeholder' => trans('settings.app_homepage_select'),
+                            'value' => setting('app-homepage'),
+                            'selectorEndpoint' => '/search/entity-selector',
+                        ])
                     </div>
                 </div>
             </div>
 @endsection
 
 @section('after-content')
-    @include('entities.selector-popup', ['entityTypes' => 'page'])
+    @include('entities.selector-popup')
 @endsection
index ad67cb85ce80dbe73fb870d83201b8fe56bebfa5..a75dd6ac1b53b62b3929d33d7d324a827810e966 100644 (file)
@@ -1,13 +1,16 @@
-{{ csrf_field() }}
+@push('head')
+    <script src="{{ versioned_asset('libs/tinymce/tinymce.min.js') }}" nonce="{{ $cspNonce }}"></script>
+@endpush
 
+{{ csrf_field() }}
 <div class="form-group title-input">
     <label for="name">{{ trans('common.name') }}</label>
     @include('form.text', ['name' => 'name', 'autofocus' => true])
 </div>
 
 <div class="form-group description-input">
-    <label for="description">{{ trans('common.description') }}</label>
-    @include('form.textarea', ['name' => 'description'])
+    <label for="description_html">{{ trans('common.description') }}</label>
+    @include('form.description-html-input')
 </div>
 
 <div component="shelf-sort" class="grid half gap-xl">
@@ -84,4 +87,7 @@
 <div class="form-group text-right">
     <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>
\ No newline at end of file
+</div>
+
+@include('entities.selector-popup')
+@include('form.editor-translations')
\ No newline at end of file
index 58fe1cd86b0ef737e71ee0b328ab7df8fa3c701a..11baccaf46354db6bb6a2395d7fd2f7b268fc41f 100644 (file)
@@ -28,7 +28,7 @@
         </div>
 
         <div class="book-content">
-            <p class="text-muted">{!! nl2br(e($shelf->description)) !!}</p>
+            <div class="text-muted break-text">{!! $shelf->descriptionHtml() !!}</div>
             @if(count($sortedVisibleShelfBooks) > 0)
                 @if($view === 'list')
                     <div class="entity-list">
index c648faaf2f463977ad6cd2977c005fd6d488c2d7..b31bd7d37ee06e66de9940a1a710bc5ab2eab7ec 100644 (file)
@@ -33,8 +33,8 @@ class BooksApiTest extends TestCase
         $this->actingAsApiEditor();
         $templatePage = $this->entities->templatePage();
         $details = [
-            'name'        => 'My API book',
-            'description' => 'A book created via the API',
+            'name'                => 'My API book',
+            'description'         => 'A book created via the API',
             'default_template_id' => $templatePage->id,
         ];
 
@@ -42,10 +42,35 @@ class BooksApiTest extends TestCase
         $resp->assertStatus(200);
 
         $newItem = Book::query()->orderByDesc('id')->where('name', '=', $details['name'])->first();
-        $resp->assertJson(array_merge($details, ['id' => $newItem->id, 'slug' => $newItem->slug]));
+        $resp->assertJson(array_merge($details, [
+            'id' => $newItem->id,
+            'slug' => $newItem->slug,
+            'description_html' => '<p>A book created via the API</p>',
+        ]));
         $this->assertActivityExists('book_create', $newItem);
     }
 
+    public function test_create_endpoint_with_html()
+    {
+        $this->actingAsApiEditor();
+        $details = [
+            'name'             => 'My API book',
+            'description_html' => '<p>A book <em>created</em> <strong>via</strong> the API</p>',
+        ];
+
+        $resp = $this->postJson($this->baseEndpoint, $details);
+        $resp->assertStatus(200);
+
+        $newItem = Book::query()->orderByDesc('id')->where('name', '=', $details['name'])->first();
+        $expectedDetails = array_merge($details, [
+            'id'          => $newItem->id,
+            'description' => 'A book created via the API',
+        ]);
+
+        $resp->assertJson($expectedDetails);
+        $this->assertDatabaseHas('books', $expectedDetails);
+    }
+
     public function test_book_name_needed_to_create()
     {
         $this->actingAsApiEditor();
@@ -61,7 +86,7 @@ class BooksApiTest extends TestCase
                 'validation' => [
                     'name' => ['The name field is required.'],
                 ],
-                'code' => 422,
+                'code'       => 422,
             ],
         ]);
     }
@@ -128,7 +153,7 @@ class BooksApiTest extends TestCase
         $templatePage = $this->entities->templatePage();
         $details = [
             'name'        => 'My updated API book',
-            'description' => 'A book created via the API',
+            'description' => 'A book updated via the API',
             'default_template_id' => $templatePage->id,
         ];
 
@@ -136,10 +161,29 @@ class BooksApiTest extends TestCase
         $book->refresh();
 
         $resp->assertStatus(200);
-        $resp->assertJson(array_merge($details, ['id' => $book->id, 'slug' => $book->slug]));
+        $resp->assertJson(array_merge($details, [
+            'id' => $book->id,
+            'slug' => $book->slug,
+            'description_html' => '<p>A book updated via the API</p>',
+        ]));
         $this->assertActivityExists('book_update', $book);
     }
 
+    public function test_update_endpoint_with_html()
+    {
+        $this->actingAsApiEditor();
+        $book = $this->entities->book();
+        $details = [
+            'name'             => 'My updated API book',
+            'description_html' => '<p>A book <strong>updated</strong> via the API</p>',
+        ];
+
+        $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']));
+    }
+
     public function test_update_increments_updated_date_if_only_tags_are_sent()
     {
         $this->actingAsApiEditor();
index 0629f3aedae61fadd338fcc9dc20cfefc959da2d..81a91887794f693acaa5d6df28df9dfca259b716 100644 (file)
@@ -51,7 +51,11 @@ class ChaptersApiTest extends TestCase
         $resp = $this->postJson($this->baseEndpoint, $details);
         $resp->assertStatus(200);
         $newItem = Chapter::query()->orderByDesc('id')->where('name', '=', $details['name'])->first();
-        $resp->assertJson(array_merge($details, ['id' => $newItem->id, 'slug' => $newItem->slug]));
+        $resp->assertJson(array_merge($details, [
+            'id' => $newItem->id,
+            'slug' => $newItem->slug,
+            'description_html' => '<p>A chapter created via the API</p>',
+        ]));
         $this->assertDatabaseHas('tags', [
             'entity_id'   => $newItem->id,
             'entity_type' => $newItem->getMorphClass(),
@@ -62,6 +66,28 @@ class ChaptersApiTest extends TestCase
         $this->assertActivityExists('chapter_create', $newItem);
     }
 
+    public function test_create_endpoint_with_html()
+    {
+        $this->actingAsApiEditor();
+        $book = $this->entities->book();
+        $details = [
+            'name'             => 'My API chapter',
+            'description_html' => '<p>A chapter <strong>created</strong> via the API</p>',
+            'book_id'          => $book->id,
+        ];
+
+        $resp = $this->postJson($this->baseEndpoint, $details);
+        $resp->assertStatus(200);
+        $newItem = Chapter::query()->orderByDesc('id')->where('name', '=', $details['name'])->first();
+
+        $expectedDetails = array_merge($details, [
+            'id'          => $newItem->id,
+            'description' => 'A chapter created via the API',
+        ]);
+        $resp->assertJson($expectedDetails);
+        $this->assertDatabaseHas('chapters', $expectedDetails);
+    }
+
     public function test_chapter_name_needed_to_create()
     {
         $this->actingAsApiEditor();
@@ -131,7 +157,7 @@ class ChaptersApiTest extends TestCase
         $chapter = $this->entities->chapter();
         $details = [
             'name'        => 'My updated API chapter',
-            'description' => 'A chapter created via the API',
+            'description' => 'A chapter updated via the API',
             'tags'        => [
                 [
                     'name'  => 'freshtag',
@@ -146,11 +172,31 @@ class ChaptersApiTest extends TestCase
 
         $resp->assertStatus(200);
         $resp->assertJson(array_merge($details, [
-            'id' => $chapter->id, 'slug' => $chapter->slug, 'book_id' => $chapter->book_id,
+            'id' => $chapter->id,
+            'slug' => $chapter->slug,
+            'book_id' => $chapter->book_id,
+            'description_html' => '<p>A chapter updated via the API</p>',
         ]));
         $this->assertActivityExists('chapter_update', $chapter);
     }
 
+    public function test_update_endpoint_with_html()
+    {
+        $this->actingAsApiEditor();
+        $chapter = $this->entities->chapter();
+        $details = [
+            'name'             => 'My updated API chapter',
+            'description_html' => '<p>A chapter <em>updated</em> via the API</p>',
+        ];
+
+        $resp = $this->putJson($this->baseEndpoint . "/{$chapter->id}", $details);
+        $resp->assertStatus(200);
+
+        $this->assertDatabaseHas('chapters', array_merge($details, [
+            'id' => $chapter->id, 'description' => 'A chapter updated via the API'
+        ]));
+    }
+
     public function test_update_increments_updated_date_if_only_tags_are_sent()
     {
         $this->actingAsApiEditor();
index cdc954ec3259e7d7827d22e905589c140b33c997..2a186e8d6328c4133b86eeb643d1436f40d5b78b 100644 (file)
@@ -52,7 +52,7 @@ class SearchApiTest extends TestCase
     public function test_all_endpoint_returns_items_with_preview_html()
     {
         $book = $this->entities->book();
-        $book->update(['name' => 'name with superuniquevalue within', 'description' => 'Description with superuniquevalue within']);
+        $book->forceFill(['name' => 'name with superuniquevalue within', 'description' => 'Description with superuniquevalue within'])->save();
         $book->indexForSearch();
 
         $resp = $this->actingAsApiAdmin()->getJson($this->baseEndpoint . '?query=superuniquevalue');
index fbfc17cb4da6af0b8c55fcbf9cdde7f19b5ac10a..f1b8ed98553cce61f099273f52dd7c1784d9511d 100644 (file)
@@ -42,7 +42,11 @@ class ShelvesApiTest extends TestCase
         $resp = $this->postJson($this->baseEndpoint, array_merge($details, ['books' => [$books[0]->id, $books[1]->id]]));
         $resp->assertStatus(200);
         $newItem = Bookshelf::query()->orderByDesc('id')->where('name', '=', $details['name'])->first();
-        $resp->assertJson(array_merge($details, ['id' => $newItem->id, 'slug' => $newItem->slug]));
+        $resp->assertJson(array_merge($details, [
+            'id' => $newItem->id,
+            'slug' => $newItem->slug,
+            'description_html' => '<p>A shelf created via the API</p>',
+        ]));
         $this->assertActivityExists('bookshelf_create', $newItem);
         foreach ($books as $index => $book) {
             $this->assertDatabaseHas('bookshelves_books', [
@@ -53,6 +57,28 @@ class ShelvesApiTest extends TestCase
         }
     }
 
+    public function test_create_endpoint_with_html()
+    {
+        $this->actingAsApiEditor();
+
+        $details = [
+            'name'             => 'My API shelf',
+            'description_html' => '<p>A <strong>shelf</strong> created via the API</p>',
+        ];
+
+        $resp = $this->postJson($this->baseEndpoint, $details);
+        $resp->assertStatus(200);
+        $newItem = Bookshelf::query()->orderByDesc('id')->where('name', '=', $details['name'])->first();
+
+        $expectedDetails = array_merge($details, [
+            'id'          => $newItem->id,
+            'description' => 'A shelf created via the API',
+        ]);
+
+        $resp->assertJson($expectedDetails);
+        $this->assertDatabaseHas('bookshelves', $expectedDetails);
+    }
+
     public function test_shelf_name_needed_to_create()
     {
         $this->actingAsApiEditor();
@@ -102,17 +128,36 @@ class ShelvesApiTest extends TestCase
         $shelf = Bookshelf::visible()->first();
         $details = [
             'name'        => 'My updated API shelf',
-            'description' => 'A shelf created via the API',
+            'description' => 'A shelf updated via the API',
         ];
 
         $resp = $this->putJson($this->baseEndpoint . "/{$shelf->id}", $details);
         $shelf->refresh();
 
         $resp->assertStatus(200);
-        $resp->assertJson(array_merge($details, ['id' => $shelf->id, 'slug' => $shelf->slug]));
+        $resp->assertJson(array_merge($details, [
+            'id' => $shelf->id,
+            'slug' => $shelf->slug,
+            'description_html' => '<p>A shelf updated via the API</p>',
+        ]));
         $this->assertActivityExists('bookshelf_update', $shelf);
     }
 
+    public function test_update_endpoint_with_html()
+    {
+        $this->actingAsApiEditor();
+        $shelf = Bookshelf::visible()->first();
+        $details = [
+            'name'             => 'My updated API shelf',
+            'description_html' => '<p>A shelf <em>updated</em> via the API</p>',
+        ];
+
+        $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']));
+    }
+
     public function test_update_increments_updated_date_if_only_tags_are_sent()
     {
         $this->actingAsApiEditor();
index 280c81febd1cad9a67db4af638dd69ee4b0a2023..62c39c2741f7321d45eed4667725a07b8019f123 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace Tests\Commands;
 
+use BookStack\Entities\Models\Entity;
 use Illuminate\Support\Facades\Artisan;
 use Symfony\Component\Console\Exception\RuntimeException;
 use Tests\TestCase;
@@ -24,6 +25,28 @@ class UpdateUrlCommandTest extends TestCase
         ]);
     }
 
+    public function test_command_updates_description_html()
+    {
+        /** @var Entity[] $models */
+        $models = [$this->entities->book(), $this->entities->chapter(), $this->entities->shelf()];
+
+        foreach ($models as $model) {
+            $model->description_html = '<a href="https://example.com/donkeys"></a>';
+            $model->save();
+        }
+
+        $this->artisan('bookstack:update-url https://example.com https://cats.example.com')
+            ->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');
+
+        foreach ($models as $model) {
+            $this->assertDatabaseHas($model->getTable(), [
+                'id'               => $model->id,
+                'description_html' => '<a href="https://cats.example.com/donkeys"></a>',
+            ]);
+        }
+    }
+
     public function test_command_requires_valid_url()
     {
         $badUrlMessage = 'The given urls are expected to be full urls starting with http:// or https://';
index c1842c175a791c335810179a7111c5c49ae31a5e..fb9862931ae4c211cdc9a184fa533d2f41a00dc5 100644 (file)
@@ -77,8 +77,8 @@ class BookShelfTest extends TestCase
     {
         $booksToInclude = Book::take(2)->get();
         $shelfInfo = [
-            'name'        => 'My test book' . Str::random(4),
-            'description' => 'Test book description ' . Str::random(10),
+            'name'             => 'My test shelf' . Str::random(4),
+            'description_html' => '<p>Test book description ' . Str::random(10) . '</p>',
         ];
         $resp = $this->asEditor()->post('/shelves', array_merge($shelfInfo, [
             'books' => $booksToInclude->implode('id', ','),
@@ -96,7 +96,7 @@ class BookShelfTest extends TestCase
         $shelf = Bookshelf::where('name', '=', $shelfInfo['name'])->first();
         $shelfPage = $this->get($shelf->getUrl());
         $shelfPage->assertSee($shelfInfo['name']);
-        $shelfPage->assertSee($shelfInfo['description']);
+        $shelfPage->assertSee($shelfInfo['description_html'], false);
         $this->withHtml($shelfPage)->assertElementContains('.tag-item', 'Test Category');
         $this->withHtml($shelfPage)->assertElementContains('.tag-item', 'Test Tag Value');
 
@@ -107,8 +107,8 @@ class BookShelfTest extends TestCase
     public function test_shelves_create_sets_cover_image()
     {
         $shelfInfo = [
-            'name'        => 'My test book' . Str::random(4),
-            'description' => 'Test book description ' . Str::random(10),
+            'name'             => 'My test shelf' . Str::random(4),
+            'description_html' => '<p>Test book description ' . Str::random(10) . '</p>',
         ];
 
         $imageFile = $this->files->uploadedImage('shelf-test.png');
@@ -174,7 +174,7 @@ class BookShelfTest extends TestCase
         // Set book ordering
         $this->asAdmin()->put($shelf->getUrl(), [
             'books' => $books->implode('id', ','),
-            'tags'  => [], 'description' => 'abc', 'name' => 'abc',
+            'tags'  => [], 'description_html' => 'abc', 'name' => 'abc',
         ]);
         $this->assertEquals(3, $shelf->books()->count());
         $shelf->refresh();
@@ -207,7 +207,7 @@ class BookShelfTest extends TestCase
         // Set book ordering
         $this->asAdmin()->put($shelf->getUrl(), [
             'books' => $books->implode('id', ','),
-            'tags'  => [], 'description' => 'abc', 'name' => 'abc',
+            'tags'  => [], 'description_html' => 'abc', 'name' => 'abc',
         ]);
         $this->assertEquals(3, $shelf->books()->count());
         $shelf->refresh();
@@ -229,8 +229,8 @@ class BookShelfTest extends TestCase
 
         $booksToInclude = Book::take(2)->get();
         $shelfInfo = [
-            'name'        => 'My test book' . Str::random(4),
-            'description' => 'Test book description ' . Str::random(10),
+            'name'             => 'My test shelf' . Str::random(4),
+            'description_html' => '<p>Test book description ' . Str::random(10) . '</p>',
         ];
 
         $resp = $this->asEditor()->put($shelf->getUrl(), array_merge($shelfInfo, [
@@ -251,7 +251,7 @@ class BookShelfTest extends TestCase
 
         $shelfPage = $this->get($shelf->getUrl());
         $shelfPage->assertSee($shelfInfo['name']);
-        $shelfPage->assertSee($shelfInfo['description']);
+        $shelfPage->assertSee($shelfInfo['description_html'], false);
         $this->withHtml($shelfPage)->assertElementContains('.tag-item', 'Test Category');
         $this->withHtml($shelfPage)->assertElementContains('.tag-item', 'Test Tag Value');
 
@@ -270,8 +270,8 @@ class BookShelfTest extends TestCase
         $testName = 'Test Book in Shelf Name';
 
         $createBookResp = $this->asEditor()->post($shelf->getUrl('/create-book'), [
-            'name'        => $testName,
-            'description' => 'Book in shelf description',
+            'name'             => $testName,
+            'description_html' => 'Book in shelf description',
         ]);
         $createBookResp->assertRedirect();
 
@@ -372,8 +372,8 @@ class BookShelfTest extends TestCase
     {
         // Create shelf
         $shelfInfo = [
-            'name'        => 'My test shelf' . Str::random(4),
-            'description' => 'Test shelf description ' . Str::random(10),
+            'name'             => 'My test shelf' . Str::random(4),
+            'description_html' => '<p>Test shelf description ' . Str::random(10) . '</p>',
         ];
 
         $this->asEditor()->post('/shelves', $shelfInfo);
@@ -381,8 +381,8 @@ class BookShelfTest extends TestCase
 
         // Create book and add to shelf
         $this->asEditor()->post($shelf->getUrl('/create-book'), [
-            'name'        => 'Test book name',
-            'description' => 'Book in shelf description',
+            'name'             => 'Test book name',
+            'description_html' => '<p>Book in shelf description</p>',
         ]);
 
         $newBook = Book::query()->orderBy('id', 'desc')->first();
@@ -403,4 +403,15 @@ class BookShelfTest extends TestCase
         $resp = $this->asEditor()->get($shelf->getUrl('/create-book'));
         $this->withHtml($resp)->assertElementContains('form a[href="' . $shelf->getUrl() . '"]', 'Cancel');
     }
+
+    public function test_show_view_displays_description_if_no_description_html_set()
+    {
+        $shelf = $this->entities->shelf();
+        $shelf->description_html = '';
+        $shelf->description = "My great\ndescription\n\nwith newlines";
+        $shelf->save();
+
+        $resp = $this->asEditor()->get($shelf->getUrl());
+        $resp->assertSee("<p>My great<br>\ndescription<br>\n<br>\nwith newlines</p>", false);
+    }
 }
index 833cabaae93c112d95e27dc516ccc0d9c2744f36..3740892460b296e44b7d4c88e17afb6c7709d421 100644 (file)
@@ -22,7 +22,7 @@ class BookTest extends TestCase
         $resp = $this->get('/create-book');
         $this->withHtml($resp)->assertElementContains('form[action="' . url('/books') . '"][method="POST"]', 'Save Book');
 
-        $resp = $this->post('/books', $book->only('name', 'description'));
+        $resp = $this->post('/books', $book->only('name', 'description_html'));
         $resp->assertRedirect('/books/my-first-book');
 
         $resp = $this->get('/books/my-first-book');
@@ -36,8 +36,8 @@ class BookTest extends TestCase
             'name' => 'My First Book',
         ]);
 
-        $this->asEditor()->post('/books', $book->only('name', 'description'));
-        $this->asEditor()->post('/books', $book->only('name', 'description'));
+        $this->asEditor()->post('/books', $book->only('name', 'description_html'));
+        $this->asEditor()->post('/books', $book->only('name', 'description_html'));
 
         $books = Book::query()->where('name', '=', $book->name)
             ->orderBy('id', 'desc')
@@ -52,9 +52,9 @@ class BookTest extends TestCase
     {
         // Cheeky initial update to refresh slug
         $this->asEditor()->post('books', [
-            'name'        => 'My book with tags',
-            'description' => 'A book with tags',
-            'tags'        => [
+            'name'             => 'My book with tags',
+            'description_html' => '<p>A book with tags</p>',
+            'tags'             => [
                 [
                     'name'  => 'Category',
                     'value' => 'Donkey Content',
@@ -79,23 +79,23 @@ class BookTest extends TestCase
     {
         $book = $this->entities->book();
         // Cheeky initial update to refresh slug
-        $this->asEditor()->put($book->getUrl(), ['name' => $book->name . '5', 'description' => $book->description]);
+        $this->asEditor()->put($book->getUrl(), ['name' => $book->name . '5', 'description_html' => $book->description_html]);
         $book->refresh();
 
         $newName = $book->name . ' Updated';
-        $newDesc = $book->description . ' with more content';
+        $newDesc = $book->description_html . '<p>with more content</p>';
 
         $resp = $this->get($book->getUrl('/edit'));
         $resp->assertSee($book->name);
-        $resp->assertSee($book->description);
+        $resp->assertSee($book->description_html);
         $this->withHtml($resp)->assertElementContains('form[action="' . $book->getUrl() . '"]', 'Save Book');
 
-        $resp = $this->put($book->getUrl(), ['name' => $newName, 'description' => $newDesc]);
+        $resp = $this->put($book->getUrl(), ['name' => $newName, 'description_html' => $newDesc]);
         $resp->assertRedirect($book->getUrl() . '-updated');
 
         $resp = $this->get($book->getUrl() . '-updated');
         $resp->assertSee($newName);
-        $resp->assertSee($newDesc);
+        $resp->assertSee($newDesc, false);
     }
 
     public function test_update_sets_tags()
@@ -184,7 +184,7 @@ class BookTest extends TestCase
 
     public function test_recently_viewed_books_updates_as_expected()
     {
-        $books = Book::all()->take(2);
+        $books = Book::take(2)->get();
 
         $resp = $this->asAdmin()->get('/books');
         $this->withHtml($resp)->assertElementNotContains('#recents', $books[0]->name)
@@ -200,7 +200,7 @@ class BookTest extends TestCase
 
     public function test_popular_books_updates_upon_visits()
     {
-        $books = Book::all()->take(2);
+        $books = Book::take(2)->get();
 
         $resp = $this->asAdmin()->get('/books');
         $this->withHtml($resp)->assertElementNotContains('#popular', $books[0]->name)
@@ -262,6 +262,33 @@ class BookTest extends TestCase
         $this->assertEquals('parta-partb-partc', $book->slug);
     }
 
+    public function test_description_limited_to_specific_html()
+    {
+        $book = $this->entities->book();
+
+        $input = '<h1>Test</h1><p id="abc" href="beans">Content<a href="#cat" data-a="b">a</a><section>Hello</section></p>';
+        $expected = '<p>Content<a href="#cat">a</a></p>';
+
+        $this->asEditor()->put($book->getUrl(), [
+            'name' => $book->name,
+            'description_html' => $input
+        ]);
+
+        $book->refresh();
+        $this->assertEquals($expected, $book->description_html);
+    }
+
+    public function test_show_view_displays_description_if_no_description_html_set()
+    {
+        $book = $this->entities->book();
+        $book->description_html = '';
+        $book->description = "My great\ndescription\n\nwith newlines";
+        $book->save();
+
+        $resp = $this->asEditor()->get($book->getUrl());
+        $resp->assertSee("<p>My great<br>\ndescription<br>\n<br>\nwith newlines</p>", false);
+    }
+
     public function test_show_view_has_copy_button()
     {
         $book = $this->entities->book();
@@ -291,6 +318,8 @@ class BookTest extends TestCase
 
         $resp->assertRedirect($copy->getUrl());
         $this->assertEquals($book->getDirectChildren()->count(), $copy->getDirectChildren()->count());
+
+        $this->get($copy->getUrl())->assertSee($book->description_html, false);
     }
 
     public function test_copy_does_not_copy_non_visible_content()
index 7fa32c252675a655748369ee3fab793a57850aa8..1577cee76d8ae24c58382111caac2fc7aabc0b66 100644 (file)
@@ -23,12 +23,23 @@ class ChapterTest extends TestCase
         $resp = $this->get($book->getUrl('/create-chapter'));
         $this->withHtml($resp)->assertElementContains('form[action="' . $book->getUrl('/create-chapter') . '"][method="POST"]', 'Save Chapter');
 
-        $resp = $this->post($book->getUrl('/create-chapter'), $chapter->only('name', 'description'));
+        $resp = $this->post($book->getUrl('/create-chapter'), $chapter->only('name', 'description_html'));
         $resp->assertRedirect($book->getUrl('/chapter/my-first-chapter'));
 
         $resp = $this->get($book->getUrl('/chapter/my-first-chapter'));
         $resp->assertSee($chapter->name);
-        $resp->assertSee($chapter->description);
+        $resp->assertSee($chapter->description_html, false);
+    }
+
+    public function test_show_view_displays_description_if_no_description_html_set()
+    {
+        $chapter = $this->entities->chapter();
+        $chapter->description_html = '';
+        $chapter->description = "My great\ndescription\n\nwith newlines";
+        $chapter->save();
+
+        $resp = $this->asEditor()->get($chapter->getUrl());
+        $resp->assertSee("<p>My great<br>\ndescription<br>\n<br>\nwith newlines</p>", false);
     }
 
     public function test_delete()
index decda52243f7d39d4169a5b0d4fb2a13e08dc726..d9b1ee466cf1db9656cfd922d7bbdefa585c613d 100644 (file)
@@ -42,6 +42,7 @@ class ConvertTest extends TestCase
         $this->assertEquals('Penguins', $newBook->tags->first()->value);
         $this->assertEquals($chapter->name, $newBook->name);
         $this->assertEquals($chapter->description, $newBook->description);
+        $this->assertEquals($chapter->description_html, $newBook->description_html);
 
         $this->assertActivityExists(ActivityType::BOOK_CREATE_FROM_CHAPTER, $newBook);
     }
@@ -105,6 +106,7 @@ class ConvertTest extends TestCase
         $this->assertEquals('Ducks', $newShelf->tags->first()->value);
         $this->assertEquals($book->name, $newShelf->name);
         $this->assertEquals($book->description, $newShelf->description);
+        $this->assertEquals($book->description_html, $newShelf->description_html);
         $this->assertEquals($newShelf->books()->count(), $bookChapterCount + 1);
         $this->assertEquals($systemBookCount + $bookChapterCount, Book::query()->count());
         $this->assertActivityExists(ActivityType::BOOKSHELF_CREATE_FROM_BOOK, $newShelf);
index 08bf17d0ada22d799a3ee271a91e5cf2acf626c3..eedcb672c998da7b53a26f76e7e59ae7fe6885e0 100644 (file)
@@ -107,18 +107,18 @@ class ExportTest extends TestCase
         $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.html"');
     }
 
-    public function test_book_html_export_shows_chapter_descriptions()
+    public function test_book_html_export_shows_html_descriptions()
     {
-        $chapterDesc = 'My custom test chapter description ' . Str::random(12);
-        $chapter = $this->entities->chapter();
-        $chapter->description = $chapterDesc;
+        $book = $this->entities->bookHasChaptersAndPages();
+        $chapter = $book->chapters()->first();
+        $book->description_html = '<p>A description with <strong>HTML</strong> within!</p>';
+        $chapter->description_html = '<p>A chapter description with <strong>HTML</strong> within!</p>';
+        $book->save();
         $chapter->save();
 
-        $book = $chapter->book;
-        $this->asEditor();
-
-        $resp = $this->get($book->getUrl('/export/html'));
-        $resp->assertSee($chapterDesc);
+        $resp = $this->asEditor()->get($book->getUrl('/export/html'));
+        $resp->assertSee($book->description_html, false);
+        $resp->assertSee($chapter->description_html, false);
     }
 
     public function test_chapter_text_export()
@@ -174,6 +174,16 @@ class ExportTest extends TestCase
         $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.html"');
     }
 
+    public function test_chapter_html_export_shows_html_descriptions()
+    {
+        $chapter = $this->entities->chapter();
+        $chapter->description_html = '<p>A description with <strong>HTML</strong> within!</p>';
+        $chapter->save();
+
+        $resp = $this->asEditor()->get($chapter->getUrl('/export/html'));
+        $resp->assertSee($chapter->description_html, false);
+    }
+
     public function test_page_html_export_contains_custom_head_if_set()
     {
         $page = $this->entities->page();
index a19e1b9015709d6fd8443d6fb83c3c677fc60006..715f7143534b09ddcf179bd2517c5a11c6461462 100644 (file)
@@ -30,7 +30,30 @@ class ReferencesTest extends TestCase
         ]);
     }
 
-    public function test_references_deleted_on_entity_delete()
+    public function test_references_created_on_book_chapter_bookshelf_update()
+    {
+        $entities = [$this->entities->book(), $this->entities->chapter(), $this->entities->shelf()];
+        $shelf = $this->entities->shelf();
+
+        foreach ($entities as $entity) {
+            $entity->refresh();
+            $this->assertDatabaseMissing('references', ['from_id' => $entity->id, 'from_type' => $entity->getMorphClass()]);
+
+            $this->asEditor()->put($entity->getUrl(), [
+                'name' => 'Reference test',
+                'description_html' => '<a href="' . $shelf->getUrl() . '">Testing</a>',
+            ]);
+
+            $this->assertDatabaseHas('references', [
+                'from_id'   => $entity->id,
+                'from_type' => $entity->getMorphClass(),
+                'to_id'     => $shelf->id,
+                'to_type'   => $shelf->getMorphClass(),
+            ]);
+        }
+    }
+
+    public function test_references_deleted_on_page_delete()
     {
         $pageA = $this->entities->page();
         $pageB = $this->entities->page();
@@ -48,6 +71,25 @@ class ReferencesTest extends TestCase
         $this->assertDatabaseMissing('references', ['to_id' => $pageA->id, 'to_type' => $pageA->getMorphClass()]);
     }
 
+    public function test_references_from_deleted_on_book_chapter_shelf_delete()
+    {
+        $entities = [$this->entities->chapter(), $this->entities->book(), $this->entities->shelf()];
+        $shelf = $this->entities->shelf();
+
+        foreach ($entities as $entity) {
+            $this->createReference($entity, $shelf);
+            $this->assertDatabaseHas('references', ['from_id' => $entity->id, 'from_type' => $entity->getMorphClass()]);
+
+            $this->asEditor()->delete($entity->getUrl());
+            app(TrashCan::class)->empty();
+
+            $this->assertDatabaseMissing('references', [
+                'from_id'   => $entity->id,
+                'from_type' => $entity->getMorphClass()
+            ]);
+        }
+    }
+
     public function test_references_to_count_visible_on_entity_show_view()
     {
         $entities = $this->entities->all();
@@ -60,13 +102,13 @@ class ReferencesTest extends TestCase
 
         foreach ($entities as $entity) {
             $resp = $this->get($entity->getUrl());
-            $resp->assertSee('Referenced on 1 page');
-            $resp->assertDontSee('Referenced on 1 pages');
+            $resp->assertSee('Referenced by 1 item');
+            $resp->assertDontSee('Referenced by 1 items');
         }
 
         $this->createReference($otherPage, $entities['page']);
         $resp = $this->get($entities['page']->getUrl());
-        $resp->assertSee('Referenced on 2 pages');
+        $resp->assertSee('Referenced by 2 items');
     }
 
     public function test_references_to_visible_on_references_page()
@@ -203,6 +245,32 @@ class ReferencesTest extends TestCase
         $this->assertEquals($expected, $page->markdown);
     }
 
+    public function test_description_links_from_book_chapter_shelf_updated_on_url_change()
+    {
+        $entities = [$this->entities->chapter(), $this->entities->book(), $this->entities->shelf()];
+        $shelf = $this->entities->shelf();
+        $this->asEditor();
+
+        foreach ($entities as $entity) {
+            $this->put($entity->getUrl(), [
+                'name' => 'Reference test',
+                'description_html' => '<a href="' . $shelf->getUrl() . '">Testing</a>',
+            ]);
+        }
+
+        $oldUrl = $shelf->getUrl();
+        $this->put($shelf->getUrl(), ['name' => 'My updated shelf link']);
+        $shelf->refresh();
+        $this->assertNotEquals($oldUrl, $shelf->getUrl());
+
+        foreach ($entities as $entity) {
+            $oldHtml = $entity->description_html;
+            $entity->refresh();
+            $this->assertNotEquals($oldHtml, $entity->description_html);
+            $this->assertStringContainsString($shelf->getUrl(), $entity->description_html);
+        }
+    }
+
     protected function createReference(Model $from, Model $to)
     {
         (new Reference())->forceFill([
index 25832b03e58837f8d813ff4e7ff261eef348d3ef..0511f2624cfdc53b52c82e2a6b16591cb2eaf22e 100644 (file)
@@ -21,7 +21,7 @@ class RegenerateReferencesTest extends TestCase
     public function test_action_runs_reference_regen()
     {
         $this->mock(ReferenceStore::class)
-            ->shouldReceive('updateForAllPages')
+            ->shouldReceive('updateForAll')
             ->once();
 
         $resp = $this->asAdmin()->post('/settings/maintenance/regenerate-references');
@@ -45,7 +45,7 @@ class RegenerateReferencesTest extends TestCase
     public function test_action_failed_shown_as_error_notification()
     {
         $this->mock(ReferenceStore::class)
-            ->shouldReceive('updateForAllPages')
+            ->shouldReceive('updateForAll')
             ->andThrow(\Exception::class, 'A badger stopped the task');
 
         $resp = $this->asAdmin()->post('/settings/maintenance/regenerate-references');