From 569542f0bb519bf9a52a6565780a7e610ff12130 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 16 Dec 2023 14:48:35 +0000 Subject: [PATCH 01/15] Input WYSIWYG: Added compontent and rough logic to book form Just as a draft for prototyping and playing around to get things started. --- resources/js/components/index.js | 1 + resources/js/components/wysiwyg-editor.js | 12 ++--- resources/js/components/wysiwyg-input.js | 26 ++++++++++ resources/js/wysiwyg/config.js | 50 ++++++++++++++++++- resources/views/books/parts/form.blade.php | 15 +++++- .../editor-translations.blade.php | 0 .../pages/parts/wysiwyg-editor.blade.php | 2 +- 7 files changed, 95 insertions(+), 11 deletions(-) create mode 100644 resources/js/components/wysiwyg-input.js rename resources/views/{pages/parts => form}/editor-translations.blade.php (100%) diff --git a/resources/js/components/index.js b/resources/js/components/index.js index a56f18a5a..3a66079d7 100644 --- a/resources/js/components/index.js +++ b/resources/js/components/index.js @@ -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'; diff --git a/resources/js/components/wysiwyg-editor.js b/resources/js/components/wysiwyg-editor.js index 21db207e6..82f60827d 100644 --- a/resources/js/components/wysiwyg-editor.js +++ b/resources/js/components/wysiwyg-editor.js @@ -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 index 000000000..88c06a334 --- /dev/null +++ b/resources/js/components/wysiwyg-input.js @@ -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]; + }); + } + +} diff --git a/resources/js/wysiwyg/config.js b/resources/js/wysiwyg/config.js index 6973db8c8..d7c6bba72 100644 --- a/resources/js/wysiwyg/config.js +++ b/resources/js/wysiwyg/config.js @@ -217,7 +217,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 +290,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: '300px', + 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: 'page-content', + 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', + contextmenu: false, + toolbar: 'bold italic underline link', + content_style: getContentStyle(options), + color_map: colorMap, + init_instance_callback(editor) { + const head = editor.getDoc().querySelector('head'); + head.innerHTML += fetchCustomHeadContent(); + }, + setup(editor) { + // + }, + }; +} + /** * @typedef {Object} WysiwygConfigOptions * @property {Element} containerElement diff --git a/resources/views/books/parts/form.blade.php b/resources/views/books/parts/form.blade.php index e22be619d..3a2e30da6 100644 --- a/resources/views/books/parts/form.blade.php +++ b/resources/views/books/parts/form.blade.php @@ -1,3 +1,6 @@ +@push('head') + +@endpush {{ csrf_field() }}
@@ -8,6 +11,15 @@
@include('form.textarea', ['name' => 'description']) + + + @if($errors->has('description_html')) +
{{ $errors->first('description_html') }}
+ @endif
@@ -62,4 +74,5 @@
-@include('entities.selector-popup', ['entityTypes' => 'page', 'selectorEndpoint' => '/search/entity-selector-templates']) \ No newline at end of file +@include('entities.selector-popup', ['entityTypes' => 'page', 'selectorEndpoint' => '/search/entity-selector-templates']) +@include('form.editor-translations') \ No newline at end of file diff --git a/resources/views/pages/parts/editor-translations.blade.php b/resources/views/form/editor-translations.blade.php similarity index 100% rename from resources/views/pages/parts/editor-translations.blade.php rename to resources/views/form/editor-translations.blade.php diff --git a/resources/views/pages/parts/wysiwyg-editor.blade.php b/resources/views/pages/parts/wysiwyg-editor.blade.php index ca6b6da8a..84a267b68 100644 --- a/resources/views/pages/parts/wysiwyg-editor.blade.php +++ b/resources/views/pages/parts/wysiwyg-editor.blade.php @@ -18,4 +18,4 @@
{{ $errors->first('html') }}
@endif -@include('pages.parts.editor-translations') \ No newline at end of file +@include('form.editor-translations') \ No newline at end of file From c622b785a969075bbc59129471d42d2df7377ea8 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 17 Dec 2023 15:02:15 +0000 Subject: [PATCH 02/15] Input WYSIWYG: Added description_html field, added store logic Rolled out HTML editor field and store logic across all target entity types. Cleaned up WYSIWYG input logic and design. Cleaned up some injected classes while there. --- app/Entities/Controllers/BookController.php | 4 +-- .../Controllers/BookshelfController.php | 30 +++++++--------- .../Controllers/ChapterController.php | 27 ++++++++------ app/Entities/Models/Book.php | 3 +- app/Entities/Models/Bookshelf.php | 1 + app/Entities/Models/Chapter.php | 1 + app/Entities/Models/HasHtmlDescription.php | 21 +++++++++++ app/Entities/Repos/BaseRepo.php | 33 ++++++++++++----- ...40913_add_description_html_to_entities.php | 36 +++++++++++++++++++ resources/js/wysiwyg/config.js | 10 +++--- resources/sass/_forms.scss | 8 +++++ resources/sass/_tinymce.scss | 7 ++++ resources/views/books/parts/form.blade.php | 13 ++----- resources/views/books/show.blade.php | 2 +- resources/views/chapters/parts/form.blade.php | 13 ++++--- .../form/description-html-input.blade.php | 8 +++++ resources/views/shelves/parts/form.blade.php | 14 +++++--- 17 files changed, 167 insertions(+), 64 deletions(-) create mode 100644 app/Entities/Models/HasHtmlDescription.php create mode 100644 database/migrations/2023_12_17_140913_add_description_html_to_entities.php create mode 100644 resources/views/form/description-html-input.blade.php diff --git a/app/Entities/Controllers/BookController.php b/app/Entities/Controllers/BookController.php index faa578893..481c621e6 100644 --- a/app/Entities/Controllers/BookController.php +++ b/app/Entities/Controllers/BookController.php @@ -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'], @@ -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'], diff --git a/app/Entities/Controllers/BookshelfController.php b/app/Entities/Controllers/BookshelfController.php index fcfd37538..acc972348 100644 --- a/app/Entities/Controllers/BookshelfController.php +++ b/app/Entities/Controllers/BookshelfController.php @@ -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', '')); @@ -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')) { diff --git a/app/Entities/Controllers/ChapterController.php b/app/Entities/Controllers/ChapterController.php index 40a537303..73f314ab6 100644 --- a/app/Entities/Controllers/ChapterController.php +++ b/app/Entities/Controllers/ChapterController.php @@ -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()); } @@ -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()); } diff --git a/app/Entities/Models/Book.php b/app/Entities/Models/Book.php index ee9a7f447..7bbe2d8a4 100644 --- a/app/Entities/Models/Book.php +++ b/app/Entities/Models/Book.php @@ -26,10 +26,11 @@ use Illuminate\Support\Collection; class Book extends Entity implements HasCoverImage { use HasFactory; + use HasHtmlDescription; public $searchFactor = 1.2; - protected $fillable = ['name', 'description']; + protected $fillable = ['name']; protected $hidden = ['pivot', 'image_id', 'deleted_at']; /** diff --git a/app/Entities/Models/Bookshelf.php b/app/Entities/Models/Bookshelf.php index 4b44025a4..cf22195f7 100644 --- a/app/Entities/Models/Bookshelf.php +++ b/app/Entities/Models/Bookshelf.php @@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; class Bookshelf extends Entity implements HasCoverImage { use HasFactory; + use HasHtmlDescription; protected $table = 'bookshelves'; diff --git a/app/Entities/Models/Chapter.php b/app/Entities/Models/Chapter.php index 98889ce3f..17fccfd6c 100644 --- a/app/Entities/Models/Chapter.php +++ b/app/Entities/Models/Chapter.php @@ -15,6 +15,7 @@ use Illuminate\Support\Collection; class Chapter extends BookChild { use HasFactory; + use HasHtmlDescription; public $searchFactor = 1.2; diff --git a/app/Entities/Models/HasHtmlDescription.php b/app/Entities/Models/HasHtmlDescription.php new file mode 100644 index 000000000..cc431f7fc --- /dev/null +++ b/app/Entities/Models/HasHtmlDescription.php @@ -0,0 +1,21 @@ +description_html ?: '

' . e($this->description) . '

'; + return HtmlContentFilter::removeScriptsFromHtmlString($html); + } +} diff --git a/app/Entities/Repos/BaseRepo.php b/app/Entities/Repos/BaseRepo.php index 2894a04e3..f6b9ff578 100644 --- a/app/Entities/Repos/BaseRepo.php +++ b/app/Entities/Repos/BaseRepo.php @@ -5,6 +5,7 @@ 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\ReferenceUpdater; use BookStack\Uploads\ImageRepo; @@ -12,15 +13,11 @@ 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 + ) { } /** @@ -29,6 +26,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, @@ -54,6 +52,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)) { @@ -99,4 +98,20 @@ 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 = $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->descriptionHtml(); + } + } } 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 index 000000000..68c52e81b --- /dev/null +++ b/database/migrations/2023_12_17_140913_add_description_html_to_entities.php @@ -0,0 +1,36 @@ + $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); + } +}; diff --git a/resources/js/wysiwyg/config.js b/resources/js/wysiwyg/config.js index d7c6bba72..f0a2dbe1c 100644 --- a/resources/js/wysiwyg/config.js +++ b/resources/js/wysiwyg/config.js @@ -304,7 +304,7 @@ export function buildForInput(options) { // Return config object return { width: '100%', - height: '300px', + height: '185px', target: options.containerElement, cache_suffix: `?version=${version}`, content_css: [ @@ -312,7 +312,7 @@ export function buildForInput(options) { ], branding: false, skin: options.darkMode ? 'tinymce-5-dark' : 'tinymce-5', - body_class: 'page-content', + body_class: 'wysiwyg-input', browser_spellcheck: true, relative_urls: false, language: options.language, @@ -323,11 +323,13 @@ export function buildForInput(options) { remove_trailing_brs: false, statusbar: false, menubar: false, - plugins: 'link autolink', + plugins: 'link autolink lists', contextmenu: false, - toolbar: 'bold italic underline link', + toolbar: 'bold italic underline link bullist numlist', content_style: getContentStyle(options), color_map: colorMap, + file_picker_types: 'file', + file_picker_callback: filePickerCallback, init_instance_callback(editor) { const head = editor.getDoc().querySelector('head'); head.innerHTML += fetchCustomHeadContent(); diff --git a/resources/sass/_forms.scss b/resources/sass/_forms.scss index cd5d929f4..b63f9cdd5 100644 --- a/resources/sass/_forms.scss +++ b/resources/sass/_forms.scss @@ -406,6 +406,14 @@ input[type=color] { height: auto; } +.description-input > .tox-tinymce { + border: 1px solid #DDD !important; + border-radius: 3px; + .tox-toolbar__primary { + justify-content: end; + } +} + .search-box { max-width: 100%; position: relative; diff --git a/resources/sass/_tinymce.scss b/resources/sass/_tinymce.scss index 8e036fc46..c4336da7c 100644 --- a/resources/sass/_tinymce.scss +++ b/resources/sass/_tinymce.scss @@ -23,6 +23,13 @@ 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; diff --git a/resources/views/books/parts/form.blade.php b/resources/views/books/parts/form.blade.php index 3a2e30da6..d380c5871 100644 --- a/resources/views/books/parts/form.blade.php +++ b/resources/views/books/parts/form.blade.php @@ -9,17 +9,8 @@
- - @include('form.textarea', ['name' => 'description']) - - - @if($errors->has('description_html')) -
{{ $errors->first('description_html') }}
- @endif + + @include('form.description-html-input')
diff --git a/resources/views/books/show.blade.php b/resources/views/books/show.blade.php index 8f7c3f6cf..5884e41fd 100644 --- a/resources/views/books/show.blade.php +++ b/resources/views/books/show.blade.php @@ -26,7 +26,7 @@

{{$book->name}}

-

{!! nl2br(e($book->description)) !!}

+

{!! $book->descriptionHtml() !!}

@if(count($bookChildren) > 0)
@foreach($bookChildren as $childElement) diff --git a/resources/views/chapters/parts/form.blade.php b/resources/views/chapters/parts/form.blade.php index 8abcebe13..7c565f43c 100644 --- a/resources/views/chapters/parts/form.blade.php +++ b/resources/views/chapters/parts/form.blade.php @@ -1,14 +1,16 @@ +@push('head') + +@endpush -{!! csrf_field() !!} - +{{ csrf_field() }}
@include('form.text', ['name' => 'name', 'autofocus' => true])
- - @include('form.textarea', ['name' => 'description']) + + @include('form.description-html-input')
@@ -24,3 +26,6 @@ {{ trans('common.cancel') }}
+ +@include('entities.selector-popup', ['entityTypes' => 'page', 'selectorEndpoint' => '/search/entity-selector-templates']) +@include('form.editor-translations') \ No newline at end of file diff --git a/resources/views/form/description-html-input.blade.php b/resources/views/form/description-html-input.blade.php new file mode 100644 index 000000000..3cf726ba4 --- /dev/null +++ b/resources/views/form/description-html-input.blade.php @@ -0,0 +1,8 @@ + +@if($errors->has('description_html')) +
{{ $errors->first('description_html') }}
+@endif \ No newline at end of file diff --git a/resources/views/shelves/parts/form.blade.php b/resources/views/shelves/parts/form.blade.php index ad67cb85c..a724c99ce 100644 --- a/resources/views/shelves/parts/form.blade.php +++ b/resources/views/shelves/parts/form.blade.php @@ -1,13 +1,16 @@ -{{ csrf_field() }} +@push('head') + +@endpush +{{ csrf_field() }}
@include('form.text', ['name' => 'name', 'autofocus' => true])
- - @include('form.textarea', ['name' => 'description']) + + @include('form.description-html-input')
@@ -84,4 +87,7 @@
{{ trans('common.cancel') }} -
\ No newline at end of file +
+ +@include('entities.selector-popup', ['entityTypes' => 'page', 'selectorEndpoint' => '/search/entity-selector-templates']) +@include('form.editor-translations') \ No newline at end of file From 307fae39c450f687aba93e589e947e5389357601 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 18 Dec 2023 16:23:40 +0000 Subject: [PATCH 03/15] Input WYSIWYG: Added reference store & fetch handling For book, shelves and chapters. Made much of the existing handling generic to entity types. Added new MixedEntityListLoader to help load lists somewhat efficiently. Only manually tested so far. --- .../Commands/RegenerateReferencesCommand.php | 2 +- app/Entities/Controllers/BookController.php | 2 +- .../Controllers/BookshelfController.php | 2 +- .../Controllers/ChapterController.php | 2 +- app/Entities/Controllers/PageController.php | 2 +- app/Entities/Models/Book.php | 2 +- app/Entities/Models/Bookshelf.php | 2 +- app/Entities/Models/Chapter.php | 2 +- app/Entities/Models/Entity.php | 9 +- app/Entities/Models/Page.php | 3 +- app/Entities/Repos/BaseRepo.php | 6 +- app/Entities/Repos/PageRepo.php | 4 +- app/Entities/Tools/MixedEntityListLoader.php | 103 ++++++++++++++++++ app/References/ReferenceController.php | 16 ++- app/References/ReferenceFetcher.php | 60 ++++------ app/References/ReferenceStore.php | 85 ++++++++++----- app/References/ReferenceUpdater.php | 4 +- app/Settings/MaintenanceController.php | 2 +- lang/en/entities.php | 4 +- resources/views/entities/meta.blade.php | 2 +- resources/views/shelves/show.blade.php | 2 +- 21 files changed, 219 insertions(+), 97 deletions(-) create mode 100644 app/Entities/Tools/MixedEntityListLoader.php diff --git a/app/Console/Commands/RegenerateReferencesCommand.php b/app/Console/Commands/RegenerateReferencesCommand.php index ea8ff8e00..563da100a 100644 --- a/app/Console/Commands/RegenerateReferencesCommand.php +++ b/app/Console/Commands/RegenerateReferencesCommand.php @@ -34,7 +34,7 @@ class RegenerateReferencesCommand extends Command DB::setDefaultConnection($this->option('database')); } - $references->updateForAllPages(); + $references->updateForAll(); DB::setDefaultConnection($connection); diff --git a/app/Entities/Controllers/BookController.php b/app/Entities/Controllers/BookController.php index 481c621e6..412feca2f 100644 --- a/app/Entities/Controllers/BookController.php +++ b/app/Entities/Controllers/BookController.php @@ -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), ]); } diff --git a/app/Entities/Controllers/BookshelfController.php b/app/Entities/Controllers/BookshelfController.php index acc972348..2f5461cdb 100644 --- a/app/Entities/Controllers/BookshelfController.php +++ b/app/Entities/Controllers/BookshelfController.php @@ -125,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), ]); } diff --git a/app/Entities/Controllers/ChapterController.php b/app/Entities/Controllers/ChapterController.php index 73f314ab6..28ad35fa4 100644 --- a/app/Entities/Controllers/ChapterController.php +++ b/app/Entities/Controllers/ChapterController.php @@ -86,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), ]); } diff --git a/app/Entities/Controllers/PageController.php b/app/Entities/Controllers/PageController.php index 0a3e76daa..adafcdc7b 100644 --- a/app/Entities/Controllers/PageController.php +++ b/app/Entities/Controllers/PageController.php @@ -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), ]); } diff --git a/app/Entities/Models/Book.php b/app/Entities/Models/Book.php index 7bbe2d8a4..52674d839 100644 --- a/app/Entities/Models/Book.php +++ b/app/Entities/Models/Book.php @@ -28,7 +28,7 @@ class Book extends Entity implements HasCoverImage use HasFactory; use HasHtmlDescription; - public $searchFactor = 1.2; + public float $searchFactor = 1.2; protected $fillable = ['name']; protected $hidden = ['pivot', 'image_id', 'deleted_at']; diff --git a/app/Entities/Models/Bookshelf.php b/app/Entities/Models/Bookshelf.php index cf22195f7..464c127b8 100644 --- a/app/Entities/Models/Bookshelf.php +++ b/app/Entities/Models/Bookshelf.php @@ -15,7 +15,7 @@ class Bookshelf extends Entity implements HasCoverImage protected $table = 'bookshelves'; - public $searchFactor = 1.2; + public float $searchFactor = 1.2; protected $fillable = ['name', 'description', 'image_id']; diff --git a/app/Entities/Models/Chapter.php b/app/Entities/Models/Chapter.php index 17fccfd6c..6c5f059ac 100644 --- a/app/Entities/Models/Chapter.php +++ b/app/Entities/Models/Chapter.php @@ -17,7 +17,7 @@ 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']; diff --git a/app/Entities/Models/Entity.php b/app/Entities/Models/Entity.php index 332510672..f07d372c3 100644 --- a/app/Entities/Models/Entity.php +++ b/app/Entities/Models/Entity.php @@ -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/Page.php b/app/Entities/Models/Page.php index 7e2c12c20..17d6f9a01 100644 --- a/app/Entities/Models/Page.php +++ b/app/Entities/Models/Page.php @@ -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']; diff --git a/app/Entities/Repos/BaseRepo.php b/app/Entities/Repos/BaseRepo.php index f6b9ff578..dbdaa9213 100644 --- a/app/Entities/Repos/BaseRepo.php +++ b/app/Entities/Repos/BaseRepo.php @@ -7,6 +7,7 @@ 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 Illuminate\Http\UploadedFile; @@ -16,7 +17,8 @@ class BaseRepo public function __construct( protected TagRepo $tagRepo, protected ImageRepo $imageRepo, - protected ReferenceUpdater $referenceUpdater + protected ReferenceUpdater $referenceUpdater, + protected ReferenceStore $referenceStore, ) { } @@ -42,6 +44,7 @@ class BaseRepo $entity->refresh(); $entity->rebuildPermissions(); $entity->indexForSearch(); + $this->referenceStore->updateForEntity($entity); } /** @@ -68,6 +71,7 @@ class BaseRepo $entity->rebuildPermissions(); $entity->indexForSearch(); + $this->referenceStore->updateForEntity($entity); if ($oldUrl !== $entity->getUrl()) { $this->referenceUpdater->updateEntityPageReferences($entity, $oldUrl); diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index 9a183469b..d491b7f2c 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -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,7 +299,7 @@ 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); diff --git a/app/Entities/Tools/MixedEntityListLoader.php b/app/Entities/Tools/MixedEntityListLoader.php new file mode 100644 index 000000000..50079e3bf --- /dev/null +++ b/app/Entities/Tools/MixedEntityListLoader.php @@ -0,0 +1,103 @@ + ['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 $idsByType + * @return array> + */ + 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; + } +} diff --git a/app/References/ReferenceController.php b/app/References/ReferenceController.php index d6978dd5b..991f47225 100644 --- a/app/References/ReferenceController.php +++ b/app/References/ReferenceController.php @@ -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, diff --git a/app/References/ReferenceFetcher.php b/app/References/ReferenceFetcher.php index c4a7d31b6..0d9883a3e 100644 --- a/app/References/ReferenceFetcher.php +++ b/app/References/ReferenceFetcher.php @@ -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), + return $this->queryReferencesToEntity($entity)->count(); + } + + protected function queryReferencesToEntity(Entity $entity): Builder + { + $baseQuery = Reference::query() + ->where('to_type', '=', $entity->getMorphClass()) + ->where('to_id', '=', $entity->id); + + return $this->permissions->restrictEntityRelationQuery( + $baseQuery, 'references', 'from_id', 'from_type' - )->count(); - - return $count; - } - - protected function queryPageReferencesToEntity(Entity $entity): Builder - { - return Reference::query() - ->where('to_type', '=', $entity->getMorphClass()) - ->where('to_id', '=', $entity->id) - ->where('from_type', '=', (new Page())->getMorphClass()); + ); } } diff --git a/app/References/ReferenceStore.php b/app/References/ReferenceStore.php index 4c6db35c5..78595084b 100644 --- a/app/References/ReferenceStore.php +++ b/app/References/ReferenceStore.php @@ -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 { - /** - * Update the outgoing references for the given page. - */ - public function updateForPage(Page $page): void - { - $this->updateForPages([$page]); + public function __construct( + protected EntityProvider $entityProvider + ) { } /** - * Update the outgoing references for all pages in the system. + * Update the outgoing references for the given entity. */ - public function updateForAllPages(): void + public function updateForEntity(Entity $entity): void { - Reference::query() - ->where('from_type', '=', (new Page())->getMorphClass()) - ->delete(); - - Page::query()->select(['id', 'html'])->chunk(100, function (Collection $pages) { - $this->updateForPages($pages->all()); - }); + $this->updateForEntities([$entity]); } /** - * Update the outgoing references for the pages in the given array. + * Update the outgoing references for all entities in the system. + */ + public function updateForAll(): void + { + Reference::query()->delete(); + + 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 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(); + } + } } diff --git a/app/References/ReferenceUpdater.php b/app/References/ReferenceUpdater.php index 248937339..db1bce4fb 100644 --- a/app/References/ReferenceUpdater.php +++ b/app/References/ReferenceUpdater.php @@ -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); } } diff --git a/app/Settings/MaintenanceController.php b/app/Settings/MaintenanceController.php index 60e5fee28..62eeecf39 100644 --- a/app/Settings/MaintenanceController.php +++ b/app/Settings/MaintenanceController.php @@ -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/lang/en/entities.php b/lang/en/entities.php index 354eee42e..f1f915544 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -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', diff --git a/resources/views/entities/meta.blade.php b/resources/views/entities/meta.blade.php index 2298be8bb..9d3c4b956 100644 --- a/resources/views/entities/meta.blade.php +++ b/resources/views/entities/meta.blade.php @@ -64,7 +64,7 @@ @icon('reference')
- {!! trans_choice('entities.meta_reference_page_count', $referenceCount, ['count' => $referenceCount]) !!} + {{ trans_choice('entities.meta_reference_count', $referenceCount, ['count' => $referenceCount]) }}
@endif diff --git a/resources/views/shelves/show.blade.php b/resources/views/shelves/show.blade.php index 58fe1cd86..e475a8080 100644 --- a/resources/views/shelves/show.blade.php +++ b/resources/views/shelves/show.blade.php @@ -28,7 +28,7 @@
-

{!! nl2br(e($shelf->description)) !!}

+

{!! $shelf->descriptionHtml() !!}

@if(count($sortedVisibleShelfBooks) > 0) @if($view === 'list')
From bc354e8b12a19ed96eab669f0926f517671c7c3e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 18 Dec 2023 18:12:36 +0000 Subject: [PATCH 04/15] Input WYSIWYG: Updated reference link updating for descriptions --- app/Entities/Models/BookChild.php | 2 +- app/Entities/Repos/BaseRepo.php | 2 +- app/Entities/Repos/PageRepo.php | 2 +- app/References/ReferenceUpdater.php | 35 +++++++++++++++++++++++------ 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/app/Entities/Models/BookChild.php b/app/Entities/Models/BookChild.php index ed08f16e6..18735e56b 100644 --- a/app/Entities/Models/BookChild.php +++ b/app/Entities/Models/BookChild.php @@ -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 diff --git a/app/Entities/Repos/BaseRepo.php b/app/Entities/Repos/BaseRepo.php index dbdaa9213..3d3d16732 100644 --- a/app/Entities/Repos/BaseRepo.php +++ b/app/Entities/Repos/BaseRepo.php @@ -74,7 +74,7 @@ class BaseRepo $this->referenceStore->updateForEntity($entity); if ($oldUrl !== $entity->getUrl()) { - $this->referenceUpdater->updateEntityPageReferences($entity, $oldUrl); + $this->referenceUpdater->updateEntityReferences($entity, $oldUrl); } } diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index d491b7f2c..7b14ea7d2 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -305,7 +305,7 @@ class PageRepo $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/References/ReferenceUpdater.php b/app/References/ReferenceUpdater.php index db1bce4fb..db355f211 100644 --- a/app/References/ReferenceUpdater.php +++ b/app/References/ReferenceUpdater.php @@ -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); } } @@ -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); From c07aa056c2e42974826285cd8e614ae49db24b4b Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 18 Dec 2023 18:31:16 +0000 Subject: [PATCH 05/15] Input WYSIWYG: Updated UpdateUrlCommand, Added chapter HTML display --- app/Console/Commands/UpdateUrlCommand.php | 3 +++ resources/views/chapters/show.blade.php | 2 +- tests/Commands/UpdateUrlCommandTest.php | 23 +++++++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/app/Console/Commands/UpdateUrlCommand.php b/app/Console/Commands/UpdateUrlCommand.php index 27f84cc89..0c95b0a3c 100644 --- a/app/Console/Commands/UpdateUrlCommand.php +++ b/app/Console/Commands/UpdateUrlCommand.php @@ -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'], diff --git a/resources/views/chapters/show.blade.php b/resources/views/chapters/show.blade.php index 0e5224d54..6fe1ce431 100644 --- a/resources/views/chapters/show.blade.php +++ b/resources/views/chapters/show.blade.php @@ -24,7 +24,7 @@

{{ $chapter->name }}

-

{!! nl2br(e($chapter->description)) !!}

+

{!! $chapter->descriptionHtml() !!}

@if(count($pages) > 0)
@foreach($pages as $page) diff --git a/tests/Commands/UpdateUrlCommandTest.php b/tests/Commands/UpdateUrlCommandTest.php index 280c81feb..62c39c274 100644 --- a/tests/Commands/UpdateUrlCommandTest.php +++ b/tests/Commands/UpdateUrlCommandTest.php @@ -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 = ''; + $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' => '', + ]); + } + } + public function test_command_requires_valid_url() { $badUrlMessage = 'The given urls are expected to be full urls starting with http:// or https://'; From 2fbed3919bce377c7091c35503e8bea8cd1f9754 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 19 Dec 2023 12:09:57 +0000 Subject: [PATCH 06/15] Input WYSIWYG: Added dynamic options for entity selector popups So that multiple elements on the page can share the same popup, with different search options. --- .../js/components/entity-selector-popup.js | 9 ++++- resources/js/components/entity-selector.js | 40 ++++++++++++++++--- resources/js/components/page-picker.js | 6 +++ resources/js/markdown/actions.js | 6 ++- resources/js/wysiwyg/config.js | 6 ++- resources/js/wysiwyg/shortcuts.js | 6 ++- resources/views/books/parts/form.blade.php | 3 +- resources/views/chapters/parts/form.blade.php | 2 +- .../views/entities/selector-popup.blade.php | 2 +- resources/views/form/page-picker.blade.php | 3 +- .../views/settings/customization.blade.php | 11 +++-- resources/views/shelves/parts/form.blade.php | 2 +- 12 files changed, 79 insertions(+), 17 deletions(-) diff --git a/resources/js/components/entity-selector-popup.js b/resources/js/components/entity-selector-popup.js index 9ff67d53e..6fb461968 100644 --- a/resources/js/components/entity-selector-popup.js +++ b/resources/js/components/entity-selector-popup.js @@ -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) { diff --git a/resources/js/components/entity-selector.js b/resources/js/components/entity-selector.js index b12eeb402..5ad991437 100644 --- a/resources/js/components/entity-selector.js +++ b/resources/js/components/entity-selector.js @@ -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 => { diff --git a/resources/js/components/page-picker.js b/resources/js/components/page-picker.js index 9bb0bee04..39af67229 100644 --- a/resources/js/components/page-picker.js +++ b/resources/js/components/page-picker.js @@ -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', }); } diff --git a/resources/js/markdown/actions.js b/resources/js/markdown/actions.js index 4909a59d0..511f1ebda 100644 --- a/resources/js/markdown/actions.js +++ b/resources/js/markdown/actions.js @@ -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. diff --git a/resources/js/wysiwyg/config.js b/resources/js/wysiwyg/config.js index f0a2dbe1c..2bd4b630b 100644 --- a/resources/js/wysiwyg/config.js +++ b/resources/js/wysiwyg/config.js @@ -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') { diff --git a/resources/js/wysiwyg/shortcuts.js b/resources/js/wysiwyg/shortcuts.js index 147e3c2d5..da9e02270 100644 --- a/resources/js/wysiwyg/shortcuts.js +++ b/resources/js/wysiwyg/shortcuts.js @@ -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', + }); }); } diff --git a/resources/views/books/parts/form.blade.php b/resources/views/books/parts/form.blade.php index d380c5871..a3d4be5e9 100644 --- a/resources/views/books/parts/form.blade.php +++ b/resources/views/books/parts/form.blade.php @@ -53,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', ])
@@ -65,5 +66,5 @@
-@include('entities.selector-popup', ['entityTypes' => 'page', 'selectorEndpoint' => '/search/entity-selector-templates']) +@include('entities.selector-popup') @include('form.editor-translations') \ No newline at end of file diff --git a/resources/views/chapters/parts/form.blade.php b/resources/views/chapters/parts/form.blade.php index 7c565f43c..c6052c93a 100644 --- a/resources/views/chapters/parts/form.blade.php +++ b/resources/views/chapters/parts/form.blade.php @@ -27,5 +27,5 @@
-@include('entities.selector-popup', ['entityTypes' => 'page', 'selectorEndpoint' => '/search/entity-selector-templates']) +@include('entities.selector-popup') @include('form.editor-translations') \ No newline at end of file diff --git a/resources/views/entities/selector-popup.blade.php b/resources/views/entities/selector-popup.blade.php index d4c941e9a..ac91725d6 100644 --- a/resources/views/entities/selector-popup.blade.php +++ b/resources/views/entities/selector-popup.blade.php @@ -5,7 +5,7 @@
- @include('entities.selector', ['name' => 'entity-selector']) + @include('entities.selector', ['name' => 'entity-selector', 'selectorEndpoint' => '']) diff --git a/resources/views/form/page-picker.blade.php b/resources/views/form/page-picker.blade.php index d9810d575..ad0a9d516 100644 --- a/resources/views/form/page-picker.blade.php +++ b/resources/views/form/page-picker.blade.php @@ -1,6 +1,7 @@ {{--Depends on entity selector popup--}} -
+
diff --git a/resources/views/settings/customization.blade.php b/resources/views/settings/customization.blade.php index 7112ebcff..4845e2055 100644 --- a/resources/views/settings/customization.blade.php +++ b/resources/views/settings/customization.blade.php @@ -3,7 +3,7 @@ @section('card')

{{ trans('settings.app_customization') }}

- {!! csrf_field() !!} + {{ csrf_field() }}
@@ -133,7 +133,12 @@
@@ -168,5 +173,5 @@ @endsection @section('after-content') - @include('entities.selector-popup', ['entityTypes' => 'page']) + @include('entities.selector-popup') @endsection diff --git a/resources/views/shelves/parts/form.blade.php b/resources/views/shelves/parts/form.blade.php index a724c99ce..a75dd6ac1 100644 --- a/resources/views/shelves/parts/form.blade.php +++ b/resources/views/shelves/parts/form.blade.php @@ -89,5 +89,5 @@
-@include('entities.selector-popup', ['entityTypes' => 'page', 'selectorEndpoint' => '/search/entity-selector-templates']) +@include('entities.selector-popup') @include('form.editor-translations') \ No newline at end of file From 077b9709d44031605dabc470a567f4349f623b99 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 19 Dec 2023 12:55:51 +0000 Subject: [PATCH 07/15] Input WYSIWYG: Added testing for description references --- tests/References/ReferencesTest.php | 70 ++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/tests/References/ReferencesTest.php b/tests/References/ReferencesTest.php index a19e1b901..bc691b0cd 100644 --- a/tests/References/ReferencesTest.php +++ b/tests/References/ReferencesTest.php @@ -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' => 'Testing', + ]); + + $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(); @@ -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' => 'Testing', + ]); + } + + $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([ From 7fd6d5b2ccca0c4ea9740366da0dc8720dadc8ad Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 19 Dec 2023 15:10:29 +0000 Subject: [PATCH 08/15] Input WYSIWYG: Updated tests, Added simple html limiting --- app/Entities/Repos/BaseRepo.php | 3 +- app/Util/HtmlDescriptionFilter.php | 75 +++++++++++++++++++ .../factories/Entities/Models/BookFactory.php | 4 +- .../Entities/Models/BookshelfFactory.php | 4 +- .../Entities/Models/ChapterFactory.php | 4 +- resources/js/wysiwyg/config.js | 2 +- tests/Entity/BookShelfTest.php | 32 ++++---- tests/Entity/BookTest.php | 42 +++++++---- tests/Entity/ChapterTest.php | 4 +- 9 files changed, 134 insertions(+), 36 deletions(-) create mode 100644 app/Util/HtmlDescriptionFilter.php diff --git a/app/Entities/Repos/BaseRepo.php b/app/Entities/Repos/BaseRepo.php index 3d3d16732..b52f3974e 100644 --- a/app/Entities/Repos/BaseRepo.php +++ b/app/Entities/Repos/BaseRepo.php @@ -10,6 +10,7 @@ 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 @@ -111,7 +112,7 @@ class BaseRepo /** @var HasHtmlDescription $entity */ if (isset($input['description_html'])) { - $entity->description_html = $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']; diff --git a/app/Util/HtmlDescriptionFilter.php b/app/Util/HtmlDescriptionFilter.php new file mode 100644 index 000000000..4494a73ce --- /dev/null +++ b/app/Util/HtmlDescriptionFilter.php @@ -0,0 +1,75 @@ + + */ + protected static array $allowedAttrsByElements = [ + 'p' => [], + 'a' => ['href', 'title'], + 'ol' => [], + 'ul' => [], + 'li' => [], + 'strong' => [], + 'em' => [], + 'br' => [], + ]; + + public static function filterFromString(string $html): string + { + $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); + } + } + } +} diff --git a/database/factories/Entities/Models/BookFactory.php b/database/factories/Entities/Models/BookFactory.php index 3bf157786..9cb8e971c 100644 --- a/database/factories/Entities/Models/BookFactory.php +++ b/database/factories/Entities/Models/BookFactory.php @@ -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' => '

' . e($description) . '

' ]; } } diff --git a/database/factories/Entities/Models/BookshelfFactory.php b/database/factories/Entities/Models/BookshelfFactory.php index 66dd1c111..edbefc3e7 100644 --- a/database/factories/Entities/Models/BookshelfFactory.php +++ b/database/factories/Entities/Models/BookshelfFactory.php @@ -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' => '

' . e($description) . '

' ]; } } diff --git a/database/factories/Entities/Models/ChapterFactory.php b/database/factories/Entities/Models/ChapterFactory.php index 36379866e..1fc49933e 100644 --- a/database/factories/Entities/Models/ChapterFactory.php +++ b/database/factories/Entities/Models/ChapterFactory.php @@ -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' => '

' . e($description) . '

' ]; } } diff --git a/resources/js/wysiwyg/config.js b/resources/js/wysiwyg/config.js index 2bd4b630b..f669849ad 100644 --- a/resources/js/wysiwyg/config.js +++ b/resources/js/wysiwyg/config.js @@ -329,7 +329,7 @@ export function buildForInput(options) { menubar: false, plugins: 'link autolink lists', contextmenu: false, - toolbar: 'bold italic underline link bullist numlist', + toolbar: 'bold italic link bullist numlist', content_style: getContentStyle(options), color_map: colorMap, file_picker_types: 'file', diff --git a/tests/Entity/BookShelfTest.php b/tests/Entity/BookShelfTest.php index c1842c175..7f6542d5c 100644 --- a/tests/Entity/BookShelfTest.php +++ b/tests/Entity/BookShelfTest.php @@ -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' => '

Test book description ' . Str::random(10) . '

', ]; $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' => '

Test book description ' . Str::random(10) . '

', ]; $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' => '

Test book description ' . Str::random(10) . '

', ]; $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' => '

Test shelf description ' . Str::random(10) . '

', ]; $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' => '

Book in shelf description

', ]); $newBook = Book::query()->orderBy('id', 'desc')->first(); diff --git a/tests/Entity/BookTest.php b/tests/Entity/BookTest.php index 833cabaae..3e37e61f7 100644 --- a/tests/Entity/BookTest.php +++ b/tests/Entity/BookTest.php @@ -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' => '

A book with tags

', + '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 . '

with more content

'; $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,22 @@ class BookTest extends TestCase $this->assertEquals('parta-partb-partc', $book->slug); } + public function test_description_limited_to_specific_html() + { + $book = $this->entities->book(); + + $input = '

Test

Contenta

Hello

'; + $expected = '

Contenta

'; + + $this->asEditor()->put($book->getUrl(), [ + 'name' => $book->name, + 'description_html' => $input + ]); + + $book->refresh(); + $this->assertEquals($expected, $book->description_html); + } + public function test_show_view_has_copy_button() { $book = $this->entities->book(); diff --git a/tests/Entity/ChapterTest.php b/tests/Entity/ChapterTest.php index 7fa32c252..a057d91b5 100644 --- a/tests/Entity/ChapterTest.php +++ b/tests/Entity/ChapterTest.php @@ -23,12 +23,12 @@ 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_delete() From a21ca446332a7020e54bea15171d4b6233ea3ab8 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 20 Dec 2023 17:21:09 +0000 Subject: [PATCH 09/15] Input WYSIWYG: Fixed existing tests, fixed empty description handling --- app/Util/HtmlDescriptionFilter.php | 4 ++++ database/seeders/DummyContentSeeder.php | 5 +++-- tests/Api/SearchApiTest.php | 2 +- tests/Entity/BookTest.php | 2 ++ tests/Entity/ConvertTest.php | 2 ++ tests/References/ReferencesTest.php | 6 +++--- tests/Settings/RegenerateReferencesTest.php | 4 ++-- 7 files changed, 17 insertions(+), 8 deletions(-) diff --git a/app/Util/HtmlDescriptionFilter.php b/app/Util/HtmlDescriptionFilter.php index 4494a73ce..7287586d1 100644 --- a/app/Util/HtmlDescriptionFilter.php +++ b/app/Util/HtmlDescriptionFilter.php @@ -31,6 +31,10 @@ class HtmlDescriptionFilter public static function filterFromString(string $html): string { + if (empty(trim($html))) { + return ''; + } + $doc = new HtmlDocument($html); $topLevel = [...$doc->getBodyChildren()]; diff --git a/database/seeders/DummyContentSeeder.php b/database/seeders/DummyContentSeeder.php index 47e8d1d7c..a4383be50 100644 --- a/database/seeders/DummyContentSeeder.php +++ b/database/seeders/DummyContentSeeder.php @@ -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); diff --git a/tests/Api/SearchApiTest.php b/tests/Api/SearchApiTest.php index cdc954ec3..2a186e8d6 100644 --- a/tests/Api/SearchApiTest.php +++ b/tests/Api/SearchApiTest.php @@ -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'); diff --git a/tests/Entity/BookTest.php b/tests/Entity/BookTest.php index 3e37e61f7..c4872785b 100644 --- a/tests/Entity/BookTest.php +++ b/tests/Entity/BookTest.php @@ -307,6 +307,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() diff --git a/tests/Entity/ConvertTest.php b/tests/Entity/ConvertTest.php index decda5224..d9b1ee466 100644 --- a/tests/Entity/ConvertTest.php +++ b/tests/Entity/ConvertTest.php @@ -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); diff --git a/tests/References/ReferencesTest.php b/tests/References/ReferencesTest.php index bc691b0cd..715f71435 100644 --- a/tests/References/ReferencesTest.php +++ b/tests/References/ReferencesTest.php @@ -102,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() diff --git a/tests/Settings/RegenerateReferencesTest.php b/tests/Settings/RegenerateReferencesTest.php index 25832b03e..0511f2624 100644 --- a/tests/Settings/RegenerateReferencesTest.php +++ b/tests/Settings/RegenerateReferencesTest.php @@ -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'); From ed5d67e6095639e1bd8a238bf77227ce2b97a5f8 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 20 Dec 2023 17:40:58 +0000 Subject: [PATCH 10/15] Input WYSIWYG: Aligned newline handling with old descriptions To ensure consistenent behaviour before/after changes. Added tests to cover. --- app/Entities/Models/HasHtmlDescription.php | 2 +- resources/views/books/show.blade.php | 2 +- resources/views/chapters/show.blade.php | 2 +- resources/views/shelves/show.blade.php | 2 +- tests/Entity/BookShelfTest.php | 11 +++++++++++ tests/Entity/BookTest.php | 11 +++++++++++ tests/Entity/ChapterTest.php | 11 +++++++++++ 7 files changed, 37 insertions(+), 4 deletions(-) diff --git a/app/Entities/Models/HasHtmlDescription.php b/app/Entities/Models/HasHtmlDescription.php index cc431f7fc..c9f08616d 100644 --- a/app/Entities/Models/HasHtmlDescription.php +++ b/app/Entities/Models/HasHtmlDescription.php @@ -15,7 +15,7 @@ trait HasHtmlDescription */ public function descriptionHtml(): string { - $html = $this->description_html ?: '

' . e($this->description) . '

'; + $html = $this->description_html ?: '

' . nl2br(e($this->description)) . '

'; return HtmlContentFilter::removeScriptsFromHtmlString($html); } } diff --git a/resources/views/books/show.blade.php b/resources/views/books/show.blade.php index 5884e41fd..dbb09fc9e 100644 --- a/resources/views/books/show.blade.php +++ b/resources/views/books/show.blade.php @@ -26,7 +26,7 @@

{{$book->name}}

-

{!! $book->descriptionHtml() !!}

+
{!! $book->descriptionHtml() !!}
@if(count($bookChildren) > 0)
@foreach($bookChildren as $childElement) diff --git a/resources/views/chapters/show.blade.php b/resources/views/chapters/show.blade.php index 6fe1ce431..45e43ad96 100644 --- a/resources/views/chapters/show.blade.php +++ b/resources/views/chapters/show.blade.php @@ -24,7 +24,7 @@

{{ $chapter->name }}

-

{!! $chapter->descriptionHtml() !!}

+
{!! $chapter->descriptionHtml() !!}
@if(count($pages) > 0)
@foreach($pages as $page) diff --git a/resources/views/shelves/show.blade.php b/resources/views/shelves/show.blade.php index e475a8080..11baccaf4 100644 --- a/resources/views/shelves/show.blade.php +++ b/resources/views/shelves/show.blade.php @@ -28,7 +28,7 @@
-

{!! $shelf->descriptionHtml() !!}

+
{!! $shelf->descriptionHtml() !!}
@if(count($sortedVisibleShelfBooks) > 0) @if($view === 'list')
diff --git a/tests/Entity/BookShelfTest.php b/tests/Entity/BookShelfTest.php index 7f6542d5c..fb9862931 100644 --- a/tests/Entity/BookShelfTest.php +++ b/tests/Entity/BookShelfTest.php @@ -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("

My great
\ndescription
\n
\nwith newlines

", false); + } } diff --git a/tests/Entity/BookTest.php b/tests/Entity/BookTest.php index c4872785b..374089246 100644 --- a/tests/Entity/BookTest.php +++ b/tests/Entity/BookTest.php @@ -278,6 +278,17 @@ class BookTest extends TestCase $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("

My great
\ndescription
\n
\nwith newlines

", false); + } + public function test_show_view_has_copy_button() { $book = $this->entities->book(); diff --git a/tests/Entity/ChapterTest.php b/tests/Entity/ChapterTest.php index a057d91b5..1577cee76 100644 --- a/tests/Entity/ChapterTest.php +++ b/tests/Entity/ChapterTest.php @@ -31,6 +31,17 @@ class ChapterTest extends TestCase $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("

My great
\ndescription
\n
\nwith newlines

", false); + } + public function test_delete() { $chapter = Chapter::query()->whereHas('pages')->first(); From 00ae04e0bd7f9d80aa80334b8a8bb55e7ca33cad Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 21 Dec 2023 13:23:52 +0000 Subject: [PATCH 11/15] Input WYSIWYG: Updated API to show/accept html descriptions Also aligned books, shelves and chapters to return description content and some relations (where not breaking API) in create/update responses also so that information can be seen direct from that input in a request. API docs and tests not yet updated to match. --- .../Controllers/BookApiController.php | 40 +++++++++----- .../Controllers/BookshelfApiController.php | 54 ++++++++++++------- .../Controllers/ChapterApiController.php | 52 ++++++++++++------ app/Entities/Models/Book.php | 2 +- app/Entities/Models/Bookshelf.php | 2 +- app/Entities/Models/Chapter.php | 2 +- dev/api/responses/books-read.json | 1 + dev/api/responses/chapters-read.json | 1 + dev/api/responses/shelves-read.json | 1 + 9 files changed, 103 insertions(+), 52 deletions(-) diff --git a/app/Entities/Controllers/BookApiController.php b/app/Entities/Controllers/BookApiController.php index 41ff11dde..aa21aea47 100644 --- a/app/Entities/Controllers/BookApiController.php +++ b/app/Entities/Controllers/BookApiController.php @@ -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'], ], ]; diff --git a/app/Entities/Controllers/BookshelfApiController.php b/app/Entities/Controllers/BookshelfApiController.php index 9bdb8256d..a12dc90ac 100644 --- a/app/Entities/Controllers/BookshelfApiController.php +++ b/app/Entities/Controllers/BookshelfApiController.php @@ -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()), ], ]; } diff --git a/app/Entities/Controllers/ChapterApiController.php b/app/Entities/Controllers/ChapterApiController.php index 7f01e445a..c21323262 100644 --- a/app/Entities/Controllers/ChapterApiController.php +++ b/app/Entities/Controllers/ChapterApiController.php @@ -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; + } } diff --git a/app/Entities/Models/Book.php b/app/Entities/Models/Book.php index 52674d839..14cb790c5 100644 --- a/app/Entities/Models/Book.php +++ b/app/Entities/Models/Book.php @@ -31,7 +31,7 @@ class Book extends Entity implements HasCoverImage public float $searchFactor = 1.2; protected $fillable = ['name']; - protected $hidden = ['pivot', 'image_id', 'deleted_at']; + protected $hidden = ['pivot', 'image_id', 'deleted_at', 'description_html']; /** * Get the url for this book. diff --git a/app/Entities/Models/Bookshelf.php b/app/Entities/Models/Bookshelf.php index 464c127b8..9ffa0ea9c 100644 --- a/app/Entities/Models/Bookshelf.php +++ b/app/Entities/Models/Bookshelf.php @@ -19,7 +19,7 @@ class Bookshelf extends Entity implements HasCoverImage 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. diff --git a/app/Entities/Models/Chapter.php b/app/Entities/Models/Chapter.php index 6c5f059ac..f30d77b5c 100644 --- a/app/Entities/Models/Chapter.php +++ b/app/Entities/Models/Chapter.php @@ -20,7 +20,7 @@ class Chapter extends BookChild 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. diff --git a/dev/api/responses/books-read.json b/dev/api/responses/books-read.json index 21e1829b8..afeebade6 100644 --- a/dev/api/responses/books-read.json +++ b/dev/api/responses/books-read.json @@ -3,6 +3,7 @@ "name": "My own book", "slug": "my-own-book", "description": "This is my own little book", + "description_html": "

This is my own little book

", "created_at": "2020-01-12T14:09:59.000000Z", "updated_at": "2020-01-12T14:11:51.000000Z", "created_by": { diff --git a/dev/api/responses/chapters-read.json b/dev/api/responses/chapters-read.json index 5f4de85f1..192ffce7c 100644 --- a/dev/api/responses/chapters-read.json +++ b/dev/api/responses/chapters-read.json @@ -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": "

How to create documentation on whatever subject you need to write about.

", "priority": 3, "created_at": "2019-05-05T21:49:56.000000Z", "updated_at": "2019-09-28T11:24:23.000000Z", diff --git a/dev/api/responses/shelves-read.json b/dev/api/responses/shelves-read.json index 802045bd8..eca06a46b 100644 --- a/dev/api/responses/shelves-read.json +++ b/dev/api/responses/shelves-read.json @@ -3,6 +3,7 @@ "name": "My shelf", "slug": "my-shelf", "description": "This is my shelf with some books", + "description_html": "

This is my shelf with some books

", "created_by": { "id": 1, "name": "Admin", From 2a7a81e74952c760f58e07c887f9797b9137c61d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 22 Dec 2023 13:17:23 +0000 Subject: [PATCH 12/15] Input WYSIWYG: Updated API testing, fixed description set issue Fixed issue where an existing description_html field would not be updated via 'description' input. --- app/Entities/Repos/BaseRepo.php | 1 + tests/Api/BooksApiTest.php | 56 +++++++++++++++++++++++++++++---- tests/Api/ChaptersApiTest.php | 52 ++++++++++++++++++++++++++++-- tests/Api/ShelvesApiTest.php | 51 ++++++++++++++++++++++++++++-- 4 files changed, 148 insertions(+), 12 deletions(-) diff --git a/app/Entities/Repos/BaseRepo.php b/app/Entities/Repos/BaseRepo.php index b52f3974e..27bf00161 100644 --- a/app/Entities/Repos/BaseRepo.php +++ b/app/Entities/Repos/BaseRepo.php @@ -116,6 +116,7 @@ class BaseRepo $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(); } } diff --git a/tests/Api/BooksApiTest.php b/tests/Api/BooksApiTest.php index c648faaf2..b31bd7d37 100644 --- a/tests/Api/BooksApiTest.php +++ b/tests/Api/BooksApiTest.php @@ -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' => '

A book created via the API

', + ])); $this->assertActivityExists('book_create', $newItem); } + public function test_create_endpoint_with_html() + { + $this->actingAsApiEditor(); + $details = [ + 'name' => 'My API book', + 'description_html' => '

A book created via the API

', + ]; + + $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' => '

A book updated via the API

', + ])); $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' => '

A book updated via the API

', + ]; + + $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(); diff --git a/tests/Api/ChaptersApiTest.php b/tests/Api/ChaptersApiTest.php index 0629f3aed..81a918877 100644 --- a/tests/Api/ChaptersApiTest.php +++ b/tests/Api/ChaptersApiTest.php @@ -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' => '

A chapter created via the API

', + ])); $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' => '

A chapter created via the API

', + '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' => '

A chapter updated via the API

', ])); $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' => '

A chapter updated via the API

', + ]; + + $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(); diff --git a/tests/Api/ShelvesApiTest.php b/tests/Api/ShelvesApiTest.php index fbfc17cb4..f1b8ed985 100644 --- a/tests/Api/ShelvesApiTest.php +++ b/tests/Api/ShelvesApiTest.php @@ -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' => '

A shelf created via the API

', + ])); $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' => '

A shelf created via the API

', + ]; + + $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' => '

A shelf updated via the API

', + ])); $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' => '

A shelf updated via the API

', + ]; + + $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(); From fb3cfaf7c7cdd1fb77f7553c02821fe29ccee2ea Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 22 Dec 2023 14:37:48 +0000 Subject: [PATCH 13/15] Input WYSIWYG: Updated API examples to align with changes --- dev/api/requests/books-create.json | 4 ++-- dev/api/requests/books-update.json | 4 ++-- dev/api/requests/chapters-create.json | 2 +- dev/api/requests/chapters-update.json | 2 +- dev/api/requests/shelves-create.json | 7 +++++-- dev/api/requests/shelves-update.json | 2 +- dev/api/responses/books-create.json | 28 +++++++++++++++++++------- dev/api/responses/books-update.json | 17 ++++++++++++---- dev/api/responses/chapters-create.json | 9 +++++---- dev/api/responses/chapters-update.json | 10 ++++----- dev/api/responses/shelves-create.json | 15 +++++++++++--- dev/api/responses/shelves-update.json | 17 ++++++++++++---- 12 files changed, 81 insertions(+), 36 deletions(-) diff --git a/dev/api/requests/books-create.json b/dev/api/requests/books-create.json index 2a38dba83..71dbdcc65 100644 --- a/dev/api/requests/books-create.json +++ b/dev/api/requests/books-create.json @@ -1,7 +1,7 @@ { "name": "My own book", - "description": "This is my own little book", - "default_template_id": 12, + "description_html": "

This is my own little book created via the API

", + "default_template_id": 2427, "tags": [ {"name": "Category", "value": "Top Content"}, {"name": "Rating", "value": "Highest"} diff --git a/dev/api/requests/books-update.json b/dev/api/requests/books-update.json index c026b7b49..30ce7e95a 100644 --- a/dev/api/requests/books-update.json +++ b/dev/api/requests/books-update.json @@ -1,7 +1,7 @@ { "name": "My updated book", - "description": "This is my book with updated details", - "default_template_id": 12, + "description_html": "

This is my book with updated details

", + "default_template_id": 2427, "tags": [ {"name": "Subject", "value": "Updates"} ] diff --git a/dev/api/requests/chapters-create.json b/dev/api/requests/chapters-create.json index a7a0e072c..e9d903387 100644 --- a/dev/api/requests/chapters-create.json +++ b/dev/api/requests/chapters-create.json @@ -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": "

This is a great new chapter that I've created via the API

", "priority": 15, "tags": [ {"name": "Category", "value": "Top Content"}, diff --git a/dev/api/requests/chapters-update.json b/dev/api/requests/chapters-update.json index 18c40301b..be675772b 100644 --- a/dev/api/requests/chapters-update.json +++ b/dev/api/requests/chapters-update.json @@ -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": "

This is an updated chapter that I've altered via the API

", "priority": 16, "tags": [ {"name": "Category", "value": "Kinda Good Content"}, diff --git a/dev/api/requests/shelves-create.json b/dev/api/requests/shelves-create.json index 39b88af7e..8f35340f6 100644 --- a/dev/api/requests/shelves-create.json +++ b/dev/api/requests/shelves-create.json @@ -1,5 +1,8 @@ { "name": "My shelf", - "description": "This is my shelf with some books", - "books": [5,1,3] + "description_html": "

This is my shelf with some books

", + "books": [5,1,3], + "tags": [ + {"name": "Category", "value": "Learning"} + ] } \ No newline at end of file diff --git a/dev/api/requests/shelves-update.json b/dev/api/requests/shelves-update.json index df5f5735d..081c8f4c1 100644 --- a/dev/api/requests/shelves-update.json +++ b/dev/api/requests/shelves-update.json @@ -1,5 +1,5 @@ { "name": "My updated shelf", - "description": "This is my update shelf with some books", + "description_html": "

This is my updated shelf with some books

", "books": [5,1,3] } \ No newline at end of file diff --git a/dev/api/responses/books-create.json b/dev/api/responses/books-create.json index 773879125..8895fb854 100644 --- a/dev/api/responses/books-create.json +++ b/dev/api/responses/books-create.json @@ -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": "

This is 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 diff --git a/dev/api/responses/books-update.json b/dev/api/responses/books-update.json index f69677c4a..dafa2feb0 100644 --- a/dev/api/responses/books-update.json +++ b/dev/api/responses/books-update.json @@ -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": "

This is my book with updated<\/em> details<\/p>", + "tags": [ + { + "name": "Subject", + "value": "Updates", + "order": 0 + } + ], + "cover": null } \ No newline at end of file diff --git a/dev/api/responses/chapters-create.json b/dev/api/responses/chapters-create.json index cf47b123d..183186b0b 100644 --- a/dev/api/responses/chapters-create.json +++ b/dev/api/responses/chapters-create.json @@ -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": "

This is a 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 diff --git a/dev/api/responses/chapters-update.json b/dev/api/responses/chapters-update.json index a4940af2d..5ac3c64c1 100644 --- a/dev/api/responses/chapters-update.json +++ b/dev/api/responses/chapters-update.json @@ -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": "

This is an 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 diff --git a/dev/api/responses/shelves-create.json b/dev/api/responses/shelves-create.json index 84caf8bdc..235557834 100644 --- a/dev/api/responses/shelves-create.json +++ b/dev/api/responses/shelves-create.json @@ -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": "

This is my shelf<\/strong> with some books<\/p>", + "tags": [ + { + "name": "Category", + "value": "Learning", + "order": 0 + } + ], + "cover": null } \ No newline at end of file diff --git a/dev/api/responses/shelves-update.json b/dev/api/responses/shelves-update.json index e199d8d68..3b3f0538e 100644 --- a/dev/api/responses/shelves-update.json +++ b/dev/api/responses/shelves-update.json @@ -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": "

This is my updated shelf<\/em> with some books<\/p>", + "tags": [ + { + "name": "Category", + "value": "Learning", + "order": 0 + } + ], + "cover": null } \ No newline at end of file From 7cd0629a75b2b70b5de1608d5171a6814088e2cc Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 22 Dec 2023 14:57:20 +0000 Subject: [PATCH 14/15] Input WYSIWYG: Updated exports to handle HTML descriptions --- resources/views/exports/book.blade.php | 2 +- resources/views/exports/chapter.blade.php | 2 +- .../exports/parts/chapter-item.blade.php | 2 +- tests/Entity/ExportTest.php | 28 +++++++++++++------ 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/resources/views/exports/book.blade.php b/resources/views/exports/book.blade.php index 42e03ea01..9de7b8eba 100644 --- a/resources/views/exports/book.blade.php +++ b/resources/views/exports/book.blade.php @@ -5,7 +5,7 @@ @section('content')

{{$book->name}}

-

{{ $book->description }}

+
{!! $book->descriptionHtml() !!}
@include('exports.parts.book-contents-menu', ['children' => $bookChildren]) diff --git a/resources/views/exports/chapter.blade.php b/resources/views/exports/chapter.blade.php index ae49fa918..515366d60 100644 --- a/resources/views/exports/chapter.blade.php +++ b/resources/views/exports/chapter.blade.php @@ -5,7 +5,7 @@ @section('content')

{{$chapter->name}}

-

{{ $chapter->description }}

+
{!! $chapter->descriptionHtml() !!}
@include('exports.parts.chapter-contents-menu', ['pages' => $pages]) diff --git a/resources/views/exports/parts/chapter-item.blade.php b/resources/views/exports/parts/chapter-item.blade.php index f58068b5e..fa0b1f228 100644 --- a/resources/views/exports/parts/chapter-item.blade.php +++ b/resources/views/exports/parts/chapter-item.blade.php @@ -1,7 +1,7 @@

{{ $chapter->name }}

-

{{ $chapter->description }}

+
{!! $chapter->descriptionHtml() !!}
@if(count($chapter->visible_pages) > 0) @foreach($chapter->visible_pages as $page) diff --git a/tests/Entity/ExportTest.php b/tests/Entity/ExportTest.php index 08bf17d0a..eedcb672c 100644 --- a/tests/Entity/ExportTest.php +++ b/tests/Entity/ExportTest.php @@ -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 = '

A description with HTML within!

'; + $chapter->description_html = '

A chapter description with HTML within!

'; + $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 = '

A description with HTML within!

'; + $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(); From 3668949705ad85c42e39dc18569e086d968f7022 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 22 Dec 2023 15:16:06 +0000 Subject: [PATCH 15/15] Input WYSIWYG: Fixed up some dark mode elements --- resources/js/wysiwyg/config.js | 6 ++---- resources/sass/_forms.scss | 1 + resources/views/books/parts/form.blade.php | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/resources/js/wysiwyg/config.js b/resources/js/wysiwyg/config.js index f669849ad..963e2970d 100644 --- a/resources/js/wysiwyg/config.js +++ b/resources/js/wysiwyg/config.js @@ -331,15 +331,13 @@ export function buildForInput(options) { contextmenu: false, toolbar: 'bold italic link bullist numlist', content_style: getContentStyle(options), - color_map: colorMap, file_picker_types: 'file', file_picker_callback: filePickerCallback, init_instance_callback(editor) { const head = editor.getDoc().querySelector('head'); head.innerHTML += fetchCustomHeadContent(); - }, - setup(editor) { - // + + editor.contentDocument.documentElement.classList.toggle('dark-mode', options.darkMode); }, }; } diff --git a/resources/sass/_forms.scss b/resources/sass/_forms.scss index b63f9cdd5..8c277c2b5 100644 --- a/resources/sass/_forms.scss +++ b/resources/sass/_forms.scss @@ -408,6 +408,7 @@ input[type=color] { .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; diff --git a/resources/views/books/parts/form.blade.php b/resources/views/books/parts/form.blade.php index a3d4be5e9..fa8f16e52 100644 --- a/resources/views/books/parts/form.blade.php +++ b/resources/views/books/parts/form.blade.php @@ -39,7 +39,7 @@
-