diff --git a/app/Entities/Controllers/BookshelfExportController.php b/app/Entities/Controllers/BookshelfExportController.php new file mode 100644 index 00000000000..44627539093 --- /dev/null +++ b/app/Entities/Controllers/BookshelfExportController.php @@ -0,0 +1,66 @@ +middleware('can:content-export'); + } + + /** + * Export a book as a PDF file. + * + * @throws Throwable + */ + public function pdf(string $bookshelfSlug) + { + $bookshelf = $this->queries->findVisibleBySlugOrFail($bookshelfSlug); + $pdfContent = $this->exportFormatter->bookshelfToPdf($bookshelf); + + return $this->download()->directly($pdfContent, $bookshelfSlug . '.pdf'); + } + + /** + * Export a book as a contained HTML file. + * + * @throws Throwable + */ + public function html(string $bookshelfSlug) + { + $bookshelf = $this->queries->findVisibleBySlugOrFail($bookshelfSlug); + $htmlContent = $this->exportFormatter->bookshelfToContainedHtml($bookshelf); + + return $this->download()->directly($htmlContent, $bookshelfSlug . '.html'); + } + + /** + * Export a book as a plain text file. + */ + public function plainText(string $bookshelfSlug) + { + $bookshelf = $this->queries->findVisibleBySlugOrFail($bookshelfSlug); + $textContent = $this->exportFormatter->bookshelfToPlainText($bookshelf); + + return $this->download()->directly($textContent, $bookshelfSlug . '.txt'); + } + + /** + * Export a book as a markdown file. + */ + public function markdown(string $bookshelfSlug) + { + $bookshelf = $this->queries->findVisibleBySlugOrFail($bookshelfSlug); + $textContent = $this->exportFormatter->bookshelfToMarkdown($bookshelf); + + return $this->download()->directly($textContent, $bookshelfSlug . '.md'); + } +} diff --git a/app/Entities/Tools/BookshelfContents.php b/app/Entities/Tools/BookshelfContents.php new file mode 100644 index 00000000000..859a11dd50a --- /dev/null +++ b/app/Entities/Tools/BookshelfContents.php @@ -0,0 +1,23 @@ +bookshelf->books()->scopes('visible')->get(); + + $books->each(function ($book) use ($renderPages) { + $book->setAttribute('bookChildrens', (new BookContents($book))->getTree(false, $renderPages)); + }); + + return collect($books); + } +} diff --git a/app/Entities/Tools/ExportFormatter.php b/app/Entities/Tools/ExportFormatter.php index beddfe8e6e0..fdd4c114df0 100644 --- a/app/Entities/Tools/ExportFormatter.php +++ b/app/Entities/Tools/ExportFormatter.php @@ -3,6 +3,7 @@ namespace BookStack\Entities\Tools; use BookStack\Entities\Models\Book; +use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Page; use BookStack\Entities\Tools\Markdown\HtmlToMarkdown; @@ -82,6 +83,25 @@ public function bookToContainedHtml(Book $book): string return $this->containHtml($html); } + /** + * Convert a bookshelf to a self-contained HTML file. + * + * @throws Throwable + */ + public function bookshelfToContainedHtml(Bookshelf $bookshelf): string + { + $bookshelfTree = (new BookshelfContents($bookshelf))->getTree(true); + $html = view('exports.shelves', [ + 'bookshelf' => $bookshelf, + 'bookshelfChildrens' => $bookshelfTree, + 'format' => 'pdf', + 'engine' => $this->pdfGenerator->getActiveEngine(), + 'locale' => user()->getLocale(), + ])->render(); + + return $this->containHtml($html); + } + /** * Convert a page to a PDF file. * @@ -142,6 +162,22 @@ public function bookToPdf(Book $book): string return $this->htmlToPdf($html); } + + public function bookshelfToPdf(Bookshelf $bookshelf): string + { + $bookshelfTree = (new BookshelfContents($bookshelf))->getTree(true); + + $html = view('exports.shelves', [ + 'bookshelf' => $bookshelf, + 'bookshelfChildrens' => $bookshelfTree, + 'format' => 'pdf', + 'engine' => $this->pdfGenerator->getActiveEngine(), + 'locale' => user()->getLocale(), + ])->render(); + + return $this->htmlToPdf($html); + } + /** * Convert normal web-page HTML to a PDF. * @@ -297,6 +333,23 @@ public function bookToPlainText(Book $book): string return $text . implode("\n\n", $parts); } + /** + * Convert a book into a plain text string. + */ + public function bookshelfToPlainText(Bookshelf $bookshelf): string + { + $bookshelfTree = (new BookshelfContents($bookshelf))->getTree(true); + $text = $bookshelf->name . "\n" . $bookshelf->description; + $text = rtrim($text) . "\n\n"; + + $parts = []; + foreach ($bookshelfTree as $bookshelfChild) { + $parts[] = $this->bookToPlainText($bookshelfChild); + } + + return $text . implode("\n\n", $parts); + } + /** * Convert a page to a Markdown file. */ @@ -340,4 +393,18 @@ public function bookToMarkdown(Book $book): string return trim($text); } + + /** + * Convert a bookshelf into a plain text string. + */ + public function bookshelfToMarkdown(Bookshelf $bookshelf): string + { + $bookshelfTree = (new BookshelfContents($bookshelf))->getTree(true); + $text = '# ' . $bookshelf->name . "\n\n"; + foreach ($bookshelfTree as $bookshelfChild) { + $text .= $this->bookToMarkdown($bookshelfChild) . "\n\n"; + } + + return trim($text); + } } diff --git a/app/Util/HtmlDescriptionFilter.php b/app/Util/HtmlDescriptionFilter.php index cb091b869f8..a4c214ae58c 100644 --- a/app/Util/HtmlDescriptionFilter.php +++ b/app/Util/HtmlDescriptionFilter.php @@ -27,6 +27,7 @@ class HtmlDescriptionFilter 'strong' => [], 'em' => [], 'br' => [], + 'img' => ['src', 'alt'], ]; public static function filterFromString(string $html): string diff --git a/resources/js/components/page-comments.js b/resources/js/components/page-comments.js index 1d6abfe2044..22f35882c39 100644 --- a/resources/js/components/page-comments.js +++ b/resources/js/components/page-comments.js @@ -93,7 +93,6 @@ export class PageComments extends Component { updateCount() { const count = this.getCommentCount(); - console.log('update count', count, this.container); this.commentsTitle.textContent = window.$trans.choice(this.countText, count, {count}); } @@ -135,7 +134,10 @@ export class PageComments extends Component { containerElement: this.formInput, darkMode: document.documentElement.classList.contains('dark-mode'), textDirection: this.wysiwygTextDirection, - translations: {}, + translations: { + imageUploadErrorText: this.$opts.imageUploadErrorText, + serverUploadLimitText: this.$opts.serverUploadLimitText, + }, translationMap: window.editor_translations, }); diff --git a/resources/js/wysiwyg-tinymce/config.js b/resources/js/wysiwyg-tinymce/config.js index 1666aa50066..fdd734c9288 100644 --- a/resources/js/wysiwyg-tinymce/config.js +++ b/resources/js/wysiwyg-tinymce/config.js @@ -318,6 +318,9 @@ export function buildForInput(options) { // Set language window.tinymce.addI18n(options.language, options.translationMap); + // Add IMage Manager Plugin + window.tinymce.PluginManager.add('imagemanager', getImagemanagerPlugin()); + // BookStack Version const version = document.querySelector('script[src*="/dist/app.js"]').getAttribute('src').split('?version=')[1]; @@ -343,13 +346,20 @@ export function buildForInput(options) { remove_trailing_brs: false, statusbar: false, menubar: false, - plugins: 'link autolink lists', + plugins: 'link autolink lists imagemanager', contextmenu: false, - toolbar: 'bold italic link bullist numlist', + toolbar: 'bold italic link bullist numlist imagemanager-insert', content_style: getContentStyle(options), - file_picker_types: 'file', - valid_elements: 'p,a[href|title|target],ol,ul,li,strong,em,br', + file_picker_types: 'file image', + automatic_uploads: false, + valid_elements: 'p,a[href|title|target],ol,ul,li,strong,em,br,+div[pre|img],img[src|alt|width|height|class|style]', file_picker_callback: filePickerCallback, + paste_preprocess(plugin, args) { + const {content} = args; + if (content.indexOf(' @@ -37,6 +39,7 @@ class="button outline">{{ trans('entities.comment_add') }} @if(userCan('comment-create-all') || $commentTree->canUpdateAny()) @push('body-end') + @include('pages.parts.image-manager', ['uploaded_to' => $page->id]) @include('form.editor-translations') @include('entities.selector-popup') diff --git a/resources/views/exports/parts/book-item.blade.php b/resources/views/exports/parts/book-item.blade.php new file mode 100644 index 00000000000..83636c0712b --- /dev/null +++ b/resources/views/exports/parts/book-item.blade.php @@ -0,0 +1,12 @@ +
+

{{ $book->name }}

+ +
{!! $book->descriptionHtml() !!}
+ +@foreach($bookChildren as $bookChild) + @if($bookChild->isA('chapter')) + @include('exports.parts.chapter-item', ['chapter' => $bookChild]) + @else + @include('exports.parts.page-item', ['page' => $bookChild, 'chapter' => null]) + @endif +@endforeach \ No newline at end of file diff --git a/resources/views/exports/parts/shelves-contents-menu.blade.php b/resources/views/exports/parts/shelves-contents-menu.blade.php new file mode 100644 index 00000000000..5871e584a48 --- /dev/null +++ b/resources/views/exports/parts/shelves-contents-menu.blade.php @@ -0,0 +1,8 @@ +@if(count($bookshelfBooks) > 0) + +@endif \ No newline at end of file diff --git a/resources/views/exports/shelves.blade.php b/resources/views/exports/shelves.blade.php new file mode 100644 index 00000000000..5fa7b76372d --- /dev/null +++ b/resources/views/exports/shelves.blade.php @@ -0,0 +1,17 @@ +@extends('layouts.export') + +@section('title', $bookshelf->name) + +@section('content') + +

{{$bookshelf->name}}

+
{!! $bookshelf->descriptionHtml() !!}
+ + @include('exports.parts.shelves-contents-menu',['bookshelfBooks' => $bookshelfChildrens]) + + @foreach($bookshelfChildrens as $bookshelfChildren) + @if($bookshelfChildren->isA('book')) + @include('exports.parts.book-item',['bookChildren'=>$bookshelfChildren->bookChildrens,'book'=>$bookshelfChildren]) + @endif + @endforeach +@endsection \ No newline at end of file diff --git a/resources/views/shelves/show.blade.php b/resources/views/shelves/show.blade.php index 11baccaf463..49400532c00 100644 --- a/resources/views/shelves/show.blade.php +++ b/resources/views/shelves/show.blade.php @@ -148,6 +148,10 @@ @include('entities.favourite-action', ['entity' => $shelf]) @endif + @if(userCan('content-export')) + @include('entities.export-menu', ['entity' => $shelf]) + @endif + @stop diff --git a/routes/web.php b/routes/web.php index 81b938f32ec..c47dfa6ba34 100644 --- a/routes/web.php +++ b/routes/web.php @@ -51,6 +51,10 @@ Route::put('/shelves/{slug}/permissions', [PermissionsController::class, 'updateForShelf']); Route::post('/shelves/{slug}/copy-permissions', [PermissionsController::class, 'copyShelfPermissionsToBooks']); Route::get('/shelves/{slug}/references', [ReferenceController::class, 'shelf']); + Route::get('/shelves/{slug}/export/pdf', [EntityControllers\BookshelfExportController::class, 'pdf']); + Route::get('/shelves/{slug}/export/html', [EntityControllers\BookshelfExportController::class, 'html']); + Route::get('/shelves/{slug}/export/plaintext', [EntityControllers\BookshelfExportController::class, 'plainText']); + Route::get('/shelves/{slug}/export/markdown', [EntityControllers\BookshelfExportController::class, 'markdown']); // Book Creation Route::get('/shelves/{shelfSlug}/create-book', [EntityControllers\BookController::class, 'create']); diff --git a/tests/Entity/CommentTest.php b/tests/Entity/CommentTest.php index 73136235ce0..ac3e4f05915 100644 --- a/tests/Entity/CommentTest.php +++ b/tests/Entity/CommentTest.php @@ -214,4 +214,16 @@ public function test_comment_editor_js_loaded_with_create_or_edit_permissions() $resp->assertSee('window.editor_translations', false); $resp->assertSee('component="entity-selector"', false); } + + public function test_images_can_add_in_comment() + { + $this->asEditor(); + $page = $this->entities->page(); + + $this->postJson("/comment/$page->id", [ + 'html' => '

4.sm.webp

', + ]); + + $this->assertStringMatchesFormat('%A%A

%A', $page->comments()->first()->html); + } } diff --git a/tests/Entity/ExportTest.php b/tests/Entity/ExportTest.php index 7aafa3b7927..d4a82d4270e 100644 --- a/tests/Entity/ExportTest.php +++ b/tests/Entity/ExportTest.php @@ -3,10 +3,12 @@ namespace Tests\Entity; use BookStack\Entities\Models\Book; +use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Page; use BookStack\Entities\Tools\PdfGenerator; use BookStack\Exceptions\PdfExportException; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\Storage; use Tests\TestCase; @@ -566,4 +568,68 @@ public function test_html_exports_contain_body_classes_for_export_identification $resp = $this->asEditor()->get($page->getUrl('/export/html')); $this->withHtml($resp)->assertElementExists('body.export.export-format-html.export-engine-none'); } + + public function test_bookshelf_text_export() + { + $bookshelf = $this->entities->shelf(); + $book = $bookshelf->books()->first(); + $directPage = $book->directPages()->first(); + $chapter = $book->chapters()->first(); + + $this->entities->updatePage($directPage, ['html' => '

My awesome page

']); + $this->asEditor(); + + $resp = $this->get($bookshelf->getUrl('/export/plaintext')); + $resp->assertStatus(200); + $resp->assertSee($bookshelf->name); + $resp->assertSee($book->name); + $resp->assertSee($chapter->name); + $resp->assertSee($directPage->name); + $resp->assertSee('My awesome page'); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $bookshelf->slug . '.txt"'); + } + + public function test_bookshelf_pdf_export() + { + $bookshelf = $this->entities->shelf(); + $this->asEditor(); + + $resp = $this->get($bookshelf->getUrl('/export/pdf')); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $bookshelf->slug . '.pdf"'); + } + + public function test_bookshelf_html_export() + { + $bookshelf = $this->entities->shelf(); + $book = $bookshelf->books()->first(); + + $this->asEditor(); + + $resp = $this->get($bookshelf->getUrl('/export/html')); + $resp->assertStatus(200); + $resp->assertSee($bookshelf->name); + $resp->assertSee($book->name); + $resp->assertSee($bookshelf->description); + $resp->assertSee($book->description); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $bookshelf->slug . '.html"'); + } + + public function test_bookshelf_markdown_export() + { + $bookshelf = Bookshelf::query()->whereHas('books', function (Builder $query) { + $query->Has('chapters')->Has('pages'); + }) + ->with(['books.chapters', 'books.pages']) + ->first(); + $book = $bookshelf->books()->first(); + $chapter = $book->chapters()->first(); + $directPage = $book->directPages()->first(); + $resp = $this->asEditor()->get($bookshelf->getUrl('/export/markdown')); + + $resp->assertSee('# ' . $bookshelf->name); + $resp->assertSee('# ' . $book->name); + $resp->assertSee('# ' . $chapter->name); + $resp->assertSee('# ' . $directPage->name); + } }