diff --git a/.env.example.complete b/.env.example.complete index e29087fab..0c7f8f6a5 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -274,7 +274,7 @@ AVATAR_URL= # Enable diagrams.net integration # Can simply be true/false to enable/disable the integration. # Alternatively, It can be URL to the diagrams.net instance you want to use. -# For URLs, The following URL parameters should be included: embed=1&proto=json&spin=1 +# For URLs, The following URL parameters should be included: embed=1&proto=json&spin=1&configure=1 DRAWIO=true # Default item listing view diff --git a/.github/translators.txt b/.github/translators.txt index d9e886fad..4dc671454 100644 --- a/.github/translators.txt +++ b/.github/translators.txt @@ -165,7 +165,7 @@ Francesco Franchina (ffranchina) :: Italian Aimrane Kds (aimrane.kds) :: Arabic whenwesober :: Indonesian Rem (remkovdhoef) :: Dutch -syn7ax69 :: Bulgarian; Turkish +syn7ax69 :: Bulgarian; Turkish; German Blaade :: French Behzad HosseinPoor (behzad.hp) :: Persian Ole Aldric (Swoy) :: Norwegian Bokmal @@ -238,3 +238,7 @@ pedromcsousa :: Portuguese Nir Louk (looknear) :: Hebrew Alex (qianmengnet) :: Chinese Simplified stothew :: German +sgenc :: Turkish +Shukrullo (vodiylik) :: Uzbek +William W. (Nevnt) :: Chinese Traditional +eamaro :: Portuguese diff --git a/app/Config/app.php b/app/Config/app.php index 10359639a..968addc69 100644 --- a/app/Config/app.php +++ b/app/Config/app.php @@ -71,7 +71,7 @@ return [ 'locale' => env('APP_LANG', 'en'), // Locales available - 'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', 'et', 'eu', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ru', 'th', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW'], + 'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', 'et', 'eu', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ru', 'th', 'tr', 'uk', 'uz', 'vi', 'zh_CN', 'zh_TW'], // Application Fallback Locale 'fallback_locale' => 'en', diff --git a/app/Entities/Models/Deletion.php b/app/Entities/Models/Deletion.php index 181c9c580..25f0ea6db 100644 --- a/app/Entities/Models/Deletion.php +++ b/app/Entities/Models/Deletion.php @@ -10,10 +10,16 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\MorphTo; /** + * @property int $id + * @property int $deleted_by + * @property string $deletable_type + * @property int $deletable_id * @property Deletable $deletable */ class Deletion extends Model implements Loggable { + protected $hidden = []; + /** * Get the related deletable record. */ diff --git a/app/Entities/Models/Page.php b/app/Entities/Models/Page.php index c8217af57..ed69bcf8b 100644 --- a/app/Entities/Models/Page.php +++ b/app/Entities/Models/Page.php @@ -22,6 +22,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne; * @property bool $template * @property bool $draft * @property int $revision_count + * @property string $editor * @property Chapter $chapter * @property Collection $attachments * @property Collection $revisions diff --git a/app/Entities/Models/PageRevision.php b/app/Entities/Models/PageRevision.php index 800e5e7f2..be2ac33a0 100644 --- a/app/Entities/Models/PageRevision.php +++ b/app/Entities/Models/PageRevision.php @@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * * @property mixed $id * @property int $page_id + * @property string $name * @property string $slug * @property string $book_slug * @property int $created_by @@ -21,13 +22,14 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * @property string $summary * @property string $markdown * @property string $html + * @property string $text * @property int $revision_number * @property Page $page * @property-read ?User $createdBy */ class PageRevision extends Model { - protected $fillable = ['name', 'html', 'text', 'markdown', 'summary']; + protected $fillable = ['name', 'text', 'summary']; protected $hidden = ['html', 'markdown', 'restricted', 'text']; /** diff --git a/app/Entities/Repos/BaseRepo.php b/app/Entities/Repos/BaseRepo.php index 6b29dad7b..9e1b41672 100644 --- a/app/Entities/Repos/BaseRepo.php +++ b/app/Entities/Repos/BaseRepo.php @@ -11,8 +11,8 @@ use Illuminate\Http\UploadedFile; class BaseRepo { - protected $tagRepo; - protected $imageRepo; + protected TagRepo $tagRepo; + protected ImageRepo $imageRepo; public function __construct(TagRepo $tagRepo, ImageRepo $imageRepo) { @@ -58,6 +58,7 @@ class BaseRepo if (isset($input['tags'])) { $this->tagRepo->saveTagsToEntity($entity, $input['tags']); + $entity->touch(); } $entity->rebuildPermissions(); diff --git a/app/Entities/Repos/DeletionRepo.php b/app/Entities/Repos/DeletionRepo.php new file mode 100644 index 000000000..5d53013dc --- /dev/null +++ b/app/Entities/Repos/DeletionRepo.php @@ -0,0 +1,36 @@ +trashCan = $trashCan; + } + + public function restore(int $id): int + { + /** @var Deletion $deletion */ + $deletion = Deletion::query()->findOrFail($id); + Activity::add(ActivityType::RECYCLE_BIN_RESTORE, $deletion); + + return $this->trashCan->restoreFromDeletion($deletion); + } + + public function destroy(int $id): int + { + /** @var Deletion $deletion */ + $deletion = Deletion::query()->findOrFail($id); + Activity::add(ActivityType::RECYCLE_BIN_DESTROY, $deletion); + + return $this->trashCan->destroyFromDeletion($deletion); + } +} diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index 828c4572f..c106d2fd3 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -10,6 +10,7 @@ use BookStack\Entities\Models\Page; use BookStack\Entities\Models\PageRevision; use BookStack\Entities\Tools\BookContents; use BookStack\Entities\Tools\PageContent; +use BookStack\Entities\Tools\PageEditorData; use BookStack\Entities\Tools\TrashCan; use BookStack\Exceptions\MoveOperationException; use BookStack\Exceptions\NotFoundException; @@ -217,11 +218,25 @@ class PageRepo } $pageContent = new PageContent($page); - if (!empty($input['markdown'] ?? '')) { + $currentEditor = $page->editor ?: PageEditorData::getSystemDefaultEditor(); + $newEditor = $currentEditor; + + $haveInput = isset($input['markdown']) || isset($input['html']); + $inputEmpty = empty($input['markdown']) && empty($input['html']); + + if ($haveInput && $inputEmpty) { + $pageContent->setNewHTML(''); + } elseif (!empty($input['markdown']) && is_string($input['markdown'])) { + $newEditor = 'markdown'; $pageContent->setNewMarkdown($input['markdown']); } elseif (isset($input['html'])) { + $newEditor = 'wysiwyg'; $pageContent->setNewHTML($input['html']); } + + if ($newEditor !== $currentEditor && userCan('editor-change')) { + $page->editor = $newEditor; + } } /** @@ -229,8 +244,12 @@ class PageRepo */ protected function savePageRevision(Page $page, string $summary = null): PageRevision { - $revision = new PageRevision($page->getAttributes()); + $revision = new PageRevision(); + $revision->name = $page->name; + $revision->html = $page->html; + $revision->markdown = $page->markdown; + $revision->text = $page->text; $revision->page_id = $page->id; $revision->slug = $page->slug; $revision->book_slug = $page->book->slug; @@ -260,10 +279,15 @@ class PageRepo return $page; } - // Otherwise save the data to a revision + // Otherwise, save the data to a revision $draft = $this->getPageRevisionToUpdate($page); $draft->fill($input); - if (setting('app-editor') !== 'markdown') { + + if (!empty($input['markdown'])) { + $draft->markdown = $input['markdown']; + $draft->html = ''; + } else { + $draft->html = $input['html']; $draft->markdown = ''; } diff --git a/app/Entities/Tools/Markdown/CustomDivConverter.php b/app/Entities/Tools/Markdown/CustomDivConverter.php new file mode 100644 index 000000000..486062390 --- /dev/null +++ b/app/Entities/Tools/Markdown/CustomDivConverter.php @@ -0,0 +1,20 @@ +getAttribute('drawio-diagram'); + if ($drawIoDiagram) { + return "
+ {{ trans('entities.pages_editor_switch_are_you_sure') }}
+
+ {{ trans('entities.pages_editor_switch_consider_following') }}
+
{{ trans('settings.app_editor_desc') }}
+ +{{ trans('settings.app_default_editor_desc') }}
{{ trans('settings.app_homepage_desc') }}
Some callout text
Another line
', - ]); - $page->save(); - - $resp = $this->asEditor()->get($page->getUrl('/export/markdown')); - $resp->assertSee("# Dogcat\n\nSome callout text
\n\nAnother line", false); - } - - public function test_page_markdown_export_handles_bookstacks_wysiwyg_codeblock_format() - { - $page = Page::query()->first()->forceFill([ - 'markdown' => '', - 'html' => 'var a = \'cat\';
Another line
', - ]); - $page->save(); - - $resp = $this->asEditor()->get($page->getUrl('/export/markdown')); - $resp->assertSee("# Dogcat\n\n```JavaScript\nvar a = 'cat';\n```\n\nAnother line", false); - } - - public function test_page_markdown_export_handles_tasklist_checkboxes() - { - $page = Page::query()->first()->forceFill([ - 'markdown' => '', - 'html' => 'Some bold text
', + "# Dogcat\n\nSome **bold** text" + ); + } + + public function test_callouts_remain_html() + { + $this->assertConversion( + 'Some callout text
Another line
', + "# Dogcat\n\nSome callout text
\n\nAnother line" + ); + } + + public function test_wysiwyg_code_format_handled_cleanly() + { + $this->assertConversion( + 'var a = \'cat\';
Another line
', + "# Dogcat\n\n```JavaScript\nvar a = 'cat';\n```\n\nAnother line" + ); + } + + public function test_tasklist_checkboxes_are_handled() + { + $this->assertConversion( + 'Test
Beans
', + "\n\nBeans" + ); + } + + protected function assertConversion(string $html, string $expectedMarkdown, bool $partialMdMatch = false) + { + $markdown = (new HtmlToMarkdown($html))->convert(); + + if ($partialMdMatch) { + static::assertStringContainsString($expectedMarkdown, $markdown); + } else { + static::assertEquals($expectedMarkdown, $markdown); + } + } +} diff --git a/tests/Entity/PageEditorTest.php b/tests/Entity/PageEditorTest.php index c06aa5bf1..d4e565435 100644 --- a/tests/Entity/PageEditorTest.php +++ b/tests/Entity/PageEditorTest.php @@ -18,36 +18,39 @@ class PageEditorTest extends TestCase $this->page = Page::query()->first(); } - public function test_default_editor_is_wysiwyg() + public function test_default_editor_is_wysiwyg_for_new_pages() { $this->assertEquals('wysiwyg', setting('app-editor')); - $this->asAdmin()->get($this->page->getUrl() . '/edit') - ->assertElementExists('#html-editor'); + $resp = $this->asAdmin()->get($this->page->book->getUrl('/create-page')); + $this->followRedirects($resp)->assertElementExists('#html-editor'); } - public function test_markdown_setting_shows_markdown_editor() + public function test_markdown_setting_shows_markdown_editor_for_new_pages() { $this->setSettings(['app-editor' => 'markdown']); - $this->asAdmin()->get($this->page->getUrl() . '/edit') + + $resp = $this->asAdmin()->get($this->page->book->getUrl('/create-page')); + $this->followRedirects($resp) ->assertElementNotExists('#html-editor') ->assertElementExists('#markdown-editor'); } public function test_markdown_content_given_to_editor() { - $this->setSettings(['app-editor' => 'markdown']); - $mdContent = '# hello. This is a test'; $this->page->markdown = $mdContent; + $this->page->editor = 'markdown'; $this->page->save(); - $this->asAdmin()->get($this->page->getUrl() . '/edit') + $this->asAdmin()->get($this->page->getUrl('/edit')) ->assertElementContains('[name="markdown"]', $mdContent); } public function test_html_content_given_to_editor_if_no_markdown() { - $this->setSettings(['app-editor' => 'markdown']); + $this->page->editor = 'markdown'; + $this->page->save(); + $this->asAdmin()->get($this->page->getUrl() . '/edit') ->assertElementContains('[name="markdown"]', $this->page->html); } @@ -102,4 +105,101 @@ class PageEditorTest extends TestCase $resp = $this->get($draft->getUrl('/edit')); $resp->assertElementContains('a[href="' . $draft->getUrl() . '"]', 'Back'); } + + public function test_switching_from_html_to_clean_markdown_works() + { + /** @var Page $page */ + $page = Page::query()->first(); + $page->html = 'Some bold content.
'; + $page->save(); + + $resp = $this->asAdmin()->get($page->getUrl('/edit?editor=markdown-clean')); + $resp->assertStatus(200); + $resp->assertSee("## A Header\n\nSome **bold** content."); + $resp->assertElementExists('#markdown-editor'); + } + + public function test_switching_from_html_to_stable_markdown_works() + { + /** @var Page $page */ + $page = Page::query()->first(); + $page->html = 'Some bold content.
'; + $page->save(); + + $resp = $this->asAdmin()->get($page->getUrl('/edit?editor=markdown-stable')); + $resp->assertStatus(200); + $resp->assertSee('Some bold content.
', true); + $resp->assertElementExists('[component="markdown-editor"]'); + } + + public function test_switching_from_markdown_to_wysiwyg_works() + { + /** @var Page $page */ + $page = Page::query()->first(); + $page->html = ''; + $page->markdown = "## A Header\n\nSome content with **bold** text!"; + $page->save(); + + $resp = $this->asAdmin()->get($page->getUrl('/edit?editor=wysiwyg')); + $resp->assertStatus(200); + $resp->assertElementExists('[component="wysiwyg-editor"]'); + $resp->assertSee("Some content with bold text!
", true); + } + + public function test_page_editor_changes_with_editor_property() + { + $resp = $this->asAdmin()->get($this->page->getUrl('/edit')); + $resp->assertElementExists('[component="wysiwyg-editor"]'); + + $this->page->markdown = "## A Header\n\nSome content with **bold** text!"; + $this->page->editor = 'markdown'; + $this->page->save(); + + $resp = $this->asAdmin()->get($this->page->getUrl('/edit')); + $resp->assertElementExists('[component="markdown-editor"]'); + } + + public function test_editor_type_switch_options_show() + { + $resp = $this->asAdmin()->get($this->page->getUrl('/edit')); + $editLink = $this->page->getUrl('/edit') . '?editor='; + $resp->assertElementContains("a[href=\"${editLink}markdown-clean\"]", '(Clean Content)'); + $resp->assertElementContains("a[href=\"${editLink}markdown-stable\"]", '(Stable Content)'); + + $resp = $this->asAdmin()->get($this->page->getUrl('/edit?editor=markdown-stable')); + $editLink = $this->page->getUrl('/edit') . '?editor='; + $resp->assertElementContains("a[href=\"${editLink}wysiwyg\"]", 'Switch to WYSIWYG Editor'); + } + + public function test_editor_type_switch_options_dont_show_if_without_change_editor_permissions() + { + $resp = $this->asEditor()->get($this->page->getUrl('/edit')); + $editLink = $this->page->getUrl('/edit') . '?editor='; + $resp->assertElementNotExists("a[href*=\"${editLink}\"]"); + } + + public function test_page_editor_type_switch_does_not_work_without_change_editor_permissions() + { + /** @var Page $page */ + $page = Page::query()->first(); + $page->html = 'Some bold content.
'; + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl('/edit?editor=markdown-stable')); + $resp->assertStatus(200); + $resp->assertElementExists('[component="wysiwyg-editor"]'); + $resp->assertElementNotExists('[component="markdown-editor"]'); + } + + public function test_page_save_does_not_change_active_editor_without_change_editor_permissions() + { + /** @var Page $page */ + $page = Page::query()->first(); + $page->html = 'Some bold content.
'; + $page->editor = 'wysiwyg'; + $page->save(); + + $this->asEditor()->put($page->getUrl(), ['name' => $page->name, 'markdown' => '## Updated content abc']); + $this->assertEquals('wysiwyg', $page->refresh()->editor); + } } diff --git a/tests/Entity/PageRevisionTest.php b/tests/Entity/PageRevisionTest.php index fc6678788..ce203ea36 100644 --- a/tests/Entity/PageRevisionTest.php +++ b/tests/Entity/PageRevisionTest.php @@ -203,4 +203,19 @@ class PageRevisionTest extends TestCase $revisionCount = $page->revisions()->count(); $this->assertEquals(12, $revisionCount); } + + public function test_revision_list_shows_editor_type() + { + /** @var Page $page */ + $page = Page::first(); + $this->asAdmin()->put($page->getUrl(), ['name' => 'Updated page', 'html' => 'new page html']); + + $resp = $this->get($page->refresh()->getUrl('/revisions')); + $resp->assertElementContains('td', '(WYSIWYG)'); + $resp->assertElementNotContains('td', '(Markdown)'); + + $this->asAdmin()->put($page->getUrl(), ['name' => 'Updated page', 'markdown' => '# Some markdown content']); + $resp = $this->get($page->refresh()->getUrl('/revisions')); + $resp->assertElementContains('td', '(Markdown)'); + } } diff --git a/tests/Unit/UrlTest.php b/tests/Unit/UrlTest.php deleted file mode 100644 index fff5414f2..000000000 --- a/tests/Unit/UrlTest.php +++ /dev/null @@ -1,22 +0,0 @@ -runWithEnv('APP_URL', 'http://example.com/bookstack', function () { - $this->assertEquals('http://example.com/bookstack/books', url('/books')); - }); - } - - public function test_url_helper_sets_correct_scheme_even_when_request_scheme_is_different() - { - $this->runWithEnv('APP_URL', 'https://example.com/', function () { - $this->get('http://example.com/login')->assertSee('https://example.com/dist/styles.css'); - }); - } -} diff --git a/tests/Uploads/AttachmentTest.php b/tests/Uploads/AttachmentTest.php index 5545edf13..27a23bcae 100644 --- a/tests/Uploads/AttachmentTest.php +++ b/tests/Uploads/AttachmentTest.php @@ -128,7 +128,8 @@ class AttachmentTest extends TestCase $pageGet->assertSee($attachment->getUrl()); $attachmentGet = $this->get($attachment->getUrl()); - $attachmentGet->assertSee('Hi, This is a test file for testing the upload process.'); + $content = $attachmentGet->streamedContent(); + $this->assertStringContainsString('Hi, This is a test file for testing the upload process.', $content); $this->deleteUploads(); } diff --git a/tests/Uploads/DrawioTest.php b/tests/Uploads/DrawioTest.php index 1fc3d1049..2ed4da7ca 100644 --- a/tests/Uploads/DrawioTest.php +++ b/tests/Uploads/DrawioTest.php @@ -71,7 +71,7 @@ class DrawioTest extends TestCase $editor = $this->getEditor(); $resp = $this->actingAs($editor)->get($page->getUrl('/edit')); - $resp->assertSee('drawio-url="https://embed.diagrams.net/?embed=1&proto=json&spin=1"', false); + $resp->assertSee('drawio-url="https://embed.diagrams.net/?embed=1&proto=json&spin=1&configure=1"', false); config()->set('services.drawio', false); $resp = $this->actingAs($editor)->get($page->getUrl('/edit')); diff --git a/tests/UrlTest.php b/tests/UrlTest.php new file mode 100644 index 000000000..90215d558 --- /dev/null +++ b/tests/UrlTest.php @@ -0,0 +1,37 @@ +runWithEnv('APP_URL', 'http://example.com/bookstack', function () { + $this->assertEquals('http://example.com/bookstack/books', url('/books')); + }); + } + + public function test_url_helper_sets_correct_scheme_even_when_request_scheme_is_different() + { + $this->runWithEnv('APP_URL', 'https://example.com/', function () { + $this->get('http://example.com/login')->assertSee('https://example.com/dist/styles.css'); + }); + } + + public function test_app_url_forces_overrides_on_base_request() + { + config()->set('app.url', 'https://donkey.example.com:8091/cool/docs'); + + // Have to manually get and wrap request in our custom type due to testing mechanics + $this->get('/login'); + $bsRequest = Request::createFrom(request()); + + $this->assertEquals('https://donkey.example.com:8091', $bsRequest->getSchemeAndHttpHost()); + $this->assertEquals('/cool/docs', $bsRequest->getBaseUrl()); + $this->assertEquals('https://donkey.example.com:8091/cool/docs/login', $bsRequest->getUri()); + } +}