You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-08-24 20:19:10 +02:00
Compare commits
74 Commits
android-v3
...
dev
Author | SHA1 | Date | |
---|---|---|---|
|
227e41b69a | ||
|
a616e26a0f | ||
|
ba0e7e2226 | ||
|
b5a4ba554d | ||
|
9037da8f2d | ||
|
6998606ec9 | ||
|
66d52c90a3 | ||
|
f6fb1f7fbf | ||
|
3aac6043da | ||
|
ae170e0aa0 | ||
|
371f027a24 | ||
|
37422f316e | ||
|
a9f284ae45 | ||
|
fd2f69cc73 | ||
|
c4eab3c79c | ||
|
a0b9c6376e | ||
|
e2fc056369 | ||
|
453b4705b1 | ||
|
4128061e40 | ||
|
432b0ca870 | ||
|
c484cd2e48 | ||
|
58f0725c6b | ||
|
bf8fbec0cd | ||
|
f1d452f130 | ||
|
26012cd7d5 | ||
|
a414241541 | ||
|
0f13bf9d51 | ||
|
c142c5c5c0 | ||
|
af5c0135dc | ||
|
8a811b9e78 | ||
|
602484f143 | ||
|
dc84db1657 | ||
|
f5882ecfcc | ||
|
30000c34ec | ||
|
6e3df1bd90 | ||
|
67196ac0b2 | ||
|
69646b5522 | ||
|
9147afce9a | ||
|
c92701c52f | ||
|
ab3e9d1a3e | ||
|
f9cab8843b | ||
|
c36289c024 | ||
|
60b6db8cd4 | ||
|
bbd8f6f40e | ||
|
34b7f4e1f8 | ||
|
06b681d897 | ||
|
f02a94bef5 | ||
|
ae6b57c5a5 | ||
|
88ab916008 | ||
|
97b0ffc263 | ||
|
ff8848d138 | ||
|
2b686e6318 | ||
|
b913d18882 | ||
|
a2c9a01722 | ||
|
000d23c20f | ||
|
9e9f2f2930 | ||
|
c5a1a759c7 | ||
|
0b6a1c75ba | ||
|
53a0f8ddbc | ||
|
67eabb5038 | ||
|
983fced410 | ||
|
4f5bbc1132 | ||
|
2f10235ecb | ||
|
cfa7d6cb31 | ||
|
f5d62a50fe | ||
|
b52f5435aa | ||
|
bfd5bfc004 | ||
|
82965fe991 | ||
|
b2c162c25b | ||
|
022e76fe8d | ||
|
4b2d1895fd | ||
|
534507a31f | ||
|
5b4a300c81 | ||
|
1de0a59313 |
@@ -10,7 +10,7 @@ QUEUE_TTL=900000
|
||||
QUEUE_RETRY_COUNT=2
|
||||
QUEUE_MAINTENANCE_INTERVAL=30000
|
||||
|
||||
HTR_CLI_DOCKER_IMAGE=joplin/htr-cli:0.0.2
|
||||
HTR_CLI_DOCKER_IMAGE=joplin/htr-cli:latest
|
||||
# Fullpath to images folder e.g.:
|
||||
#HTR_CLI_IMAGES_FOLDER=/home/user/joplin/packages/transcribe/images
|
||||
HTR_CLI_IMAGES_FOLDER=
|
||||
@@ -18,6 +18,8 @@ HTR_CLI_IMAGES_FOLDER=
|
||||
QUEUE_DRIVER=pg
|
||||
# QUEUE_DRIVER=sqlite
|
||||
|
||||
FILE_STORAGE_MAINTENANCE_INTERVAL=3600000
|
||||
FILE_STORAGE_TTL=604800000 # one week
|
||||
|
||||
# =============================================================================
|
||||
# Queue driver
|
||||
|
@@ -698,7 +698,6 @@ packages/app-mobile/components/NoteEditor/RichTextEditor.js
|
||||
packages/app-mobile/components/NoteEditor/SearchPanel.js
|
||||
packages/app-mobile/components/NoteEditor/WarningBanner.js
|
||||
packages/app-mobile/components/NoteEditor/commandDeclarations.js
|
||||
packages/app-mobile/components/NoteEditor/hooks/useCodeMirrorPlugins.js
|
||||
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.test.js
|
||||
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.js
|
||||
packages/app-mobile/components/NoteEditor/testing/createTestEditorProps.js
|
||||
@@ -868,6 +867,7 @@ packages/app-mobile/contentScripts/imageEditorBundle/utils/useEditorMessenger.js
|
||||
packages/app-mobile/contentScripts/markdownEditorBundle/contentScript.js
|
||||
packages/app-mobile/contentScripts/markdownEditorBundle/types.js
|
||||
packages/app-mobile/contentScripts/markdownEditorBundle/useWebViewSetup.js
|
||||
packages/app-mobile/contentScripts/markdownEditorBundle/utils/useCodeMirrorPlugins.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.test.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/index.js
|
||||
@@ -1004,6 +1004,8 @@ packages/editor/CodeMirror/editorCommands/sortSelectedLines.test.js
|
||||
packages/editor/CodeMirror/editorCommands/sortSelectedLines.js
|
||||
packages/editor/CodeMirror/editorCommands/supportsCommand.js
|
||||
packages/editor/CodeMirror/extensions/biDirectionalTextExtension.js
|
||||
packages/editor/CodeMirror/extensions/ctrlClickActionExtension.js
|
||||
packages/editor/CodeMirror/extensions/ctrlClickCheckboxExtension.js
|
||||
packages/editor/CodeMirror/extensions/keyUpHandlerExtension.js
|
||||
packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.js
|
||||
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.test.js
|
||||
@@ -1074,14 +1076,18 @@ packages/editor/CodeMirror/utils/isInSyntaxNode.js
|
||||
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/allLanguages.js
|
||||
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/defaultLanguage.js
|
||||
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/lookUpLanguage.js
|
||||
packages/editor/CodeMirror/utils/markdown/getCheckboxAtPosition.js
|
||||
packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.test.js
|
||||
packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.js
|
||||
packages/editor/CodeMirror/utils/markdown/stripBlockquote.js
|
||||
packages/editor/CodeMirror/utils/markdown/toggleCheckboxAt.js
|
||||
packages/editor/CodeMirror/utils/setupVim.js
|
||||
packages/editor/ProseMirror/commands.test.js
|
||||
packages/editor/ProseMirror/commands.js
|
||||
packages/editor/ProseMirror/createEditor.js
|
||||
packages/editor/ProseMirror/index.js
|
||||
packages/editor/ProseMirror/plugins/detailsPlugin.test.js
|
||||
packages/editor/ProseMirror/plugins/detailsPlugin.js
|
||||
packages/editor/ProseMirror/plugins/inputRulesPlugin.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.js
|
||||
@@ -1102,12 +1108,15 @@ packages/editor/ProseMirror/types.js
|
||||
packages/editor/ProseMirror/utils/UndoStackSynchronizer.js
|
||||
packages/editor/ProseMirror/utils/canReplaceSelectionWith.js
|
||||
packages/editor/ProseMirror/utils/computeSelectionFormatting.js
|
||||
packages/editor/ProseMirror/utils/dom/createButton.js
|
||||
packages/editor/ProseMirror/utils/dom/createTextArea.js
|
||||
packages/editor/ProseMirror/utils/dom/createTextNode.js
|
||||
packages/editor/ProseMirror/utils/dom/createUniqueId.js
|
||||
packages/editor/ProseMirror/utils/extractSelectedLinesTo.test.js
|
||||
packages/editor/ProseMirror/utils/extractSelectedLinesTo.js
|
||||
packages/editor/ProseMirror/utils/forEachHeading.js
|
||||
packages/editor/ProseMirror/utils/jumpToHash.js
|
||||
packages/editor/ProseMirror/utils/makeLinksClickableInElement.js
|
||||
packages/editor/ProseMirror/utils/preprocessEditorInput.test.js
|
||||
packages/editor/ProseMirror/utils/preprocessEditorInput.js
|
||||
packages/editor/ProseMirror/utils/sanitizeHtml.js
|
||||
@@ -1765,6 +1774,7 @@ packages/tools/fuzzer/Client.js
|
||||
packages/tools/fuzzer/ClientPool.js
|
||||
packages/tools/fuzzer/Server.js
|
||||
packages/tools/fuzzer/constants.js
|
||||
packages/tools/fuzzer/model/FolderRecord.js
|
||||
packages/tools/fuzzer/sync-fuzzer.js
|
||||
packages/tools/fuzzer/types.js
|
||||
packages/tools/fuzzer/utils/SeededRandom.js
|
||||
|
12
.gitignore
vendored
12
.gitignore
vendored
@@ -671,7 +671,6 @@ packages/app-mobile/components/NoteEditor/RichTextEditor.js
|
||||
packages/app-mobile/components/NoteEditor/SearchPanel.js
|
||||
packages/app-mobile/components/NoteEditor/WarningBanner.js
|
||||
packages/app-mobile/components/NoteEditor/commandDeclarations.js
|
||||
packages/app-mobile/components/NoteEditor/hooks/useCodeMirrorPlugins.js
|
||||
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.test.js
|
||||
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.js
|
||||
packages/app-mobile/components/NoteEditor/testing/createTestEditorProps.js
|
||||
@@ -841,6 +840,7 @@ packages/app-mobile/contentScripts/imageEditorBundle/utils/useEditorMessenger.js
|
||||
packages/app-mobile/contentScripts/markdownEditorBundle/contentScript.js
|
||||
packages/app-mobile/contentScripts/markdownEditorBundle/types.js
|
||||
packages/app-mobile/contentScripts/markdownEditorBundle/useWebViewSetup.js
|
||||
packages/app-mobile/contentScripts/markdownEditorBundle/utils/useCodeMirrorPlugins.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.test.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/index.js
|
||||
@@ -977,6 +977,8 @@ packages/editor/CodeMirror/editorCommands/sortSelectedLines.test.js
|
||||
packages/editor/CodeMirror/editorCommands/sortSelectedLines.js
|
||||
packages/editor/CodeMirror/editorCommands/supportsCommand.js
|
||||
packages/editor/CodeMirror/extensions/biDirectionalTextExtension.js
|
||||
packages/editor/CodeMirror/extensions/ctrlClickActionExtension.js
|
||||
packages/editor/CodeMirror/extensions/ctrlClickCheckboxExtension.js
|
||||
packages/editor/CodeMirror/extensions/keyUpHandlerExtension.js
|
||||
packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.js
|
||||
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.test.js
|
||||
@@ -1047,14 +1049,18 @@ packages/editor/CodeMirror/utils/isInSyntaxNode.js
|
||||
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/allLanguages.js
|
||||
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/defaultLanguage.js
|
||||
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/lookUpLanguage.js
|
||||
packages/editor/CodeMirror/utils/markdown/getCheckboxAtPosition.js
|
||||
packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.test.js
|
||||
packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.js
|
||||
packages/editor/CodeMirror/utils/markdown/stripBlockquote.js
|
||||
packages/editor/CodeMirror/utils/markdown/toggleCheckboxAt.js
|
||||
packages/editor/CodeMirror/utils/setupVim.js
|
||||
packages/editor/ProseMirror/commands.test.js
|
||||
packages/editor/ProseMirror/commands.js
|
||||
packages/editor/ProseMirror/createEditor.js
|
||||
packages/editor/ProseMirror/index.js
|
||||
packages/editor/ProseMirror/plugins/detailsPlugin.test.js
|
||||
packages/editor/ProseMirror/plugins/detailsPlugin.js
|
||||
packages/editor/ProseMirror/plugins/inputRulesPlugin.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.js
|
||||
@@ -1075,12 +1081,15 @@ packages/editor/ProseMirror/types.js
|
||||
packages/editor/ProseMirror/utils/UndoStackSynchronizer.js
|
||||
packages/editor/ProseMirror/utils/canReplaceSelectionWith.js
|
||||
packages/editor/ProseMirror/utils/computeSelectionFormatting.js
|
||||
packages/editor/ProseMirror/utils/dom/createButton.js
|
||||
packages/editor/ProseMirror/utils/dom/createTextArea.js
|
||||
packages/editor/ProseMirror/utils/dom/createTextNode.js
|
||||
packages/editor/ProseMirror/utils/dom/createUniqueId.js
|
||||
packages/editor/ProseMirror/utils/extractSelectedLinesTo.test.js
|
||||
packages/editor/ProseMirror/utils/extractSelectedLinesTo.js
|
||||
packages/editor/ProseMirror/utils/forEachHeading.js
|
||||
packages/editor/ProseMirror/utils/jumpToHash.js
|
||||
packages/editor/ProseMirror/utils/makeLinksClickableInElement.js
|
||||
packages/editor/ProseMirror/utils/preprocessEditorInput.test.js
|
||||
packages/editor/ProseMirror/utils/preprocessEditorInput.js
|
||||
packages/editor/ProseMirror/utils/sanitizeHtml.js
|
||||
@@ -1738,6 +1747,7 @@ packages/tools/fuzzer/Client.js
|
||||
packages/tools/fuzzer/ClientPool.js
|
||||
packages/tools/fuzzer/Server.js
|
||||
packages/tools/fuzzer/constants.js
|
||||
packages/tools/fuzzer/model/FolderRecord.js
|
||||
packages/tools/fuzzer/sync-fuzzer.js
|
||||
packages/tools/fuzzer/types.js
|
||||
packages/tools/fuzzer/utils/SeededRandom.js
|
||||
|
BIN
Assets/WebsiteAssets/images/sponsors/DamanGame.png
Normal file
BIN
Assets/WebsiteAssets/images/sponsors/DamanGame.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
BIN
Assets/WebsiteAssets/images/sponsors/EssayShark.png
Normal file
BIN
Assets/WebsiteAssets/images/sponsors/EssayShark.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
215
Assets/WebsiteAssets/locales/hr_HR.po
Normal file
215
Assets/WebsiteAssets/locales/hr_HR.po
Normal file
@@ -0,0 +1,215 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"POT-Creation-Date: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: Milo Ivir <mail@mivirtype.de>\n"
|
||||
"Language-Team: \n"
|
||||
"Language: hr_HR\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: Poedit 3.6\n"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/partials/plan.mustache:13
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/partials/plan.mustache:9
|
||||
msgid "/month"
|
||||
msgstr "/mjesec"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/partials/plan.mustache:19
|
||||
msgid "/year"
|
||||
msgstr "/godina"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/plans.mustache:8
|
||||
msgid ""
|
||||
"<a href=\"https://joplincloud.com\">Joplin Cloud</a> allows you to "
|
||||
"synchronise your notes across devices. It also lets you publish notes, and "
|
||||
"collaborate on notebooks with your friends, family or colleagues."
|
||||
msgstr ""
|
||||
"<a href=\"https://joplincloud.com\">Joplin Cloud</a> omogućuje "
|
||||
"sinkronizaciju bilješki na različitim uređajima. Omogućuje i objavljivanje "
|
||||
"bilješki i suradnju na bilježnicama s prijateljima, obitelji ili kolegama."
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:205
|
||||
msgid "<span class=\"frame-bg frame-bg-yellow-lg\">Customise</span> it"
|
||||
msgstr "<span class=\"frame-bg frame-bg-yellow-lg\">Prilagodi</span> uslugu"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:104
|
||||
msgid "<span class=\"frame-bg frame-bg-yellow\">Multimedia</span> notes"
|
||||
msgstr "<span class=\"frame-bg frame-bg-yellow\">Multimedijske</span> bilješke"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:256
|
||||
msgid "100% <span class=\"frame-bg frame-bg-yellow-lg\">your data</span>"
|
||||
msgstr "100 % <span class=\"frame-bg frame-bg-yellow-lg\">tvoji podaci</span>"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:298
|
||||
msgid "A <span class=\"frame-bg frame-bg-yellow-lg\">French</span> Alternative"
|
||||
msgstr ""
|
||||
"<span class=\"frame-bg frame-bg-yellow-lg\">Francuska</span> alternativa"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:236
|
||||
msgid ""
|
||||
"Access your notes from your computer, phone or tablet by synchronising with "
|
||||
"various services, including Joplin Cloud, Dropbox and OneDrive. The app is "
|
||||
"available on Windows, macOS, Linux, Android and iOS. A terminal app is also "
|
||||
"available!"
|
||||
msgstr ""
|
||||
"Pristupi svojim bilješkama s računala, mobitela ili tableta sinkronizacijom "
|
||||
"s raznim uslugama, uključujući Joplin Cloud, Dropbox i OneDrive. Program je "
|
||||
"dostupan za Windows, macOS, Linux, Android i iOS sustave. Dostupan je i "
|
||||
"program za terminal!"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/plans.mustache:49
|
||||
msgid ""
|
||||
"Already have a Joplin Cloud account? <a href=\"https://"
|
||||
"joplincloud.com\">Login now</a>"
|
||||
msgstr ""
|
||||
"Već imaš Joplin Cloud račun? <a href=\"https://joplincloud.com\">Prijavi se "
|
||||
"sada</a>"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:208
|
||||
msgid ""
|
||||
"Customise the app with plugins, custom themes and multiple text editors "
|
||||
"(Rich Text or Markdown). Or create your own scripts and plugins using the "
|
||||
"Extension API."
|
||||
msgstr ""
|
||||
"Prilagodi program pomoću dodataka, prilagođenih tema i uređivača teksta "
|
||||
"(formatirani tekst ili Markdown). Ili izradi vlastita skripta i dodatke "
|
||||
"pomoću API-ja za proširenja."
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:242
|
||||
msgid "Download it now"
|
||||
msgstr "Preuzmi sada"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:112
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:63
|
||||
msgid "Download the app"
|
||||
msgstr "Preuzmi program"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:213
|
||||
msgid "Find out more"
|
||||
msgstr "Saznaj više"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:54
|
||||
msgid "Free your <span class=\"frame-bg frame-bg-blue\">notes</span>"
|
||||
msgstr "Oslobodi svoje <span class=\"frame-bg frame-bg-blue\">bilješke</span>"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:175
|
||||
msgid "Get the clipper"
|
||||
msgstr "Nabavi Clipper"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:107
|
||||
msgid ""
|
||||
"Images, videos, PDFs and audio files are supported. Create math expressions "
|
||||
"and diagrams directly from the app. Take photos with the mobile app and save "
|
||||
"them to a note."
|
||||
msgstr ""
|
||||
"Podržane su slike, videozapisi, PDF-ovi i audio datoteke. Stvori matematičke "
|
||||
"izraze i dijagrame izravno iz programa. Snimaj fotografije s programom za "
|
||||
"mobitel i spremi ih u bilješku."
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:327
|
||||
msgid "In the <span class=\"frame-bg frame-bg-yellow\">Press</span>"
|
||||
msgstr "<span class=\"frame-bg frame-bg-yellow\">Recenzije</span>"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/plans.mustache:5
|
||||
msgid "Joplin Cloud <span class=\"frame-bg frame-bg-yellow\">plans</span>"
|
||||
msgstr "Joplin Cloud <span class=\"frame-bg frame-bg-yellow\">tarife</span>"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:301
|
||||
msgid ""
|
||||
"Joplin Cloud is based in France. This means your data is protected by strict "
|
||||
"European Union privacy laws. In addition, Joplin Cloud implements strong end-"
|
||||
"to-end encryption so that not even us can have access to your data."
|
||||
msgstr ""
|
||||
"Joplin Cloud ima sjedište u Francuskoj. To znači da su tvoji podaci "
|
||||
"zaštićeni strogim zakonima o privatnosti Europske unije. Osim toga, Joplin "
|
||||
"Cloud implementira snažno sveobuhvatno šifriranje (end-to-end encryption) "
|
||||
"tako da čak ni mi ne možemo pristupiti tvojim podacima."
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:57
|
||||
msgid ""
|
||||
"Joplin is an open source note-taking app. Capture your thoughts and securely "
|
||||
"access them from any device."
|
||||
msgstr ""
|
||||
"Joplin je program za bilješke otvorenog koda. Zabilježi svoje misli i "
|
||||
"sigurno im pristupi s bilo kojeg uređaja."
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:262
|
||||
msgid "More about E2EE"
|
||||
msgstr "Više o E2EE"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:391
|
||||
msgid "Our <span class=\"frame-bg frame-bg-blue-lg\">sponsors</span>"
|
||||
msgstr "Naši <span class=\"frame-bg frame-bg-blue-lg\">sponzori</span>"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/plans.mustache:23
|
||||
msgid "Pay Monthly"
|
||||
msgstr "Plaćaj mjesečno"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/plans.mustache:30
|
||||
msgid "Pay Yearly"
|
||||
msgstr "Plaćaj godišnje"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:167
|
||||
msgid ""
|
||||
"Save <span class=\"frame-bg frame-bg-blue\">web pages</span> <br>as notes"
|
||||
msgstr ""
|
||||
"Spremaj <span class=\"frame-bg frame-bg-blue\">web stranice</span> <br>kao "
|
||||
"bilješke"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:65
|
||||
msgid "Sign up with Joplin Cloud"
|
||||
msgstr "Registriraj se na Joplin Cloud"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:394
|
||||
msgid "Thank you for your support!"
|
||||
msgstr "Hvala ti na podršci!"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:257
|
||||
msgid ""
|
||||
"The app is open source and your notes are saved to an open format, so you'll "
|
||||
"always have access to them. Uses End-To-End Encryption (E2EE) to secure your "
|
||||
"notes and ensure no-one but yourself can access them."
|
||||
msgstr ""
|
||||
"Program je otvorenog koda i tvoje se bilješke spremaju u otvorenom formatu, "
|
||||
"tako da ćeš im uvijek moći pristupiti. Program koristi sveobuhvatno "
|
||||
"šifriranje – engl. End-To-End Encryption (E2EE) – kako bi zaštitila tvoje "
|
||||
"bilješke i osigurala da im nitko osim tebe ne može pristupiti."
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:144
|
||||
msgid "Try it now"
|
||||
msgstr "Isprobaj sada"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:170
|
||||
msgid ""
|
||||
"Use the web clipper extension, available on Chrome and Firefox, to save web "
|
||||
"pages or take screenshots as notes."
|
||||
msgstr ""
|
||||
"Koristi proširenje Web Clipper, dostupno za Chrome i Firefox, za spremanje "
|
||||
"web stranica ili snimanje ekrana kao bilješku."
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:138
|
||||
msgid ""
|
||||
"With Joplin Cloud, share your notes with your friends, family or colleagues "
|
||||
"and collaborate on them."
|
||||
msgstr ""
|
||||
"Joplin Cloud ti omogućuje da dijeliš bilješke s prijateljima, obitelji ili "
|
||||
"kolegama te da na njima surađujete."
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:137
|
||||
msgid "Work <span class=\"frame-bg frame-bg-yellow\">together</span>"
|
||||
msgstr "<span class=\"frame-bg frame-bg-yellow\">Surađuj</span> s drugima"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:141
|
||||
msgid ""
|
||||
"You can also publish a note to the internet and share the URL with others."
|
||||
msgstr "Bilješke možeš objaviti i na internetu te dijeliti URL s drugima."
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:233
|
||||
msgid ""
|
||||
"Your notes, <span class=\"frame-bg frame-bg-blue-lg\">everywhere</span> you "
|
||||
"are"
|
||||
msgstr ""
|
||||
"Tvoje bilješke, <span class=\"frame-bg frame-bg-blue-lg\">gdje god</span> se "
|
||||
"nalaziš"
|
@@ -31,7 +31,7 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read
|
||||
# Sponsors
|
||||
|
||||
<!-- SPONSORS-ORG -->
|
||||
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://www.hosting.de/nextcloud/?mtm_campaign=managed-nextcloud&mtm_kwd=joplinapp&mtm_source=joplinapp-webseite&mtm_medium=banner"><img title="Hosting.de" width="256" src="https://joplinapp.org/images/sponsors/HostingDe.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="topagency"/></a> <a href="https://realgambling.ca/"><img title="RealGambling.ca" width="256" src="https://joplinapp.org/images/sponsors/RealGambling.png" alt="RealGambling.ca"/></a> <a href="https://essaypro.com/"><img title="write an essay online with EssayPro" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="write an essay online with EssayPro"/></a> <a href="https://www.slotozilla.com/nz/no-deposit-bonus"><img title="casino without making any upfront cost" width="256" src="https://joplinapp.org/images/sponsors/Slotozilla.png" alt="casino without making any upfront cost"/></a> <a href="https://essaywriter.pro"><img title="write my essay services by EssayWriter" width="256" src="https://joplinapp.org/images/sponsors/EssayWriterPro.png" alt="write my essay services by EssayWriter"/></a> <a href="https://essayservice.com"><img title="quick and reliable service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/EssayService.png" alt="quick and reliable service to write my paper for me"/></a> <a href="https://writepaper.com/"><img title="best service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/WritePaper.png" alt="best service to write my paper for me"/></a> <a href="https://paperwriter.com/"><img title="high-quality paper writing service PaperWriter" width="256" src="https://joplinapp.org/images/sponsors/PaperWriter.png" alt="high-quality paper writing service PaperWriter"/></a> <a href="https://homeworkguy.org/someone-to-take-my-online-class"><img title="someone to take my online class" width="256" src="https://joplinapp.org/images/sponsors/HomeworkGuy.png" alt="someone to take my online class"/></a> <a href="https://www.bestetf.net/"><img title="BestETF" width="256" src="https://joplinapp.org/images/sponsors/BestEtf.png" alt="BestETF"/></a> <a href="https://freespinny.io/free-spins-no-deposit/"><img title="Freespinny.io Free Spins Bonus site" width="256" src="https://joplinapp.org/images/sponsors/Freespinny.png" alt="Freespinny.io Free Spins Bonus site"/></a>
|
||||
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="topagency"/></a> <a href="https://www.slotozilla.com/nz/no-deposit-bonus"><img title="casino without making any upfront cost" width="256" src="https://joplinapp.org/images/sponsors/Slotozilla.png" alt="casino without making any upfront cost"/></a> <a href="https://essayservice.com"><img title="quick and reliable service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/EssayService.png" alt="quick and reliable service to write my paper for me"/></a> <a href="https://writepaper.com/"><img title="best service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/WritePaper.png" alt="best service to write my paper for me"/></a> <a href="https://paperwriter.com/"><img title="high-quality paper writing service PaperWriter" width="256" src="https://joplinapp.org/images/sponsors/PaperWriter.png" alt="high-quality paper writing service PaperWriter"/></a> <a href="https://www.bestetf.net/"><img title="BestETF" width="256" src="https://joplinapp.org/images/sponsors/BestEtf.png" alt="BestETF"/></a> <a href="https://freespinny.io/free-spins-no-deposit/"><img title="Freespinny.io Free Spins Bonus site" width="256" src="https://joplinapp.org/images/sponsors/Freespinny.png" alt="Freespinny.io Free Spins Bonus site"/></a> <a href="https://damangameplay.in"><img title="Daman Game" width="256" src="https://joplinapp.org/images/sponsors/DamanGame.png" alt="Daman Game"/></a> <a href="https://essayshark.com"><img title="EssayShark - essay writers for hire" width="256" src="https://joplinapp.org/images/sponsors/EssayShark.png" alt="EssayShark - essay writers for hire"/></a>
|
||||
<!-- SPONSORS-ORG -->
|
||||
|
||||
* * *
|
||||
@@ -40,9 +40,8 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read
|
||||
| | | | |
|
||||
| :---: | :---: | :---: | :---: |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/97193607?s=96&v=4"/></br>[Akhil-CM](https://github.com/Akhil-CM) | <img width="50" src="https://avatars2.githubusercontent.com/u/552452?s=96&v=4"/></br>[andypiper](https://github.com/andypiper) | <img width="50" src="https://avatars2.githubusercontent.com/u/215668?s=96&v=4"/></br>[avanderberg](https://github.com/avanderberg) | <img width="50" src="https://avatars2.githubusercontent.com/u/67130?s=96&v=4"/></br>[chr15m](https://github.com/chr15m) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/1177810?s=96&v=4"/></br>[felixstorm](https://github.com/felixstorm) | <img width="50" src="https://avatars2.githubusercontent.com/u/8030470?s=96&v=4"/></br>[Galliver7](https://github.com/Galliver7) | <img width="50" src="https://avatars2.githubusercontent.com/u/64712218?s=96&v=4"/></br>[Hegghammer](https://github.com/Hegghammer) | <img width="50" src="https://avatars2.githubusercontent.com/u/11947658?s=96&v=4"/></br>[KentBrockman](https://github.com/KentBrockman) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/42319182?s=96&v=4"/></br>[marcdw1289](https://github.com/marcdw1289) | <img width="50" src="https://avatars2.githubusercontent.com/u/1788010?s=96&v=4"/></br>[maxtruxa](https://github.com/maxtruxa) | <img width="50" src="https://avatars2.githubusercontent.com/u/327998?s=96&v=4"/></br>[sif](https://github.com/sif) | <img width="50" src="https://avatars2.githubusercontent.com/u/765564?s=96&v=4"/></br>[taskcruncher](https://github.com/taskcruncher) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/668977?s=96&v=4"/></br>[ugoertz](https://github.com/ugoertz) | | | |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/1177810?s=96&v=4"/></br>[felixstorm](https://github.com/felixstorm) | <img width="50" src="https://avatars2.githubusercontent.com/u/11947658?s=96&v=4"/></br>[KentBrockman](https://github.com/KentBrockman) | <img width="50" src="https://avatars2.githubusercontent.com/u/42319182?s=96&v=4"/></br>[marcdw1289](https://github.com/marcdw1289) | <img width="50" src="https://avatars2.githubusercontent.com/u/668977?s=96&v=4"/></br>[ugoertz](https://github.com/ugoertz) |
|
||||
| | | | |
|
||||
<!-- SPONSORS-GITHUB -->
|
||||
|
||||
# Community
|
||||
|
@@ -16,13 +16,13 @@
|
||||
"version": "",
|
||||
"platforms": ["aarch64-darwin", "x86_64-darwin"],
|
||||
},
|
||||
"python": "3.13.2",
|
||||
"python": "3.13.3",
|
||||
"bat": "latest",
|
||||
"electron": {
|
||||
"version": "latest",
|
||||
"excluded_platforms": ["aarch64-darwin", "x86_64-darwin"],
|
||||
},
|
||||
"git": "2.47.2",
|
||||
"git": "2.48.1",
|
||||
},
|
||||
"shell": {
|
||||
"init_hook": [
|
||||
|
13
fastlane/metadata/android/hr/full_description.txt
Normal file
13
fastlane/metadata/android/hr/full_description.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
<strong>Joplin</strong> je besplatan program otvorenog koda za bilješke i popis zadataka koji može obraditi veliki broj bilješki organizirane u bilježnice. Bilješke se mogu pretraživati, kopirati, označavati i mijenjati izravno iz programa ili iz vlastitog uređivača teksta.
|
||||
|
||||
Bilješke su u <a href="https://joplinapp.org/help/apps/markdown">Markdown formatu</a>.
|
||||
|
||||
Iz Evernotea izvezene bilješke <a href="https://joplinapp.org/help/apps/import_export">mogu se uvesti</a> u Joplin, uključujući formatirani sadržaj (koji se pretvara u Markdown), resurse (slike, privitke itd.) i potpune metapodatke (geografski podaci mjesta, vrijeme aktualiziranja, vrijeme stvaranja itd.). Mogu se uvesti i obične Markdown datoteke.
|
||||
|
||||
Joplin radi ponajprije s lokalnim podacima (offline first), što znači da uvijek imaš sve svoje podatke na mobitelu ili računalu. To osigurava da su tvoje bilješke uvijek dostupne, bez obzira je li imaš internetsku vezu ili ne.</p>
|
||||
|
||||
Bilješke se mogu sigurno <a href="https://joplinapp.org/help/apps/sync">sinkronizirati</a> pomoću <a href="https://joplinapp.org/help/apps/sync/e2ee">sveobuhvatnog šifriranja</a> s raznim uslugama u oblaku, uključujući Nextcloud, Dropbox, OneDrive i <a href="https://joplinapp.org/plans/">Joplin Cloud</a>.
|
||||
|
||||
Pretraživanje cijelog teksta dostupno je na svim platformama za brzo pronalaženje potrebnih informacija. Program se može prilagoditi pomoću dodataka i tema, a možeš stvoriti i vlastite.
|
||||
|
||||
Program je dostupan za Windows, Linux, macOS, Android i iOS sustave. <a href="https://joplinapp.org/help/apps/clipper">Web Clipper</a>, za spremanje web stranica i snimaka ekrana iz tvog preglednika, je također dostupan za <a href="https://addons.mozilla.org/firefox/addon/joplin-web-clipper/">Firefox</a> i <a href="https://chrome.google.com/webstore/detail/joplin-web-clipper/alofnhikmmkdbbbgpnglcpdollgjjfek">Chrome</a>.
|
1
fastlane/metadata/android/hr/short_description.txt
Normal file
1
fastlane/metadata/android/hr/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
Program za bilješke i popis zadataka sa sinkronizacijom između Linuxa, macOS-a, Windowsa i mobitela
|
@@ -380,6 +380,13 @@ class AppGui {
|
||||
this.widget('noteList').toggleShowIds();
|
||||
}
|
||||
|
||||
toggleFolderCollapse() {
|
||||
const folderList = this.widget('folderList');
|
||||
if (folderList && folderList.toggleFolderCollapse) {
|
||||
folderList.toggleFolderCollapse();
|
||||
}
|
||||
}
|
||||
|
||||
widget(name) {
|
||||
if (name === 'root') return this.rootWidget_;
|
||||
return this.rootWidget_.childByName(name);
|
||||
@@ -506,6 +513,8 @@ class AppGui {
|
||||
this.toggleNoteMetadata();
|
||||
} else if (cmd === 'toggle_ids') {
|
||||
this.toggleFolderIds();
|
||||
} else if (cmd === 'toggle_folder_collapse') {
|
||||
this.toggleFolderCollapse();
|
||||
} else if (cmd === 'enter_command_line_mode') {
|
||||
const cmd = await this.widget('statusBar').prompt();
|
||||
if (!cmd) return;
|
||||
|
@@ -220,6 +220,7 @@ class Application extends BaseApplication {
|
||||
return { ...this.commandMetadata_ };
|
||||
}
|
||||
|
||||
|
||||
public hasGui() {
|
||||
return this.gui() && !this.gui().isDummy();
|
||||
}
|
||||
@@ -330,6 +331,7 @@ class Application extends BaseApplication {
|
||||
{ keys: ['mb'], type: 'prompt', command: 'mkbook ""', cursorPosition: -2 },
|
||||
{ keys: ['yn'], type: 'prompt', command: 'cp $n ""', cursorPosition: -2 },
|
||||
{ keys: ['dn'], type: 'prompt', command: 'mv $n ""', cursorPosition: -2 },
|
||||
{ keys: ['z'], type: 'function', command: 'toggle_folder_collapse' },
|
||||
];
|
||||
|
||||
// Filter the keymap item by command so that items in keymap.json can override
|
||||
|
@@ -6,6 +6,7 @@ import app from './app';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { ImportOptions } from '@joplin/lib/services/interop/types';
|
||||
import { unique } from '@joplin/lib/array';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
|
||||
class Command extends BaseCommand {
|
||||
public override usage() {
|
||||
@@ -32,14 +33,16 @@ class Command extends BaseCommand {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public override async action(args: any) {
|
||||
const folder = await app().loadItem(BaseModel.TYPE_FOLDER, args.notebook);
|
||||
let destinationFolder = await app().loadItem(BaseModel.TYPE_FOLDER, args.notebook);
|
||||
|
||||
if (args.notebook && !folder) throw new Error(_('Cannot find "%s".', args.notebook));
|
||||
if (args.notebook && !destinationFolder) throw new Error(_('Cannot find "%s".', args.notebook));
|
||||
|
||||
if (!destinationFolder) destinationFolder = await Folder.defaultFolder();
|
||||
|
||||
const importOptions: ImportOptions = {};
|
||||
importOptions.path = args.path;
|
||||
importOptions.format = args.options.format ? args.options.format : 'auto';
|
||||
importOptions.destinationFolderId = folder ? folder.id : null;
|
||||
importOptions.destinationFolderId = destinationFolder ? destinationFolder.id : null;
|
||||
|
||||
let lastProgress = '';
|
||||
|
||||
|
@@ -149,6 +149,7 @@ class Command extends BaseCommand {
|
||||
waiting: invitation.status === ShareUserStatus.Waiting,
|
||||
rejected: invitation.status === ShareUserStatus.Rejected,
|
||||
folderId: invitation.share.folder_id,
|
||||
canWrite: !!invitation.can_write,
|
||||
fromUser: {
|
||||
email: invitation.share.user?.email,
|
||||
},
|
||||
|
@@ -2,6 +2,7 @@ import BaseCommand from './base-command';
|
||||
import app from './app';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import BaseModel from '@joplin/lib/BaseModel';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
|
||||
class Command extends BaseCommand {
|
||||
public override usage() {
|
||||
@@ -20,6 +21,18 @@ class Command extends BaseCommand {
|
||||
public override async action(args: any) {
|
||||
const folder = await app().loadItem(BaseModel.TYPE_FOLDER, args['notebook']);
|
||||
if (!folder) throw new Error(_('Cannot find "%s".', args['notebook']));
|
||||
|
||||
// Auto-expand parent folders in GUI if present
|
||||
if (app().gui() && app().gui().widget && app().gui().widget('folderList')) {
|
||||
const folderListWidget = app().gui().widget('folderList');
|
||||
if (folderListWidget.expandToFolder) {
|
||||
// Get all folders to pass to expandToFolder
|
||||
const folders = await Folder.all();
|
||||
folderListWidget.folders = folders; // Ensure widget has current folders
|
||||
folderListWidget.expandToFolder(folder.id);
|
||||
}
|
||||
}
|
||||
|
||||
app().switchCurrentFolder(folder);
|
||||
}
|
||||
}
|
||||
|
@@ -4,11 +4,14 @@ import BaseModel from '@joplin/lib/BaseModel';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { FolderEntity } from '@joplin/lib/services/database/types';
|
||||
import { getDisplayParentId, getTrashFolderId } from '@joplin/lib/services/trash';
|
||||
import {
|
||||
getDisplayParentId,
|
||||
getTrashFolderId,
|
||||
} from '@joplin/lib/services/trash';
|
||||
const ListWidget = require('tkwidgets/ListWidget.js');
|
||||
|
||||
export default class FolderListWidget extends ListWidget {
|
||||
|
||||
export default class FolderListWidget extends ListWidget {
|
||||
private folders_: FolderEntity[] = [];
|
||||
|
||||
public constructor() {
|
||||
@@ -31,7 +34,18 @@ export default class FolderListWidget extends ListWidget {
|
||||
if (item === '-') {
|
||||
output.push('-'.repeat(this.innerWidth));
|
||||
} else if (item.type_ === Folder.modelType()) {
|
||||
output.push(' '.repeat(this.folderDepth(this.folders, item.id)));
|
||||
const depth = this.folderDepth(this.folders, item.id);
|
||||
output.push(' '.repeat(depth));
|
||||
|
||||
// Add collapse/expand indicator
|
||||
const hasChildren = this.folderHasChildren_(this.folders, item.id);
|
||||
if (hasChildren) {
|
||||
const collapsedFolders = Setting.value('collapsedFolderIds');
|
||||
const isCollapsed = collapsedFolders.includes(item.id);
|
||||
output.push(isCollapsed ? '[+] ' : '[-] ');
|
||||
} else {
|
||||
output.push(' '); // Space for alignment
|
||||
}
|
||||
|
||||
if (this.showIds) {
|
||||
output.push(Folder.shortId(item.id));
|
||||
@@ -65,7 +79,10 @@ export default class FolderListWidget extends ListWidget {
|
||||
let output = 0;
|
||||
while (true) {
|
||||
const folder = BaseModel.byId(folders, folderId);
|
||||
const folderParentId = getDisplayParentId(folder, folders.find(f => f.id === folder.parent_id));
|
||||
const folderParentId = getDisplayParentId(
|
||||
folder,
|
||||
folders.find((f) => f.id === folder.parent_id),
|
||||
);
|
||||
if (!folder || !folderParentId) return output;
|
||||
output++;
|
||||
folderId = folderParentId;
|
||||
@@ -153,7 +170,10 @@ export default class FolderListWidget extends ListWidget {
|
||||
public folderHasChildren_(folders: FolderEntity[], folderId: string) {
|
||||
for (let i = 0; i < folders.length; i++) {
|
||||
const folder = folders[i];
|
||||
const folderParentId = getDisplayParentId(folder, folders.find(f => f.id === folder.parent_id));
|
||||
const folderParentId = getDisplayParentId(
|
||||
folder,
|
||||
folders.find((f) => f.id === folder.parent_id),
|
||||
);
|
||||
if (folderParentId === folderId) return true;
|
||||
}
|
||||
return false;
|
||||
@@ -161,7 +181,12 @@ export default class FolderListWidget extends ListWidget {
|
||||
|
||||
public render() {
|
||||
if (this.updateItems_) {
|
||||
this.logger().debug('Rebuilding items...', this.notesParentType, this.selectedJoplinItemId, this.selectedSearchId);
|
||||
this.logger().debug(
|
||||
'Rebuilding items...',
|
||||
this.notesParentType,
|
||||
this.selectedJoplinItemId,
|
||||
this.selectedSearchId,
|
||||
);
|
||||
const wasSelectedItemId = this.selectedJoplinItemId;
|
||||
const previousParentType = this.notesParentType;
|
||||
|
||||
@@ -170,12 +195,20 @@ export default class FolderListWidget extends ListWidget {
|
||||
const orderFolders = (parentId: string) => {
|
||||
for (let i = 0; i < this.folders.length; i++) {
|
||||
const f = this.folders[i];
|
||||
const originalParent = this.folders_.find(f => f.id === f.parent_id);
|
||||
const originalParent = this.folders_.find(
|
||||
(f) => f.id === f.parent_id,
|
||||
);
|
||||
|
||||
const folderParentId = getDisplayParentId(f, originalParent); // f.parent_id ? f.parent_id : '';
|
||||
if (folderParentId === parentId) {
|
||||
newItems.push(f);
|
||||
if (this.folderHasChildren_(this.folders, f.id)) orderFolders(f.id);
|
||||
// Only recurse into children if the folder is not collapsed
|
||||
if (this.folderHasChildren_(this.folders, f.id)) {
|
||||
const collapsedFolders = Setting.value('collapsedFolderIds');
|
||||
if (!collapsedFolders.includes(f.id)) {
|
||||
orderFolders(f.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -221,4 +254,53 @@ export default class FolderListWidget extends ListWidget {
|
||||
const index = this.itemIndexByKey('id', itemId);
|
||||
this.currentIndex = index >= 0 ? index : 0;
|
||||
}
|
||||
|
||||
public toggleFolderCollapse() {
|
||||
const item = this.currentItem;
|
||||
if (item && item.type_ === Folder.modelType() && this.folderHasChildren_(this.folders, item.id)) {
|
||||
const collapsedFolders = Setting.value('collapsedFolderIds');
|
||||
const isCollapsed = collapsedFolders.includes(item.id);
|
||||
if (isCollapsed) {
|
||||
const newCollapsed = collapsedFolders.filter((id: string) => id !== item.id);
|
||||
Setting.setValue('collapsedFolderIds', newCollapsed);
|
||||
} else {
|
||||
Setting.setValue('collapsedFolderIds', [...collapsedFolders, item.id]);
|
||||
}
|
||||
this.updateItems_ = true;
|
||||
this.invalidate();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
public expandToFolder(folderId: string) {
|
||||
// Find all parent folders and expand them
|
||||
const parentsToExpand: string[] = [];
|
||||
let currentId = folderId;
|
||||
|
||||
while (currentId) {
|
||||
const folder = BaseModel.byId(this.folders, currentId);
|
||||
if (!folder) break;
|
||||
|
||||
const parentId = getDisplayParentId(
|
||||
folder,
|
||||
this.folders.find((f) => f.id === folder.parent_id),
|
||||
);
|
||||
if (parentId) {
|
||||
parentsToExpand.unshift(parentId);
|
||||
currentId = parentId;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Expand all parent folders
|
||||
const collapsedFolders = Setting.value('collapsedFolderIds');
|
||||
const newCollapsed = collapsedFolders.filter((id: string) => !parentsToExpand.includes(id));
|
||||
Setting.setValue('collapsedFolderIds', newCollapsed);
|
||||
|
||||
this.updateItems_ = true;
|
||||
this.invalidate();
|
||||
}
|
||||
}
|
||||
|
@@ -57,7 +57,7 @@
|
||||
"proper-lockfile": "4.1.2",
|
||||
"redux": "4.2.1",
|
||||
"server-destroy": "1.0.1",
|
||||
"sharp": "0.34.1",
|
||||
"sharp": "0.34.2",
|
||||
"sprintf-js": "1.1.3",
|
||||
"sqlite3": "5.1.6",
|
||||
"string-padding": "1.0.2",
|
||||
@@ -73,7 +73,7 @@
|
||||
"@joplin/tools": "~3.4",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/node": "18.19.100",
|
||||
"@types/node": "18.19.103",
|
||||
"@types/proper-lockfile": "^4.1.2",
|
||||
"gulp": "4.0.2",
|
||||
"jest": "29.7.0",
|
||||
|
@@ -114,6 +114,7 @@ const Editor = (props: Props, ref: ForwardedRef<CodeMirrorControl>) => {
|
||||
const url = parseResourceUrl(src);
|
||||
if (!url.itemId) return null;
|
||||
const item = await Resource.load(url.itemId);
|
||||
if (!item) return null;
|
||||
return `${getResourceBaseUrl()}/${resourceFilename(item)}`;
|
||||
},
|
||||
});
|
||||
|
@@ -343,6 +343,7 @@ class NotePropertiesDialog extends React.Component<Props, State> {
|
||||
style={styles.input}
|
||||
id={uniqueId(key)}
|
||||
name={uniqueId(key)}
|
||||
autoFocus
|
||||
/>;
|
||||
|
||||
editCompHandler = () => {
|
||||
@@ -363,6 +364,7 @@ class NotePropertiesDialog extends React.Component<Props, State> {
|
||||
id={uniqueId(key)}
|
||||
name={uniqueId(key)}
|
||||
aria-invalid={!this.state.isValid.location}
|
||||
autoFocus
|
||||
/>
|
||||
{
|
||||
this.state.isValid.location ? null
|
||||
@@ -387,6 +389,7 @@ class NotePropertiesDialog extends React.Component<Props, State> {
|
||||
style={styles.input}
|
||||
id={uniqueId(key)}
|
||||
name={uniqueId(key)}
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "3.4.5",
|
||||
"version": "3.4.7",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.bundle.js",
|
||||
"private": true,
|
||||
@@ -142,13 +142,13 @@
|
||||
"@joplin/renderer": "~3.4",
|
||||
"@joplin/tools": "~3.4",
|
||||
"@joplin/utils": "~3.4",
|
||||
"@playwright/test": "1.51.1",
|
||||
"@playwright/test": "1.52.0",
|
||||
"@sentry/electron": "4.24.0",
|
||||
"@testing-library/react-hooks": "8.0.1",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/mustache": "4.2.6",
|
||||
"@types/node": "18.19.100",
|
||||
"@types/react": "18.3.21",
|
||||
"@types/node": "18.19.103",
|
||||
"@types/react": "18.3.22",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"@types/react-redux": "7.1.33",
|
||||
"@types/styled-components": "5.1.32",
|
||||
@@ -160,7 +160,7 @@
|
||||
"compare-versions": "6.1.1",
|
||||
"countable": "3.0.1",
|
||||
"debounce": "1.2.1",
|
||||
"electron": "35.5.1",
|
||||
"electron": "35.7.5",
|
||||
"electron-builder": "24.13.3",
|
||||
"electron-updater": "6.6.2",
|
||||
"electron-window-state": "5.0.3",
|
||||
@@ -179,7 +179,6 @@
|
||||
"moment": "2.30.1",
|
||||
"mustache": "4.2.0",
|
||||
"nan": "2.22.2",
|
||||
"node-fetch": "2.6.7",
|
||||
"node-notifier": "10.0.1",
|
||||
"node-rsa": "1.1.1",
|
||||
"pdfjs-dist": "3.11.174",
|
||||
@@ -211,6 +210,7 @@
|
||||
"@joplin/onenote-converter": "~3.4",
|
||||
"fs-extra": "11.2.0",
|
||||
"keytar": "7.9.0",
|
||||
"node-fetch": "2.6.7",
|
||||
"sqlite3": "5.1.6"
|
||||
}
|
||||
}
|
||||
|
@@ -30,7 +30,7 @@ const makeBuildContext = (entryPoint: string, renderer: boolean, computeFileSize
|
||||
// in the final bundle.
|
||||
name: 'joplin--relative-imports-for-externals',
|
||||
setup: build => {
|
||||
const externalRegex = /^(.*\.node|sqlite3|electron|@electron\/remote\/.*|electron\/.*|@mapbox\/node-pre-gyp|jsdom)$/;
|
||||
const externalRegex = /^(.*\.node|sqlite3|node-fetch|electron|@electron\/remote\/.*|electron\/.*|@mapbox\/node-pre-gyp|jsdom)$/;
|
||||
build.onResolve({ filter: externalRegex }, args => {
|
||||
// Electron packages don't need relative requires
|
||||
if (args.path === 'electron' || args.path.startsWith('electron/')) {
|
||||
|
@@ -25,7 +25,7 @@ async function main() {
|
||||
// wrong one. However it means it will have to be manually upgraded for each
|
||||
// new Electron release. Some ABI map there:
|
||||
// https://github.com/electron/node-abi/tree/master/test
|
||||
const forceAbiArgs = '--force-abi 134';
|
||||
const forceAbiArgs = '--force-abi 138';
|
||||
|
||||
if (isWindows()) {
|
||||
// Cannot run this in parallel, or the 64-bit version might end up
|
||||
|
@@ -540,6 +540,7 @@ const ComboBox: React.FC<Props> = ({
|
||||
};
|
||||
const activeId = `${baseId}-${selectedIndex}`;
|
||||
const searchResults = <NestableFlatList
|
||||
keyboardShouldPersistTaps="handled"
|
||||
ref={listRef}
|
||||
data={results}
|
||||
{...searchResultProps}
|
||||
|
@@ -3,13 +3,11 @@ import themeToCss from '@joplin/lib/services/style/themeToCss';
|
||||
import ExtendedWebView from '../ExtendedWebView';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { NativeSyntheticEvent } from 'react-native';
|
||||
|
||||
import { EditorProps } from './types';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import useCodeMirrorPlugins from './hooks/useCodeMirrorPlugins';
|
||||
import { WebViewErrorEvent } from 'react-native-webview/lib/RNCWebViewNativeComponent';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { OnMessageEvent } from '../ExtendedWebView/types';
|
||||
@@ -117,16 +115,16 @@ const MarkdownEditor: React.FC<EditorProps> = props => {
|
||||
onEditorEvent: props.onEditorEvent,
|
||||
onAttachFile: props.onAttach,
|
||||
editorOptions: {
|
||||
parentElementClassName: 'CodeMirror',
|
||||
parentElementOrClassName: 'CodeMirror',
|
||||
initialText: props.initialText,
|
||||
initialNoteId: props.noteId,
|
||||
settings: props.editorSettings,
|
||||
onLocalize: _,
|
||||
},
|
||||
webviewRef,
|
||||
pluginStates: props.plugins,
|
||||
});
|
||||
|
||||
props.editorRef.current = editorWebViewSetup.api.editor;
|
||||
props.editorRef.current = editorWebViewSetup.api.mainEditor;
|
||||
|
||||
const injectedJavaScript = `
|
||||
window.onerror = (message, source, lineno) => {
|
||||
@@ -154,11 +152,6 @@ const MarkdownEditor: React.FC<EditorProps> = props => {
|
||||
const css = useCss(props.themeId);
|
||||
const html = useHtml();
|
||||
|
||||
const codeMirrorPlugins = useCodeMirrorPlugins(props.plugins);
|
||||
useEffect(() => {
|
||||
void editorWebViewSetup.api.editor.setContentScripts(codeMirrorPlugins);
|
||||
}, [codeMirrorPlugins, editorWebViewSetup]);
|
||||
|
||||
const onMessage = useCallback((event: OnMessageEvent) => {
|
||||
const data = event.nativeEvent.data;
|
||||
|
||||
@@ -183,7 +176,7 @@ const MarkdownEditor: React.FC<EditorProps> = props => {
|
||||
html={html}
|
||||
injectedJavaScript={injectedJavaScript}
|
||||
css={css}
|
||||
hasPluginScripts={codeMirrorPlugins.length > 0}
|
||||
hasPluginScripts={editorWebViewSetup.hasPlugins}
|
||||
onMessage={onMessage}
|
||||
onLoadEnd={editorWebViewSetup.webViewEventHandlers.onLoadEnd}
|
||||
onError={onError}
|
||||
|
@@ -232,6 +232,10 @@ const useEditorControl = (
|
||||
onResourceDownloaded: (id: string) => {
|
||||
editorRef.current.onResourceDownloaded(id);
|
||||
},
|
||||
|
||||
remove: () => {
|
||||
editorRef.current.remove();
|
||||
},
|
||||
};
|
||||
|
||||
return control;
|
||||
@@ -300,6 +304,7 @@ function NoteEditor(props: Props) {
|
||||
editorControl.searchControl.hideSearch();
|
||||
}
|
||||
break;
|
||||
case EditorEventType.Remove:
|
||||
case EditorEventType.Scroll:
|
||||
// Not handled
|
||||
break;
|
||||
|
@@ -369,4 +369,69 @@ describe('RichTextEditor', () => {
|
||||
expect(body.trim()).toBe('Test:\n\n$$\n3^2 + 4^2 = \\sqrt{625}\n$$\n\nTest. testing');
|
||||
});
|
||||
});
|
||||
|
||||
it('should be possible show an editor for math blocks', async () => {
|
||||
let body = 'Test:\n\n$$3^2 + 4^2 = 5^2$$';
|
||||
render(<WrappedEditor
|
||||
noteBody={body}
|
||||
onBodyChange={newBody => { body = newBody; }}
|
||||
/>);
|
||||
|
||||
const editButton = await findElement<HTMLButtonElement>('button.edit');
|
||||
editButton.click();
|
||||
|
||||
const editor = await findElement('dialog .cm-editor');
|
||||
expect(editor).toBeTruthy();
|
||||
expect(editor.textContent).toContain('3^2 + 4^2 = 5^2');
|
||||
});
|
||||
|
||||
it('should preserve table of contents blocks on edit', async () => {
|
||||
let body = '# Heading\n\n# Heading 2\n\n[toc]\n\nTest.';
|
||||
|
||||
render(<WrappedEditor
|
||||
noteBody={body}
|
||||
onBodyChange={newBody => { body = newBody; }}
|
||||
/>);
|
||||
|
||||
// Should render the [toc] as a joplin-editable
|
||||
const renderedTableOfContents = await findElement<HTMLElement>('div.joplin-editable');
|
||||
expect(renderedTableOfContents).toBeTruthy();
|
||||
// Should have a link for each heading
|
||||
expect(renderedTableOfContents.querySelectorAll('a[href]')).toHaveLength(2);
|
||||
|
||||
const window = await getEditorWindow();
|
||||
mockTyping(window, ' testing');
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(body.trim()).toBe('# Heading\n\n# Heading 2\n\n[toc]\n\nTest. testing');
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
'**bold**',
|
||||
'*italic*',
|
||||
'$\\text{math}$',
|
||||
'<span style="color: red;">test</span>',
|
||||
'`code`',
|
||||
'==highlight==ed',
|
||||
'<sup>Super</sup>script',
|
||||
'<sub>Sub</sub>script',
|
||||
])('should preserve inline markup on edit (case %#)', async (initialBody) => {
|
||||
initialBody += 'test'; // Ensure that typing will add new content outside the formatting
|
||||
let body = initialBody;
|
||||
|
||||
render(<WrappedEditor
|
||||
noteBody={body}
|
||||
onBodyChange={newBody => { body = newBody; }}
|
||||
/>);
|
||||
|
||||
await findElement<HTMLElement>('div.prosemirror-editor');
|
||||
|
||||
const window = await getEditorWindow();
|
||||
mockTyping(window, ' testing');
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(body.trim()).toBe(`${initialBody} testing`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -1,11 +1,10 @@
|
||||
import * as React from 'react';
|
||||
import TextInput from './TextInput';
|
||||
import { View, StyleSheet, TextInputProps, ViewStyle, TextInput as ReactNativeTextInput } from 'react-native';
|
||||
import { View, StyleSheet, TextInputProps, ViewStyle, TextInput as ReactNativeTextInput, Keyboard } from 'react-native';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { Ref, useCallback, useMemo } from 'react';
|
||||
import { themeStyle } from './global-style';
|
||||
import IconButton from './IconButton';
|
||||
import Icon from './Icon';
|
||||
|
||||
|
||||
interface SearchInputProps extends TextInputProps {
|
||||
@@ -58,11 +57,12 @@ const SearchInput: React.FC<SearchInputProps> = ({ inputRef, themeId, value, con
|
||||
}, [onChangeText]);
|
||||
|
||||
return <View style={[styles.root, containerStyle]}>
|
||||
<Icon
|
||||
aria-hidden={true}
|
||||
name='material magnify'
|
||||
accessibilityLabel={null}
|
||||
style={styles.icon}
|
||||
<IconButton
|
||||
iconName='material magnify'
|
||||
onPress={() => Keyboard.dismiss()}
|
||||
description={_('Hide keyboard')}
|
||||
iconStyle={styles.icon}
|
||||
themeId={themeId}
|
||||
/>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
|
@@ -171,6 +171,7 @@ const TagsBox: React.FC<TagsBoxProps> = props => {
|
||||
return <View style={props.styles.tagBoxRoot}>
|
||||
<Text style={props.styles.header} role='heading'>{_('Associated tags:')}</Text>
|
||||
<ScrollView
|
||||
keyboardShouldPersistTaps="handled"
|
||||
style={props.styles.tagBoxScrollView}
|
||||
// On web, specifying aria-live here announces changes to the associated tags.
|
||||
// However, on Android (and possibly iOS), this breaks focus behavior:
|
||||
|
@@ -1,30 +1,57 @@
|
||||
import { createEditor } from '@joplin/editor/CodeMirror';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import WebViewToRNMessenger from '../../utils/ipc/WebViewToRNMessenger';
|
||||
import { EditorProcessApi, EditorProps, MainProcessApi } from './types';
|
||||
import { EditorProcessApi, EditorProps, EditorWithParentProps, ExportedWebViewGlobals, MainProcessApi } from './types';
|
||||
import readFileToBase64 from '../utils/readFileToBase64';
|
||||
import { EditorControl } from '@joplin/editor/types';
|
||||
import { EditorEventType } from '@joplin/editor/events';
|
||||
|
||||
export { default as setUpLogger } from '../utils/setUpLogger';
|
||||
|
||||
export const initializeEditor = ({
|
||||
parentElementClassName,
|
||||
interface ExtendedWindow extends ExportedWebViewGlobals, Window { }
|
||||
declare const window: ExtendedWindow;
|
||||
|
||||
let mainEditor: EditorControl|null = null;
|
||||
let allEditors: EditorControl[] = [];
|
||||
const messenger = new WebViewToRNMessenger<EditorProcessApi, MainProcessApi>('markdownEditor', {
|
||||
get mainEditor() {
|
||||
return mainEditor;
|
||||
},
|
||||
updatePlugins(contentScripts) {
|
||||
for (const editor of allEditors) {
|
||||
void editor.setContentScripts(contentScripts);
|
||||
}
|
||||
},
|
||||
updateSettings(settings) {
|
||||
for (const editor of allEditors) {
|
||||
editor.updateSettings(settings);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
export const createEditorWithParent = ({
|
||||
parentElementOrClassName,
|
||||
initialText,
|
||||
initialNoteId,
|
||||
settings,
|
||||
onLocalize,
|
||||
}: EditorProps) => {
|
||||
const messenger = new WebViewToRNMessenger<EditorProcessApi, MainProcessApi>('markdownEditor', null);
|
||||
|
||||
const parentElement = document.getElementsByClassName(parentElementClassName)[0] as HTMLElement;
|
||||
onEvent,
|
||||
}: EditorWithParentProps) => {
|
||||
const parentElement = (() => {
|
||||
if (parentElementOrClassName instanceof HTMLElement) {
|
||||
return parentElementOrClassName;
|
||||
}
|
||||
return document.getElementsByClassName(parentElementOrClassName)[0] as HTMLElement;
|
||||
})();
|
||||
if (!parentElement) {
|
||||
throw new Error(`Unable to find parent element for editor (class name: ${JSON.stringify(parentElementClassName)})`);
|
||||
throw new Error(`Unable to find parent element for editor (class name: ${JSON.stringify(parentElementOrClassName)})`);
|
||||
}
|
||||
|
||||
const control = createEditor(parentElement, {
|
||||
initialText,
|
||||
initialNoteId,
|
||||
settings,
|
||||
onLocalize,
|
||||
onLocalize: messenger.remoteApi.onLocalize,
|
||||
|
||||
onPasteFile: async (data) => {
|
||||
const base64 = await readFileToBase64(data);
|
||||
@@ -34,14 +61,32 @@ export const initializeEditor = ({
|
||||
onLogMessage: message => {
|
||||
void messenger.remoteApi.logMessage(message);
|
||||
},
|
||||
onEvent: (event): void => {
|
||||
void messenger.remoteApi.onEditorEvent(event);
|
||||
onEvent: (event) => {
|
||||
onEvent(event);
|
||||
|
||||
if (event.kind === EditorEventType.Remove) {
|
||||
allEditors = allEditors.filter(other => other !== control);
|
||||
}
|
||||
},
|
||||
resolveImageSrc: (src) => {
|
||||
return messenger.remoteApi.onResolveImageSrc(src);
|
||||
},
|
||||
});
|
||||
|
||||
allEditors.push(control);
|
||||
void messenger.remoteApi.onEditorAdded();
|
||||
|
||||
return control;
|
||||
};
|
||||
|
||||
export const createMainEditor = (props: EditorProps) => {
|
||||
const control = createEditorWithParent({
|
||||
...props,
|
||||
onEvent: (event) => {
|
||||
void messenger.remoteApi.onEditorEvent(event);
|
||||
},
|
||||
});
|
||||
|
||||
// Works around https://github.com/laurent22/joplin/issues/10047 by handling
|
||||
// the text/uri-list MIME type when pasting, rather than sending the paste event
|
||||
// to CodeMirror.
|
||||
@@ -57,6 +102,7 @@ export const initializeEditor = ({
|
||||
|
||||
// Note: Just adding an onclick listener seems sufficient to focus the editor when its background
|
||||
// is tapped.
|
||||
const parentElement = control.editor.dom.parentElement;
|
||||
parentElement.addEventListener('click', (event) => {
|
||||
const activeElement = document.querySelector(':focus');
|
||||
if (!parentElement.contains(activeElement) && event.target === parentElement) {
|
||||
@@ -64,8 +110,9 @@ export const initializeEditor = ({
|
||||
}
|
||||
});
|
||||
|
||||
messenger.setLocalInterface({
|
||||
editor: control,
|
||||
});
|
||||
mainEditor = control;
|
||||
return control;
|
||||
};
|
||||
|
||||
window.createEditorWithParent = createEditorWithParent;
|
||||
window.createMainEditor = createMainEditor;
|
||||
|
@@ -1,8 +1,29 @@
|
||||
import { EditorEvent } from '@joplin/editor/events';
|
||||
import { EditorControl, EditorSettings, OnLocalize } from '@joplin/editor/types';
|
||||
import { ContentScriptData, EditorControl, EditorSettings, LocalizationResult } from '@joplin/editor/types';
|
||||
|
||||
|
||||
export interface EditorProps {
|
||||
parentElementOrClassName: HTMLElement|string;
|
||||
initialText: string;
|
||||
initialNoteId: string|null;
|
||||
settings: EditorSettings;
|
||||
}
|
||||
|
||||
export interface EditorWithParentProps extends EditorProps {
|
||||
onEvent: (editorEvent: EditorEvent)=> void;
|
||||
}
|
||||
|
||||
// The Markdown editor exposes global functions within its <WebView>.
|
||||
// These functions can be used externally.
|
||||
export interface ExportedWebViewGlobals {
|
||||
createEditorWithParent: (options: EditorWithParentProps)=> EditorControl;
|
||||
createMainEditor: (props: EditorProps)=> EditorControl;
|
||||
}
|
||||
|
||||
export interface EditorProcessApi {
|
||||
editor: EditorControl;
|
||||
mainEditor: EditorControl;
|
||||
updateSettings: (settings: EditorSettings)=> void;
|
||||
updatePlugins: (contentScripts: ContentScriptData[])=> void;
|
||||
}
|
||||
|
||||
export interface SelectionRange {
|
||||
@@ -10,16 +31,10 @@ export interface SelectionRange {
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface EditorProps {
|
||||
parentElementClassName: string;
|
||||
initialText: string;
|
||||
initialNoteId: string;
|
||||
onLocalize: OnLocalize;
|
||||
settings: EditorSettings;
|
||||
}
|
||||
|
||||
export interface MainProcessApi {
|
||||
onLocalize(text: string): LocalizationResult;
|
||||
onEditorEvent(event: EditorEvent): Promise<void>;
|
||||
onEditorAdded(): Promise<void>;
|
||||
logMessage(message: string): Promise<void>;
|
||||
onPasteFile(type: string, dataBase64: string): Promise<void>;
|
||||
onResolveImageSrc(src: string): Promise<string|null>;
|
||||
|
@@ -7,6 +7,9 @@ import { OnMessageEvent, WebViewControl } from '../../components/ExtendedWebView
|
||||
import { EditorEvent } from '@joplin/editor/events';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import useCodeMirrorPlugins from './utils/useCodeMirrorPlugins';
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
import { parseResourceUrl } from '@joplin/lib/urlUtils';
|
||||
const { isImageMimeType } = require('@joplin/lib/resourceUtils');
|
||||
@@ -15,9 +18,10 @@ const logger = Logger.create('markdownEditor');
|
||||
|
||||
interface Props {
|
||||
editorOptions: EditorOptions;
|
||||
initialSelection: SelectionRange;
|
||||
initialSelection: SelectionRange|null;
|
||||
noteHash: string;
|
||||
globalSearch: string;
|
||||
pluginStates: PluginStates;
|
||||
onEditorEvent: (event: EditorEvent)=> void;
|
||||
onAttachFile: (mime: string, base64: string)=> void;
|
||||
|
||||
@@ -33,9 +37,11 @@ const defaultSearchState: SearchState = {
|
||||
dialogVisible: false,
|
||||
};
|
||||
|
||||
type Result = SetUpResult<EditorProcessApi> & { hasPlugins: boolean };
|
||||
|
||||
const useWebViewSetup = ({
|
||||
editorOptions, initialSelection, noteHash, globalSearch, webviewRef, onEditorEvent, onAttachFile,
|
||||
}: Props): SetUpResult<EditorProcessApi> => {
|
||||
editorOptions, pluginStates, initialSelection, noteHash, globalSearch, webviewRef, onEditorEvent, onAttachFile,
|
||||
}: Props): Result => {
|
||||
const setInitialSelectionJs = initialSelection ? `
|
||||
cm.select(${initialSelection.start}, ${initialSelection.end});
|
||||
cm.execCommand('scrollSelectionIntoView');
|
||||
@@ -51,20 +57,21 @@ const useWebViewSetup = ({
|
||||
` : '';
|
||||
|
||||
const injectedJavaScript = useMemo(() => `
|
||||
if (typeof markdownEditorBundle === 'undefined') {
|
||||
${shim.injectedJs('markdownEditorBundle')};
|
||||
window.markdownEditorBundle = markdownEditorBundle;
|
||||
markdownEditorBundle.setUpLogger();
|
||||
}
|
||||
|
||||
if (!window.cm) {
|
||||
const parentClassName = ${JSON.stringify(editorOptions.parentElementClassName)};
|
||||
const foundParent = document.getElementsByClassName(parentClassName).length > 0;
|
||||
const parentClassName = ${JSON.stringify(editorOptions?.parentElementOrClassName)};
|
||||
const foundParent = !!parentClassName && document.getElementsByClassName(parentClassName).length > 0;
|
||||
|
||||
// On Android, injectedJavaScript can be run multiple times, including once before the
|
||||
// document has loaded. To avoid logging an error each time the editor starts, don't throw
|
||||
// if the parent element can't be found:
|
||||
if (foundParent) {
|
||||
${shim.injectedJs('markdownEditorBundle')};
|
||||
markdownEditorBundle.setUpLogger();
|
||||
|
||||
window.cm = markdownEditorBundle.initializeEditor(
|
||||
${JSON.stringify(editorOptions)}
|
||||
);
|
||||
window.cm = markdownEditorBundle.createMainEditor(${JSON.stringify(editorOptions)});
|
||||
|
||||
${jumpToHashJs}
|
||||
// Set the initial selection after jumping to the header -- the initial selection,
|
||||
@@ -75,7 +82,7 @@ const useWebViewSetup = ({
|
||||
window.onresize = () => {
|
||||
cm.execCommand('scrollSelectionIntoView');
|
||||
};
|
||||
} else {
|
||||
} else if (parentClassName) {
|
||||
console.log('No parent element found with class name ', parentClassName);
|
||||
}
|
||||
}
|
||||
@@ -101,6 +108,10 @@ const useWebViewSetup = ({
|
||||
const onAttachRef = useRef(onAttachFile);
|
||||
onAttachRef.current = onAttachFile;
|
||||
|
||||
const codeMirrorPlugins = useCodeMirrorPlugins(pluginStates);
|
||||
const codeMirrorPluginsRef = useRef(codeMirrorPlugins);
|
||||
codeMirrorPluginsRef.current = codeMirrorPlugins;
|
||||
|
||||
const editorMessenger = useMemo(() => {
|
||||
const localApi: MainProcessApi = {
|
||||
async onEditorEvent(event) {
|
||||
@@ -112,6 +123,13 @@ const useWebViewSetup = ({
|
||||
async onPasteFile(type, data) {
|
||||
onAttachRef.current(type, data);
|
||||
},
|
||||
async onLocalize(text) {
|
||||
const localizationFunction = _;
|
||||
return localizationFunction(text);
|
||||
},
|
||||
async onEditorAdded() {
|
||||
messenger.remoteApi.updatePlugins(codeMirrorPluginsRef.current);
|
||||
},
|
||||
async onResolveImageSrc(src) {
|
||||
const url = parseResourceUrl(src);
|
||||
if (!url.itemId) return null;
|
||||
@@ -153,17 +171,22 @@ const useWebViewSetup = ({
|
||||
|
||||
const editorSettings = editorOptions.settings;
|
||||
useEffect(() => {
|
||||
api.editor.updateSettings(editorSettings);
|
||||
api.updateSettings(editorSettings);
|
||||
}, [api, editorSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
api.updatePlugins(codeMirrorPlugins);
|
||||
}, [codeMirrorPlugins, api]);
|
||||
|
||||
return useMemo(() => ({
|
||||
pageSetup: {
|
||||
js: injectedJavaScript,
|
||||
css: '',
|
||||
},
|
||||
hasPlugins: codeMirrorPlugins.length > 0,
|
||||
api,
|
||||
webViewEventHandlers,
|
||||
}), [injectedJavaScript, api, webViewEventHandlers]);
|
||||
}), [injectedJavaScript, api, webViewEventHandlers, codeMirrorPlugins]);
|
||||
};
|
||||
|
||||
export default useWebViewSetup;
|
||||
|
@@ -7,6 +7,8 @@ import '@joplin/editor/ProseMirror/styles';
|
||||
import readFileToBase64 from '../../utils/readFileToBase64';
|
||||
import { EditorLanguageType } from '@joplin/editor/types';
|
||||
import convertHtmlToMarkdown from './convertHtmlToMarkdown';
|
||||
import { ExportedWebViewGlobals as MarkdownEditorWebViewGlobals } from '../../markdownEditorBundle/types';
|
||||
import { EditorEventType } from '@joplin/editor/events';
|
||||
|
||||
const postprocessHtml = (html: HTMLElement) => {
|
||||
// Fix resource URLs
|
||||
@@ -35,13 +37,16 @@ const htmlToMarkdown = (html: HTMLElement): string => {
|
||||
return convertHtmlToMarkdown(html);
|
||||
};
|
||||
|
||||
export const initialize = async ({
|
||||
settings,
|
||||
initialText,
|
||||
initialNoteId,
|
||||
parentElementClassName,
|
||||
initialSearch,
|
||||
}: EditorProps) => {
|
||||
export const initialize = async (
|
||||
{
|
||||
settings,
|
||||
initialText,
|
||||
initialNoteId,
|
||||
parentElementClassName,
|
||||
initialSearch,
|
||||
}: EditorProps,
|
||||
markdownEditorApi: MarkdownEditorWebViewGlobals,
|
||||
) => {
|
||||
const messenger = new WebViewToRNMessenger<EditorProcessApi, MainProcessApi>('rich-text-editor', null);
|
||||
const parentElement = document.getElementsByClassName(parentElementClassName)[0];
|
||||
if (!parentElement) throw new Error('Parent element not found');
|
||||
@@ -109,6 +114,18 @@ export const initialize = async ({
|
||||
return postprocessHtml(html).outerHTML;
|
||||
}
|
||||
},
|
||||
}, (parent, language, onChange) => {
|
||||
return markdownEditorApi.createEditorWithParent({
|
||||
initialText: '',
|
||||
initialNoteId: '',
|
||||
parentElementOrClassName: parent,
|
||||
settings: { ...editor.getSettings(), language },
|
||||
onEvent: (event) => {
|
||||
if (event.kind === EditorEventType.Change) {
|
||||
onChange(event.value);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
editor.setSearchState(initialSearch);
|
||||
|
||||
|
@@ -4,6 +4,7 @@ import { SetUpResult } from '../types';
|
||||
import { EditorControl, EditorSettings } from '@joplin/editor/types';
|
||||
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
|
||||
import { EditorProcessApi, EditorProps, MainProcessApi } from './types';
|
||||
import useMarkdownEditorSetup from '../markdownEditorBundle/useWebViewSetup';
|
||||
import useRendererSetup from '../rendererBundle/useWebViewSetup';
|
||||
import { EditorEvent } from '@joplin/editor/events';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
@@ -92,7 +93,10 @@ const useMessenger = (props: UseMessengerProps) => {
|
||||
}, [props.webviewRef]);
|
||||
};
|
||||
|
||||
type UseSourceProps = Props & { renderer: SetUpResult<RendererControl> };
|
||||
type UseSourceProps = Props & {
|
||||
renderer: SetUpResult<RendererControl>;
|
||||
markdownEditor: SetUpResult<unknown>;
|
||||
};
|
||||
|
||||
const useSource = (props: UseSourceProps) => {
|
||||
const propsRef = useRef(props);
|
||||
@@ -100,6 +104,8 @@ const useSource = (props: UseSourceProps) => {
|
||||
|
||||
const rendererJs = props.renderer.pageSetup.js;
|
||||
const rendererCss = props.renderer.pageSetup.css;
|
||||
const markdownEditorJs = props.markdownEditor.pageSetup.js;
|
||||
const markdownEditorCss = props.markdownEditor.pageSetup.css;
|
||||
|
||||
return useMemo(() => {
|
||||
const editorOptions: EditorProps = {
|
||||
@@ -117,6 +123,7 @@ const useSource = (props: UseSourceProps) => {
|
||||
css: `
|
||||
${shim.injectedCss('richTextEditorBundle')}
|
||||
${rendererCss}
|
||||
${markdownEditorCss}
|
||||
|
||||
/* Increase the size of the editor to make it easier to focus the editor. */
|
||||
.prosemirror-editor {
|
||||
@@ -125,19 +132,23 @@ const useSource = (props: UseSourceProps) => {
|
||||
`,
|
||||
js: `
|
||||
${rendererJs}
|
||||
${markdownEditorJs}
|
||||
|
||||
if (!window.richTextEditorCreated) {
|
||||
window.richTextEditorCreated = true;
|
||||
${shim.injectedJs('richTextEditorBundle')}
|
||||
richTextEditorBundle.setUpLogger();
|
||||
richTextEditorBundle.initialize(${JSON.stringify(editorOptions)}).then(function(editor) {
|
||||
richTextEditorBundle.initialize(
|
||||
${JSON.stringify(editorOptions)},
|
||||
window,
|
||||
).then(function(editor) {
|
||||
/* For testing */
|
||||
window.joplinRichTextEditor_ = editor;
|
||||
});
|
||||
}
|
||||
`,
|
||||
};
|
||||
}, [rendererJs, rendererCss]);
|
||||
}, [rendererJs, rendererCss, markdownEditorCss, markdownEditorJs]);
|
||||
};
|
||||
|
||||
const useWebViewSetup = (props: Props): SetUpResult<EditorControl> => {
|
||||
@@ -148,8 +159,23 @@ const useWebViewSetup = (props: Props): SetUpResult<EditorControl> => {
|
||||
pluginStates: props.pluginStates,
|
||||
themeId: props.themeId,
|
||||
});
|
||||
const markdownEditor = useMarkdownEditorSetup({
|
||||
webviewRef: props.webviewRef,
|
||||
onAttachFile: props.onAttachFile,
|
||||
initialSelection: null,
|
||||
noteHash: '',
|
||||
globalSearch: props.globalSearch,
|
||||
editorOptions: {
|
||||
settings: props.settings,
|
||||
initialNoteId: null,
|
||||
parentElementOrClassName: '',
|
||||
initialText: '',
|
||||
},
|
||||
onEditorEvent: (_event)=>{},
|
||||
pluginStates: props.pluginStates,
|
||||
});
|
||||
const messenger = useMessenger({ ...props, renderer });
|
||||
const pageSetup = useSource({ ...props, renderer });
|
||||
const pageSetup = useSource({ ...props, renderer, markdownEditor });
|
||||
|
||||
useEffect(() => {
|
||||
void messenger.remoteApi.editor.updateSettings(props.settings);
|
||||
@@ -163,14 +189,16 @@ const useWebViewSetup = (props: Props): SetUpResult<EditorControl> => {
|
||||
onLoadEnd: () => {
|
||||
messenger.onWebViewLoaded();
|
||||
renderer.webViewEventHandlers.onLoadEnd();
|
||||
markdownEditor.webViewEventHandlers.onLoadEnd();
|
||||
},
|
||||
onMessage: (event) => {
|
||||
messenger.onWebViewMessage(event);
|
||||
renderer.webViewEventHandlers.onMessage(event);
|
||||
markdownEditor.webViewEventHandlers.onMessage(event);
|
||||
},
|
||||
},
|
||||
};
|
||||
}, [messenger, pageSetup, renderer.webViewEventHandlers]);
|
||||
}, [messenger, pageSetup, renderer.webViewEventHandlers, markdownEditor.webViewEventHandlers]);
|
||||
};
|
||||
|
||||
export default useWebViewSetup;
|
||||
|
@@ -533,7 +533,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 142;
|
||||
CURRENT_PROJECT_VERSION = 143;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
@@ -542,7 +542,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.4.0;
|
||||
MARKETING_VERSION = 13.4.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -568,7 +568,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 142;
|
||||
CURRENT_PROJECT_VERSION = 143;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
@@ -576,7 +576,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.4.0;
|
||||
MARKETING_VERSION = 13.4.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -769,18 +769,18 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 142;
|
||||
CURRENT_PROJECT_VERSION = 143;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.4.0;
|
||||
MARKETING_VERSION = 13.4.1;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
@@ -812,18 +812,18 @@
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 142;
|
||||
CURRENT_PROJECT_VERSION = 143;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.4.0;
|
||||
MARKETING_VERSION = 13.4.1;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
|
@@ -58,7 +58,7 @@
|
||||
"react-native-file-viewer": "2.1.5",
|
||||
"react-native-fs": "2.20.0",
|
||||
"react-native-get-random-values": "1.11.0",
|
||||
"react-native-image-picker": "7.2.3",
|
||||
"react-native-image-picker": "8.0.0",
|
||||
"react-native-localize": "3.4.1",
|
||||
"react-native-modal-datetime-picker": "18.0.0",
|
||||
"react-native-paper": "5.13.5",
|
||||
@@ -66,7 +66,7 @@
|
||||
"react-native-quick-actions": "0.3.13",
|
||||
"react-native-quick-crypto": "0.7.13",
|
||||
"react-native-rsa-native": "2.0.5",
|
||||
"react-native-safe-area-context": "5.4.0",
|
||||
"react-native-safe-area-context": "5.4.1",
|
||||
"react-native-securerandom": "1.0.1",
|
||||
"react-native-share": "12.0.11",
|
||||
"react-native-sqlite-storage": "6.0.1",
|
||||
@@ -106,10 +106,10 @@
|
||||
"@testing-library/react-native": "13.2.0",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/node": "18.19.100",
|
||||
"@types/node": "18.19.103",
|
||||
"@types/react": "19.0.14",
|
||||
"@types/react-redux": "7.1.33",
|
||||
"@types/serviceworker": "0.0.134",
|
||||
"@types/serviceworker": "0.0.135",
|
||||
"@types/tar-stream": "3.1.3",
|
||||
"babel-jest": "29.7.0",
|
||||
"babel-loader": "9.1.3",
|
||||
@@ -123,14 +123,14 @@
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"jetifier": "2.0.0",
|
||||
"js-draw": "1.30.0",
|
||||
"jsdom": "26.0.0",
|
||||
"jsdom": "26.1.0",
|
||||
"nodemon": "3.1.10",
|
||||
"punycode": "2.3.1",
|
||||
"react-dom": "19.0.0",
|
||||
"react-native-web": "0.20.0",
|
||||
"react-refresh": "0.17.0",
|
||||
"react-test-renderer": "19.0.0",
|
||||
"sharp": "0.34.1",
|
||||
"sharp": "0.34.2",
|
||||
"sqlite3": "5.1.6",
|
||||
"timers-browserify": "2.0.12",
|
||||
"ts-jest": "29.3.1",
|
||||
|
@@ -39,6 +39,7 @@ import selectedNoteIdExtension, { setNoteIdEffect } from './extensions/selectedN
|
||||
import ctrlKeyStateClassExtension from './extensions/modifierKeyCssExtension';
|
||||
import ctrlClickLinksExtension from './extensions/links/ctrlClickLinksExtension';
|
||||
import { RenderedContentContext } from './extensions/rendering/types';
|
||||
import ctrlClickCheckboxExtension from './extensions/ctrlClickCheckboxExtension';
|
||||
|
||||
// Newer versions of CodeMirror by default use Chrome's EditContext API.
|
||||
// While this might be stable enough for desktop use, it causes significant
|
||||
@@ -255,6 +256,7 @@ const createEditor = (
|
||||
ctrlClickLinksExtension(link => {
|
||||
props.onEvent({ kind: EditorEventType.FollowLink, link });
|
||||
}),
|
||||
ctrlClickCheckboxExtension(),
|
||||
|
||||
highlightSpecialChars(),
|
||||
indentOnInput(),
|
||||
@@ -351,6 +353,9 @@ const createEditor = (
|
||||
onLogMessage: props.onLogMessage,
|
||||
onRemove: () => {
|
||||
editor.destroy();
|
||||
props.onEvent({
|
||||
kind: EditorEventType.Remove,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
@@ -0,0 +1,33 @@
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { Prec } from '@codemirror/state';
|
||||
|
||||
const hasMultipleCursors = (view: EditorView) => {
|
||||
return view.state.selection.ranges.length > 1;
|
||||
};
|
||||
|
||||
type OnCtrlClick = (view: EditorView, event: MouseEvent)=> boolean;
|
||||
|
||||
const ctrlClickActionExtension = (onCtrlClick: OnCtrlClick) => {
|
||||
return [
|
||||
Prec.high([
|
||||
EditorView.domEventHandlers({
|
||||
mousedown: (event: MouseEvent, view: EditorView) => {
|
||||
const hasModifier = event.ctrlKey || event.metaKey;
|
||||
// The default CodeMirror action for ctrl-click is to add another cursor
|
||||
// to the document. If the user already has multiple cursors, assume that
|
||||
// the ctrl-click action is intended to add another.
|
||||
if (hasModifier && !hasMultipleCursors(view)) {
|
||||
const handled = onCtrlClick(view, event);
|
||||
if (handled) {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
]),
|
||||
];
|
||||
};
|
||||
|
||||
export default ctrlClickActionExtension;
|
@@ -0,0 +1,30 @@
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import modifierKeyCssExtension from './modifierKeyCssExtension';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import getCheckboxAtPosition from '../utils/markdown/getCheckboxAtPosition';
|
||||
import toggleCheckboxAt from '../utils/markdown/toggleCheckboxAt';
|
||||
import ctrlClickActionExtension from './ctrlClickActionExtension';
|
||||
|
||||
|
||||
const ctrlClickCheckboxExtension = () => {
|
||||
return [
|
||||
modifierKeyCssExtension,
|
||||
EditorView.theme({
|
||||
'&.-ctrl-or-cmd-pressed .cm-taskMarker': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
}),
|
||||
ctrlClickActionExtension((view, event) => {
|
||||
const target = view.posAtCoords(event);
|
||||
const taskMarker = getCheckboxAtPosition(target, syntaxTree(view.state));
|
||||
|
||||
if (taskMarker) {
|
||||
toggleCheckboxAt(target)(view);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
export default ctrlClickCheckboxExtension;
|
@@ -4,12 +4,10 @@ import modifierKeyCssExtension from '../modifierKeyCssExtension';
|
||||
import openLink from './utils/openLink';
|
||||
import getUrlAtPosition from './utils/getUrlAtPosition';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { Prec } from '@codemirror/state';
|
||||
|
||||
import ctrlClickActionExtension from '../ctrlClickActionExtension';
|
||||
|
||||
type OnOpenLink = (url: string, view: EditorView)=> void;
|
||||
|
||||
|
||||
const ctrlClickLinksExtension = (onOpenExternalLink: OnOpenLink) => {
|
||||
return [
|
||||
modifierKeyCssExtension,
|
||||
@@ -19,27 +17,16 @@ const ctrlClickLinksExtension = (onOpenExternalLink: OnOpenLink) => {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
}),
|
||||
Prec.high([
|
||||
EditorView.domEventHandlers({
|
||||
mousedown: (event: MouseEvent, view: EditorView) => {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
const target = view.posAtCoords(event);
|
||||
const url = getUrlAtPosition(target, syntaxTree(view.state), view.state);
|
||||
const hasMultipleCursors = view.state.selection.ranges.length > 1;
|
||||
ctrlClickActionExtension((view: EditorView, event: MouseEvent) => {
|
||||
const target = view.posAtCoords(event);
|
||||
const url = getUrlAtPosition(target, syntaxTree(view.state), view.state);
|
||||
|
||||
// The default CodeMirror action for ctrl-click is to add another cursor
|
||||
// to the document. If the user already has multiple cursors, assume that
|
||||
// the ctrl-click action is intended to add another.
|
||||
if (url && !hasMultipleCursors) {
|
||||
openLink(url.url, view, onOpenExternalLink);
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
]),
|
||||
if (url) {
|
||||
openLink(url.url, view, onOpenExternalLink);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
|
@@ -1,30 +1,10 @@
|
||||
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||
import { SyntaxNodeRef } from '@lezer/common';
|
||||
import makeReplaceExtension from './utils/makeInlineReplaceExtension';
|
||||
import toggleCheckboxAt from '../../utils/markdown/toggleCheckboxAt';
|
||||
|
||||
const checkboxClassName = 'cm-ext-checkbox-toggle';
|
||||
|
||||
const toggleCheckbox = (view: EditorView, linePos: number) => {
|
||||
if (linePos >= view.state.doc.length) {
|
||||
// Position out of range
|
||||
return false;
|
||||
}
|
||||
|
||||
const line = view.state.doc.lineAt(linePos);
|
||||
const checkboxMarkup = line.text.match(/\[(x|\s)\]/);
|
||||
if (!checkboxMarkup) {
|
||||
// Couldn't find the checkbox
|
||||
return false;
|
||||
}
|
||||
|
||||
const isChecked = checkboxMarkup[0] === '[x]';
|
||||
const checkboxPos = checkboxMarkup.index! + line.from;
|
||||
|
||||
view.dispatch({
|
||||
changes: [{ from: checkboxPos, to: checkboxPos + 3, insert: isChecked ? '[ ]' : '[x]' }],
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
class CheckboxWidget extends WidgetType {
|
||||
public constructor(private checked: boolean, private depth: number, private label: string) {
|
||||
@@ -58,7 +38,7 @@ class CheckboxWidget extends WidgetType {
|
||||
container.appendChild(checkbox);
|
||||
|
||||
checkbox.oninput = () => {
|
||||
toggleCheckbox(view, view.posAtDOM(container));
|
||||
toggleCheckboxAt(view.posAtDOM(container))(view);
|
||||
};
|
||||
|
||||
this.applyContainerClasses(container);
|
||||
|
@@ -0,0 +1,21 @@
|
||||
import { Tree } from '@lezer/common';
|
||||
|
||||
const getCheckboxAtPosition = (pos: number, tree: Tree) => {
|
||||
let iterator = tree.resolveStack(pos);
|
||||
|
||||
while (true) {
|
||||
if (iterator.node.name === 'TaskMarker') {
|
||||
return iterator.node;
|
||||
}
|
||||
|
||||
if (!iterator.next) {
|
||||
break;
|
||||
} else {
|
||||
iterator = iterator.next;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default getCheckboxAtPosition;
|
@@ -0,0 +1,26 @@
|
||||
import { Command, EditorView } from '@codemirror/view';
|
||||
|
||||
const toggleCheckbox = (linePos: number): Command => (target: EditorView) => {
|
||||
const state = target.state;
|
||||
if (linePos >= state.doc.length) {
|
||||
// Position out of range
|
||||
return false;
|
||||
}
|
||||
|
||||
const line = state.doc.lineAt(linePos);
|
||||
const checkboxMarkup = line.text.match(/\[(x|\s)\]/);
|
||||
if (!checkboxMarkup) {
|
||||
// Couldn't find the checkbox
|
||||
return false;
|
||||
}
|
||||
|
||||
const isChecked = checkboxMarkup[0] === '[x]';
|
||||
const checkboxPos = checkboxMarkup.index! + line.from;
|
||||
|
||||
target.dispatch({
|
||||
changes: [{ from: checkboxPos, to: checkboxPos + 3, insert: isChecked ? '[ ]' : '[x]' }],
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
export default toggleCheckbox;
|
@@ -202,7 +202,7 @@ const commands: Record<EditorCommandType, ExtendedCommand|null> = {
|
||||
[EditorCommandType.ReplaceSelection]: null,
|
||||
[EditorCommandType.SetText]: null,
|
||||
[EditorCommandType.JumpToHash]: (state, dispatch, view, [targetHash]) => {
|
||||
return jumpToHash(targetHash, schema.nodes.heading)(state, dispatch, view);
|
||||
return jumpToHash(targetHash)(state, dispatch, view);
|
||||
},
|
||||
};
|
||||
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import { ContentScriptData, EditorCommandType, EditorControl, EditorProps, EditorSettings, SearchState, UpdateBodyOptions, UserEventSource } from '../types';
|
||||
import { EditorState, TextSelection, Transaction } from 'prosemirror-state';
|
||||
import { EditorView } from 'prosemirror-view';
|
||||
@@ -21,16 +22,23 @@ import listPlugin from './plugins/listPlugin';
|
||||
import searchExtension from './plugins/searchPlugin';
|
||||
import joplinEditorApiPlugin, { setEditorApi } from './plugins/joplinEditorApiPlugin';
|
||||
import linkTooltipPlugin from './plugins/linkTooltipPlugin';
|
||||
import { RendererControl } from './types';
|
||||
import { OnCreateCodeEditor as OnCreateCodeEditor, RendererControl } from './types';
|
||||
import resourcePlaceholderPlugin, { onResourceDownloaded } from './plugins/resourcePlaceholderPlugin';
|
||||
import getFileFromPasteEvent from '../utils/getFileFromPasteEvent';
|
||||
import { RenderResult } from '../../renderer/types';
|
||||
import detailsPlugin from './plugins/detailsPlugin';
|
||||
|
||||
interface ProseMirrorControl extends EditorControl {
|
||||
getSettings(): EditorSettings;
|
||||
}
|
||||
|
||||
|
||||
const createEditor = async (
|
||||
parentElement: HTMLElement,
|
||||
props: EditorProps,
|
||||
renderer: RendererControl,
|
||||
): Promise<EditorControl> => {
|
||||
createCodeEditor: OnCreateCodeEditor,
|
||||
): Promise<ProseMirrorControl> => {
|
||||
const renderNodeToMarkup = (node: Node|DocumentFragment) => {
|
||||
return renderer.renderHtmlToMarkup(node);
|
||||
};
|
||||
@@ -73,6 +81,7 @@ const createEditor = async (
|
||||
gapCursor(),
|
||||
dropCursor(),
|
||||
history(),
|
||||
detailsPlugin,
|
||||
searchPlugin,
|
||||
joplinEditablePlugin,
|
||||
markupTracker,
|
||||
@@ -89,6 +98,7 @@ const createEditor = async (
|
||||
setEditorApi(state.tr, {
|
||||
onEvent: props.onEvent,
|
||||
renderer,
|
||||
createCodeEditor: createCodeEditor,
|
||||
localize: async (input: string) => {
|
||||
if (cachedLocalizations.has(input)) {
|
||||
return cachedLocalizations.get(input);
|
||||
@@ -157,7 +167,7 @@ const createEditor = async (
|
||||
},
|
||||
});
|
||||
|
||||
const editorControl: EditorControl = {
|
||||
const editorControl: ProseMirrorControl = {
|
||||
supportsCommand: (name: EditorCommandType | string) => {
|
||||
return name in commands && !!commands[name as keyof typeof commands];
|
||||
},
|
||||
@@ -192,6 +202,9 @@ const createEditor = async (
|
||||
updateBody: async (newBody: string, _updateBodyOptions?: UpdateBodyOptions) => {
|
||||
view.updateState(await createInitialState(newBody));
|
||||
},
|
||||
getSettings: () => {
|
||||
return settings;
|
||||
},
|
||||
updateSettings: async (newSettings: EditorSettings) => {
|
||||
const oldSettings = settings;
|
||||
settings = newSettings;
|
||||
@@ -271,6 +284,11 @@ const createEditor = async (
|
||||
const resourceSrc = renderedImage?.src;
|
||||
onResourceDownloaded(view, resourceId, resourceSrc);
|
||||
},
|
||||
remove: () => {
|
||||
view.dom.remove();
|
||||
props.onEvent({ kind: EditorEventType.Remove });
|
||||
},
|
||||
focus: () => focus('createEditor', view),
|
||||
};
|
||||
return editorControl;
|
||||
};
|
||||
|
64
packages/editor/ProseMirror/plugins/detailsPlugin.test.ts
Normal file
64
packages/editor/ProseMirror/plugins/detailsPlugin.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import createTestEditor from '../testing/createTestEditor';
|
||||
import detailsPlugin from './detailsPlugin';
|
||||
import originalMarkupPlugin from './originalMarkupPlugin';
|
||||
|
||||
describe('detailsPlugin', () => {
|
||||
it('should add jop-noMdConv attributes to <details> and <summary>', () => {
|
||||
const serializer = new XMLSerializer();
|
||||
const markupToHtml = originalMarkupPlugin(node => serializer.serializeToString(node));
|
||||
const view = createTestEditor({
|
||||
html: `
|
||||
<details><summary>Test</summary>
|
||||
<p>Test...</p>
|
||||
</details>
|
||||
`,
|
||||
plugins: [detailsPlugin, markupToHtml.plugin],
|
||||
});
|
||||
|
||||
// Serialize, then parse to normalize the HTML (for comparison
|
||||
// with the HTML serialized by markupToHtml).
|
||||
const expectedState = serializer.serializeToString(
|
||||
new DOMParser().parseFromString([
|
||||
'<details class="jop-noMdConv"><summary class="jop-noMdConv">Test</summary>',
|
||||
'<p>Test...</p>',
|
||||
'</details>',
|
||||
].join(''), 'text/html').querySelector('details'),
|
||||
);
|
||||
|
||||
expect(
|
||||
markupToHtml.stateToMarkup(view.state).trim(),
|
||||
).toBe(expectedState);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ initialOpen: false },
|
||||
{ initialOpen: true },
|
||||
])('toggling the details element should update its state (%j)', ({ initialOpen }) => {
|
||||
const view = createTestEditor({
|
||||
html: `
|
||||
<details${initialOpen ? ' open' : ''}><summary>Test summary</summary>
|
||||
<p>Test content.</p>
|
||||
</details>
|
||||
`,
|
||||
plugins: [detailsPlugin],
|
||||
});
|
||||
|
||||
const details = view.dom.querySelector('details');
|
||||
details.open = !initialOpen;
|
||||
details.dispatchEvent(new Event('toggle'));
|
||||
|
||||
// The changes to the DOM should be reflected in the editor state
|
||||
expect(view.state.doc.toJSON()).toMatchObject({
|
||||
content: [
|
||||
{
|
||||
type: 'details',
|
||||
attrs: { open: !initialOpen },
|
||||
content: [
|
||||
{ type: 'details_summary' },
|
||||
{ type: 'paragraph' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
123
packages/editor/ProseMirror/plugins/detailsPlugin.ts
Normal file
123
packages/editor/ProseMirror/plugins/detailsPlugin.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Plugin } from 'prosemirror-state';
|
||||
import { AttributeSpec, Node, NodeSpec } from 'prosemirror-model';
|
||||
import { EditorView, NodeView, ViewMutationRecord } from 'prosemirror-view';
|
||||
|
||||
type NodeAttrs = Readonly<{
|
||||
open: boolean;
|
||||
}>;
|
||||
|
||||
const attrsSpec = {
|
||||
open: { default: true, validate: 'boolean' },
|
||||
} satisfies Record<keyof NodeAttrs, AttributeSpec>;
|
||||
|
||||
const detailsSpec: NodeSpec = {
|
||||
group: 'block',
|
||||
inline: false,
|
||||
attrs: attrsSpec,
|
||||
content: 'details_summary block+',
|
||||
parseDOM: [
|
||||
{
|
||||
tag: 'details',
|
||||
getAttrs: (node): NodeAttrs => {
|
||||
return {
|
||||
open: node.hasAttribute('open') && node.getAttribute('open') !== 'false',
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
toDOM: (node) => [
|
||||
'details',
|
||||
{
|
||||
...(node.attrs.open ? { open: true } : {}),
|
||||
|
||||
// Allows the details element to be correctly converted back to Markdown (in Markdown notes).
|
||||
class: 'jop-noMdConv',
|
||||
},
|
||||
0,
|
||||
],
|
||||
};
|
||||
|
||||
const detailsSummarySpec: NodeSpec = {
|
||||
inline: false,
|
||||
content: 'inline+',
|
||||
parseDOM: [
|
||||
{ tag: 'summary' },
|
||||
],
|
||||
toDOM: () => ['summary', { class: 'jop-noMdConv' }, 0],
|
||||
};
|
||||
|
||||
export const nodeSpecs = {
|
||||
details: detailsSpec,
|
||||
details_summary: detailsSummarySpec,
|
||||
};
|
||||
|
||||
type GetPosition = ()=> number|undefined;
|
||||
|
||||
class DetailsView implements NodeView {
|
||||
public readonly dom: HTMLDetailsElement;
|
||||
public readonly contentDOM: HTMLElement;
|
||||
|
||||
public constructor(private node_: Node, view: EditorView, getPosition: GetPosition) {
|
||||
this.dom = document.createElement('details');
|
||||
this.dom.open = this.node_.attrs.open;
|
||||
this.dom.ontoggle = () => {
|
||||
const position = getPosition();
|
||||
if (this.dom.open !== this.node_.attrs.open && position !== undefined) {
|
||||
view.dispatch(view.state.tr.setNodeAttribute(
|
||||
position, 'open', this.dom.open,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Allow the user to click on the "summary" label's text without toggling the details
|
||||
// element:
|
||||
this.dom.onclick = (event) => {
|
||||
const summaryNode = this.dom.querySelector('summary');
|
||||
if (event.target === summaryNode) {
|
||||
const contentRange = document.createRange();
|
||||
contentRange.setStart(summaryNode, 0);
|
||||
contentRange.setEnd(summaryNode, summaryNode.childNodes.length);
|
||||
const bbox = contentRange.getBoundingClientRect();
|
||||
|
||||
const horizontalPadding = 10;
|
||||
const eventIsInLabelText = (
|
||||
event.x >= bbox.left &&
|
||||
event.x <= bbox.left + bbox.width + horizontalPadding &&
|
||||
event.y >= bbox.top &&
|
||||
event.y <= bbox.top + bbox.height
|
||||
);
|
||||
|
||||
if (eventIsInLabelText) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.contentDOM = this.dom;
|
||||
}
|
||||
|
||||
public ignoreMutation(mutation: ViewMutationRecord) {
|
||||
// Prevent ProseMirror from immediately resetting the "open" attribute when toggled.
|
||||
return mutation.target === this.dom && mutation.type === 'attributes' && mutation.attributeName === 'open';
|
||||
}
|
||||
|
||||
public update(node: Node) {
|
||||
if (node.type.spec !== this.node_.type.spec) return false;
|
||||
|
||||
this.node_ = node;
|
||||
this.dom.open = node.attrs.open;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const detailsPlugin = new Plugin({
|
||||
props: {
|
||||
nodeViews: {
|
||||
details: (node, view, getPos, _decorations) => {
|
||||
return new DetailsView(node, view, getPos);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default detailsPlugin;
|
@@ -1,6 +1,7 @@
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import createTextNode from '../../utils/dom/createTextNode';
|
||||
import createTextArea from '../../utils/dom/createTextArea';
|
||||
import { EditorApi } from '../joplinEditorApiPlugin';
|
||||
import { EditorLanguageType } from '../../../types';
|
||||
|
||||
interface SourceBlockData {
|
||||
start: string;
|
||||
@@ -12,31 +13,40 @@ interface Options {
|
||||
editorLabel: string|Promise<string>;
|
||||
doneLabel: string|Promise<string>;
|
||||
block: SourceBlockData;
|
||||
editorApi: EditorApi;
|
||||
onSave: (newContent: SourceBlockData)=> void;
|
||||
onDismiss: ()=> void;
|
||||
}
|
||||
|
||||
const createEditorDialog = ({ editorLabel, doneLabel, block, onSave }: Options) => {
|
||||
const createEditorDialog = ({ editorApi, doneLabel, block, onSave, onDismiss }: Options) => {
|
||||
const dialog = document.createElement('dialog');
|
||||
dialog.classList.add('editor-dialog', '-visible');
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
dialog.onclose = () => {
|
||||
onDismiss();
|
||||
dialog.remove();
|
||||
editor.remove();
|
||||
};
|
||||
|
||||
const { textArea, label: textAreaLabel } = createTextArea({
|
||||
label: editorLabel,
|
||||
initialContent: block.content,
|
||||
onChange: (newContent) => {
|
||||
const editor = editorApi.createCodeEditor(
|
||||
dialog,
|
||||
EditorLanguageType.Markdown,
|
||||
(newContent) => {
|
||||
block = {
|
||||
...block,
|
||||
start: '',
|
||||
end: '',
|
||||
content: newContent,
|
||||
};
|
||||
onSave(block);
|
||||
},
|
||||
spellCheck: false,
|
||||
});
|
||||
|
||||
);
|
||||
editor.updateBody([
|
||||
block.start,
|
||||
block.content,
|
||||
block.end,
|
||||
].join(''));
|
||||
|
||||
const submitButton = document.createElement('button');
|
||||
submitButton.appendChild(createTextNode(doneLabel));
|
||||
@@ -45,14 +55,12 @@ const createEditorDialog = ({ editorLabel, doneLabel, block, onSave }: Options)
|
||||
if (dialog.close) {
|
||||
dialog.close();
|
||||
} else {
|
||||
// .remove the dialog in browsers with limited support for
|
||||
// HTMLDialogElement (and in JSDOM).
|
||||
dialog.remove();
|
||||
// Handle the case where the dialog element is not supported by the
|
||||
// browser/testing environment.
|
||||
dialog.onclose(new Event('close'));
|
||||
}
|
||||
};
|
||||
|
||||
dialog.appendChild(textAreaLabel);
|
||||
dialog.appendChild(textArea);
|
||||
dialog.appendChild(submitButton);
|
||||
|
||||
|
||||
@@ -61,7 +69,7 @@ const createEditorDialog = ({ editorLabel, doneLabel, block, onSave }: Options)
|
||||
dialog.showModal();
|
||||
} else {
|
||||
dialog.classList.add('-fake-modal');
|
||||
focus('createEditorDialog/legacy', textArea);
|
||||
focus('createEditorDialog/legacy', editor);
|
||||
}
|
||||
|
||||
return {};
|
||||
|
@@ -99,4 +99,22 @@ describe('joplinEditablePlugin', () => {
|
||||
// Should render the updated content
|
||||
expect(renderedEditable.querySelector('.test-content').innerHTML).toBe('Mocked!');
|
||||
});
|
||||
|
||||
test('should make #hash links clickable', () => {
|
||||
const editor = createEditor(`
|
||||
<div class="joplin-editable">
|
||||
<a href="#test-heading-1">Test</a>
|
||||
<a href="#test-heading-2">Test</a>
|
||||
</div>
|
||||
<h1>Test heading 1</h1>
|
||||
<h1>Test heading 2</h1>
|
||||
`);
|
||||
const hashLinks = editor.dom.querySelectorAll<HTMLAnchorElement>('a[href^="#test"]');
|
||||
|
||||
hashLinks[0].click();
|
||||
expect(editor.state.selection.$from.parent.textContent).toBe('Test heading 1');
|
||||
|
||||
hashLinks[1].click();
|
||||
expect(editor.state.selection.$from.parent.textContent).toBe('Test heading 2');
|
||||
});
|
||||
});
|
||||
|
@@ -1,33 +1,49 @@
|
||||
import { Plugin } from 'prosemirror-state';
|
||||
import { Node, NodeSpec } from 'prosemirror-model';
|
||||
import { Node, NodeSpec, TagParseRule } from 'prosemirror-model';
|
||||
import { EditorView, NodeView } from 'prosemirror-view';
|
||||
import sanitizeHtml from '../../utils/sanitizeHtml';
|
||||
import createEditorDialog from './createEditorDialog';
|
||||
import { getEditorApi } from '../joplinEditorApiPlugin';
|
||||
import { msleep } from '@joplin/utils/time';
|
||||
import createTextNode from '../../utils/dom/createTextNode';
|
||||
import postProcessRenderedHtml from './postProcessRenderedHtml';
|
||||
import createButton from '../../utils/dom/createButton';
|
||||
import makeLinksClickableInElement from '../../utils/makeLinksClickableInElement';
|
||||
|
||||
// See the fold example for more information about
|
||||
// writing similar ProseMirror plugins:
|
||||
// https://prosemirror.net/examples/fold/
|
||||
|
||||
interface JoplinEditableAttributes {
|
||||
contentHtml: string;
|
||||
source: string;
|
||||
language: string;
|
||||
openCharacters: string;
|
||||
closeCharacters: string;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
const makeJoplinEditableSpec = (inline: boolean): NodeSpec => ({
|
||||
const joplinEditableAttributes = {
|
||||
contentHtml: { default: '', validate: 'string' },
|
||||
source: { default: '', validate: 'string' },
|
||||
language: { default: '', validate: 'string' },
|
||||
openCharacters: { default: '', validate: 'string' },
|
||||
closeCharacters: { default: '', validate: 'string' },
|
||||
readOnly: { default: false, validate: 'boolean' },
|
||||
} satisfies Record<keyof JoplinEditableAttributes, unknown>;
|
||||
|
||||
const makeJoplinEditableSpec = (
|
||||
inline: boolean,
|
||||
// Additional tags that should be interpreted as joplinEditable-like blocks.
|
||||
additionalParseRules: TagParseRule[],
|
||||
): NodeSpec => ({
|
||||
group: inline ? 'inline' : 'block',
|
||||
inline: inline,
|
||||
draggable: true,
|
||||
attrs: {
|
||||
contentHtml: { default: '', validate: 'string' },
|
||||
source: { default: '', validate: 'string' },
|
||||
language: { default: '', validate: 'string' },
|
||||
openCharacters: { default: '', validate: 'string' },
|
||||
closeCharacters: { default: '', validate: 'string' },
|
||||
},
|
||||
attrs: joplinEditableAttributes,
|
||||
parseDOM: [
|
||||
{
|
||||
tag: `${inline ? 'span' : 'div'}.joplin-editable`,
|
||||
getAttrs: node => {
|
||||
getAttrs: (node): Partial<JoplinEditableAttributes> => {
|
||||
const sourceNode = node.querySelector('.joplin-source');
|
||||
return {
|
||||
contentHtml: node.innerHTML,
|
||||
@@ -35,20 +51,38 @@ const makeJoplinEditableSpec = (inline: boolean): NodeSpec => ({
|
||||
openCharacters: sourceNode?.getAttribute('data-joplin-source-open'),
|
||||
closeCharacters: sourceNode?.getAttribute('data-joplin-source-close'),
|
||||
language: sourceNode?.getAttribute('data-joplin-language'),
|
||||
readOnly: !!node.hasAttribute('data-joplin-readonly'),
|
||||
};
|
||||
},
|
||||
},
|
||||
...additionalParseRules,
|
||||
],
|
||||
toDOM: node => {
|
||||
const attrs = node.attrs as JoplinEditableAttributes;
|
||||
const content = document.createElement(inline ? 'span' : 'div');
|
||||
content.classList.add('joplin-editable');
|
||||
content.innerHTML = sanitizeHtml(node.attrs.contentHtml);
|
||||
content.innerHTML = sanitizeHtml(attrs.contentHtml);
|
||||
|
||||
const sourceNode = content.querySelector('.joplin-source');
|
||||
const getSourceNode = () => {
|
||||
let sourceNode = content.querySelector('.joplin-source');
|
||||
// If the node has a "source" attribute, its content still needs to be saved
|
||||
if (!sourceNode && attrs.source) {
|
||||
sourceNode = document.createElement(inline ? 'span' : 'div');
|
||||
sourceNode.classList.add('joplin-source');
|
||||
content.appendChild(sourceNode);
|
||||
}
|
||||
return sourceNode;
|
||||
};
|
||||
|
||||
const sourceNode = getSourceNode();
|
||||
if (sourceNode) {
|
||||
sourceNode.textContent = node.attrs.source;
|
||||
sourceNode.setAttribute('data-joplin-source-open', node.attrs.openCharacters);
|
||||
sourceNode.setAttribute('data-joplin-source-close', node.attrs.closeCharacters);
|
||||
sourceNode.textContent = attrs.source;
|
||||
sourceNode.setAttribute('data-joplin-source-open', attrs.openCharacters);
|
||||
sourceNode.setAttribute('data-joplin-source-close', attrs.closeCharacters);
|
||||
}
|
||||
|
||||
if (attrs.readOnly) {
|
||||
content.setAttribute('data-joplin-readonly', 'true');
|
||||
}
|
||||
|
||||
return content;
|
||||
@@ -56,13 +90,34 @@ const makeJoplinEditableSpec = (inline: boolean): NodeSpec => ({
|
||||
});
|
||||
|
||||
export const nodeSpecs = {
|
||||
joplinEditableInline: makeJoplinEditableSpec(true),
|
||||
joplinEditableBlock: makeJoplinEditableSpec(false),
|
||||
joplinEditableInline: makeJoplinEditableSpec(true, []),
|
||||
joplinEditableBlock: makeJoplinEditableSpec(false, [
|
||||
// Table of contents regions are also handled as block editable regions
|
||||
{
|
||||
tag: 'nav.table-of-contents',
|
||||
getAttrs: (node): false|Partial<JoplinEditableAttributes> => {
|
||||
// Additional validation to check that this is indeed a [toc].
|
||||
if (node.children.length !== 1 || node.children[0]?.tagName !== 'UL') {
|
||||
return false; // The rule doesn't match
|
||||
}
|
||||
|
||||
return {
|
||||
contentHtml: node.innerHTML,
|
||||
source: '[toc]',
|
||||
// Disable the [toc]'s default rerendering behavior -- table of contents rendering
|
||||
// requires the document's full content and won't work if "[toc]" is rendered on its
|
||||
// own.
|
||||
readOnly: true,
|
||||
};
|
||||
},
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
type GetPosition = ()=> number;
|
||||
|
||||
class EditableSourceBlockView implements NodeView {
|
||||
private editDialogVisible_ = false;
|
||||
public readonly dom: HTMLElement;
|
||||
public constructor(private node: Node, inline: boolean, private view: EditorView, private getPosition: GetPosition) {
|
||||
if ((node.attrs.contentHtml ?? undefined) === undefined) {
|
||||
@@ -71,16 +126,26 @@ class EditableSourceBlockView implements NodeView {
|
||||
|
||||
this.dom = document.createElement(inline ? 'span' : 'div');
|
||||
this.dom.classList.add('joplin-editable');
|
||||
|
||||
// The link tooltip used for other in-editor links won't be shown for links within a
|
||||
// rendered source block -- these links need custom logic to be clickable:
|
||||
makeLinksClickableInElement(this.dom, view);
|
||||
|
||||
this.updateContent_();
|
||||
}
|
||||
|
||||
private showEditDialog_() {
|
||||
if (this.editDialogVisible_) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { localize: _ } = getEditorApi(this.view.state);
|
||||
|
||||
let saveCounter = 0;
|
||||
createEditorDialog({
|
||||
doneLabel: _('Done'),
|
||||
editorLabel: _('Code:'),
|
||||
editorApi: getEditorApi(this.view.state),
|
||||
block: {
|
||||
content: this.node.attrs.source,
|
||||
start: this.node.attrs.openCharacters,
|
||||
@@ -118,6 +183,9 @@ class EditableSourceBlockView implements NodeView {
|
||||
),
|
||||
);
|
||||
},
|
||||
onDismiss: () => {
|
||||
this.editDialogVisible_ = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -126,21 +194,19 @@ class EditableSourceBlockView implements NodeView {
|
||||
this.dom.innerHTML = sanitizeHtml(html);
|
||||
};
|
||||
|
||||
const attrs = this.node.attrs as JoplinEditableAttributes;
|
||||
const addEditButton = () => {
|
||||
const editButton = document.createElement('button');
|
||||
editButton.classList.add('edit');
|
||||
|
||||
const { localize: _ } = getEditorApi(this.view.state);
|
||||
|
||||
editButton.appendChild(createTextNode(_('Edit')));
|
||||
editButton.onclick = (event) => {
|
||||
this.showEditDialog_();
|
||||
event.preventDefault();
|
||||
};
|
||||
this.dom.appendChild(editButton);
|
||||
const editButton = createButton(_('Edit'), () => this.showEditDialog_());
|
||||
editButton.classList.add('edit');
|
||||
|
||||
if (!attrs.readOnly) {
|
||||
this.dom.appendChild(editButton);
|
||||
}
|
||||
};
|
||||
|
||||
setDomContentSafe(this.node.attrs.contentHtml);
|
||||
setDomContentSafe(attrs.contentHtml);
|
||||
postProcessRenderedHtml(this.dom, this.node.isInline);
|
||||
addEditButton();
|
||||
}
|
||||
|
@@ -1,10 +1,13 @@
|
||||
import { EditorState, Plugin, Transaction } from 'prosemirror-state';
|
||||
import { OnEventCallback, OnLocalize } from '../../types';
|
||||
import { RendererControl } from '../types';
|
||||
import { OnCreateCodeEditor, RendererControl } from '../types';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import createTextArea from '../utils/dom/createTextArea';
|
||||
|
||||
export interface EditorApi {
|
||||
renderer: RendererControl;
|
||||
onEvent: OnEventCallback;
|
||||
createCodeEditor: OnCreateCodeEditor;
|
||||
localize: OnLocalize;
|
||||
}
|
||||
|
||||
@@ -30,8 +33,23 @@ const joplinEditorApiPlugin = new Plugin<EditorApi>({
|
||||
throw new Error('Not initialized');
|
||||
},
|
||||
},
|
||||
settings: null,
|
||||
localize: input => input,
|
||||
|
||||
// A default implementation for testing environments
|
||||
createCodeEditor: (parent, _language, onChange) => {
|
||||
const editor = createTextArea({ label: 'Editor', initialContent: '', onChange });
|
||||
parent.appendChild(editor.textArea);
|
||||
|
||||
return {
|
||||
focus: () => focus('joplinEditorApiPlugin', editor.textArea),
|
||||
remove: () => {
|
||||
editor.textArea.remove();
|
||||
},
|
||||
updateBody: (newValue) => {
|
||||
editor.textArea.value = newValue;
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
apply: (tr, value) => {
|
||||
const proposedValue = tr.getMeta(joplinEditorApiPlugin);
|
||||
|
@@ -62,7 +62,7 @@ class LinkTooltip {
|
||||
this.tooltipContent_.onclick = () => {
|
||||
const href = linkMark.attrs.href;
|
||||
if (href.startsWith('#')) {
|
||||
const command = jumpToHash(href.substring(1), schema.nodes.heading);
|
||||
const command = jumpToHash(href.substring(1));
|
||||
command(view.state, view.dispatch, view);
|
||||
} else {
|
||||
this.onEditorEvent_({
|
||||
|
@@ -3,6 +3,7 @@ import { nodeSpecs as joplinEditableNodes } from './plugins/joplinEditablePlugin
|
||||
import { tableNodes } from 'prosemirror-tables';
|
||||
import { nodeSpecs as listNodes } from './plugins/listPlugin';
|
||||
import { nodeSpecs as resourcePlaceholderNodes } from './plugins/resourcePlaceholderPlugin';
|
||||
import { nodeSpecs as detailsNodes } from './plugins/detailsPlugin';
|
||||
|
||||
// For reference, see:
|
||||
// - https://prosemirror.net/docs/guide/#schema
|
||||
@@ -20,6 +21,9 @@ const domOutputSpecs = {
|
||||
listItem: ['li', 0],
|
||||
blockQuote: ['blockquote', 0],
|
||||
hr: ['hr'],
|
||||
sub: ['sub', 0],
|
||||
sup: ['sup', 0],
|
||||
mark: ['mark', 0],
|
||||
} satisfies Record<string, DOMOutputSpec>;
|
||||
|
||||
type AttributeSpecs = Record<string, AttributeSpec>;
|
||||
@@ -129,6 +133,7 @@ const nodes = addDefaultToplevelAttributes({
|
||||
return result;
|
||||
},
|
||||
},
|
||||
...detailsNodes,
|
||||
...resourcePlaceholderNodes,
|
||||
...listNodes,
|
||||
...joplinEditableNodes,
|
||||
@@ -214,6 +219,18 @@ const marks = {
|
||||
toDOM: () => domOutputSpecs.code,
|
||||
excludes: '_',
|
||||
},
|
||||
sub: {
|
||||
parseDOM: [{ tag: 'sub' }],
|
||||
toDOM: () => domOutputSpecs.sub,
|
||||
},
|
||||
sup: {
|
||||
parseDOM: [{ tag: 'sup' }],
|
||||
toDOM: () => domOutputSpecs.sup,
|
||||
},
|
||||
mark: {
|
||||
parseDOM: [{ tag: 'mark' }],
|
||||
toDOM: () => domOutputSpecs.mark,
|
||||
},
|
||||
color: {
|
||||
inclusive: false,
|
||||
parseDOM: [{
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import { RenderResult } from '../../renderer/types';
|
||||
import { EditorLanguageType } from '../types';
|
||||
|
||||
interface MarkupToHtmlOptions {
|
||||
isFullPageRender: boolean;
|
||||
@@ -12,3 +13,15 @@ export interface RendererControl {
|
||||
renderMarkupToHtml: MarkupToHtml;
|
||||
renderHtmlToMarkup: HtmlToMarkup;
|
||||
}
|
||||
|
||||
export interface CodeEditorControl {
|
||||
focus: ()=> void;
|
||||
remove: ()=> void;
|
||||
updateBody: (newValue: string)=> void;
|
||||
}
|
||||
export type OnCodeEditorChange = (newValue: string)=> void;
|
||||
|
||||
// Creates a text editor for editing code blocks
|
||||
export type OnCreateCodeEditor = (
|
||||
parent: HTMLElement, language: EditorLanguageType, onChange: OnCodeEditorChange,
|
||||
)=> CodeEditorControl;
|
||||
|
41
packages/editor/ProseMirror/utils/dom/createButton.ts
Normal file
41
packages/editor/ProseMirror/utils/dom/createButton.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { LocalizationResult } from '../../../types';
|
||||
import createTextNode from './createTextNode';
|
||||
|
||||
type OnClick = ()=> void;
|
||||
|
||||
const createButton = (label: LocalizationResult, onClick: OnClick) => {
|
||||
const button = document.createElement('button');
|
||||
button.appendChild(createTextNode(label));
|
||||
|
||||
// Works around an issue on iOS in which certain <button> elements within the selected
|
||||
// region of a contenteditable container do not emit a "click" event when tapped with a touchscreen.
|
||||
const applyIOSClickWorkaround = () => {
|
||||
// touchend events can be received even when a touch is no longer contained within
|
||||
// the initial element.
|
||||
const buttonContainsTouch = (touch: Touch) => {
|
||||
return document.elementFromPoint(touch.clientX, touch.clientY) === button;
|
||||
};
|
||||
|
||||
let containedTouchStart = false;
|
||||
button.addEventListener('touchcancel', () => {
|
||||
containedTouchStart = false;
|
||||
});
|
||||
button.addEventListener('touchstart', () => {
|
||||
containedTouchStart = true;
|
||||
});
|
||||
button.addEventListener('touchend', (event) => {
|
||||
if (containedTouchStart && event.touches.length === 0 && buttonContainsTouch(event.changedTouches[0])) {
|
||||
onClick();
|
||||
event.preventDefault();
|
||||
}
|
||||
containedTouchStart = false;
|
||||
});
|
||||
};
|
||||
|
||||
applyIOSClickWorkaround();
|
||||
button.onclick = onClick;
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
export default createButton;
|
@@ -4,14 +4,12 @@ import createUniqueId from './createUniqueId';
|
||||
|
||||
interface Options {
|
||||
label: LocalizationResult;
|
||||
spellCheck: boolean;
|
||||
initialContent: string;
|
||||
onChange: (newContent: string)=> void;
|
||||
}
|
||||
|
||||
const createTextArea = ({ label, initialContent, spellCheck, onChange }: Options) => {
|
||||
const createTextArea = ({ label, initialContent, onChange }: Options) => {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.spellcheck = spellCheck;
|
||||
textArea.oninput = () => {
|
||||
onChange(textArea.value);
|
||||
};
|
||||
|
@@ -4,47 +4,133 @@ import extractSelectedLinesTo from './extractSelectedLinesTo';
|
||||
import schema from '../schema';
|
||||
|
||||
describe('extractSelectedLinesTo', () => {
|
||||
test('should extract a single line containing the cursor to a heading', () => {
|
||||
test.each([
|
||||
{
|
||||
label: 'should extract a single line containing the cursor to a heading',
|
||||
initial: {
|
||||
docHtml: '<p>Line 1<br>Line 2<br>Line 3</p>',
|
||||
// Put the cursor in the middle of the second line
|
||||
cursorPosition: '<Line 1|Line'.length,
|
||||
},
|
||||
convertTo: { type: schema.nodes.heading, attrs: { level: 1 } },
|
||||
expected: {
|
||||
// The section of the document containing the cursor should now be a new line
|
||||
docJson: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'Line 1' }],
|
||||
},
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { level: 1 },
|
||||
content: [{ type: 'text', text: 'Line 2' }],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'Line 3' }],
|
||||
},
|
||||
],
|
||||
// The cursor should move to the end of the extracted line
|
||||
cursorPosition: '<Line 1><Line 2'.length,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'should convert an empty paragraph to a heading',
|
||||
initial: {
|
||||
docHtml: '<p>Line 1</p><p></p><p>Line 3</p>',
|
||||
cursorPosition: '<Line 1><'.length,
|
||||
},
|
||||
convertTo: { type: schema.nodes.heading },
|
||||
expected: {
|
||||
docJson: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'Line 1' }],
|
||||
},
|
||||
{
|
||||
type: 'heading',
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'Line 3' }],
|
||||
},
|
||||
],
|
||||
cursorPosition: '<Line 1><'.length,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'should convert the last line in a paragraph to a heading',
|
||||
initial: {
|
||||
docHtml: '<p>Line 1<br/></p><p>End</p>',
|
||||
cursorPosition: '<Line 1|'.length,
|
||||
},
|
||||
convertTo: { type: schema.nodes.heading },
|
||||
expected: {
|
||||
docJson: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'Line 1' }],
|
||||
},
|
||||
{
|
||||
type: 'heading',
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'End' }],
|
||||
},
|
||||
],
|
||||
cursorPosition: '<Line 1><'.length,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'should convert the first line in a paragraph to a heading',
|
||||
initial: {
|
||||
docHtml: '<p><br/>Line 1</p><p>End</p>',
|
||||
cursorPosition: '<'.length,
|
||||
},
|
||||
convertTo: { type: schema.nodes.heading },
|
||||
expected: {
|
||||
docJson: [
|
||||
{
|
||||
type: 'heading',
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'Line 1' }],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'End' }],
|
||||
},
|
||||
],
|
||||
cursorPosition: '<'.length,
|
||||
},
|
||||
},
|
||||
])('$label', ({ initial, expected, convertTo }) => {
|
||||
const view = createTestEditor({
|
||||
html: '<p>Line 1<br>Line 2<br>Line 3</p>',
|
||||
html: initial.docHtml,
|
||||
});
|
||||
view.dispatch(view.state.tr.setSelection(
|
||||
// Put the cursor in the middle of the second line
|
||||
TextSelection.create(view.state.doc, '<Line 1|Line'.length),
|
||||
TextSelection.create(
|
||||
view.state.doc,
|
||||
initial.cursorPosition,
|
||||
),
|
||||
));
|
||||
|
||||
const { transaction } = extractSelectedLinesTo(
|
||||
{ type: schema.nodes.heading, attrs: { level: 1 } },
|
||||
{ type: convertTo.type, attrs: convertTo.attrs ?? { } },
|
||||
view.state.tr,
|
||||
view.state.selection,
|
||||
);
|
||||
|
||||
view.dispatch(transaction);
|
||||
|
||||
// The section of the document containing the cursor should now be a new line
|
||||
expect(view.state.doc.toJSON()).toMatchObject({
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'Line 1' }],
|
||||
},
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { level: 1 },
|
||||
content: [{ type: 'text', text: 'Line 2' }],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'Line 3' }],
|
||||
},
|
||||
],
|
||||
content: expected.docJson,
|
||||
});
|
||||
|
||||
// The selection should still be in the heading
|
||||
expect(view.state.selection.$anchor.parent.toJSON()).toMatchObject({
|
||||
type: 'heading',
|
||||
attrs: { level: 1 },
|
||||
});
|
||||
// All of the tests in this group expect a single cursor
|
||||
expect(view.state.selection.empty).toBe(true);
|
||||
expect(view.state.selection.from).toBe(expected.cursorPosition);
|
||||
});
|
||||
|
||||
test('should extract multiple lines in the same paragraph to a new paragraph', () => {
|
||||
|
@@ -33,7 +33,8 @@ const extractSelectedLinesTo = (extractTo: ExtractToOptions, transaction: Transa
|
||||
|
||||
const firstParagraphFrom = firstParagraphPos;
|
||||
const lastParagraph = transaction.doc.nodeAt(lastParagraphPos);
|
||||
const lastParagraphTo = lastParagraphPos + lastParagraph.nodeSize;
|
||||
// -1: Exclude the end token
|
||||
const lastParagraphTo = lastParagraphPos + lastParagraph.nodeSize - 1;
|
||||
|
||||
// Find the previous and next <br/>s (or the start/end of the paragraph)
|
||||
let fromBreakPosition = firstParagraphFrom;
|
||||
|
27
packages/editor/ProseMirror/utils/forEachHeading.ts
Normal file
27
packages/editor/ProseMirror/utils/forEachHeading.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import uslug from '@joplin/fork-uslug/lib/uslug';
|
||||
import { Node } from 'prosemirror-model';
|
||||
|
||||
type OnHeading = (node: Node, hash: string, pos: number)=> boolean|void;
|
||||
|
||||
const forEachHeading = (doc: Node, callback: OnHeading) => {
|
||||
let done = false;
|
||||
const seenHashes = new Set<string>();
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'heading') {
|
||||
const originalHash = uslug(node.textContent);
|
||||
|
||||
let hash = originalHash;
|
||||
let counter = 1;
|
||||
while (seenHashes.has(hash)) {
|
||||
counter++;
|
||||
hash = `${originalHash}-${counter}`;
|
||||
}
|
||||
seenHashes.add(hash);
|
||||
|
||||
done = !!callback(node, hash, pos);
|
||||
}
|
||||
return !done;
|
||||
});
|
||||
};
|
||||
|
||||
export default forEachHeading;
|
@@ -1,18 +1,18 @@
|
||||
import { Command, TextSelection } from 'prosemirror-state';
|
||||
import uslug from '@joplin/fork-uslug/lib/uslug';
|
||||
import { NodeType } from 'prosemirror-model';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import forEachHeading from './forEachHeading';
|
||||
|
||||
const jumpToHash = (targetHash: string): Command => (state, dispatch, view) => {
|
||||
if (targetHash.startsWith('#')) {
|
||||
targetHash = targetHash.substring(1);
|
||||
}
|
||||
|
||||
const jumpToHash = (targetHash: string, headingType: NodeType): Command => (state, dispatch, view) => {
|
||||
let targetHeaderPos: number|null = null;
|
||||
state.doc.descendants((node, pos) => {
|
||||
if (node.type === headingType) {
|
||||
const hash = uslug(node.textContent);
|
||||
if (hash === targetHash) {
|
||||
// Subtract one to move the selection to the end of
|
||||
// the node:
|
||||
targetHeaderPos = pos + node.nodeSize - 1;
|
||||
}
|
||||
forEachHeading(view.state.doc, (node, hash, pos) => {
|
||||
if (hash === targetHash) {
|
||||
// Subtract one to move the selection to the end of
|
||||
// the node:
|
||||
targetHeaderPos = pos + node.nodeSize - 1;
|
||||
}
|
||||
|
||||
return targetHeaderPos !== null;
|
||||
|
@@ -0,0 +1,33 @@
|
||||
import { EditorView } from 'prosemirror-view';
|
||||
import jumpToHash from './jumpToHash';
|
||||
import { getEditorApi } from '../plugins/joplinEditorApiPlugin';
|
||||
import { EditorEventType } from '../../events';
|
||||
|
||||
const makeLinksClickableInElement = (element: HTMLElement, view: EditorView) => {
|
||||
const followLink = (target: HTMLAnchorElement) => {
|
||||
const href = target.getAttribute('href');
|
||||
if (href) {
|
||||
if (href.startsWith('#')) {
|
||||
return jumpToHash(href)(view.state, view.dispatch, view);
|
||||
} else {
|
||||
getEditorApi(view.state).onEvent({
|
||||
kind: EditorEventType.FollowLink,
|
||||
link: href,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
element.addEventListener('click', event => {
|
||||
if (event.target instanceof Element && !event.defaultPrevented) {
|
||||
const closestLink = event.target.closest<HTMLAnchorElement>('a[href]');
|
||||
if (closestLink && followLink(closestLink)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default makeLinksClickableInElement;
|
@@ -10,6 +10,7 @@ export enum EditorEventType {
|
||||
EditLink,
|
||||
FollowLink,
|
||||
Scroll,
|
||||
Remove,
|
||||
}
|
||||
|
||||
export interface ChangeEvent {
|
||||
@@ -62,9 +63,13 @@ export interface FollowLinkEvent {
|
||||
link: string;
|
||||
}
|
||||
|
||||
export interface RemoveEvent {
|
||||
kind: EditorEventType.Remove;
|
||||
}
|
||||
|
||||
export type EditorEvent =
|
||||
ChangeEvent|UndoRedoDepthChangeEvent|SelectionRangeChangeEvent|
|
||||
EditorScrolledEvent|
|
||||
SelectionFormattingChangeEvent|UpdateSearchDialogEvent|
|
||||
RequestEditLinkEvent|FollowLinkEvent;
|
||||
RequestEditLinkEvent|FollowLinkEvent|RemoveEvent;
|
||||
|
||||
|
@@ -18,7 +18,7 @@
|
||||
"@joplin/utils": "~3.4",
|
||||
"@testing-library/react-hooks": "8.0.1",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/react": "18.3.21",
|
||||
"@types/react": "18.3.22",
|
||||
"@types/react-redux": "7.1.33",
|
||||
"@types/styled-components": "5.1.32",
|
||||
"jest": "29.7.0",
|
||||
|
@@ -128,6 +128,9 @@ export interface EditorControl {
|
||||
|
||||
// Called when a resource associated with the current note finishes downloading.
|
||||
onResourceDownloaded(id: string): void;
|
||||
|
||||
remove(): void;
|
||||
focus(): void;
|
||||
}
|
||||
|
||||
export enum EditorLanguageType {
|
||||
|
@@ -46,7 +46,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/node": "18.19.100",
|
||||
"@types/node": "18.19.103",
|
||||
"@typescript-eslint/eslint-plugin": "6.21.0",
|
||||
"@typescript-eslint/parser": "6.21.0",
|
||||
"coveralls": "3.1.1",
|
||||
|
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/node": "18.19.100",
|
||||
"@types/node": "18.19.103",
|
||||
"jest": "29.7.0",
|
||||
"typescript": "5.8.2"
|
||||
},
|
||||
|
@@ -25,7 +25,7 @@
|
||||
"author": "Laurent Cozic",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@adobe/css-tools": "4.4.2",
|
||||
"@adobe/css-tools": "4.4.3",
|
||||
"@joplin/fork-htmlparser2": "^4.1.58",
|
||||
"datauri": "4.1.0",
|
||||
"fs-extra": "11.2.0",
|
||||
|
@@ -214,7 +214,7 @@ class BaseModel {
|
||||
return fields.indexOf(name) >= 0;
|
||||
}
|
||||
|
||||
public static fieldNames(withPrefix = false) {
|
||||
public static fieldNames(withPrefix: string|boolean = false) {
|
||||
const output = this.db().tableFieldNames(this.tableName());
|
||||
if (!withPrefix) return output;
|
||||
|
||||
|
@@ -445,6 +445,16 @@ export default class Synchronizer {
|
||||
logger.error('Error indexing resources:', error);
|
||||
}
|
||||
|
||||
// Before syncing, we run the share service maintenance, which is going
|
||||
// to fetch share invitations and clear share_ids for unshared items, if any.
|
||||
if (this.shareService_) {
|
||||
try {
|
||||
await this.shareService_.maintenance();
|
||||
} catch (error) {
|
||||
logger.error('Could not run share service maintenance:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Before synchronising make sure all share_id properties are set
|
||||
// correctly so as to share/unshare the right items.
|
||||
try {
|
||||
@@ -596,7 +606,7 @@ export default class Synchronizer {
|
||||
if (this.cancelling()) break;
|
||||
|
||||
let local = locals[i];
|
||||
const ItemClass: typeof BaseItem = BaseItem.itemClass(local);
|
||||
const ItemClass = BaseItem.itemClass(local);
|
||||
const path = BaseItem.systemPath(local);
|
||||
|
||||
// Safety check to avoid infinite loops.
|
||||
@@ -1158,16 +1168,6 @@ export default class Synchronizer {
|
||||
this.cancelling_ = false;
|
||||
}
|
||||
|
||||
// After syncing, we run the share service maintenance, which is going
|
||||
// to fetch share invitations, if any.
|
||||
if (this.shareService_) {
|
||||
try {
|
||||
await this.shareService_.maintenance();
|
||||
} catch (error) {
|
||||
logger.error('Could not run share service maintenance:', error);
|
||||
}
|
||||
}
|
||||
|
||||
this.progressReport_.completedTime = time.unixMs();
|
||||
|
||||
this.logSyncOperation('finished', null, null, `Synchronisation finished [${synchronizationId}]`);
|
||||
|
@@ -125,7 +125,7 @@ export default class BaseItem extends BaseModel {
|
||||
}
|
||||
|
||||
// Need to dynamically load the classes like this to avoid circular dependencies
|
||||
public static getClass(name: string) {
|
||||
public static getClass<T extends typeof BaseItem>(name: string): T {
|
||||
for (let i = 0; i < BaseItem.syncItemDefinitions_.length; i++) {
|
||||
if (BaseItem.syncItemDefinitions_[i].className === name) {
|
||||
const classRef = BaseItem.syncItemDefinitions_[i].classRef;
|
||||
@@ -710,8 +710,7 @@ export default class BaseItem extends BaseModel {
|
||||
// // CHANGED:
|
||||
// 'SELECT * FROM [ITEMS] items JOIN sync_items s ON s.item_id = items.id WHERE sync_target = ? AND'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
let extraWhere: any = [];
|
||||
let extraWhere: string[]|string = [];
|
||||
if (className === 'Note') extraWhere.push('is_conflict = 0');
|
||||
if (className === 'Resource') extraWhere.push('encryption_blob_encrypted = 0');
|
||||
if (ItemClass.encryptionSupported()) extraWhere.push('encryption_applied = 0');
|
||||
@@ -774,8 +773,7 @@ export default class BaseItem extends BaseModel {
|
||||
changedItems = await ItemClass.modelSelectAll(sql);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const neverSyncedItemIds = neverSyncedItem.map((it: any) => it.id);
|
||||
const neverSyncedItemIds = neverSyncedItem.map((it: BaseItemEntity) => it.id);
|
||||
const items = neverSyncedItem.concat(changedItems);
|
||||
|
||||
if (i >= classNames.length - 1) {
|
||||
|
@@ -102,6 +102,40 @@ describe('models/Folder.sharing', () => {
|
||||
expect(folder5.share_id).toBe('');
|
||||
}));
|
||||
|
||||
it('should clear the share ID of a folder immediately when moved out of a shared folder', async () => {
|
||||
let folder1 = await createFolderTree('', [
|
||||
{
|
||||
title: 'folder 1',
|
||||
children: [
|
||||
{
|
||||
title: 'folder 2',
|
||||
children: [
|
||||
{
|
||||
title: 'folder 3',
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await Folder.save({ id: folder1.id, share_id: 'test123456' });
|
||||
await Folder.updateAllShareIds(resourceService(), []);
|
||||
|
||||
folder1 = await Folder.loadByTitle('folder 1');
|
||||
const folder2 = await Folder.loadByTitle('folder 2');
|
||||
|
||||
expect(folder1.share_id).toBe('test123456');
|
||||
expect(folder2.share_id).toBe('test123456');
|
||||
|
||||
await Folder.moveToFolder(folder2.id, '');
|
||||
// Should have updated the share_id of folder 2 during "moveToFolder":
|
||||
expect(await Folder.loadByTitle('folder 2')).toMatchObject({
|
||||
share_id: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should update the share ID when a folder is moved in or out of shared folder', (async () => {
|
||||
let folder1 = await createFolderTree('', [
|
||||
{
|
||||
|
@@ -938,15 +938,28 @@ export default class Folder extends BaseItem {
|
||||
public static async moveToFolder(folderId: string, targetFolderId: string) {
|
||||
if (!(await this.canNestUnder(folderId, targetFolderId))) throw new Error(_('Cannot move notebook to this location'));
|
||||
|
||||
// When moving a note to a different folder, the user timestamp is not updated.
|
||||
// However updated_time is updated so that the note can be synced later on.
|
||||
|
||||
const modifiedFolder = {
|
||||
const original = await this.load(folderId);
|
||||
const modifiedFolder: FolderEntity = {
|
||||
id: folderId,
|
||||
parent_id: targetFolderId,
|
||||
|
||||
// When moving a note to a different folder, the user timestamp is not updated.
|
||||
// However updated_time is updated so that the note can be synced later on.
|
||||
updated_time: time.unixMs(),
|
||||
share_id: original.share_id,
|
||||
};
|
||||
|
||||
const wasShared = !!modifiedFolder.share_id;
|
||||
const movedToTopLevel = original.parent_id !== '' && targetFolderId === '';
|
||||
if (wasShared && movedToTopLevel) {
|
||||
// When a shared subfolder is converted to a toplevel folder, clear its share_id
|
||||
// as soon as possible. Without this, modifiedFolder would be incorrectly treated
|
||||
// as a root shared folder by some logic.
|
||||
// Since the folder's children aren't toplevel, they won't be considered root
|
||||
// shared folders and are updated later.
|
||||
modifiedFolder.share_id = '';
|
||||
}
|
||||
|
||||
return Folder.save(modifiedFolder, { autoTimestamp: false });
|
||||
}
|
||||
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import BaseModel, { DeleteOptions, ModelType } from '../BaseModel';
|
||||
import BaseItem from './BaseItem';
|
||||
import type FolderClass from './Folder';
|
||||
import type ResourceClass from './Resource';
|
||||
import ItemChange from './ItemChange';
|
||||
import Setting from './Setting';
|
||||
import shim from '../shim';
|
||||
@@ -9,7 +10,6 @@ import markdownUtils from '../markdownUtils';
|
||||
import { FolderEntity, NoteEntity } from '../services/database/types';
|
||||
import Tag from './Tag';
|
||||
const { sprintf } = require('sprintf-js');
|
||||
import Resource from './Resource';
|
||||
import syncDebugLog from '../services/synchronizer/syncDebugLog';
|
||||
import { toFileProtocolPath, toForwardSlashes } from '../path-utils';
|
||||
const { pregQuote, substrWithEllipsis } = require('../string-utils.js');
|
||||
@@ -181,7 +181,7 @@ export default class Note extends BaseItem {
|
||||
// this.logger().debug('replaceResourceInternalToExternalLinks', 'options:', options, 'body:', body);
|
||||
|
||||
const resourceIds = await this.linkedResourceIds(body);
|
||||
const Resource = this.getClass('Resource');
|
||||
const Resource = this.getClass<typeof ResourceClass>('Resource');
|
||||
|
||||
for (let i = 0; i < resourceIds.length; i++) {
|
||||
const id = resourceIds[i];
|
||||
@@ -216,6 +216,7 @@ export default class Note extends BaseItem {
|
||||
pathsToTry.push(`file://${shim.pathRelativeToCwd(resourceDir)}`);
|
||||
pathsToTry.push(`file:///${shim.pathRelativeToCwd(resourceDir)}`);
|
||||
} else {
|
||||
const Resource = this.getClass<typeof ResourceClass>('Resource');
|
||||
pathsToTry.push(Resource.baseRelativeDirectoryPath());
|
||||
}
|
||||
|
||||
@@ -242,6 +243,7 @@ export default class Note extends BaseItem {
|
||||
pathsToTry = temp;
|
||||
|
||||
// this.logger().debug('replaceResourceExternalToInternalLinks', 'options:', options, 'pathsToTry:', pathsToTry);
|
||||
const Resource = this.getClass<typeof ResourceClass>('Resource');
|
||||
|
||||
for (const basePath of pathsToTry) {
|
||||
const reStrings = [
|
||||
@@ -408,7 +410,7 @@ export default class Note extends BaseItem {
|
||||
}
|
||||
}
|
||||
|
||||
const Folder: typeof FolderClass = BaseItem.getClass('Folder');
|
||||
const Folder = BaseItem.getClass<typeof FolderClass>('Folder');
|
||||
|
||||
const parentFolder: FolderEntity = await Folder.load(parentId, { fields: ['id', 'deleted_time'] });
|
||||
const parentInTrash = parentFolder ? !!parentFolder.deleted_time : false;
|
||||
@@ -615,7 +617,8 @@ export default class Note extends BaseItem {
|
||||
}
|
||||
|
||||
public static async copyToFolder(noteId: string, folderId: string) {
|
||||
if (folderId === this.getClass('Folder').conflictFolderId()) throw new Error(_('Cannot copy note to "%s" notebook', this.getClass('Folder').conflictFolderTitle()));
|
||||
const Folder = this.getClass<typeof FolderClass>('Folder');
|
||||
if (folderId === Folder.conflictFolderId()) throw new Error(_('Cannot copy note to "%s" notebook', Folder.conflictFolderTitle()));
|
||||
|
||||
return Note.duplicate(noteId, {
|
||||
changes: {
|
||||
@@ -627,7 +630,8 @@ export default class Note extends BaseItem {
|
||||
}
|
||||
|
||||
public static async moveToFolder(noteId: string, folderId: string, saveOptions: SaveOptions|null = null) {
|
||||
if (folderId === this.getClass('Folder').conflictFolderId()) throw new Error(_('Cannot move note to "%s" notebook', this.getClass('Folder').conflictFolderTitle()));
|
||||
const Folder = this.getClass<typeof FolderClass>('Folder');
|
||||
if (folderId === Folder.conflictFolderId()) throw new Error(_('Cannot move note to "%s" notebook', Folder.conflictFolderTitle()));
|
||||
|
||||
// When moving a note to a different folder, the user timestamp is not
|
||||
// updated. However updated_time is updated so that the note can be
|
||||
@@ -702,6 +706,7 @@ export default class Note extends BaseItem {
|
||||
private static async duplicateNoteResources(noteBody: string): Promise<string> {
|
||||
const resourceIds = await this.linkedResourceIds(noteBody);
|
||||
let newBody: string = noteBody;
|
||||
const Resource = this.getClass<typeof ResourceClass>('Resource');
|
||||
|
||||
for (const resourceId of resourceIds) {
|
||||
const newResource = await Resource.duplicateResource(resourceId);
|
||||
|
@@ -25,23 +25,23 @@
|
||||
"@types/jsdom": "21.1.7",
|
||||
"@types/markdown-it": "13.0.9",
|
||||
"@types/mustache": "4.2.6",
|
||||
"@types/node": "18.19.100",
|
||||
"@types/node": "18.19.103",
|
||||
"@types/node-rsa": "1.1.4",
|
||||
"@types/react": "18.3.21",
|
||||
"@types/react": "18.3.22",
|
||||
"@types/uuid": "10.0.0",
|
||||
"clean-html": "1.5.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-expect-message": "1.1.3",
|
||||
"jsdom": "26.0.0",
|
||||
"jsdom": "26.1.0",
|
||||
"pdfjs-dist": "3.11.174",
|
||||
"react": "18.3.1",
|
||||
"react-test-renderer": "18.3.1",
|
||||
"sharp": "0.34.1",
|
||||
"sharp": "0.34.2",
|
||||
"tesseract.js": "5.1.1",
|
||||
"typescript": "5.8.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@adobe/css-tools": "4.4.2",
|
||||
"@adobe/css-tools": "4.4.3",
|
||||
"@aws-sdk/client-s3": "3.296.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.296.0",
|
||||
"@joplin/fork-htmlparser2": "^4.1.58",
|
||||
|
@@ -8,6 +8,7 @@ import shim from '../shim';
|
||||
import KvStore from './KvStore';
|
||||
import EncryptionService from './e2ee/EncryptionService';
|
||||
import PerformanceLogger from '../PerformanceLogger';
|
||||
import AsyncActionQueue from '../AsyncActionQueue';
|
||||
|
||||
const EventEmitter = require('events');
|
||||
const perfLogger = PerformanceLogger.create();
|
||||
@@ -44,7 +45,7 @@ export default class DecryptionWorker {
|
||||
private eventEmitter_: any;
|
||||
private kvStore_: KvStore = null;
|
||||
private maxDecryptionAttempts_ = 2;
|
||||
private startCalls_: boolean[] = [];
|
||||
private taskQueue_: AsyncActionQueue = new AsyncActionQueue();
|
||||
private encryptionService_: EncryptionService = null;
|
||||
|
||||
public constructor() {
|
||||
@@ -328,15 +329,25 @@ export default class DecryptionWorker {
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public async start(options: any = {}) {
|
||||
this.startCalls_.push(true);
|
||||
const startTask = perfLogger.taskStart('DecryptionWorker/start');
|
||||
public async start(options: any = {}): Promise<DecryptionResult> {
|
||||
let output = null;
|
||||
try {
|
||||
output = await this.start_(options);
|
||||
} finally {
|
||||
this.startCalls_.pop();
|
||||
startTask.onEnd();
|
||||
let lastError: Error;
|
||||
|
||||
// Use taskQueue_ to ensure that only one decryption task is running at a time.
|
||||
this.taskQueue_.push(async () => {
|
||||
const startTask = perfLogger.taskStart('DecryptionWorker/start');
|
||||
try {
|
||||
output = await this.start_(options);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
} finally {
|
||||
startTask.onEnd();
|
||||
}
|
||||
});
|
||||
await this.taskQueue_.processAllNow();
|
||||
|
||||
if (lastError) {
|
||||
throw lastError;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
@@ -350,13 +361,6 @@ export default class DecryptionWorker {
|
||||
this.eventEmitter_ = null;
|
||||
DecryptionWorker.instance_ = null;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const iid = shim.setInterval(() => {
|
||||
if (!this.startCalls_.length) {
|
||||
shim.clearInterval(iid);
|
||||
resolve(null);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
await this.taskQueue_.waitForAllDone();
|
||||
}
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import Setting from '../../models/Setting';
|
||||
import shim from '../../shim';
|
||||
import { switchClient, setupDatabaseAndSynchronizer } from '../../testing/test-utils';
|
||||
import { switchClient, setupDatabaseAndSynchronizer, withWarningSilenced } from '../../testing/test-utils';
|
||||
import KeychainService from './KeychainService';
|
||||
import KeychainServiceDriverDummy from './KeychainServiceDriver.dummy';
|
||||
import KeychainServiceDriverElectron from './KeychainServiceDriver.electron';
|
||||
@@ -134,6 +134,21 @@ describe('KeychainService', () => {
|
||||
expect(Setting.value('keychain.supported')).toBe(0);
|
||||
});
|
||||
|
||||
test('should handle the case where safeStorage.encryptString throws', async () => {
|
||||
mockSafeStorage({
|
||||
encryptString: () => {
|
||||
throw new Error('Failed!');
|
||||
},
|
||||
});
|
||||
Setting.setValue('keychain.supported', -1);
|
||||
|
||||
await KeychainService.instance().initialize(makeDrivers());
|
||||
await withWarningSilenced(/Encrypting a setting failed/, async () => {
|
||||
await KeychainService.instance().detectIfKeychainSupported();
|
||||
});
|
||||
expect(Setting.value('keychain.supported')).toBe(0);
|
||||
});
|
||||
|
||||
test('should load settings from a read-only KeychainService if not present in the database', async () => {
|
||||
mockSafeStorage({});
|
||||
|
||||
|
@@ -31,7 +31,13 @@ export default class KeychainServiceDriver extends KeychainServiceDriverBase {
|
||||
if (canUseSafeStorage()) {
|
||||
logger.debug('Saving password with electron safe storage. ID: ', name);
|
||||
|
||||
const encrypted = await shim.electronBridge().safeStorage.encryptString(password);
|
||||
let encrypted;
|
||||
try {
|
||||
encrypted = await shim.electronBridge().safeStorage.encryptString(password);
|
||||
} catch (error) {
|
||||
logger.warn('Encrypting a setting failed. Missing keychain permission?', error);
|
||||
return false;
|
||||
}
|
||||
await KvStore.instance().setValue(`${kvStorePrefix}${name}`, encrypted);
|
||||
} else {
|
||||
// Unsupported.
|
||||
|
@@ -27,7 +27,7 @@
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/pdfjs-dist": "2.10.378",
|
||||
"@types/react": "18.3.21",
|
||||
"@types/react": "18.3.22",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"@types/styled-components": "5.1.32",
|
||||
"babel-jest": "29.7.0",
|
||||
|
@@ -30,7 +30,7 @@
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/node": "18.19.100",
|
||||
"@types/node": "18.19.103",
|
||||
"jest": "29.7.0",
|
||||
"source-map-loader": "5.0.0",
|
||||
"typescript": "5.8.2",
|
||||
|
@@ -22,7 +22,7 @@
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/markdown-it": "13.0.9",
|
||||
"@types/node": "18.19.100",
|
||||
"@types/node": "18.19.103",
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"mermaid": "11.6.0",
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/server",
|
||||
"version": "3.4.1",
|
||||
"version": "3.4.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start-dev": "yarn build && JOPLIN_IS_TESTING=1 nodemon --config nodemon.json --ext ts,js,mustache,css,tsx dist/app.js --env dev",
|
||||
@@ -47,7 +47,7 @@
|
||||
"node-os-utils": "1.3.7",
|
||||
"nodemailer": "6.10.1",
|
||||
"nodemon": "3.1.10",
|
||||
"pg": "8.14.1",
|
||||
"pg": "8.15.6",
|
||||
"pm2": "5.4.3",
|
||||
"pretty-bytes": "5.6.0",
|
||||
"prettycron": "0.10.0",
|
||||
@@ -80,7 +80,7 @@
|
||||
"gulp": "4.0.2",
|
||||
"jest": "29.7.0",
|
||||
"jest-expect-message": "1.1.3",
|
||||
"jsdom": "26.0.0",
|
||||
"jsdom": "26.1.0",
|
||||
"node-mocks-http": "1.16.2",
|
||||
"source-map-support": "0.5.21",
|
||||
"typescript": "5.8.2"
|
||||
|
@@ -146,6 +146,9 @@ async function main() {
|
||||
}
|
||||
|
||||
function acceptOrigin(origin: string): boolean {
|
||||
// Origin can be string "null"
|
||||
if (origin === 'null') return false;
|
||||
|
||||
const hostname = (new URL(origin)).hostname;
|
||||
const userContentDomain = envVariables.USER_CONTENT_BASE_URL ? (new URL(envVariables.USER_CONTENT_BASE_URL)).hostname : '';
|
||||
|
||||
|
@@ -18,6 +18,23 @@ type JobWithResult = {
|
||||
state: string;
|
||||
};
|
||||
|
||||
type TranscribeJobResponse = {
|
||||
jobId: string;
|
||||
};
|
||||
|
||||
type TranscribeResponse = TranscribeJobResponse | JobWithResult | string;
|
||||
|
||||
type FetchResponse = {
|
||||
json?: ()=> Promise<TranscribeResponse>;
|
||||
text?: ()=> Promise<string>;
|
||||
status: number;
|
||||
};
|
||||
|
||||
const fetchSpyOn = (response: FetchResponse) => {
|
||||
jest.spyOn(global, 'fetch').mockImplementation(
|
||||
jest.fn(() => Promise.resolve(response)) as jest.Mock,
|
||||
);
|
||||
};
|
||||
|
||||
describe('api_transcribe', () => {
|
||||
|
||||
@@ -41,16 +58,12 @@ describe('api_transcribe', () => {
|
||||
|
||||
test('should create job', async () => {
|
||||
const { session } = await createUserAndSession(1);
|
||||
|
||||
jest.spyOn(global, 'fetch').mockImplementation(
|
||||
jest.fn(() => Promise.resolve(
|
||||
{
|
||||
json: () => Promise.resolve(
|
||||
{ jobId: '608626f1-cad9-4b07-a02e-ec427c47147f' },
|
||||
),
|
||||
status: 200,
|
||||
})) as jest.Mock,
|
||||
);
|
||||
fetchSpyOn({
|
||||
json: () => Promise.resolve(
|
||||
{ jobId: '608626f1-cad9-4b07-a02e-ec427c47147f' },
|
||||
),
|
||||
status: 200,
|
||||
});
|
||||
const fileContent = await readFile(`${testAssetDir}/htr_example.png`);
|
||||
const tempFilePath = await makeTempFileWithContent(fileContent);
|
||||
const response = await postApi<TranscribeJob>(session.id, 'transcribe', {},
|
||||
@@ -65,15 +78,12 @@ describe('api_transcribe', () => {
|
||||
test('should create job and return response eventually', async () => {
|
||||
const { session } = await createUserAndSession(1);
|
||||
|
||||
jest.spyOn(global, 'fetch').mockImplementation(
|
||||
jest.fn(() => Promise.resolve(
|
||||
{
|
||||
json: () => Promise.resolve(
|
||||
{ jobId: '608626f1-cad9-4b07-a02e-ec427c47147f' },
|
||||
),
|
||||
status: 200,
|
||||
})) as jest.Mock,
|
||||
);
|
||||
fetchSpyOn({
|
||||
json: () => Promise.resolve(
|
||||
{ jobId: '608626f1-cad9-4b07-a02e-ec427c47147f' },
|
||||
),
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const fileContent = await readFile(`${testAssetDir}/htr_example.png`);
|
||||
const tempFilePath = await makeTempFileWithContent(fileContent);
|
||||
@@ -85,19 +95,16 @@ describe('api_transcribe', () => {
|
||||
|
||||
expect(postResponse.jobId).not.toBe(undefined);
|
||||
|
||||
jest.spyOn(global, 'fetch').mockImplementation(
|
||||
jest.fn(() => Promise.resolve(
|
||||
fetchSpyOn({
|
||||
json: () => Promise.resolve(
|
||||
{
|
||||
json: (): Promise<JobWithResult> => Promise.resolve(
|
||||
{
|
||||
id: '608626f1-cad9-4b07-a02e-ec427c47147f',
|
||||
state: 'completed',
|
||||
result: { result: 'transcription' },
|
||||
},
|
||||
),
|
||||
status: 200,
|
||||
})) as jest.Mock,
|
||||
);
|
||||
id: '608626f1-cad9-4b07-a02e-ec427c47147f',
|
||||
state: 'completed',
|
||||
result: { result: 'transcription' },
|
||||
},
|
||||
),
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const getResponse = await getApi<JobWithResult>(session.id, `transcribe/${postResponse.jobId}`, {});
|
||||
expect(getResponse.id).toBe(postResponse.jobId);
|
||||
@@ -108,13 +115,10 @@ describe('api_transcribe', () => {
|
||||
test('should throw a error if API returns error 400', async () => {
|
||||
const { session } = await createUserAndSession(1);
|
||||
|
||||
jest.spyOn(global, 'fetch').mockImplementation(
|
||||
jest.fn(() => Promise.resolve(
|
||||
{
|
||||
json: () => Promise.resolve(''),
|
||||
status: 400,
|
||||
})) as jest.Mock,
|
||||
);
|
||||
fetchSpyOn({
|
||||
json: () => Promise.resolve(''),
|
||||
status: 400,
|
||||
});
|
||||
|
||||
const fileContent = await readFile(`${testAssetDir}/htr_example.png`);
|
||||
const tempFilePath = await makeTempFileWithContent(fileContent);
|
||||
@@ -131,13 +135,10 @@ describe('api_transcribe', () => {
|
||||
test('should throw error if API returns error 500', async () => {
|
||||
const { session } = await createUserAndSession(1);
|
||||
|
||||
jest.spyOn(global, 'fetch').mockImplementation(
|
||||
jest.fn(() => Promise.resolve(
|
||||
{
|
||||
json: () => Promise.resolve(''),
|
||||
status: 500,
|
||||
})) as jest.Mock,
|
||||
);
|
||||
fetchSpyOn({
|
||||
json: () => Promise.resolve(''),
|
||||
status: 500,
|
||||
});
|
||||
|
||||
const fileContent = await readFile(`${testAssetDir}/htr_example.png`);
|
||||
const tempFilePath = await makeTempFileWithContent(fileContent);
|
||||
@@ -153,13 +154,10 @@ describe('api_transcribe', () => {
|
||||
test('should throw 500 error is something unexpected', async () => {
|
||||
const { session } = await createUserAndSession(1);
|
||||
|
||||
jest.spyOn(global, 'fetch').mockImplementation(
|
||||
jest.fn(() => Promise.resolve(
|
||||
{
|
||||
json: () => Promise.reject(new Error('Something went wrong')),
|
||||
status: 200,
|
||||
})) as jest.Mock,
|
||||
);
|
||||
fetchSpyOn({
|
||||
json: () => Promise.reject(new Error('Something went wrong')),
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const fileContent = await readFile(`${testAssetDir}/htr_example.png`);
|
||||
const tempFilePath = await makeTempFileWithContent(fileContent);
|
||||
@@ -174,4 +172,28 @@ describe('api_transcribe', () => {
|
||||
expect(error.message.startsWith('POST /api/transcribe {"status":500,"body":{"error":"Something went wrong"')).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['non-json', 'Something went wrong'],
|
||||
['json', JSON.stringify({ error: 'Something went wrong' })],
|
||||
])('should be able to handle %s error responses', async (_type: string, result: string) => {
|
||||
const { session } = await createUserAndSession(1);
|
||||
|
||||
fetchSpyOn({
|
||||
text: () => Promise.resolve(result),
|
||||
status: 500,
|
||||
});
|
||||
|
||||
const fileContent = await readFile(`${testAssetDir}/htr_example.png`);
|
||||
const tempFilePath = await makeTempFileWithContent(fileContent);
|
||||
const error = await expectThrow(() =>
|
||||
postApi<TranscribeJob>(session.id, 'transcribe', {},
|
||||
{
|
||||
filePath: tempFilePath,
|
||||
},
|
||||
));
|
||||
|
||||
const body = JSON.parse(error.message.split('POST /api/transcribe ')[1]);
|
||||
expect(error.httpCode).toBe(502);
|
||||
expect(body.body.error).toBe('Something went wrong');
|
||||
});
|
||||
});
|
||||
|
@@ -17,6 +17,18 @@ const isHtrSupported = () => {
|
||||
return config().TRANSCRIBE_ENABLED;
|
||||
};
|
||||
|
||||
const parseResponseSafely = async (response: Response) => {
|
||||
const text = await response.text();
|
||||
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (parseError) {
|
||||
const truncatedText = text.substring(0, 1000);
|
||||
|
||||
return { error: truncatedText };
|
||||
}
|
||||
};
|
||||
|
||||
router.get('api/transcribe/:id', async (path: SubPath, _ctx: AppContext) => {
|
||||
if (!isHtrSupported()) {
|
||||
throw new ErrorNotImplemented('HTR feature is not enabled in this server');
|
||||
@@ -36,8 +48,8 @@ router.get('api/transcribe/:id', async (path: SubPath, _ctx: AppContext) => {
|
||||
const responseJson = await response.json();
|
||||
throw new ErrorBadRequest(responseJson.error);
|
||||
} else if (response.status >= 500) {
|
||||
const responseJson = await response.json();
|
||||
throw new ErrorBadGateway(responseJson.error);
|
||||
const responseParsed = await parseResponseSafely(response);
|
||||
throw new ErrorBadGateway(responseParsed.error);
|
||||
}
|
||||
|
||||
const responseJson = await response.json();
|
||||
@@ -77,8 +89,8 @@ router.post('api/transcribe', async (_path: SubPath, ctx: AppContext) => {
|
||||
const responseJson = await response.json();
|
||||
throw new ErrorBadRequest(responseJson.error);
|
||||
} else if (response.status >= 500) {
|
||||
const responseJson = await response.json();
|
||||
throw new ErrorBadGateway(responseJson.error);
|
||||
const responseParsed = await parseResponseSafely(response);
|
||||
throw new ErrorBadGateway(responseParsed.error);
|
||||
}
|
||||
|
||||
const responseJson = await response.json();
|
||||
|
@@ -196,4 +196,7 @@ orderedmap
|
||||
labelledby
|
||||
isTextblock
|
||||
deflt
|
||||
LOCALAPPDATA
|
||||
LOCALAPPDATA
|
||||
Daman
|
||||
vikasmanimc
|
||||
traspire
|
||||
|
@@ -1,11 +1,10 @@
|
||||
import { strict as assert } from 'assert';
|
||||
import { ActionableClient, FolderData, FolderMetadata, FuzzContext, ItemId, NoteData, TreeItem, isFolder } from './types';
|
||||
import { ActionableClient, FolderData, FuzzContext, ItemId, NoteData, ShareOptions, TreeItem, assertIsFolder, isFolder } from './types';
|
||||
import type Client from './Client';
|
||||
import FolderRecord from './model/FolderRecord';
|
||||
|
||||
interface ClientData {
|
||||
childIds: ItemId[];
|
||||
// Shared folders belonging to the client
|
||||
sharedFolderIds: ItemId[];
|
||||
}
|
||||
|
||||
class ActionTracker {
|
||||
@@ -13,6 +12,26 @@ class ActionTracker {
|
||||
private tree_: Map<string, ClientData> = new Map();
|
||||
public constructor(private readonly context_: FuzzContext) {}
|
||||
|
||||
private getToplevelParent_(item: ItemId|TreeItem) {
|
||||
let itemId = typeof item === 'string' ? item : item.id;
|
||||
const originalItemId = itemId;
|
||||
const seenIds = new Set<ItemId>();
|
||||
while (this.idToItem_.get(itemId)?.parentId) {
|
||||
seenIds.add(itemId);
|
||||
|
||||
itemId = this.idToItem_.get(itemId).parentId;
|
||||
if (seenIds.has(itemId)) {
|
||||
throw new Error('Assertion failure: Item hierarchy is not a tree.');
|
||||
}
|
||||
}
|
||||
|
||||
const toplevelItem = this.idToItem_.get(itemId);
|
||||
assert.ok(toplevelItem, `Parent not found for item, top:${itemId} (started at ${originalItemId})`);
|
||||
assert.equal(toplevelItem.parentId, '', 'Should be a toplevel item');
|
||||
|
||||
return toplevelItem;
|
||||
}
|
||||
|
||||
private checkRep_() {
|
||||
const checkItem = (itemId: ItemId) => {
|
||||
assert.match(itemId, /^[a-zA-Z0-9]{32}$/, 'item IDs should be 32 character alphanumeric strings');
|
||||
@@ -33,6 +52,20 @@ class ActionTracker {
|
||||
checkItem(childId);
|
||||
}
|
||||
|
||||
// Shared folders
|
||||
assert.ok(item.ownedByEmail, 'all folders should have a "shareOwner" property (even if not shared)');
|
||||
if (item.isRootSharedItem) {
|
||||
assert.equal(item.parentId, '', 'only toplevel folders should be shared');
|
||||
}
|
||||
for (const sharedWith of item.shareRecipients) {
|
||||
assert.ok(this.tree_.has(sharedWith), 'all sharee users should exist');
|
||||
}
|
||||
// isSharedWith is only valid for toplevel folders
|
||||
if (item.parentId === '') {
|
||||
assert.ok(!item.isSharedWith(item.ownedByEmail), 'the share owner should not be in an item\'s sharedWith list');
|
||||
}
|
||||
|
||||
// Uniqueness
|
||||
assert.equal(
|
||||
item.childIds.length,
|
||||
[...new Set(item.childIds)].length,
|
||||
@@ -56,10 +89,12 @@ class ActionTracker {
|
||||
|
||||
public track(client: { email: string }) {
|
||||
const clientId = client.email;
|
||||
this.tree_.set(clientId, {
|
||||
childIds: [],
|
||||
sharedFolderIds: [],
|
||||
});
|
||||
// If the client's remote account already exists, continue using it:
|
||||
if (!this.tree_.has(clientId)) {
|
||||
this.tree_.set(clientId, {
|
||||
childIds: [],
|
||||
});
|
||||
}
|
||||
|
||||
const getChildIds = (itemId: ItemId) => {
|
||||
const item = this.idToItem_.get(itemId);
|
||||
@@ -71,10 +106,7 @@ class ActionTracker {
|
||||
if (!parent) throw new Error(`Parent with ID ${parentId} not found.`);
|
||||
if (!isFolder(parent)) throw new Error(`Item ${parentId} is not a folder`);
|
||||
|
||||
this.idToItem_.set(parentId, {
|
||||
...parent,
|
||||
childIds: updateFn(parent.childIds),
|
||||
});
|
||||
this.idToItem_.set(parentId, parent.withChildren(updateFn(parent.childIds)));
|
||||
};
|
||||
const addRootItem = (itemId: ItemId) => {
|
||||
const clientData = this.tree_.get(clientId);
|
||||
@@ -113,7 +145,8 @@ class ActionTracker {
|
||||
return true;
|
||||
};
|
||||
|
||||
const isOwnedByThis = this.tree_.get(clientId).sharedFolderIds.includes(itemId);
|
||||
const item = this.idToItem_.get(itemId);
|
||||
const isOwnedByThis = isFolder(item) && item.ownedByEmail === clientId;
|
||||
|
||||
if (isOwnedByThis) { // Unshare
|
||||
let removed = false;
|
||||
@@ -122,12 +155,6 @@ class ActionTracker {
|
||||
removed ||= result;
|
||||
}
|
||||
|
||||
const clientData = this.tree_.get(clientId);
|
||||
this.tree_.set(clientId, {
|
||||
...clientData,
|
||||
sharedFolderIds: clientData.sharedFolderIds.filter(id => id !== itemId),
|
||||
});
|
||||
|
||||
// At this point, the item shouldn't be a child of any clients:
|
||||
assert.ok(hasBeenCompletelyRemoved(), 'item should be removed from all clients');
|
||||
assert.ok(removed, 'should be a toplevel item');
|
||||
@@ -214,14 +241,40 @@ class ActionTracker {
|
||||
return result;
|
||||
};
|
||||
|
||||
const listFoldersDetailed = () => {
|
||||
return mapItems((item): FolderData => {
|
||||
const getAllFolders = () => {
|
||||
return mapItems((item): FolderRecord => {
|
||||
return isFolder(item) ? item : null;
|
||||
}).filter(item => !!item);
|
||||
};
|
||||
|
||||
const isReadOnly = (item: ItemId|TreeItem) => {
|
||||
if (item === '') return false;
|
||||
|
||||
const toplevelItem = this.getToplevelParent_(item);
|
||||
assertIsFolder(toplevelItem);
|
||||
return toplevelItem.isReadOnlySharedWith(clientId);
|
||||
};
|
||||
|
||||
const isShared = (item: TreeItem) => {
|
||||
const toplevelItem = this.getToplevelParent_(item);
|
||||
assertIsFolder(toplevelItem);
|
||||
return toplevelItem.isRootSharedItem;
|
||||
};
|
||||
|
||||
const assertWriteable = (item: ItemId|TreeItem) => {
|
||||
if (typeof item !== 'string') {
|
||||
item = item.id;
|
||||
}
|
||||
|
||||
if (isReadOnly(item)) {
|
||||
throw new Error(`Item is read-only: ${item}`);
|
||||
}
|
||||
};
|
||||
|
||||
const tracker: ActionableClient = {
|
||||
createNote: (data: NoteData) => {
|
||||
assertWriteable(data.parentId);
|
||||
|
||||
assert.ok(!!data.parentId, `note ${data.id} should have a parentId`);
|
||||
assert.ok(!this.idToItem_.has(data.id), `note ${data.id} should not yet exist`);
|
||||
this.idToItem_.set(data.id, {
|
||||
@@ -233,6 +286,8 @@ class ActionTracker {
|
||||
return Promise.resolve();
|
||||
},
|
||||
updateNote: (data: NoteData) => {
|
||||
assertWriteable(data.parentId);
|
||||
|
||||
const oldItem = this.idToItem_.get(data.id);
|
||||
assert.ok(oldItem, `note ${data.id} should exist`);
|
||||
assert.ok(!!data.parentId, `note ${data.id} should have a parentId`);
|
||||
@@ -246,13 +301,17 @@ class ActionTracker {
|
||||
this.checkRep_();
|
||||
return Promise.resolve();
|
||||
},
|
||||
createFolder: (data: FolderMetadata) => {
|
||||
this.idToItem_.set(data.id, {
|
||||
createFolder: (data: FolderData) => {
|
||||
const parentId = data.parentId ?? '';
|
||||
assertWriteable(parentId);
|
||||
|
||||
this.idToItem_.set(data.id, new FolderRecord({
|
||||
...data,
|
||||
parentId: data.parentId ?? '',
|
||||
parentId: parentId ?? '',
|
||||
childIds: getChildIds(data.id),
|
||||
isShareRoot: false,
|
||||
});
|
||||
sharedWith: [],
|
||||
ownedByEmail: clientId,
|
||||
}));
|
||||
addChild(data.parentId, data.id);
|
||||
|
||||
this.checkRep_();
|
||||
@@ -263,62 +322,83 @@ class ActionTracker {
|
||||
|
||||
const item = this.idToItem_.get(id);
|
||||
if (!item) throw new Error(`Not found ${id}`);
|
||||
if (!isFolder(item)) throw new Error(`Not a folder ${id}`);
|
||||
assertIsFolder(item);
|
||||
assertWriteable(item);
|
||||
|
||||
removeItemRecursive(id);
|
||||
|
||||
this.checkRep_();
|
||||
return Promise.resolve();
|
||||
},
|
||||
shareFolder: (id: ItemId, shareWith: Client) => {
|
||||
const shareWithChildIds = this.tree_.get(shareWith.email).childIds;
|
||||
if (shareWithChildIds.includes(id)) {
|
||||
throw new Error(`Folder ${id} already shared with ${shareWith.email}`);
|
||||
}
|
||||
assert.ok(this.idToItem_.has(id), 'should exist');
|
||||
shareFolder: (id: ItemId, shareWith: Client, options: ShareOptions) => {
|
||||
const itemToShare = this.idToItem_.get(id);
|
||||
assertIsFolder(itemToShare);
|
||||
|
||||
const sharerClient = this.tree_.get(clientId);
|
||||
if (!sharerClient.sharedFolderIds.includes(id)) {
|
||||
this.tree_.set(clientId, {
|
||||
...sharerClient,
|
||||
sharedFolderIds: [...sharerClient.sharedFolderIds, id],
|
||||
});
|
||||
}
|
||||
const alreadyShared = itemToShare.isSharedWith(shareWith.email);
|
||||
assert.ok(!alreadyShared, `Folder ${id} should not yet be shared with ${shareWith.email}`);
|
||||
|
||||
const shareWithChildIds = this.tree_.get(shareWith.email).childIds;
|
||||
assert.ok(
|
||||
!shareWithChildIds.includes(id), `Share recipient (${shareWith.email}) should not have a folder with ID ${id} before receiving the share.`,
|
||||
);
|
||||
|
||||
this.tree_.set(shareWith.email, {
|
||||
...this.tree_.get(shareWith.email),
|
||||
childIds: [...shareWithChildIds, id],
|
||||
});
|
||||
|
||||
this.idToItem_.set(id, {
|
||||
...this.idToItem_.get(id),
|
||||
isShareRoot: true,
|
||||
this.idToItem_.set(
|
||||
id, itemToShare.withShared(shareWith.email, options.readOnly),
|
||||
);
|
||||
|
||||
this.checkRep_();
|
||||
return Promise.resolve();
|
||||
},
|
||||
removeFromShare: (id: ItemId, shareWith: Client) => {
|
||||
const targetItem = this.idToItem_.get(id);
|
||||
assertIsFolder(targetItem);
|
||||
|
||||
assert.ok(targetItem.isSharedWith(shareWith.email), `Folder ${id} should be shared with ${shareWith.label}`);
|
||||
|
||||
const otherSubTree = this.tree_.get(shareWith.email);
|
||||
this.tree_.set(shareWith.email, {
|
||||
...otherSubTree,
|
||||
childIds: otherSubTree.childIds.filter(childId => childId !== id),
|
||||
});
|
||||
|
||||
this.idToItem_.set(id, targetItem.withUnshared(shareWith.email));
|
||||
|
||||
this.checkRep_();
|
||||
return Promise.resolve();
|
||||
},
|
||||
moveItem: (itemId, newParentId) => {
|
||||
const item = this.idToItem_.get(itemId);
|
||||
assert.ok(item, `item with ${itemId} should exist`);
|
||||
|
||||
if (newParentId) {
|
||||
const parent = this.idToItem_.get(newParentId);
|
||||
assert.ok(parent, `parent with ID ${newParentId} should exist`);
|
||||
} else {
|
||||
assert.equal(newParentId, '', 'parentId should be empty if a toplevel folder');
|
||||
}
|
||||
const validateParameters = () => {
|
||||
assert.ok(item, `item with ${itemId} should exist`);
|
||||
|
||||
if (isFolder(item)) {
|
||||
assert.equal(item.isShareRoot, false, 'cannot move toplevel shared folders without first unsharing');
|
||||
}
|
||||
if (newParentId) {
|
||||
const parent = this.idToItem_.get(newParentId);
|
||||
assert.ok(parent, `parent with ID ${newParentId} should exist`);
|
||||
} else {
|
||||
assert.equal(newParentId, '', 'parentId should be empty if a toplevel folder');
|
||||
}
|
||||
|
||||
if (isFolder(item)) {
|
||||
assert.equal(item.isRootSharedItem, false, 'cannot move toplevel shared folders without first unsharing');
|
||||
}
|
||||
|
||||
assertWriteable(itemId);
|
||||
assertWriteable(newParentId);
|
||||
};
|
||||
validateParameters();
|
||||
|
||||
removeChild(item.parentId, itemId);
|
||||
addChild(newParentId, itemId);
|
||||
this.idToItem_.set(itemId, {
|
||||
...item,
|
||||
parentId: newParentId,
|
||||
});
|
||||
this.idToItem_.set(
|
||||
itemId,
|
||||
isFolder(item) ? item.withParent(newParentId) : { ...item, parentId: newParentId },
|
||||
);
|
||||
|
||||
this.checkRep_();
|
||||
return Promise.resolve();
|
||||
@@ -327,17 +407,21 @@ class ActionTracker {
|
||||
listNotes: () => {
|
||||
const notes = mapItems(item => {
|
||||
return isFolder(item) ? null : item;
|
||||
}).filter(item => !!item);
|
||||
}).filter(item => !!item).map(item => ({
|
||||
...item,
|
||||
isShared: isShared(item),
|
||||
}));
|
||||
|
||||
this.checkRep_();
|
||||
return Promise.resolve(notes);
|
||||
},
|
||||
listFolders: () => {
|
||||
this.checkRep_();
|
||||
const folderData = listFoldersDetailed().map(item => ({
|
||||
const folderData = getAllFolders().map(item => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
parentId: item.parentId,
|
||||
isShared: isShared(item),
|
||||
}));
|
||||
|
||||
return Promise.resolve(folderData);
|
||||
@@ -366,10 +450,13 @@ class ActionTracker {
|
||||
return Promise.resolve(descendants);
|
||||
},
|
||||
randomFolder: async (options) => {
|
||||
let folders = listFoldersDetailed();
|
||||
let folders = getAllFolders();
|
||||
if (options.filter) {
|
||||
folders = folders.filter(options.filter);
|
||||
}
|
||||
if (!options.includeReadOnly) {
|
||||
folders = folders.filter(folder => !isReadOnly(folder.id));
|
||||
}
|
||||
|
||||
const folderIndex = this.context_.randInt(0, folders.length);
|
||||
return folders.length ? folders[folderIndex] : null;
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import uuid, { createSecureRandom } from '@joplin/lib/uuid';
|
||||
import { ActionableClient, FolderMetadata, FuzzContext, HttpMethod, ItemId, Json, NoteData, RandomFolderOptions, UserData } from './types';
|
||||
import { ActionableClient, FolderData, FuzzContext, HttpMethod, ItemId, Json, NoteData, RandomFolderOptions, RandomNoteOptions, ShareOptions } from './types';
|
||||
import { join } from 'path';
|
||||
import { mkdir, remove } from 'fs-extra';
|
||||
import getStringProperty from './utils/getStringProperty';
|
||||
@@ -12,6 +12,7 @@ import { commandToString } from '@joplin/utils';
|
||||
import { quotePath } from '@joplin/utils/path';
|
||||
import getNumberProperty from './utils/getNumberProperty';
|
||||
import retryWithCount from './utils/retryWithCount';
|
||||
import resolvePathWithinDir from '@joplin/lib/utils/resolvePathWithinDir';
|
||||
import { msleep, Second } from '@joplin/utils/time';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { spawn } from 'child_process';
|
||||
@@ -21,6 +22,62 @@ import Stream = require('stream');
|
||||
|
||||
const logger = Logger.create('Client');
|
||||
|
||||
type AccountData = Readonly<{
|
||||
email: string;
|
||||
password: string;
|
||||
serverId: string;
|
||||
e2eePassword: string;
|
||||
associatedClientCount: number;
|
||||
onClientConnected: ()=> void;
|
||||
onClientDisconnected: ()=> Promise<void>;
|
||||
}>;
|
||||
|
||||
const createNewAccount = async (email: string, context: FuzzContext): Promise<AccountData> => {
|
||||
const password = createSecureRandom();
|
||||
const apiOutput = await context.execApi('POST', 'api/users', {
|
||||
email,
|
||||
});
|
||||
const serverId = getStringProperty(apiOutput, 'id');
|
||||
|
||||
// The password needs to be set *after* creating the user.
|
||||
const userRoute = `api/users/${encodeURIComponent(serverId)}`;
|
||||
await context.execApi('PATCH', userRoute, {
|
||||
email,
|
||||
password,
|
||||
email_confirmed: 1,
|
||||
});
|
||||
|
||||
const closeAccount = async () => {
|
||||
await context.execApi('DELETE', userRoute, {});
|
||||
};
|
||||
|
||||
let referenceCounter = 0;
|
||||
return {
|
||||
email,
|
||||
password,
|
||||
e2eePassword: createSecureRandom().replace(/^-/, '_'),
|
||||
serverId,
|
||||
get associatedClientCount() {
|
||||
return referenceCounter;
|
||||
},
|
||||
onClientConnected: () => {
|
||||
referenceCounter++;
|
||||
},
|
||||
onClientDisconnected: async () => {
|
||||
referenceCounter --;
|
||||
assert.ok(referenceCounter >= 0, 'reference counter should be non-negative');
|
||||
if (referenceCounter === 0) {
|
||||
await closeAccount();
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
type ApiData = Readonly<{
|
||||
port: number;
|
||||
token: string;
|
||||
}>;
|
||||
|
||||
type OnCloseListener = ()=> void;
|
||||
|
||||
type ChildProcessWrapper = {
|
||||
@@ -33,76 +90,57 @@ type ChildProcessWrapper = {
|
||||
// Should match the prompt used by the CLI "batch" command.
|
||||
const cliProcessPromptString = 'command> ';
|
||||
|
||||
|
||||
|
||||
class Client implements ActionableClient {
|
||||
public readonly email: string;
|
||||
|
||||
public static async create(actionTracker: ActionTracker, context: FuzzContext) {
|
||||
const account = await createNewAccount(`${uuid.create()}@localhost`, context);
|
||||
|
||||
try {
|
||||
return await this.fromAccount(account, actionTracker, context);
|
||||
} catch (error) {
|
||||
logger.error('Error creating client:', error);
|
||||
await account.onClientDisconnected();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static async fromAccount(account: AccountData, actionTracker: ActionTracker, context: FuzzContext) {
|
||||
const id = uuid.create();
|
||||
const profileDirectory = join(context.baseDir, id);
|
||||
await mkdir(profileDirectory);
|
||||
|
||||
const email = `${id}@localhost`;
|
||||
const password = createSecureRandom();
|
||||
const apiOutput = await context.execApi('POST', 'api/users', {
|
||||
email,
|
||||
});
|
||||
const serverId = getStringProperty(apiOutput, 'id');
|
||||
|
||||
// The password needs to be set *after* creating the user.
|
||||
const userRoute = `api/users/${encodeURIComponent(serverId)}`;
|
||||
await context.execApi('PATCH', userRoute, {
|
||||
email,
|
||||
password,
|
||||
email_confirmed: 1,
|
||||
});
|
||||
|
||||
const closeAccount = async () => {
|
||||
await context.execApi('DELETE', userRoute, {});
|
||||
const apiData: ApiData = {
|
||||
token: createSecureRandom().replace(/[-]/g, '_'),
|
||||
port: await ClipperServer.instance().findAvailablePort(),
|
||||
};
|
||||
|
||||
try {
|
||||
const userData = {
|
||||
email: getStringProperty(apiOutput, 'email'),
|
||||
password,
|
||||
};
|
||||
const client = new Client(
|
||||
context,
|
||||
actionTracker,
|
||||
actionTracker.track({ email: account.email }),
|
||||
account,
|
||||
profileDirectory,
|
||||
apiData,
|
||||
`${account.email}${account.associatedClientCount ? ` (${account.associatedClientCount})` : ''}`,
|
||||
);
|
||||
|
||||
assert.equal(email, userData.email);
|
||||
account.onClientConnected();
|
||||
|
||||
const apiToken = createSecureRandom().replace(/[-]/g, '_');
|
||||
const apiPort = await ClipperServer.instance().findAvailablePort();
|
||||
// Joplin Server sync
|
||||
const targetId = context.isJoplinCloud ? '10' : '9';
|
||||
await client.execCliCommand_('config', 'sync.target', targetId);
|
||||
await client.execCliCommand_('config', `sync.${targetId}.path`, context.serverUrl);
|
||||
await client.execCliCommand_('config', `sync.${targetId}.username`, account.email);
|
||||
await client.execCliCommand_('config', `sync.${targetId}.password`, account.password);
|
||||
await client.execCliCommand_('config', 'api.token', apiData.token);
|
||||
await client.execCliCommand_('config', 'api.port', String(apiData.port));
|
||||
|
||||
const client = new Client(
|
||||
actionTracker.track({ email }),
|
||||
userData,
|
||||
profileDirectory,
|
||||
apiPort,
|
||||
apiToken,
|
||||
);
|
||||
await client.execCliCommand_('e2ee', 'enable', '--password', account.e2eePassword);
|
||||
logger.info('Created and configured client');
|
||||
|
||||
client.onClose(closeAccount);
|
||||
|
||||
// Joplin Server sync
|
||||
await client.execCliCommand_('config', 'sync.target', '9');
|
||||
await client.execCliCommand_('config', 'sync.9.path', context.serverUrl);
|
||||
await client.execCliCommand_('config', 'sync.9.username', userData.email);
|
||||
await client.execCliCommand_('config', 'sync.9.password', userData.password);
|
||||
await client.execCliCommand_('config', 'api.token', apiToken);
|
||||
await client.execCliCommand_('config', 'api.port', String(apiPort));
|
||||
|
||||
const e2eePassword = createSecureRandom().replace(/^-/, '_');
|
||||
await client.execCliCommand_('e2ee', 'enable', '--password', e2eePassword);
|
||||
logger.info('Created and configured client');
|
||||
|
||||
await client.startClipperServer_();
|
||||
|
||||
await client.sync();
|
||||
return client;
|
||||
} catch (error) {
|
||||
await closeAccount();
|
||||
throw error;
|
||||
}
|
||||
await client.startClipperServer_();
|
||||
return client;
|
||||
}
|
||||
|
||||
private onCloseListeners_: OnCloseListener[] = [];
|
||||
@@ -116,13 +154,15 @@ class Client implements ActionableClient {
|
||||
private transcript_: string[] = [];
|
||||
|
||||
private constructor(
|
||||
private readonly context_: FuzzContext,
|
||||
private readonly globalActionTracker_: ActionTracker,
|
||||
private readonly tracker_: ActionableClient,
|
||||
userData: UserData,
|
||||
private readonly account_: AccountData,
|
||||
private readonly profileDirectory: string,
|
||||
private readonly apiPort_: number,
|
||||
private readonly apiToken_: string,
|
||||
private readonly apiData_: ApiData,
|
||||
private readonly clientLabel_: string,
|
||||
) {
|
||||
this.email = userData.email;
|
||||
this.email = account_.email;
|
||||
|
||||
// Don't skip child process-related tasks.
|
||||
this.childProcessQueue_.setCanSkipTaskHandler(() => false);
|
||||
@@ -186,9 +226,11 @@ class Client implements ActionableClient {
|
||||
public async close() {
|
||||
assert.ok(!this.closed_, 'should not be closed');
|
||||
|
||||
await this.account_.onClientDisconnected();
|
||||
|
||||
// Before removing the profile directory, verify that the profile directory is in the
|
||||
// expected location:
|
||||
const profileDirectory = this.profileDirectory;
|
||||
const profileDirectory = resolvePathWithinDir(this.context_.baseDir, this.profileDirectory);
|
||||
assert.ok(profileDirectory, 'profile directory for client should be contained within the main temporary profiles directory (should be safe to delete)');
|
||||
await remove(profileDirectory);
|
||||
|
||||
@@ -204,8 +246,16 @@ class Client implements ActionableClient {
|
||||
this.onCloseListeners_.push(listener);
|
||||
}
|
||||
|
||||
public async createClientOnSameAccount() {
|
||||
return await Client.fromAccount(this.account_, this.globalActionTracker_, this.context_);
|
||||
}
|
||||
|
||||
public hasSameAccount(other: Client) {
|
||||
return other.account_ === this.account_;
|
||||
}
|
||||
|
||||
public get label() {
|
||||
return this.email;
|
||||
return this.clientLabel_;
|
||||
}
|
||||
|
||||
private get cliCommandArguments() {
|
||||
@@ -318,8 +368,8 @@ class Client implements ActionableClient {
|
||||
// eslint-disable-next-line no-dupe-class-members -- This is not a duplicate class member
|
||||
private async execApiCommand_(method: HttpMethod, route: string, data: Json|null = null): Promise<string> {
|
||||
route = route.replace(/^[/]/, '');
|
||||
const url = new URL(`http://localhost:${this.apiPort_}/${route}`);
|
||||
url.searchParams.append('token', this.apiToken_);
|
||||
const url = new URL(`http://localhost:${this.apiData_.port}/${route}`);
|
||||
url.searchParams.append('token', this.apiData_.token);
|
||||
|
||||
this.transcript_.push(`\n[[${method} ${url}; body: ${JSON.stringify(data)}]]\n`);
|
||||
|
||||
@@ -399,7 +449,7 @@ class Client implements ActionableClient {
|
||||
});
|
||||
}
|
||||
|
||||
public async createFolder(folder: FolderMetadata) {
|
||||
public async createFolder(folder: FolderData) {
|
||||
logger.info('Create folder', folder.id, 'in', `${folder.parentId ?? 'root'}/${this.label}`);
|
||||
await this.tracker_.createFolder(folder);
|
||||
|
||||
@@ -461,38 +511,62 @@ class Client implements ActionableClient {
|
||||
await this.execCliCommand_('rmbook', '--permanent', '--force', id);
|
||||
}
|
||||
|
||||
public async shareFolder(id: string, shareWith: Client) {
|
||||
await this.tracker_.shareFolder(id, shareWith);
|
||||
public async shareFolder(id: string, shareWith: Client, options: ShareOptions) {
|
||||
await this.tracker_.shareFolder(id, shareWith, options);
|
||||
|
||||
logger.info('Share', id, 'with', shareWith.label);
|
||||
await this.execCliCommand_('share', 'add', id, shareWith.email);
|
||||
const getPendingInvitations = async (target: Client) => {
|
||||
const shareWithIncoming = JSON.parse((await target.execCliCommand_('share', 'list', '--json')).stdout);
|
||||
return shareWithIncoming.invitations.filter((invitation: unknown) => {
|
||||
if (typeof invitation !== 'object' || !('accepted' in invitation)) {
|
||||
throw new Error('Invalid invitation format');
|
||||
}
|
||||
return !invitation.accepted;
|
||||
});
|
||||
};
|
||||
|
||||
await this.sync();
|
||||
await shareWith.sync();
|
||||
await retryWithCount(async () => {
|
||||
logger.info('Share', id, 'with', shareWith.label, options.readOnly ? '(read-only)' : '');
|
||||
const readOnlyArgs = options.readOnly ? ['--read-only'] : [];
|
||||
await this.execCliCommand_(
|
||||
'share', 'add', ...readOnlyArgs, id, shareWith.email,
|
||||
);
|
||||
|
||||
const shareWithIncoming = JSON.parse((await shareWith.execCliCommand_('share', 'list', '--json')).stdout);
|
||||
const pendingInvitations = shareWithIncoming.invitations.filter((invitation: unknown) => {
|
||||
if (typeof invitation !== 'object' || !('accepted' in invitation)) {
|
||||
throw new Error('Invalid invitation format');
|
||||
}
|
||||
return !invitation.accepted;
|
||||
});
|
||||
assert.deepEqual(pendingInvitations, [
|
||||
{
|
||||
accepted: false,
|
||||
waiting: true,
|
||||
rejected: false,
|
||||
folderId: id,
|
||||
fromUser: {
|
||||
email: this.email,
|
||||
await this.sync();
|
||||
await shareWith.sync();
|
||||
|
||||
const pendingInvitations = await getPendingInvitations(shareWith);
|
||||
assert.deepEqual(pendingInvitations, [
|
||||
{
|
||||
accepted: false,
|
||||
waiting: true,
|
||||
rejected: false,
|
||||
canWrite: !options.readOnly,
|
||||
folderId: id,
|
||||
fromUser: {
|
||||
email: this.email,
|
||||
},
|
||||
},
|
||||
], 'there should be a single incoming share from the expected user');
|
||||
}, {
|
||||
count: 2,
|
||||
delayOnFailure: count => count * Second,
|
||||
onFail: (error)=>{
|
||||
logger.warn('Share failed:', error);
|
||||
},
|
||||
], 'there should be a single incoming share from the expected user');
|
||||
});
|
||||
|
||||
await shareWith.execCliCommand_('share', 'accept', id);
|
||||
await shareWith.sync();
|
||||
}
|
||||
|
||||
public async removeFromShare(id: string, other: Client) {
|
||||
await this.tracker_.removeFromShare(id, other);
|
||||
|
||||
logger.info('Remove', other.label, 'from share', id);
|
||||
await this.execCliCommand_('share', 'remove', id, other.email);
|
||||
await other.sync();
|
||||
}
|
||||
|
||||
public async moveItem(itemId: ItemId, newParentId: ItemId) {
|
||||
logger.info('Move', itemId, 'to', newParentId);
|
||||
await this.tracker_.moveItem(itemId, newParentId);
|
||||
@@ -502,7 +576,7 @@ class Client implements ActionableClient {
|
||||
|
||||
public async listNotes() {
|
||||
const params = {
|
||||
fields: 'id,parent_id,body,title,is_conflict,conflict_original_id',
|
||||
fields: 'id,parent_id,body,title,is_conflict,conflict_original_id,share_id',
|
||||
include_deleted: '1',
|
||||
include_conflicts: '1',
|
||||
};
|
||||
@@ -517,13 +591,14 @@ class Client implements ActionableClient {
|
||||
) : getStringProperty(item, 'parent_id'),
|
||||
title: getStringProperty(item, 'title'),
|
||||
body: getStringProperty(item, 'body'),
|
||||
isShared: getStringProperty(item, 'share_id') !== '',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async listFolders() {
|
||||
const params = {
|
||||
fields: 'id,parent_id,title',
|
||||
fields: 'id,parent_id,title,share_id',
|
||||
include_deleted: '1',
|
||||
};
|
||||
return await this.execPagedApiCommand_(
|
||||
@@ -534,6 +609,7 @@ class Client implements ActionableClient {
|
||||
id: getStringProperty(item, 'id'),
|
||||
parentId: getStringProperty(item, 'parent_id'),
|
||||
title: getStringProperty(item, 'title'),
|
||||
isShared: getStringProperty(item, 'share_id') !== '',
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -546,8 +622,8 @@ class Client implements ActionableClient {
|
||||
return this.tracker_.allFolderDescendants(parentId);
|
||||
}
|
||||
|
||||
public async randomNote() {
|
||||
return this.tracker_.randomNote();
|
||||
public async randomNote(options: RandomNoteOptions) {
|
||||
return this.tracker_.randomNote(options);
|
||||
}
|
||||
|
||||
public async checkState() {
|
||||
|
@@ -38,6 +38,10 @@ export default class ClientPool {
|
||||
});
|
||||
}
|
||||
|
||||
public clientsByEmail(email: string) {
|
||||
return this.clients.filter(client => client.email === email);
|
||||
}
|
||||
|
||||
public randomClient(filter: ClientFilter = ()=>true) {
|
||||
const clients = this.clients_.filter(filter);
|
||||
return clients[
|
||||
@@ -45,6 +49,17 @@ export default class ClientPool {
|
||||
];
|
||||
}
|
||||
|
||||
public async newWithSameAccount(sourceClient: Client) {
|
||||
const client = await sourceClient.createClientOnSameAccount();
|
||||
this.listenForClientClose_(client);
|
||||
this.clients_ = [...this.clients_, client];
|
||||
return client;
|
||||
}
|
||||
|
||||
public othersWithSameAccount(client: Client) {
|
||||
return this.clients_.filter(other => other !== client && other.hasSameAccount(client));
|
||||
}
|
||||
|
||||
public async checkState() {
|
||||
for (const client of this.clients_) {
|
||||
await client.checkState();
|
||||
|
@@ -1,6 +1,5 @@
|
||||
import { join } from 'path';
|
||||
import { join, resolve } from 'path';
|
||||
import { HttpMethod, Json, UserData } from './types';
|
||||
import { packagesDir } from './constants';
|
||||
import JoplinServerApi from '@joplin/lib/JoplinServerApi';
|
||||
import { Env } from '@joplin/lib/models/Setting';
|
||||
import execa = require('execa');
|
||||
@@ -27,16 +26,17 @@ export default class Server {
|
||||
private server_: execa.ExecaChildProcess<string>;
|
||||
|
||||
public constructor(
|
||||
serverBaseDirectory: string,
|
||||
private readonly serverUrl_: string,
|
||||
private readonly adminAuth_: UserData,
|
||||
) {
|
||||
const serverDir = join(packagesDir, 'server');
|
||||
const serverDir = resolve(serverBaseDirectory);
|
||||
const mainEntrypoint = join(serverDir, 'dist', 'app.js');
|
||||
this.server_ = execa.node(mainEntrypoint, [
|
||||
'--env', 'dev',
|
||||
], {
|
||||
env: { JOPLIN_IS_TESTING: '1' },
|
||||
cwd: join(packagesDir, 'server'),
|
||||
cwd: serverDir,
|
||||
stdin: 'ignore', // No stdin
|
||||
// For debugging:
|
||||
// stderr: process.stderr,
|
||||
|
144
packages/tools/fuzzer/model/FolderRecord.ts
Normal file
144
packages/tools/fuzzer/model/FolderRecord.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import type { FolderData, ItemId } from '../types';
|
||||
|
||||
export type ShareRecord = {
|
||||
email: string;
|
||||
readOnly: boolean;
|
||||
};
|
||||
|
||||
interface InitializationOptions extends FolderData {
|
||||
childIds: ItemId[];
|
||||
sharedWith: ShareRecord[];
|
||||
// Email of the Joplin Server account that controls the item
|
||||
ownedByEmail: string;
|
||||
}
|
||||
|
||||
const validateId = (id: string) => {
|
||||
return !!id.match(/^[a-zA-Z0-9]{32}$/);
|
||||
};
|
||||
|
||||
export default class FolderRecord implements FolderData {
|
||||
public readonly parentId: string;
|
||||
public readonly id: string;
|
||||
public readonly title: string;
|
||||
public readonly ownedByEmail: string;
|
||||
public readonly childIds: ItemId[];
|
||||
private readonly sharedWith_: ShareRecord[];
|
||||
|
||||
public constructor(options: InitializationOptions) {
|
||||
this.parentId = options.parentId;
|
||||
this.id = options.id;
|
||||
this.title = options.title;
|
||||
this.childIds = options.childIds;
|
||||
this.ownedByEmail = options.ownedByEmail;
|
||||
this.sharedWith_ = options.sharedWith;
|
||||
|
||||
if (this.parentId !== '' && !validateId(this.parentId)) {
|
||||
throw new Error(`Invalid parent ID: ${this.parentId}`);
|
||||
}
|
||||
|
||||
if (!validateId(this.id)) {
|
||||
throw new Error(`Invalid ID: ${this.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
public get shareRecipients() {
|
||||
return this.sharedWith_.map(sharee => sharee.email);
|
||||
}
|
||||
|
||||
private get metadata_(): InitializationOptions {
|
||||
return {
|
||||
parentId: this.parentId,
|
||||
id: this.id,
|
||||
title: this.title,
|
||||
ownedByEmail: this.ownedByEmail,
|
||||
childIds: [...this.childIds],
|
||||
sharedWith: [...this.sharedWith_],
|
||||
};
|
||||
}
|
||||
|
||||
public get isRootSharedItem() {
|
||||
return this.sharedWith_.length > 0;
|
||||
}
|
||||
|
||||
public isSharedWith(email: string) {
|
||||
assert.equal(this.parentId, '', 'only supported for toplevel folders');
|
||||
return this.sharedWith_.some(record => record.email === email);
|
||||
}
|
||||
|
||||
public isReadOnlySharedWith(email: string) {
|
||||
assert.equal(this.parentId, '', 'only supported for toplevel folders');
|
||||
return this.sharedWith_.some(record => record.email === email && record.readOnly);
|
||||
}
|
||||
|
||||
public withTitle(title: string) {
|
||||
return new FolderRecord({
|
||||
...this.metadata_,
|
||||
title,
|
||||
});
|
||||
}
|
||||
|
||||
public withParent(parentId: ItemId) {
|
||||
return new FolderRecord({
|
||||
...this.metadata_,
|
||||
parentId,
|
||||
});
|
||||
}
|
||||
|
||||
public withId(id: ItemId) {
|
||||
return new FolderRecord({
|
||||
...this.metadata_,
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
public withChildren(childIds: ItemId[]) {
|
||||
return new FolderRecord({
|
||||
...this.metadata_,
|
||||
childIds: [...childIds],
|
||||
});
|
||||
}
|
||||
|
||||
public withChildAdded(childId: ItemId) {
|
||||
if (this.childIds.includes(childId)) {
|
||||
return this;
|
||||
}
|
||||
|
||||
return this.withChildren([...this.childIds, childId]);
|
||||
}
|
||||
|
||||
public withChildRemoved(childId: ItemId) {
|
||||
return this.withChildren(
|
||||
this.childIds.filter(id => id !== childId),
|
||||
);
|
||||
}
|
||||
|
||||
public withShared(recipientEmail: string, readOnly: boolean) {
|
||||
if (this.isSharedWith(recipientEmail) && this.isReadOnlySharedWith(recipientEmail) === readOnly) {
|
||||
return this;
|
||||
}
|
||||
|
||||
if (this.parentId !== '') {
|
||||
throw new Error('Cannot share non-top-level folder');
|
||||
}
|
||||
|
||||
return new FolderRecord({
|
||||
...this.metadata_,
|
||||
sharedWith: [
|
||||
...this.sharedWith_.filter(record => record.email !== recipientEmail),
|
||||
{ email: recipientEmail, readOnly },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
public withUnshared(recipientEmail: string) {
|
||||
if (!this.isSharedWith(recipientEmail)) {
|
||||
return this;
|
||||
}
|
||||
|
||||
return new FolderRecord({
|
||||
...this.metadata_,
|
||||
sharedWith: this.sharedWith_.filter(record => record.email !== recipientEmail),
|
||||
});
|
||||
}
|
||||
}
|
@@ -14,6 +14,7 @@ import yargs = require('yargs');
|
||||
import { strict as assert } from 'assert';
|
||||
import openDebugSession from './utils/openDebugSession';
|
||||
import { Second } from '@joplin/utils/time';
|
||||
import { packagesDir } from './constants';
|
||||
const { shimInit } = require('@joplin/lib/shim-init-node');
|
||||
|
||||
const globalLogger = new Logger();
|
||||
@@ -42,7 +43,7 @@ const createProfilesDirectory = async () => {
|
||||
|
||||
const doRandomAction = async (context: FuzzContext, client: Client, clientPool: ClientPool) => {
|
||||
const selectOrCreateParentFolder = async () => {
|
||||
let parentId = (await client.randomFolder({}))?.id;
|
||||
let parentId = (await client.randomFolder({ includeReadOnly: false }))?.id;
|
||||
|
||||
// Create a toplevel folder to serve as this
|
||||
// folder's parent if none exist yet
|
||||
@@ -58,8 +59,9 @@ const doRandomAction = async (context: FuzzContext, client: Client, clientPool:
|
||||
return parentId;
|
||||
};
|
||||
|
||||
const selectOrCreateNote = async () => {
|
||||
let note = await client.randomNote();
|
||||
const selectOrCreateWriteableNote = async () => {
|
||||
const options = { includeReadOnly: false };
|
||||
let note = await client.randomNote(options);
|
||||
|
||||
if (!note) {
|
||||
await client.createNote({
|
||||
@@ -69,7 +71,7 @@ const doRandomAction = async (context: FuzzContext, client: Client, clientPool:
|
||||
body: 'Body',
|
||||
});
|
||||
|
||||
note = await client.randomNote();
|
||||
note = await client.randomNote(options);
|
||||
assert.ok(note, 'should have selected a random note');
|
||||
}
|
||||
|
||||
@@ -111,7 +113,7 @@ const doRandomAction = async (context: FuzzContext, client: Client, clientPool:
|
||||
return true;
|
||||
},
|
||||
renameNote: async () => {
|
||||
const note = await selectOrCreateNote();
|
||||
const note = await selectOrCreateWriteableNote();
|
||||
|
||||
await client.updateNote({
|
||||
...note,
|
||||
@@ -121,7 +123,7 @@ const doRandomAction = async (context: FuzzContext, client: Client, clientPool:
|
||||
return true;
|
||||
},
|
||||
updateNoteBody: async () => {
|
||||
const note = await selectOrCreateNote();
|
||||
const note = await selectOrCreateWriteableNote();
|
||||
|
||||
await client.updateNote({
|
||||
...note,
|
||||
@@ -131,10 +133,10 @@ const doRandomAction = async (context: FuzzContext, client: Client, clientPool:
|
||||
return true;
|
||||
},
|
||||
moveNote: async () => {
|
||||
const note = await client.randomNote();
|
||||
if (!note) return false;
|
||||
const note = await selectOrCreateWriteableNote();
|
||||
const targetParent = await client.randomFolder({
|
||||
filter: folder => folder.id !== note.parentId,
|
||||
includeReadOnly: false,
|
||||
});
|
||||
if (!targetParent) return false;
|
||||
|
||||
@@ -143,19 +145,42 @@ const doRandomAction = async (context: FuzzContext, client: Client, clientPool:
|
||||
return true;
|
||||
},
|
||||
shareFolder: async () => {
|
||||
const other = clientPool.randomClient(c => !c.hasSameAccount(client));
|
||||
if (!other) return false;
|
||||
|
||||
const target = await client.randomFolder({
|
||||
filter: candidate => (
|
||||
!candidate.parentId && !candidate.isShareRoot
|
||||
),
|
||||
filter: candidate => {
|
||||
const isToplevel = !candidate.parentId;
|
||||
const ownedByCurrent = candidate.ownedByEmail === client.email;
|
||||
const alreadyShared = isToplevel && candidate.isSharedWith(other.email);
|
||||
return isToplevel && ownedByCurrent && !alreadyShared;
|
||||
},
|
||||
includeReadOnly: true,
|
||||
});
|
||||
if (!target) return false;
|
||||
|
||||
const other = clientPool.randomClient(c => c !== client);
|
||||
await client.shareFolder(target.id, other);
|
||||
const readOnly = context.randInt(0, 2) === 1 && context.isJoplinCloud;
|
||||
await client.shareFolder(target.id, other, { readOnly });
|
||||
return true;
|
||||
},
|
||||
unshareFolder: async () => {
|
||||
const target = await client.randomFolder({
|
||||
filter: candidate => {
|
||||
return candidate.isRootSharedItem && candidate.ownedByEmail === client.email;
|
||||
},
|
||||
includeReadOnly: true,
|
||||
});
|
||||
if (!target) return false;
|
||||
|
||||
const recipientIndex = context.randInt(0, target.shareRecipients.length);
|
||||
const recipientEmail = target.shareRecipients[recipientIndex];
|
||||
const recipient = clientPool.clientsByEmail(recipientEmail)[0];
|
||||
assert.ok(recipient, `invalid state -- recipient ${recipientEmail} should exist`);
|
||||
await client.removeFromShare(target.id, recipient);
|
||||
return true;
|
||||
},
|
||||
deleteFolder: async () => {
|
||||
const target = await client.randomFolder({});
|
||||
const target = await client.randomFolder({ includeReadOnly: false });
|
||||
if (!target) return false;
|
||||
|
||||
await client.deleteFolder(target.id);
|
||||
@@ -165,6 +190,7 @@ const doRandomAction = async (context: FuzzContext, client: Client, clientPool:
|
||||
const target = await client.randomFolder({
|
||||
// Don't choose items that are already toplevel
|
||||
filter: item => !!item.parentId,
|
||||
includeReadOnly: false,
|
||||
});
|
||||
if (!target) return false;
|
||||
|
||||
@@ -174,7 +200,8 @@ const doRandomAction = async (context: FuzzContext, client: Client, clientPool:
|
||||
moveFolderTo: async () => {
|
||||
const target = await client.randomFolder({
|
||||
// Don't move shared folders (should not be allowed by the GUI in the main apps).
|
||||
filter: item => !item.isShareRoot,
|
||||
filter: item => !item.isRootSharedItem,
|
||||
includeReadOnly: false,
|
||||
});
|
||||
if (!target) return false;
|
||||
|
||||
@@ -185,12 +212,70 @@ const doRandomAction = async (context: FuzzContext, client: Client, clientPool:
|
||||
// Avoid making the folder a child of itself
|
||||
return !targetDescendants.has(item.id);
|
||||
},
|
||||
includeReadOnly: false,
|
||||
});
|
||||
if (!newParent) return false;
|
||||
|
||||
await client.moveItem(target.id, newParent.id);
|
||||
return true;
|
||||
},
|
||||
newClientOnSameAccount: async () => {
|
||||
const welcomeNoteCount = context.randInt(0, 30);
|
||||
logger.info(`Syncing a new client on the same account ${welcomeNoteCount > 0 ? `(with ${welcomeNoteCount} initial notes)` : ''}`);
|
||||
const createClientInitialNotes = async (client: Client) => {
|
||||
if (welcomeNoteCount === 0) return;
|
||||
|
||||
// Create a new folder. Usually, new clients have a default set of
|
||||
// welcome notes when first syncing.
|
||||
const testNotesFolderId = uuid.create();
|
||||
await client.createFolder({
|
||||
id: testNotesFolderId,
|
||||
title: 'Test -- from secondary client',
|
||||
parentId: '',
|
||||
});
|
||||
|
||||
for (let i = 0; i < welcomeNoteCount; i++) {
|
||||
await client.createNote({
|
||||
parentId: testNotesFolderId,
|
||||
id: uuid.create(),
|
||||
title: `Test note ${i}/${welcomeNoteCount}`,
|
||||
body: `Test note (in account ${client.email}), created ${Date.now()}.`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
await client.sync();
|
||||
|
||||
const other = await clientPool.newWithSameAccount(client);
|
||||
await createClientInitialNotes(other);
|
||||
|
||||
// Sometimes, a delay is needed between client creation
|
||||
// and initial sync. Retry the initial sync and the checkState
|
||||
// on failure:
|
||||
await retryWithCount(async () => {
|
||||
await other.sync();
|
||||
await other.checkState();
|
||||
}, {
|
||||
delayOnFailure: (count) => Second * count,
|
||||
count: 3,
|
||||
onFail: async (error) => {
|
||||
logger.warn('other.sync/other.checkState failed with', error, 'retrying...');
|
||||
},
|
||||
});
|
||||
|
||||
await client.sync();
|
||||
return true;
|
||||
},
|
||||
removeClientsOnSameAccount: async () => {
|
||||
const others = clientPool.othersWithSameAccount(client);
|
||||
if (others.length === 0) return false;
|
||||
|
||||
for (const otherClient of others) {
|
||||
assert.notEqual(otherClient, client);
|
||||
await otherClient.close();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
const actionKeys = [...Object.keys(actions)] as (keyof typeof actions)[];
|
||||
@@ -211,6 +296,9 @@ interface Options {
|
||||
maximumSteps: number;
|
||||
maximumStepsBetweenSyncs: number;
|
||||
clientCount: number;
|
||||
|
||||
serverPath: string;
|
||||
isJoplinCloud: boolean;
|
||||
}
|
||||
|
||||
const main = async (options: Options) => {
|
||||
@@ -242,7 +330,7 @@ const main = async (options: Options) => {
|
||||
|
||||
try {
|
||||
const joplinServerUrl = 'http://localhost:22300/';
|
||||
const server = new Server(joplinServerUrl, {
|
||||
const server = new Server(options.serverPath, joplinServerUrl, {
|
||||
email: 'admin@localhost',
|
||||
password: env['FUZZER_SERVER_ADMIN_PASSWORD'] ?? 'admin',
|
||||
});
|
||||
@@ -258,8 +346,13 @@ const main = async (options: Options) => {
|
||||
logger.info('Starting with seed', options.seed);
|
||||
const random = new SeededRandom(options.seed);
|
||||
|
||||
if (options.isJoplinCloud) {
|
||||
logger.info('Sync target: Joplin Cloud');
|
||||
}
|
||||
|
||||
const fuzzContext: FuzzContext = {
|
||||
serverUrl: joplinServerUrl,
|
||||
isJoplinCloud: options.isJoplinCloud,
|
||||
baseDir: profilesDirectory.path,
|
||||
execApi: server.execApi.bind(server),
|
||||
randInt: (a, b) => random.nextInRange(a, b),
|
||||
@@ -348,13 +441,24 @@ void yargs
|
||||
default: 3,
|
||||
defaultDescription: 'Number of client apps to create.',
|
||||
},
|
||||
'joplin-cloud': {
|
||||
type: 'string',
|
||||
default: '',
|
||||
defaultDescription: [
|
||||
'A path: If provided, this should be an absolute path to a Joplin Cloud repository. ',
|
||||
'This also enables testing for some Joplin Cloud-specific features (e.g. read-only shares).',
|
||||
].join(''),
|
||||
},
|
||||
});
|
||||
},
|
||||
async (argv) => {
|
||||
const serverPath = argv.joplinCloud ? argv.joplinCloud : join(packagesDir, 'server');
|
||||
await main({
|
||||
seed: argv.seed,
|
||||
maximumSteps: argv.steps,
|
||||
clientCount: argv.clients,
|
||||
serverPath: serverPath,
|
||||
isJoplinCloud: !!argv.joplinCloud,
|
||||
maximumStepsBetweenSyncs: argv['steps-between-syncs'],
|
||||
});
|
||||
},
|
||||
|
@@ -1,45 +1,72 @@
|
||||
import type Client from './Client';
|
||||
import type FolderRecord from './model/FolderRecord';
|
||||
|
||||
export type Json = string|number|Json[]|{ [key: string]: Json };
|
||||
|
||||
export type HttpMethod = 'GET'|'POST'|'DELETE'|'PUT'|'PATCH';
|
||||
|
||||
export type ItemId = string;
|
||||
export type NoteData = {
|
||||
export interface NoteData {
|
||||
parentId: ItemId;
|
||||
id: ItemId;
|
||||
title: string;
|
||||
body: string;
|
||||
};
|
||||
export type FolderMetadata = {
|
||||
}
|
||||
export interface DetailedNoteData extends NoteData {
|
||||
isShared: boolean;
|
||||
}
|
||||
export interface FolderData {
|
||||
parentId: ItemId;
|
||||
id: ItemId;
|
||||
title: string;
|
||||
};
|
||||
export type FolderData = FolderMetadata & {
|
||||
childIds: ItemId[];
|
||||
isShareRoot: boolean;
|
||||
};
|
||||
export type TreeItem = NoteData|FolderData;
|
||||
}
|
||||
export interface DetailedFolderData extends FolderData {
|
||||
isShared: boolean;
|
||||
}
|
||||
|
||||
export const isFolder = (item: TreeItem): item is FolderData => {
|
||||
export type TreeItem = NoteData|FolderRecord;
|
||||
|
||||
export const isFolder = (item: TreeItem): item is FolderRecord => {
|
||||
return 'childIds' in item;
|
||||
};
|
||||
|
||||
// Typescript type assertions require type definitions on the left for arrow functions.
|
||||
// See https://github.com/microsoft/TypeScript/issues/53450.
|
||||
export const assertIsFolder: (item: TreeItem)=> asserts item is FolderRecord = item => {
|
||||
if (!item) {
|
||||
throw new Error(`Item ${item} is not a folder`);
|
||||
}
|
||||
|
||||
if (!isFolder(item)) {
|
||||
throw new Error(`Expected item with ID ${item?.id} to be a folder.`);
|
||||
}
|
||||
};
|
||||
|
||||
export interface FuzzContext {
|
||||
serverUrl: string;
|
||||
isJoplinCloud: boolean;
|
||||
baseDir: string;
|
||||
execApi: (method: HttpMethod, route: string, debugAction: Json)=> Promise<Json>;
|
||||
randInt: (low: number, high: number)=> number;
|
||||
}
|
||||
|
||||
export interface RandomFolderOptions {
|
||||
filter?: (folder: FolderData)=> boolean;
|
||||
includeReadOnly: boolean;
|
||||
filter?: (folder: FolderRecord)=> boolean;
|
||||
}
|
||||
|
||||
export interface RandomNoteOptions {
|
||||
includeReadOnly: boolean;
|
||||
}
|
||||
|
||||
export interface ShareOptions {
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
export interface ActionableClient {
|
||||
createFolder(data: FolderMetadata): Promise<void>;
|
||||
shareFolder(id: ItemId, shareWith: Client): Promise<void>;
|
||||
createFolder(data: FolderData): Promise<void>;
|
||||
shareFolder(id: ItemId, shareWith: Client, options: ShareOptions): Promise<void>;
|
||||
removeFromShare(id: string, shareWith: Client): Promise<void>;
|
||||
deleteFolder(id: ItemId): Promise<void>;
|
||||
createNote(data: NoteData): Promise<void>;
|
||||
updateNote(data: NoteData): Promise<void>;
|
||||
@@ -47,10 +74,10 @@ export interface ActionableClient {
|
||||
sync(): Promise<void>;
|
||||
|
||||
listNotes(): Promise<NoteData[]>;
|
||||
listFolders(): Promise<FolderMetadata[]>;
|
||||
listFolders(): Promise<DetailedFolderData[]>;
|
||||
allFolderDescendants(parentId: ItemId): Promise<ItemId[]>;
|
||||
randomFolder(options: RandomFolderOptions): Promise<FolderMetadata>;
|
||||
randomNote(): Promise<NoteData>;
|
||||
randomFolder(options: RandomFolderOptions): Promise<FolderRecord>;
|
||||
randomNote(options: RandomNoteOptions): Promise<NoteData>;
|
||||
}
|
||||
|
||||
export interface UserData {
|
||||
|
@@ -6,7 +6,7 @@ const logger = Logger.create('retryWithCount');
|
||||
interface Options {
|
||||
count: number;
|
||||
delayOnFailure?: (retryCount: number)=> number;
|
||||
onFail: (error: Error)=> Promise<void>;
|
||||
onFail: (error: Error)=> void|Promise<void>;
|
||||
}
|
||||
|
||||
const retryWithCount = async (task: ()=> Promise<void>, { count, delayOnFailure, onFail }: Options) => {
|
||||
|
@@ -7,6 +7,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Joplin-CLI 1.0.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: ERYpTION\n"
|
||||
"Language-Team: \n"
|
||||
"Language: da_DK\n"
|
||||
@@ -425,9 +427,8 @@ msgid "Add recipient:"
|
||||
msgstr "Tilføj modtager:"
|
||||
|
||||
#: packages/app-mobile/components/TagEditor.tsx:264
|
||||
#, fuzzy
|
||||
msgid "Add tags:"
|
||||
msgstr "Nye etiketter:"
|
||||
msgstr "Tilføj etiketter:"
|
||||
|
||||
#: packages/app-mobile/components/screens/Note/Note.tsx:1726
|
||||
msgid "Add title"
|
||||
@@ -442,18 +443,16 @@ msgid "Add to note"
|
||||
msgstr "Tilføj til note"
|
||||
|
||||
#: packages/app-mobile/components/ComboBox.tsx:89
|
||||
#, fuzzy
|
||||
msgid "Added new: %s"
|
||||
msgstr "Tilføj ny"
|
||||
msgstr "Tilføjet ny: %s"
|
||||
|
||||
#: packages/app-mobile/components/TagEditor.tsx:218
|
||||
#, fuzzy
|
||||
msgid "Added tag: %s"
|
||||
msgstr "Tilføj etiketten %s til note"
|
||||
msgstr "Tilføjet etikette: %s"
|
||||
|
||||
#: packages/app-mobile/components/TagEditor.tsx:205
|
||||
msgid "Adds tag"
|
||||
msgstr ""
|
||||
msgstr "Tilføjer etikette"
|
||||
|
||||
#: packages/server/src/services/MustacheService.ts:162
|
||||
#: packages/server/src/services/MustacheService.ts:286
|
||||
@@ -633,7 +632,7 @@ msgstr "Aritim Mørk"
|
||||
|
||||
#: packages/app-mobile/components/TagEditor.tsx:172
|
||||
msgid "Associated tags:"
|
||||
msgstr ""
|
||||
msgstr "Tilknyttede etiketter:"
|
||||
|
||||
#: packages/app-mobile/utils/lockToSingleInstance.ts:15
|
||||
msgid ""
|
||||
@@ -1150,9 +1149,8 @@ msgid "Code View"
|
||||
msgstr "Kodevisning"
|
||||
|
||||
#: packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.ts:83
|
||||
#, fuzzy
|
||||
msgid "Code:"
|
||||
msgstr "Kode"
|
||||
msgstr "Kode:"
|
||||
|
||||
#: packages/lib/utils/joplinCloud/index.ts:184
|
||||
msgid "Collaborate on a notebook with others"
|
||||
@@ -1326,14 +1324,12 @@ msgid "Control character"
|
||||
msgstr "Kontroltegn"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteEditor.tsx:660
|
||||
#, fuzzy
|
||||
msgid "Convert it"
|
||||
msgstr "Konverter til note"
|
||||
msgstr "Konverter den"
|
||||
|
||||
#: packages/app-desktop/commands/convertNoteToMarkdown.ts:11
|
||||
#, fuzzy
|
||||
msgid "Convert note to Markdown"
|
||||
msgstr "Konverter til opgave"
|
||||
msgstr "Konverter note til Markdown"
|
||||
|
||||
#: packages/app-mobile/components/screens/Note/Note.tsx:1332
|
||||
msgid "Convert to note"
|
||||
@@ -1440,9 +1436,8 @@ msgid "Could not connect to plugin repository."
|
||||
msgstr "Kunne ikke forbinde til plugin-lager."
|
||||
|
||||
#: packages/app-desktop/commands/convertNoteToMarkdown.ts:45
|
||||
#, fuzzy
|
||||
msgid "Could not convert note to Markdown: %s"
|
||||
msgstr "Kunne ikke eksportere noterne: %s"
|
||||
msgstr "Kunne ikke konvertere note til Markdown: %s"
|
||||
|
||||
#: packages/app-desktop/InteropServiceHelper.ts:224
|
||||
msgid "Could not export notes: %s"
|
||||
@@ -1497,9 +1492,8 @@ msgid "Create a notebook"
|
||||
msgstr "Opret en notesbog"
|
||||
|
||||
#: packages/app-mobile/components/FolderPicker.tsx:112
|
||||
#, fuzzy
|
||||
msgid "Create new notebook"
|
||||
msgstr "Opretter en ny notesbog."
|
||||
msgstr "Opretter ny notesbog"
|
||||
|
||||
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addProfile.ts:9
|
||||
#: packages/app-mobile/components/ProfileSwitcher/ProfileEditor.tsx:88
|
||||
@@ -1507,9 +1501,8 @@ msgid "Create new profile..."
|
||||
msgstr "Opret ny profil..."
|
||||
|
||||
#: packages/app-mobile/components/screens/DocumentScanner/NotePreview.tsx:169
|
||||
#, fuzzy
|
||||
msgid "Create note"
|
||||
msgstr "Opret notesbog"
|
||||
msgstr "Opret note"
|
||||
|
||||
#: packages/app-desktop/gui/EditFolderDialog/Dialog.tsx:166
|
||||
msgid "Create notebook"
|
||||
@@ -1579,14 +1572,12 @@ msgid "Creating new to-do..."
|
||||
msgstr "Opretter nye gøremål..."
|
||||
|
||||
#: packages/app-mobile/components/screens/DocumentScanner/DocumentScanner.tsx:78
|
||||
#, fuzzy
|
||||
msgid "Creating note \"%s\"..."
|
||||
msgstr "Opretter ny note..."
|
||||
msgstr "Opretter note \"%s\"..."
|
||||
|
||||
#: packages/app-mobile/components/screens/DocumentScanner/DocumentScanner.tsx:130
|
||||
#, fuzzy
|
||||
msgid "Creating note."
|
||||
msgstr "Opretter ny note..."
|
||||
msgstr "Opretter ny note."
|
||||
|
||||
#: packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/ExportDebugReportButton.tsx:29
|
||||
msgid "Creating report..."
|
||||
@@ -1688,7 +1679,7 @@ msgstr "Standard"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1924
|
||||
msgid "Default title to use for documents created by the scanner."
|
||||
msgstr ""
|
||||
msgstr "Standardtitel til brug for dokumenter, der er oprettet af scanneren."
|
||||
|
||||
#: packages/app-cli/app/help-utils.js:71
|
||||
msgid "Default: %s"
|
||||
@@ -1977,7 +1968,7 @@ msgstr ""
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1923
|
||||
msgid "Document scanner: Title template"
|
||||
msgstr ""
|
||||
msgstr "Dokumentscanner: Skabelon til titel"
|
||||
|
||||
#: packages/app-cli/app/command-share.ts:65
|
||||
msgid ""
|
||||
@@ -1989,7 +1980,7 @@ msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteEditor.tsx:662
|
||||
msgid "Don't show this message again"
|
||||
msgstr ""
|
||||
msgstr "Vis ikke denne besked igen"
|
||||
|
||||
#: packages/lib/models/Setting.ts:1299
|
||||
msgid "Donate, website"
|
||||
@@ -2114,14 +2105,12 @@ msgid "Edit"
|
||||
msgstr "Redigér"
|
||||
|
||||
#: packages/app-mobile/components/screens/Note/Note.tsx:1368
|
||||
#, fuzzy
|
||||
msgid "Edit as Markdown"
|
||||
msgstr "Markdown"
|
||||
msgstr "Rediger som Markdown"
|
||||
|
||||
#: packages/app-mobile/components/screens/Note/Note.tsx:1368
|
||||
#, fuzzy
|
||||
msgid "Edit as Rich Text"
|
||||
msgstr "Rich Text"
|
||||
msgstr "Rediger som Rich Text"
|
||||
|
||||
#: packages/app-mobile/components/NoteEditor/EditLinkDialog.tsx:138
|
||||
msgid "Edit link"
|
||||
@@ -2281,14 +2270,12 @@ msgid "Enable Fountain syntax support"
|
||||
msgstr "Slå understøttelse af Fountain syntaks til"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:564
|
||||
#, fuzzy
|
||||
msgid "Enable handwritten transcription"
|
||||
msgstr "Aktivér kryptering"
|
||||
msgstr "Aktivér håndskrevet transskription"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:741
|
||||
#, fuzzy
|
||||
msgid "Enable HTML-to-Markdown conversion banner"
|
||||
msgstr "Aktivér Markdown-værktøjslinjen"
|
||||
msgstr "Aktivér banner for HTML-til-Markdown-konvertering"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1063
|
||||
msgid "Enable Linkify"
|
||||
@@ -2899,9 +2886,8 @@ msgid "Hide password"
|
||||
msgstr "Skjul adgangskode"
|
||||
|
||||
#: packages/app-mobile/components/NoteEditor/WarningBanner.tsx:24
|
||||
#, fuzzy
|
||||
msgid "Hides warning"
|
||||
msgstr "Advarsel"
|
||||
msgstr "Skjuler advarsel"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.ts:14
|
||||
msgid "Highlight"
|
||||
@@ -2953,6 +2939,8 @@ msgid ""
|
||||
"If an image attachment is on its own line and followed by a blank line, it "
|
||||
"will be rendered just below its Markdown source."
|
||||
msgstr ""
|
||||
"Hvis en billedvedhæftning står på sin egen linje og efterfølges af en tom "
|
||||
"linje, vil den blive gengivet lige under Markdown-kilden."
|
||||
|
||||
#: packages/lib/services/joplinCloudUtils.ts:37
|
||||
msgid ""
|
||||
@@ -3646,13 +3634,12 @@ msgid "Markdown editor"
|
||||
msgstr "Markdown-editor"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1473
|
||||
#, fuzzy
|
||||
msgid "Markdown editor: Render images"
|
||||
msgstr "Markdown-editor"
|
||||
msgstr "Markdown-editor: Gengiv billeder"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1463
|
||||
msgid "Markdown editor: Render markup in editor"
|
||||
msgstr ""
|
||||
msgstr "Markdown-editor: Lav markup i editoren"
|
||||
|
||||
#: packages/app-cli/app/command-done.ts:15
|
||||
msgid "Marks a to-do as done."
|
||||
@@ -3872,9 +3859,8 @@ msgid ""
|
||||
msgstr "Ny notesbog \"%s\" bliver oprettet og filen \"%s\" importeres til den"
|
||||
|
||||
#: packages/app-mobile/components/FolderPicker.tsx:71
|
||||
#, fuzzy
|
||||
msgid "New notebook title"
|
||||
msgstr "Notesbogstitel:"
|
||||
msgstr "Ny notesbogstitel"
|
||||
|
||||
#: packages/app-mobile/setupQuickActions.ts:32
|
||||
msgid "New photo"
|
||||
@@ -3990,9 +3976,8 @@ msgid "No tab selected"
|
||||
msgstr "Ingen fane valgt"
|
||||
|
||||
#: packages/app-mobile/components/TagEditor.tsx:167
|
||||
#, fuzzy
|
||||
msgid "No tags"
|
||||
msgstr "Nye etiketter:"
|
||||
msgstr "Ingen etiketter"
|
||||
|
||||
#: packages/app-cli/app/command-edit.ts:31
|
||||
msgid ""
|
||||
@@ -4112,9 +4097,8 @@ msgid "Note list style"
|
||||
msgstr "Notatliste-stil"
|
||||
|
||||
#: packages/app-mobile/components/screens/DocumentScanner/DocumentScanner.tsx:128
|
||||
#, fuzzy
|
||||
msgid "Note preview"
|
||||
msgstr "Notefremviser"
|
||||
msgstr "Note-forhåndsvisning"
|
||||
|
||||
#: packages/app-desktop/gui/NotePropertiesDialog.tsx:497
|
||||
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showNoteProperties.ts:7
|
||||
@@ -4206,9 +4190,8 @@ msgid "OCR: Language data URL or path"
|
||||
msgstr "OCR: URL eller sti til sprogdata"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:604
|
||||
#, fuzzy
|
||||
msgid "OCR: Search in extracted content"
|
||||
msgstr "Søg i alle noterne"
|
||||
msgstr "OCR: Søg i udtrukket indhold"
|
||||
|
||||
#: packages/app-cli/app/utils/shimInitCli.ts:12
|
||||
#: packages/app-desktop/bridge.ts:392 packages/app-desktop/bridge.ts:405
|
||||
@@ -4475,7 +4458,7 @@ msgstr "Tilladelse nødvendig"
|
||||
|
||||
#: packages/app-mobile/components/screens/DocumentScanner/DocumentScanner.tsx:93
|
||||
msgid "Photo %d"
|
||||
msgstr ""
|
||||
msgstr "Foto %d"
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.tsx:261
|
||||
msgid ""
|
||||
@@ -4816,9 +4799,8 @@ msgid "Re-upload local data to sync target"
|
||||
msgstr "Upload lokal data igen til synkroniseringsmål"
|
||||
|
||||
#: packages/app-mobile/components/NoteEditor/WarningBanner.tsx:19
|
||||
#, fuzzy
|
||||
msgid "Read more"
|
||||
msgstr "Få mere at vide"
|
||||
msgstr "Læs mere"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/WarningBanner/WarningBanner.tsx:40
|
||||
msgid "Read more about it"
|
||||
@@ -4829,9 +4811,8 @@ msgid "Read time: %s min"
|
||||
msgstr "Læsetid: %s min"
|
||||
|
||||
#: packages/app-cli/app/command-batch.ts:45
|
||||
#, fuzzy
|
||||
msgid "Reading commands from standard input is only available in CLI mode."
|
||||
msgstr "Kommando \"%s\" er kun til rådighed i GUI tilstand"
|
||||
msgstr "Det er kun muligt at læse kommandoer fra standardinput i CLI-tilstand."
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.tsx:314
|
||||
msgid "Recipient has accepted the invitation"
|
||||
@@ -4850,9 +4831,8 @@ msgid "Recipients:"
|
||||
msgstr "Modtagere:"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts:142
|
||||
#, fuzzy
|
||||
msgid "Recognize handwritten image"
|
||||
msgstr "Ændr størrelsen på store billeder:"
|
||||
msgstr "Genkend håndskrevet billede"
|
||||
|
||||
#: packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/RecommendedBadge.tsx:72
|
||||
msgid "Recommended"
|
||||
@@ -4906,9 +4886,8 @@ msgid "Remove"
|
||||
msgstr "Fjern"
|
||||
|
||||
#: packages/app-mobile/components/TagEditor.tsx:129
|
||||
#, fuzzy
|
||||
msgid "Remove %s"
|
||||
msgstr "Fjern"
|
||||
msgstr "Fjern %s"
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.tsx:331
|
||||
msgid "Remove %s from share"
|
||||
@@ -4927,9 +4906,8 @@ msgid "Removed %s from share."
|
||||
msgstr "Fjern %s fra deling."
|
||||
|
||||
#: packages/app-mobile/components/TagEditor.tsx:230
|
||||
#, fuzzy
|
||||
msgid "Removed tag: %s"
|
||||
msgstr "Omdøb etikette:"
|
||||
msgstr "Fjernet etikette: %s"
|
||||
|
||||
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/renameFolder.ts:8
|
||||
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/renameTag.ts:8
|
||||
@@ -4950,7 +4928,7 @@ msgstr "Omdøber det aktuelle <item> (note eller notesbog) til <name>."
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1464
|
||||
msgid "Renders markup on all lines that don't include the cursor."
|
||||
msgstr ""
|
||||
msgstr "Laver markup på alle linjer, der ikke omfatter markøren."
|
||||
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.tsx:162
|
||||
msgid "Renew token"
|
||||
@@ -5198,9 +5176,8 @@ msgid "Save geo-location with notes"
|
||||
msgstr "Gem geo-lokation i noter"
|
||||
|
||||
#: packages/app-mobile/components/screens/Notes/NewNoteButton.tsx:81
|
||||
#, fuzzy
|
||||
msgid "Scan notebook"
|
||||
msgstr "Vælger en notesbog"
|
||||
msgstr "Scan notesbog"
|
||||
|
||||
#: packages/app-mobile/components/CameraView/ScannedBarcodes.tsx:90
|
||||
msgid "Scanned code"
|
||||
@@ -5249,9 +5226,8 @@ msgid "Search shown"
|
||||
msgstr "Søg viste"
|
||||
|
||||
#: packages/app-mobile/components/TagEditor.tsx:272
|
||||
#, fuzzy
|
||||
msgid "Search tags"
|
||||
msgstr "Søgeresultater"
|
||||
msgstr "Søg etiketter"
|
||||
|
||||
#: packages/app-cli/app/gui/FolderListWidget.ts:56
|
||||
msgid "Search:"
|
||||
@@ -5300,18 +5276,16 @@ msgid "Select file..."
|
||||
msgstr "Vælg fil..."
|
||||
|
||||
#: packages/app-mobile/components/screens/DocumentScanner/NotePreview.tsx:147
|
||||
#, fuzzy
|
||||
msgid "Select notebook"
|
||||
msgstr "Vælger en notesbog"
|
||||
msgstr "Vælg notesbog"
|
||||
|
||||
#: packages/app-mobile/components/screens/folder.js:109
|
||||
msgid "Select parent notebook"
|
||||
msgstr "Vælg overordnet notesbog"
|
||||
|
||||
#: packages/app-mobile/components/ComboBox.tsx:367
|
||||
#, fuzzy
|
||||
msgid "Selected: %s"
|
||||
msgstr "Oprettet: %s"
|
||||
msgstr "Valgte: %s"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/localisation.ts:9
|
||||
msgid "Selection deleted"
|
||||
@@ -6133,11 +6107,12 @@ msgid "The note \"%s\" has been successfully restored to the notebook \"%s\"."
|
||||
msgstr "Noten \"%s\" er blevet gendannet til notesbogen \"%s\"."
|
||||
|
||||
#: packages/app-desktop/gui/ConversionNotification/ConversionNotification.tsx:22
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
"The note has been converted to Markdown and the original note has been moved "
|
||||
"to the trash"
|
||||
msgstr "Notesbogen og dens indhold blev flyttet til papirkurven."
|
||||
msgstr ""
|
||||
"Noten er blevet konverteret til Markdown, og den oprindelige note er blevet "
|
||||
"flyttet til papirkurven."
|
||||
|
||||
#: packages/app-desktop/gui/TrashNotification/TrashNotification.tsx:45
|
||||
msgid "The note was successfully moved to the trash."
|
||||
@@ -6367,14 +6342,16 @@ msgid ""
|
||||
"This feature is disabled by default, you need to manually enable it by "
|
||||
"turning on the option to 'Enable handwritten transcription'."
|
||||
msgstr ""
|
||||
"Denne funktion er som standard deaktiveret, og du skal aktivere den manuelt "
|
||||
"ved at slå indstillingen 'Aktiver håndskreven transskription' til."
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts:146
|
||||
msgid "This feature is only available on Joplin Cloud and Joplin Server."
|
||||
msgstr ""
|
||||
msgstr "Denne funktion er kun tilgængelig på Joplin Cloud og Joplin Server."
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts:158
|
||||
msgid "This image type is not supported by the recognition system."
|
||||
msgstr ""
|
||||
msgstr "Denne billedtype understøttes ikke af genkendelsessystemet."
|
||||
|
||||
#: packages/app-desktop/gui/ResourceScreen.tsx:298
|
||||
msgid ""
|
||||
@@ -6429,6 +6406,8 @@ msgstr "Denne note har ingen historie"
|
||||
msgid ""
|
||||
"This note is in HTML format. Convert it to Markdown to edit it more easily."
|
||||
msgstr ""
|
||||
"Denne note er i HTML-format. Konverter den til Markdown for at gøre det "
|
||||
"lettere at redigere den."
|
||||
|
||||
#: packages/lib/services/plugins/PluginService.ts:535
|
||||
msgid "This plugin doesn't support %s."
|
||||
@@ -6535,6 +6514,7 @@ msgstr "Indtast din hovedadgangskode nedenfor for at fortsætte."
|
||||
#: packages/app-mobile/components/ComboBox.tsx:568
|
||||
msgid "To create a new tag, type the name and press enter."
|
||||
msgstr ""
|
||||
"For at oprette en ny etikette skal du skrive navnet og trykke på enter."
|
||||
|
||||
#: packages/app-cli/app/app-gui.js:458
|
||||
msgid "To delete a tag, untag the associated notes."
|
||||
@@ -6915,9 +6895,8 @@ msgid "Upgrade the sync target to the latest version."
|
||||
msgstr "Opgrader synkmålet til den seneste version."
|
||||
|
||||
#: packages/app-mobile/components/CameraView/CameraView.web.tsx:45
|
||||
#, fuzzy
|
||||
msgid "Upload photo"
|
||||
msgstr "Tag et foto"
|
||||
msgstr "Upload foto"
|
||||
|
||||
#: packages/app-desktop/gui/NotePropertiesDialog.tsx:86
|
||||
#: packages/app-mobile/components/NoteEditor/EditLinkDialog.tsx:109
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Joplin 1.0.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: summoner <summoner@vivaldi.net>\n"
|
||||
"Language-Team: \n"
|
||||
"Language: hu_HU\n"
|
||||
@@ -430,9 +432,8 @@ msgid "Add recipient:"
|
||||
msgstr "Címzett hozzáadása:"
|
||||
|
||||
#: packages/app-mobile/components/TagEditor.tsx:264
|
||||
#, fuzzy
|
||||
msgid "Add tags:"
|
||||
msgstr "Új címkék:"
|
||||
msgstr "Címkék hozzáadása:"
|
||||
|
||||
#: packages/app-mobile/components/screens/Note/Note.tsx:1726
|
||||
msgid "Add title"
|
||||
@@ -447,18 +448,16 @@ msgid "Add to note"
|
||||
msgstr "Hozzáadás jegyzethez"
|
||||
|
||||
#: packages/app-mobile/components/ComboBox.tsx:89
|
||||
#, fuzzy
|
||||
msgid "Added new: %s"
|
||||
msgstr "Új hozzáadása"
|
||||
msgstr "Új hozzáadva: %s"
|
||||
|
||||
#: packages/app-mobile/components/TagEditor.tsx:218
|
||||
#, fuzzy
|
||||
msgid "Added tag: %s"
|
||||
msgstr "A(z) %s címke hozzáadása a jegyzethez"
|
||||
msgstr "Hozzáadott címke: %s"
|
||||
|
||||
#: packages/app-mobile/components/TagEditor.tsx:205
|
||||
msgid "Adds tag"
|
||||
msgstr ""
|
||||
msgstr "Címke hozzáadása"
|
||||
|
||||
#: packages/server/src/services/MustacheService.ts:162
|
||||
#: packages/server/src/services/MustacheService.ts:286
|
||||
@@ -639,7 +638,7 @@ msgstr "Aritim sötét"
|
||||
|
||||
#: packages/app-mobile/components/TagEditor.tsx:172
|
||||
msgid "Associated tags:"
|
||||
msgstr ""
|
||||
msgstr "Kapcsolódó címkék:"
|
||||
|
||||
#: packages/app-mobile/utils/lockToSingleInstance.ts:15
|
||||
msgid ""
|
||||
@@ -749,11 +748,11 @@ msgstr "Frissítések automatikus ellenőrzése"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1932
|
||||
msgid "Automatically delete notes in the trash after a number of days"
|
||||
msgstr "Automatikusan törölje a kuka tartalmát bizonyos napok elteltével"
|
||||
msgstr "Kuka tartalmának automatikusan törlése bizonyos napok elteltével"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:629
|
||||
msgid "Automatically switch theme to match system theme"
|
||||
msgstr "Automatikusan váltson olyan témára, ami egyezik a rendszer témájával"
|
||||
msgstr "Automatikus átváltás a rendszer témájával egyező témára"
|
||||
|
||||
#: packages/app-desktop/gui/ConfigScreen/ButtonBar.tsx:48
|
||||
#: packages/app-desktop/gui/ConfigScreen/ConfigScreen.tsx:475
|
||||
@@ -1164,9 +1163,8 @@ msgid "Code View"
|
||||
msgstr "Kódnézet"
|
||||
|
||||
#: packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.ts:83
|
||||
#, fuzzy
|
||||
msgid "Code:"
|
||||
msgstr "Kód"
|
||||
msgstr "Kód:"
|
||||
|
||||
#: packages/lib/utils/joplinCloud/index.ts:184
|
||||
msgid "Collaborate on a notebook with others"
|
||||
@@ -1339,14 +1337,12 @@ msgid "Control character"
|
||||
msgstr "Vezérlőkarakter"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteEditor.tsx:660
|
||||
#, fuzzy
|
||||
msgid "Convert it"
|
||||
msgstr "Átalakítás jegyzetté"
|
||||
msgstr "Átalakítás"
|
||||
|
||||
#: packages/app-desktop/commands/convertNoteToMarkdown.ts:11
|
||||
#, fuzzy
|
||||
msgid "Convert note to Markdown"
|
||||
msgstr "Átalakítás tennivalóra"
|
||||
msgstr "Jegyzet átalakítása Markdown formátumba"
|
||||
|
||||
#: packages/app-mobile/components/screens/Note/Note.tsx:1332
|
||||
msgid "Convert to note"
|
||||
@@ -1395,7 +1391,7 @@ msgstr "Hivatkozás másolása a weboldalra"
|
||||
#: packages/app-desktop/gui/utils/NoteListUtils.ts:121
|
||||
#: packages/app-mobile/components/screens/Note/Note.tsx:1341
|
||||
msgid "Copy Markdown link"
|
||||
msgstr "Markdown-hivatkozás másolása"
|
||||
msgstr "Markdown hivatkozás másolása"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts:201
|
||||
msgid "Copy path to clipboard"
|
||||
@@ -1452,9 +1448,8 @@ msgid "Could not connect to plugin repository."
|
||||
msgstr "Nem sikerült kapcsolódni a bővítménytárolóhoz."
|
||||
|
||||
#: packages/app-desktop/commands/convertNoteToMarkdown.ts:45
|
||||
#, fuzzy
|
||||
msgid "Could not convert note to Markdown: %s"
|
||||
msgstr "Nem sikerült exportálni a jegyzeteket: %s"
|
||||
msgstr "Nem sikerült a jegyzetet átalakítani Markdown formátumba: %s"
|
||||
|
||||
#: packages/app-desktop/InteropServiceHelper.ts:224
|
||||
msgid "Could not export notes: %s"
|
||||
@@ -1509,9 +1504,8 @@ msgid "Create a notebook"
|
||||
msgstr "Jegyzetfüzet létrehozása"
|
||||
|
||||
#: packages/app-mobile/components/FolderPicker.tsx:112
|
||||
#, fuzzy
|
||||
msgid "Create new notebook"
|
||||
msgstr "Új jegyzetfüzet létrehozása."
|
||||
msgstr "Új jegyzetfüzet létrehozása"
|
||||
|
||||
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addProfile.ts:9
|
||||
#: packages/app-mobile/components/ProfileSwitcher/ProfileEditor.tsx:88
|
||||
@@ -1519,9 +1513,8 @@ msgid "Create new profile..."
|
||||
msgstr "Új profil létrehozása…"
|
||||
|
||||
#: packages/app-mobile/components/screens/DocumentScanner/NotePreview.tsx:169
|
||||
#, fuzzy
|
||||
msgid "Create note"
|
||||
msgstr "Jegyzetfüzet létrehozása"
|
||||
msgstr "Jegyzet létrehozása"
|
||||
|
||||
#: packages/app-desktop/gui/EditFolderDialog/Dialog.tsx:166
|
||||
msgid "Create notebook"
|
||||
@@ -1591,14 +1584,12 @@ msgid "Creating new to-do..."
|
||||
msgstr "Új teendő létrehozása…"
|
||||
|
||||
#: packages/app-mobile/components/screens/DocumentScanner/DocumentScanner.tsx:78
|
||||
#, fuzzy
|
||||
msgid "Creating note \"%s\"..."
|
||||
msgstr "Új jegyzet létrehozása…"
|
||||
msgstr "A(z) „%s” nevű jegyzet létrehozása…"
|
||||
|
||||
#: packages/app-mobile/components/screens/DocumentScanner/DocumentScanner.tsx:130
|
||||
#, fuzzy
|
||||
msgid "Creating note."
|
||||
msgstr "Új jegyzet létrehozása…"
|
||||
msgstr "Jegyzet létrehozása."
|
||||
|
||||
#: packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/ExportDebugReportButton.tsx:29
|
||||
msgid "Creating report..."
|
||||
@@ -1701,6 +1692,8 @@ msgstr "Alapértelmezett"
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1924
|
||||
msgid "Default title to use for documents created by the scanner."
|
||||
msgstr ""
|
||||
"A dokumentumbeolvasó által létrehozott dokumentumokhoz használt "
|
||||
"alapértelmezett cím."
|
||||
|
||||
#: packages/app-cli/app/help-utils.js:71
|
||||
msgid "Default: %s"
|
||||
@@ -1990,7 +1983,7 @@ msgstr ""
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1923
|
||||
msgid "Document scanner: Title template"
|
||||
msgstr ""
|
||||
msgstr "Dokumentumbeolvasó: Címsablon"
|
||||
|
||||
#: packages/app-cli/app/command-share.ts:65
|
||||
msgid ""
|
||||
@@ -2002,7 +1995,7 @@ msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteEditor.tsx:662
|
||||
msgid "Don't show this message again"
|
||||
msgstr ""
|
||||
msgstr "Ne jelenjen meg többé ez az üzenet"
|
||||
|
||||
#: packages/lib/models/Setting.ts:1299
|
||||
msgid "Donate, website"
|
||||
@@ -2128,14 +2121,12 @@ msgid "Edit"
|
||||
msgstr "Szerkesztés"
|
||||
|
||||
#: packages/app-mobile/components/screens/Note/Note.tsx:1368
|
||||
#, fuzzy
|
||||
msgid "Edit as Markdown"
|
||||
msgstr "Markdown"
|
||||
msgstr "Szerkesztés Markdownként"
|
||||
|
||||
#: packages/app-mobile/components/screens/Note/Note.tsx:1368
|
||||
#, fuzzy
|
||||
msgid "Edit as Rich Text"
|
||||
msgstr "Formázott szöveg"
|
||||
msgstr "Szerkesztés formázott szövegként"
|
||||
|
||||
#: packages/app-mobile/components/NoteEditor/EditLinkDialog.tsx:138
|
||||
msgid "Edit link"
|
||||
@@ -2295,14 +2286,12 @@ msgid "Enable Fountain syntax support"
|
||||
msgstr "Forgatókönyv-szintaxis engedélyezése"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:564
|
||||
#, fuzzy
|
||||
msgid "Enable handwritten transcription"
|
||||
msgstr "Titkosítás engedélyezése"
|
||||
msgstr "Kézzel írt átírás engedélyezése"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:741
|
||||
#, fuzzy
|
||||
msgid "Enable HTML-to-Markdown conversion banner"
|
||||
msgstr "Markdown-eszköztár engedélyezése"
|
||||
msgstr "HTML-Markdown átalakítási kísérőoldal engedélyezése"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1063
|
||||
msgid "Enable Linkify"
|
||||
@@ -2310,7 +2299,7 @@ msgstr "Linkify engedélyezése"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1079
|
||||
msgid "Enable markdown emoji"
|
||||
msgstr "Markdown-emodzsi engedélyezése"
|
||||
msgstr "Markdown emodzsi engedélyezése"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1065
|
||||
msgid "Enable math expressions"
|
||||
@@ -2318,11 +2307,11 @@ msgstr "Matematikai képletek engedélyezése"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1067
|
||||
msgid "Enable Mermaid diagrams support"
|
||||
msgstr "Mermaid-diagram támogatásának engedélyezése"
|
||||
msgstr "Mermaid diagram támogatásának engedélyezése"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1081
|
||||
msgid "Enable multimarkdown table extension"
|
||||
msgstr "Multimarkdown-táblázatkiegészítő engedélyezése"
|
||||
msgstr "Multimarkdown táblázatkiegészítő engedélyezése"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1645
|
||||
msgid "Enable note history"
|
||||
@@ -2351,7 +2340,7 @@ msgstr "Lágy törések engedélyezése"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1453
|
||||
msgid "Enable spell checking in Markdown editor"
|
||||
msgstr "Helyesírás-ellenőrzés engedélyezése a Markdown-szerkesztőben"
|
||||
msgstr "Helyesírás-ellenőrzés engedélyezése a Markdown szerkesztőben"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:903
|
||||
msgid "Enable spellcheck in the text editor"
|
||||
@@ -2363,7 +2352,7 @@ msgstr "Tartalomjegyzék-kiegészítő engedélyezése"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:914
|
||||
msgid "Enable the Markdown toolbar"
|
||||
msgstr "Markdown-eszköztár engedélyezése"
|
||||
msgstr "Markdown eszköztár engedélyezése"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1062
|
||||
msgid "Enable typographer support"
|
||||
@@ -2390,7 +2379,7 @@ msgid ""
|
||||
"Enables Markdown list continuation, auto-closing HTML tags, and other markup "
|
||||
"autocompletions."
|
||||
msgstr ""
|
||||
"Engedélyezi a Markdown-lista folytatását, az automatikus HTML-címkék "
|
||||
"Engedélyezi a Markdown lista folytatását, az automatikus HTML-címkék "
|
||||
"bezárását és egyéb automatikus jelölőkiegészítéseket."
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:762
|
||||
@@ -2398,7 +2387,7 @@ msgid ""
|
||||
"Enables Markdown pattern replacement in the Rich Text Editor. For example, "
|
||||
"when enabled, typing **bold** creates bold text."
|
||||
msgstr ""
|
||||
"Engedélyezi a Markdown-minták cseréjét a formázott szöveges szerkesztőben. "
|
||||
"Engedélyezi a Markdown minták cseréjét a formázott szöveges szerkesztőben. "
|
||||
"Ha például engedélyezve van, a **félkövér** beírása félkövér szöveget hoz "
|
||||
"létre."
|
||||
|
||||
@@ -2919,9 +2908,8 @@ msgid "Hide password"
|
||||
msgstr "Jelszó elrejtése"
|
||||
|
||||
#: packages/app-mobile/components/NoteEditor/WarningBanner.tsx:24
|
||||
#, fuzzy
|
||||
msgid "Hides warning"
|
||||
msgstr "Figyelmeztetés"
|
||||
msgstr "Figyelmeztetések elrejtése"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.ts:14
|
||||
msgid "Highlight"
|
||||
@@ -2973,6 +2961,8 @@ msgid ""
|
||||
"If an image attachment is on its own line and followed by a blank line, it "
|
||||
"will be rendered just below its Markdown source."
|
||||
msgstr ""
|
||||
"Ha egy képmelléklet a saját sorában található, és utána egy üres sor "
|
||||
"következik, akkor a Markdown forrása alatt jelenik meg."
|
||||
|
||||
#: packages/lib/services/joplinCloudUtils.ts:37
|
||||
msgid ""
|
||||
@@ -3675,13 +3665,12 @@ msgid "Markdown editor"
|
||||
msgstr "Markdown szerkesztő"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1473
|
||||
#, fuzzy
|
||||
msgid "Markdown editor: Render images"
|
||||
msgstr "Markdown szerkesztő"
|
||||
msgstr "Markdown szerkesztő: képek renderelése"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1463
|
||||
msgid "Markdown editor: Render markup in editor"
|
||||
msgstr ""
|
||||
msgstr "Markdown szerkesztő: Markup renderelés a szerkesztőben"
|
||||
|
||||
#: packages/app-cli/app/command-done.ts:15
|
||||
msgid "Marks a to-do as done."
|
||||
@@ -3905,9 +3894,8 @@ msgstr ""
|
||||
"Új „%s” nevű jegyzetfüzet jön létre, és a(z) „%s” fájl ebbe lesz importálva"
|
||||
|
||||
#: packages/app-mobile/components/FolderPicker.tsx:71
|
||||
#, fuzzy
|
||||
msgid "New notebook title"
|
||||
msgstr "Jegyzetfüzet címe:"
|
||||
msgstr "Új jegyzetfüzet címe"
|
||||
|
||||
#: packages/app-mobile/setupQuickActions.ts:32
|
||||
msgid "New photo"
|
||||
@@ -4024,9 +4012,8 @@ msgid "No tab selected"
|
||||
msgstr "Nincs lap kijelölve"
|
||||
|
||||
#: packages/app-mobile/components/TagEditor.tsx:167
|
||||
#, fuzzy
|
||||
msgid "No tags"
|
||||
msgstr "Új címkék:"
|
||||
msgstr "Nincsenek címkék"
|
||||
|
||||
#: packages/app-cli/app/command-edit.ts:31
|
||||
msgid ""
|
||||
@@ -4146,9 +4133,8 @@ msgid "Note list style"
|
||||
msgstr "Jegyzetlista stílusa"
|
||||
|
||||
#: packages/app-mobile/components/screens/DocumentScanner/DocumentScanner.tsx:128
|
||||
#, fuzzy
|
||||
msgid "Note preview"
|
||||
msgstr "Jegyzetmegjelenítő"
|
||||
msgstr "Jegyzet előnézete"
|
||||
|
||||
#: packages/app-desktop/gui/NotePropertiesDialog.tsx:497
|
||||
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showNoteProperties.ts:7
|
||||
@@ -4242,9 +4228,8 @@ msgid "OCR: Language data URL or path"
|
||||
msgstr "OCR: Nyelvi adatok webcíme vagy elérési útja"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:604
|
||||
#, fuzzy
|
||||
msgid "OCR: Search in extracted content"
|
||||
msgstr "Keresés az összes jegyzetben"
|
||||
msgstr "OCR: Keresés a kivonatolt tartalomban"
|
||||
|
||||
#: packages/app-cli/app/utils/shimInitCli.ts:12
|
||||
#: packages/app-desktop/bridge.ts:392 packages/app-desktop/bridge.ts:405
|
||||
@@ -4512,7 +4497,7 @@ msgstr "Engedély szükséges"
|
||||
|
||||
#: packages/app-mobile/components/screens/DocumentScanner/DocumentScanner.tsx:93
|
||||
msgid "Photo %d"
|
||||
msgstr ""
|
||||
msgstr "Kép: %d"
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.tsx:261
|
||||
msgid ""
|
||||
@@ -4859,7 +4844,6 @@ msgid "Re-upload local data to sync target"
|
||||
msgstr "Helyi adatok újrafeltöltése a szinkronizálási célra"
|
||||
|
||||
#: packages/app-mobile/components/NoteEditor/WarningBanner.tsx:19
|
||||
#, fuzzy
|
||||
msgid "Read more"
|
||||
msgstr "Tudjon meg többet"
|
||||
|
||||
@@ -4872,9 +4856,9 @@ msgid "Read time: %s min"
|
||||
msgstr "Olvasási idő: %s perc"
|
||||
|
||||
#: packages/app-cli/app/command-batch.ts:45
|
||||
#, fuzzy
|
||||
msgid "Reading commands from standard input is only available in CLI mode."
|
||||
msgstr "A(z) „%s” parancs csak GUI módban érhető el"
|
||||
msgstr ""
|
||||
"A parancsok általános bemenetről történő olvasása csak CLI-módban érhető el."
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.tsx:314
|
||||
msgid "Recipient has accepted the invitation"
|
||||
@@ -4893,9 +4877,8 @@ msgid "Recipients:"
|
||||
msgstr "Címzettek:"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts:142
|
||||
#, fuzzy
|
||||
msgid "Recognize handwritten image"
|
||||
msgstr "Nagy méretű képek átméretezése:"
|
||||
msgstr "Kézzel írt kép felismerése"
|
||||
|
||||
#: packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/RecommendedBadge.tsx:72
|
||||
msgid "Recommended"
|
||||
@@ -4949,9 +4932,8 @@ msgid "Remove"
|
||||
msgstr "Eltávolítás"
|
||||
|
||||
#: packages/app-mobile/components/TagEditor.tsx:129
|
||||
#, fuzzy
|
||||
msgid "Remove %s"
|
||||
msgstr "Eltávolítás"
|
||||
msgstr "%s eltávolítása"
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.tsx:331
|
||||
msgid "Remove %s from share"
|
||||
@@ -4970,9 +4952,8 @@ msgid "Removed %s from share."
|
||||
msgstr "A(z) %s eltávolítva a megosztásból."
|
||||
|
||||
#: packages/app-mobile/components/TagEditor.tsx:230
|
||||
#, fuzzy
|
||||
msgid "Removed tag: %s"
|
||||
msgstr "Címke átnevezése:"
|
||||
msgstr "Címke eltávolítva: %s"
|
||||
|
||||
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/renameFolder.ts:8
|
||||
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/renameTag.ts:8
|
||||
@@ -4994,6 +4975,7 @@ msgstr "A megadott <item> (jegyzet vagy jegyzetfüzet) átnevezése <name> névr
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1464
|
||||
msgid "Renders markup on all lines that don't include the cursor."
|
||||
msgstr ""
|
||||
"Rendereli a Markupot az összes olyan sorban, amely nem tartalmazza a kurzort."
|
||||
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.tsx:162
|
||||
msgid "Renew token"
|
||||
@@ -5241,12 +5223,11 @@ msgstr "Módosítások mentése?"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:882
|
||||
msgid "Save geo-location with notes"
|
||||
msgstr "A földrajzi helyszín mentése a jegyzetekhez"
|
||||
msgstr "Földrajzi helyszín mentése a jegyzetekhez"
|
||||
|
||||
#: packages/app-mobile/components/screens/Notes/NewNoteButton.tsx:81
|
||||
#, fuzzy
|
||||
msgid "Scan notebook"
|
||||
msgstr "Egy jegyzetfüzet kijelölése"
|
||||
msgstr "Jegyzetfüzet beolvasása"
|
||||
|
||||
#: packages/app-mobile/components/CameraView/ScannedBarcodes.tsx:90
|
||||
msgid "Scanned code"
|
||||
@@ -5295,9 +5276,8 @@ msgid "Search shown"
|
||||
msgstr "Keresés a láthatók között"
|
||||
|
||||
#: packages/app-mobile/components/TagEditor.tsx:272
|
||||
#, fuzzy
|
||||
msgid "Search tags"
|
||||
msgstr "Keresési eredmény"
|
||||
msgstr "Címkék keresése"
|
||||
|
||||
#: packages/app-cli/app/gui/FolderListWidget.ts:56
|
||||
msgid "Search:"
|
||||
@@ -5346,18 +5326,16 @@ msgid "Select file..."
|
||||
msgstr "Fájl kijelölése…"
|
||||
|
||||
#: packages/app-mobile/components/screens/DocumentScanner/NotePreview.tsx:147
|
||||
#, fuzzy
|
||||
msgid "Select notebook"
|
||||
msgstr "Egy jegyzetfüzet kijelölése"
|
||||
msgstr "Jegyzetfüzet kiválasztása"
|
||||
|
||||
#: packages/app-mobile/components/screens/folder.js:109
|
||||
msgid "Select parent notebook"
|
||||
msgstr "Szülő jegyzetfüzet kijelölése"
|
||||
|
||||
#: packages/app-mobile/components/ComboBox.tsx:367
|
||||
#, fuzzy
|
||||
msgid "Selected: %s"
|
||||
msgstr "Létrehozva: %s"
|
||||
msgstr "Kiválasztva: %s"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/localisation.ts:9
|
||||
msgid "Selection deleted"
|
||||
@@ -6192,14 +6170,16 @@ msgstr ""
|
||||
#: packages/lib/services/RevisionService.ts:274
|
||||
msgid "The note \"%s\" has been successfully restored to the notebook \"%s\"."
|
||||
msgstr ""
|
||||
"A(z) „%s” jegyzetet sikeresen vissza lettek állítva a(z) „%s” jegyzetfüzetbe."
|
||||
"A(z) „%s” nevű jegyzet sikeresen vissza lett állítva a(z) „%s” nevű "
|
||||
"jegyzetfüzetbe."
|
||||
|
||||
#: packages/app-desktop/gui/ConversionNotification/ConversionNotification.tsx:22
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
"The note has been converted to Markdown and the original note has been moved "
|
||||
"to the trash"
|
||||
msgstr "A jegyzetfüzet és annak tartalma sikeresen át lett helyezve a kukába."
|
||||
msgstr ""
|
||||
"A jegyzet Markdown formátumba lett konvertálva, és az eredeti jegyzet át "
|
||||
"lett helyezve a kukába"
|
||||
|
||||
#: packages/app-desktop/gui/TrashNotification/TrashNotification.tsx:45
|
||||
msgid "The note was successfully moved to the trash."
|
||||
@@ -6440,14 +6420,18 @@ msgid ""
|
||||
"This feature is disabled by default, you need to manually enable it by "
|
||||
"turning on the option to 'Enable handwritten transcription'."
|
||||
msgstr ""
|
||||
"Ez a funkció alapértelmezetten ki van kapcsolva, kézzel kell engedélyezni a "
|
||||
"„Kézzel írt átírás engedélyezése” beállítás bekapcsolásával."
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts:146
|
||||
msgid "This feature is only available on Joplin Cloud and Joplin Server."
|
||||
msgstr ""
|
||||
"Ez a funkció csak a Joplin Cloud és a Joplin Server szolgáltatásokban érhető "
|
||||
"el."
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts:158
|
||||
msgid "This image type is not supported by the recognition system."
|
||||
msgstr ""
|
||||
msgstr "Ezt a képtípust nem támogatja a képfelismerő rendszer."
|
||||
|
||||
#: packages/app-desktop/gui/ResourceScreen.tsx:298
|
||||
msgid ""
|
||||
@@ -6502,6 +6486,8 @@ msgstr "Ennek a jegyzetnek nincsenek előzményei"
|
||||
msgid ""
|
||||
"This note is in HTML format. Convert it to Markdown to edit it more easily."
|
||||
msgstr ""
|
||||
"Ez a jegyzet HTML-formátumban van. Alakítsa át Markdown formátumba, hogy "
|
||||
"könnyebben szerkeszthesse."
|
||||
|
||||
#: packages/lib/services/plugins/PluginService.ts:535
|
||||
msgid "This plugin doesn't support %s."
|
||||
@@ -6607,6 +6593,8 @@ msgstr "A folytatáshoz adja meg az alábbiakban a mesterjelszót."
|
||||
#: packages/app-mobile/components/ComboBox.tsx:568
|
||||
msgid "To create a new tag, type the name and press enter."
|
||||
msgstr ""
|
||||
"Új címke létrehozásához írja be a címke nevét, majd nyomja meg az enter "
|
||||
"billentyűt."
|
||||
|
||||
#: packages/app-cli/app/app-gui.js:458
|
||||
msgid "To delete a tag, untag the associated notes."
|
||||
@@ -6990,9 +6978,8 @@ msgid "Upgrade the sync target to the latest version."
|
||||
msgstr "Fejlessze a szinkronizálási célt a legfrissebb verzióra."
|
||||
|
||||
#: packages/app-mobile/components/CameraView/CameraView.web.tsx:45
|
||||
#, fuzzy
|
||||
msgid "Upload photo"
|
||||
msgstr "Kép készítése"
|
||||
msgstr "Kép feltöltése"
|
||||
|
||||
#: packages/app-desktop/gui/NotePropertiesDialog.tsx:86
|
||||
#: packages/app-mobile/components/NoteEditor/EditLinkDialog.tsx:109
|
||||
|
@@ -2,19 +2,21 @@
|
||||
# Copyright (C) YEAR Laurent Cozic
|
||||
# This file is distributed under the same license as the Joplin-CLI package.
|
||||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
||||
# Liffindra Angga Zaaldian <lzaaldian@gmail.com>, 2025.
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Joplin-CLI 1.0.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"Last-Translator: Liffindra Angga Zaaldian <lzaaldian@gmail.com>\n"
|
||||
"Language-Team: \n"
|
||||
"Language: id_ID\n"
|
||||
"Language-Team: Indonesian\n"
|
||||
"Language: id\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=1; plural=0\n"
|
||||
"X-Generator: Gtranslator 42.0\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
"X-Generator: Gtranslator 48.0\n"
|
||||
"PO-Revision-Date: 2025-08-17 00:11+0700\n"
|
||||
|
||||
#: packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.tsx:611
|
||||
msgid "- Camera: to allow taking a picture and attaching it to a note."
|
||||
@@ -421,9 +423,8 @@ msgid "Add recipient:"
|
||||
msgstr "Tambah penerima:"
|
||||
|
||||
#: packages/app-mobile/components/TagEditor.tsx:264
|
||||
#, fuzzy
|
||||
msgid "Add tags:"
|
||||
msgstr "Label baru:"
|
||||
msgstr "Tambah label:"
|
||||
|
||||
#: packages/app-mobile/components/screens/Note/Note.tsx:1726
|
||||
msgid "Add title"
|
||||
@@ -438,18 +439,16 @@ msgid "Add to note"
|
||||
msgstr "Tambahkan ke catatan"
|
||||
|
||||
#: packages/app-mobile/components/ComboBox.tsx:89
|
||||
#, fuzzy
|
||||
msgid "Added new: %s"
|
||||
msgstr "Tambah baru"
|
||||
msgstr "Baru ditambahkan: %s"
|
||||
|
||||
#: packages/app-mobile/components/TagEditor.tsx:218
|
||||
#, fuzzy
|
||||
msgid "Added tag: %s"
|
||||
msgstr "Tambah label %s ke catatan"
|
||||
msgstr "Ditambahkan ke label: %s"
|
||||
|
||||
#: packages/app-mobile/components/TagEditor.tsx:205
|
||||
msgid "Adds tag"
|
||||
msgstr ""
|
||||
msgstr "Menambahkan label"
|
||||
|
||||
#: packages/server/src/services/MustacheService.ts:162
|
||||
#: packages/server/src/services/MustacheService.ts:286
|
||||
@@ -633,7 +632,7 @@ msgstr "Aritim Dark"
|
||||
|
||||
#: packages/app-mobile/components/TagEditor.tsx:172
|
||||
msgid "Associated tags:"
|
||||
msgstr ""
|
||||
msgstr "Label terkait:"
|
||||
|
||||
#: packages/app-mobile/utils/lockToSingleInstance.ts:15
|
||||
msgid ""
|
||||
@@ -1154,9 +1153,8 @@ msgid "Code View"
|
||||
msgstr "Tampilan Kode"
|
||||
|
||||
#: packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.ts:83
|
||||
#, fuzzy
|
||||
msgid "Code:"
|
||||
msgstr "Kode"
|
||||
msgstr "Kode:"
|
||||
|
||||
#: packages/lib/utils/joplinCloud/index.ts:184
|
||||
msgid "Collaborate on a notebook with others"
|
||||
@@ -1328,14 +1326,12 @@ msgid "Control character"
|
||||
msgstr "Karakter kendali"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteEditor.tsx:660
|
||||
#, fuzzy
|
||||
msgid "Convert it"
|
||||
msgstr "Ubah ke catatan"
|
||||
msgstr "Ubah"
|
||||
|
||||
#: packages/app-desktop/commands/convertNoteToMarkdown.ts:11
|
||||
#, fuzzy
|
||||
msgid "Convert note to Markdown"
|
||||
msgstr "Ubah ke tugas"
|
||||
msgstr "Ubah catatan ke Markdown"
|
||||
|
||||
#: packages/app-mobile/components/screens/Note/Note.tsx:1332
|
||||
msgid "Convert to note"
|
||||
@@ -1440,9 +1436,8 @@ msgid "Could not connect to plugin repository."
|
||||
msgstr "Tidak dapat terhubung ke repositori plugin."
|
||||
|
||||
#: packages/app-desktop/commands/convertNoteToMarkdown.ts:45
|
||||
#, fuzzy
|
||||
msgid "Could not convert note to Markdown: %s"
|
||||
msgstr "Tidak dapat mengekspor catatan: %s"
|
||||
msgstr "Tidak dapat mengubah catatan ke Markdown: %s"
|
||||
|
||||
#: packages/app-desktop/InteropServiceHelper.ts:224
|
||||
msgid "Could not export notes: %s"
|
||||
@@ -1497,9 +1492,8 @@ msgid "Create a notebook"
|
||||
msgstr "Buat buku catatan"
|
||||
|
||||
#: packages/app-mobile/components/FolderPicker.tsx:112
|
||||
#, fuzzy
|
||||
msgid "Create new notebook"
|
||||
msgstr "Membuat buku catatan baru."
|
||||
msgstr "Buat buku catatan baru"
|
||||
|
||||
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addProfile.ts:9
|
||||
#: packages/app-mobile/components/ProfileSwitcher/ProfileEditor.tsx:88
|
||||
@@ -1507,9 +1501,8 @@ msgid "Create new profile..."
|
||||
msgstr "Buat profil baru..."
|
||||
|
||||
#: packages/app-mobile/components/screens/DocumentScanner/NotePreview.tsx:169
|
||||
#, fuzzy
|
||||
msgid "Create note"
|
||||
msgstr "Buat buku catatan"
|
||||
msgstr "Buat catatan"
|
||||
|
||||
#: packages/app-desktop/gui/EditFolderDialog/Dialog.tsx:166
|
||||
msgid "Create notebook"
|
||||
@@ -1579,14 +1572,12 @@ msgid "Creating new to-do..."
|
||||
msgstr "Membuat tugas baru..."
|
||||
|
||||
#: packages/app-mobile/components/screens/DocumentScanner/DocumentScanner.tsx:78
|
||||
#, fuzzy
|
||||
msgid "Creating note \"%s\"..."
|
||||
msgstr "Membuat catatan baru..."
|
||||
msgstr "Membuat catatan \"%s\"..."
|
||||
|
||||
#: packages/app-mobile/components/screens/DocumentScanner/DocumentScanner.tsx:130
|
||||
#, fuzzy
|
||||
msgid "Creating note."
|
||||
msgstr "Membuat catatan baru..."
|
||||
msgstr "Membuat catatan."
|
||||
|
||||
#: packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/ExportDebugReportButton.tsx:29
|
||||
msgid "Creating report..."
|
||||
@@ -1688,7 +1679,7 @@ msgstr "Bawaan"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1924
|
||||
msgid "Default title to use for documents created by the scanner."
|
||||
msgstr ""
|
||||
msgstr "Judul bawaan untuk digunakan pada dokumen yang dibuat oleh pemindai."
|
||||
|
||||
#: packages/app-cli/app/help-utils.js:71
|
||||
msgid "Default: %s"
|
||||
@@ -1979,7 +1970,7 @@ msgstr ""
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1923
|
||||
msgid "Document scanner: Title template"
|
||||
msgstr ""
|
||||
msgstr "Pemindai dokumen: Templat judul"
|
||||
|
||||
#: packages/app-cli/app/command-share.ts:65
|
||||
msgid ""
|
||||
@@ -1991,7 +1982,7 @@ msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteEditor.tsx:662
|
||||
msgid "Don't show this message again"
|
||||
msgstr ""
|
||||
msgstr "Jangan tampilkan pesan ini lagi"
|
||||
|
||||
#: packages/lib/models/Setting.ts:1299
|
||||
msgid "Donate, website"
|
||||
@@ -2117,14 +2108,12 @@ msgid "Edit"
|
||||
msgstr "Sunting"
|
||||
|
||||
#: packages/app-mobile/components/screens/Note/Note.tsx:1368
|
||||
#, fuzzy
|
||||
msgid "Edit as Markdown"
|
||||
msgstr "Markdown"
|
||||
msgstr "Sunting sebagai Markdown"
|
||||
|
||||
#: packages/app-mobile/components/screens/Note/Note.tsx:1368
|
||||
#, fuzzy
|
||||
msgid "Edit as Rich Text"
|
||||
msgstr "Teks Kaya"
|
||||
msgstr "Sunting sebagai Teks Kaya"
|
||||
|
||||
#: packages/app-mobile/components/NoteEditor/EditLinkDialog.tsx:138
|
||||
msgid "Edit link"
|
||||
@@ -2284,14 +2273,12 @@ msgid "Enable Fountain syntax support"
|
||||
msgstr "Nyalakan dukungan sintaksis Fountain"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:564
|
||||
#, fuzzy
|
||||
msgid "Enable handwritten transcription"
|
||||
msgstr "Nyalakan enkripsi"
|
||||
msgstr "Nyalakan transkripsi tulisan tangan"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:741
|
||||
#, fuzzy
|
||||
msgid "Enable HTML-to-Markdown conversion banner"
|
||||
msgstr "Nyalakan bilah alat Markdown"
|
||||
msgstr "Nyalakan spanduk penukaran HTML-ke-Markdown"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1063
|
||||
msgid "Enable Linkify"
|
||||
@@ -2904,9 +2891,8 @@ msgid "Hide password"
|
||||
msgstr "Sembunyikan kata sandi"
|
||||
|
||||
#: packages/app-mobile/components/NoteEditor/WarningBanner.tsx:24
|
||||
#, fuzzy
|
||||
msgid "Hides warning"
|
||||
msgstr "Peringatan"
|
||||
msgstr "Sembunyikan peringatan"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.ts:14
|
||||
msgid "Highlight"
|
||||
@@ -2958,6 +2944,9 @@ msgid ""
|
||||
"If an image attachment is on its own line and followed by a blank line, it "
|
||||
"will be rendered just below its Markdown source."
|
||||
msgstr ""
|
||||
"Jika sebuah lampiran foto berada pada barisnya sendiri dan diikuti oleh "
|
||||
"sebuah baris kosong, lampiran tersebut akan ditampilkan tepat di bawah "
|
||||
"sumber Markdownnya."
|
||||
|
||||
#: packages/lib/services/joplinCloudUtils.ts:37
|
||||
msgid ""
|
||||
@@ -3652,13 +3641,12 @@ msgid "Markdown editor"
|
||||
msgstr "Penyunting Markdown"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1473
|
||||
#, fuzzy
|
||||
msgid "Markdown editor: Render images"
|
||||
msgstr "Penyunting Markdown"
|
||||
msgstr "Penyunting Markdown: Tampilkan gambar"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1463
|
||||
msgid "Markdown editor: Render markup in editor"
|
||||
msgstr ""
|
||||
msgstr "Penyunting Markdown: Tampilkan markup pada penyunting"
|
||||
|
||||
#: packages/app-cli/app/command-done.ts:15
|
||||
msgid "Marks a to-do as done."
|
||||
@@ -3881,9 +3869,8 @@ msgstr ""
|
||||
"dalamnya"
|
||||
|
||||
#: packages/app-mobile/components/FolderPicker.tsx:71
|
||||
#, fuzzy
|
||||
msgid "New notebook title"
|
||||
msgstr "Judul buku catatan:"
|
||||
msgstr "Judul buku catatan baru"
|
||||
|
||||
#: packages/app-mobile/setupQuickActions.ts:32
|
||||
msgid "New photo"
|
||||
@@ -4001,9 +3988,8 @@ msgid "No tab selected"
|
||||
msgstr "Tidak ada tab yang dipilih"
|
||||
|
||||
#: packages/app-mobile/components/TagEditor.tsx:167
|
||||
#, fuzzy
|
||||
msgid "No tags"
|
||||
msgstr "Label baru:"
|
||||
msgstr "Tidak ada label"
|
||||
|
||||
#: packages/app-cli/app/command-edit.ts:31
|
||||
msgid ""
|
||||
@@ -4123,9 +4109,8 @@ msgid "Note list style"
|
||||
msgstr "Gaya daftar catatan"
|
||||
|
||||
#: packages/app-mobile/components/screens/DocumentScanner/DocumentScanner.tsx:128
|
||||
#, fuzzy
|
||||
msgid "Note preview"
|
||||
msgstr "Yang melihat catatan"
|
||||
msgstr "Pratinjau catatan"
|
||||
|
||||
#: packages/app-desktop/gui/NotePropertiesDialog.tsx:497
|
||||
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showNoteProperties.ts:7
|
||||
@@ -4219,9 +4204,8 @@ msgid "OCR: Language data URL or path"
|
||||
msgstr "OCR: Jalur atau URL data bahasa"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:604
|
||||
#, fuzzy
|
||||
msgid "OCR: Search in extracted content"
|
||||
msgstr "Cari di semua catatan"
|
||||
msgstr "OCR: Cari di isi yang diambil"
|
||||
|
||||
#: packages/app-cli/app/utils/shimInitCli.ts:12
|
||||
#: packages/app-desktop/bridge.ts:392 packages/app-desktop/bridge.ts:405
|
||||
@@ -4488,7 +4472,7 @@ msgstr "Perlu izin"
|
||||
|
||||
#: packages/app-mobile/components/screens/DocumentScanner/DocumentScanner.tsx:93
|
||||
msgid "Photo %d"
|
||||
msgstr ""
|
||||
msgstr "Foto %d"
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.tsx:261
|
||||
msgid ""
|
||||
@@ -4835,9 +4819,8 @@ msgid "Re-upload local data to sync target"
|
||||
msgstr "Unggah ulang data lokal ke sasaran penyelerasan"
|
||||
|
||||
#: packages/app-mobile/components/NoteEditor/WarningBanner.tsx:19
|
||||
#, fuzzy
|
||||
msgid "Read more"
|
||||
msgstr "Pelajari lebih lanjut"
|
||||
msgstr "Baca selebihnya"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/WarningBanner/WarningBanner.tsx:40
|
||||
msgid "Read more about it"
|
||||
@@ -4848,9 +4831,8 @@ msgid "Read time: %s min"
|
||||
msgstr "Waktu baca: %s menit"
|
||||
|
||||
#: packages/app-cli/app/command-batch.ts:45
|
||||
#, fuzzy
|
||||
msgid "Reading commands from standard input is only available in CLI mode."
|
||||
msgstr "Perintah \"%s\" hanya tersedia dalam mode GUI"
|
||||
msgstr "Membaca perintah dari masukan standar hanya tersedia pada mode CLI."
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.tsx:314
|
||||
msgid "Recipient has accepted the invitation"
|
||||
@@ -4869,9 +4851,8 @@ msgid "Recipients:"
|
||||
msgstr "Penerima:"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts:142
|
||||
#, fuzzy
|
||||
msgid "Recognize handwritten image"
|
||||
msgstr "Ubah ukuran gambar besar:"
|
||||
msgstr "Kenali gambar dari tulisan tangan"
|
||||
|
||||
#: packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/RecommendedBadge.tsx:72
|
||||
msgid "Recommended"
|
||||
@@ -4925,9 +4906,8 @@ msgid "Remove"
|
||||
msgstr "Hapus"
|
||||
|
||||
#: packages/app-mobile/components/TagEditor.tsx:129
|
||||
#, fuzzy
|
||||
msgid "Remove %s"
|
||||
msgstr "Hapus"
|
||||
msgstr "Hapus %s"
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.tsx:331
|
||||
msgid "Remove %s from share"
|
||||
@@ -4946,9 +4926,8 @@ msgid "Removed %s from share."
|
||||
msgstr "Telah menghapus %s dari berbagi."
|
||||
|
||||
#: packages/app-mobile/components/TagEditor.tsx:230
|
||||
#, fuzzy
|
||||
msgid "Removed tag: %s"
|
||||
msgstr "Ganti nama label:"
|
||||
msgstr "Label terhapus: %s"
|
||||
|
||||
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/renameFolder.ts:8
|
||||
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/renameTag.ts:8
|
||||
@@ -4971,7 +4950,7 @@ msgstr ""
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1464
|
||||
msgid "Renders markup on all lines that don't include the cursor."
|
||||
msgstr ""
|
||||
msgstr "Tampilkan markup pada semua baris yang tidak menyertakan penunjuk."
|
||||
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.tsx:162
|
||||
msgid "Renew token"
|
||||
@@ -5221,9 +5200,8 @@ msgid "Save geo-location with notes"
|
||||
msgstr "Simpan geolokasi dengan catatan"
|
||||
|
||||
#: packages/app-mobile/components/screens/Notes/NewNoteButton.tsx:81
|
||||
#, fuzzy
|
||||
msgid "Scan notebook"
|
||||
msgstr "Pilih buku catatan"
|
||||
msgstr "Pindai buku catatan"
|
||||
|
||||
#: packages/app-mobile/components/CameraView/ScannedBarcodes.tsx:90
|
||||
msgid "Scanned code"
|
||||
@@ -5269,12 +5247,11 @@ msgstr "Hasil pencarian"
|
||||
|
||||
#: packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.tsx:182
|
||||
msgid "Search shown"
|
||||
msgstr "Pencarian menampilkan"
|
||||
msgstr "Pencarian ditampilkan"
|
||||
|
||||
#: packages/app-mobile/components/TagEditor.tsx:272
|
||||
#, fuzzy
|
||||
msgid "Search tags"
|
||||
msgstr "Hasil pencarian"
|
||||
msgstr "Label pencarian"
|
||||
|
||||
#: packages/app-cli/app/gui/FolderListWidget.ts:56
|
||||
msgid "Search:"
|
||||
@@ -5323,7 +5300,6 @@ msgid "Select file..."
|
||||
msgstr "Pilih berkas..."
|
||||
|
||||
#: packages/app-mobile/components/screens/DocumentScanner/NotePreview.tsx:147
|
||||
#, fuzzy
|
||||
msgid "Select notebook"
|
||||
msgstr "Pilih buku catatan"
|
||||
|
||||
@@ -5332,9 +5308,8 @@ msgid "Select parent notebook"
|
||||
msgstr "Pilih buku catatan induk"
|
||||
|
||||
#: packages/app-mobile/components/ComboBox.tsx:367
|
||||
#, fuzzy
|
||||
msgid "Selected: %s"
|
||||
msgstr "Dibuat: %s"
|
||||
msgstr "Dipilih: %s"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/localisation.ts:9
|
||||
msgid "Selection deleted"
|
||||
@@ -6167,11 +6142,12 @@ msgid "The note \"%s\" has been successfully restored to the notebook \"%s\"."
|
||||
msgstr "Catatan \"%s\" telah berhasil dipulihkan ke buku catatan \"%s\"."
|
||||
|
||||
#: packages/app-desktop/gui/ConversionNotification/ConversionNotification.tsx:22
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
"The note has been converted to Markdown and the original note has been moved "
|
||||
"to the trash"
|
||||
msgstr "Buku catatan dan isinya telah berhasil dipindahkan ke tempat sampah."
|
||||
msgstr ""
|
||||
"Catatan telah diubah ke Markdown dan catatan asli telah dipindahkan ke tong "
|
||||
"sampah"
|
||||
|
||||
#: packages/app-desktop/gui/TrashNotification/TrashNotification.tsx:45
|
||||
msgid "The note was successfully moved to the trash."
|
||||
@@ -6404,14 +6380,16 @@ msgid ""
|
||||
"This feature is disabled by default, you need to manually enable it by "
|
||||
"turning on the option to 'Enable handwritten transcription'."
|
||||
msgstr ""
|
||||
"Fitur ini dimatikan secara bawaan, Anda perlu menyalakannya secara manual "
|
||||
"dengan menyalakan pilihan untuk 'Menyalakan transkripsi tulisan tangan'."
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts:146
|
||||
msgid "This feature is only available on Joplin Cloud and Joplin Server."
|
||||
msgstr ""
|
||||
msgstr "Fitur ini hanya tersedia pada Joplin Cloud dan Joplin Server."
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts:158
|
||||
msgid "This image type is not supported by the recognition system."
|
||||
msgstr ""
|
||||
msgstr "Jenis gambar ini tidak didukung oleh sistem pengenalan."
|
||||
|
||||
#: packages/app-desktop/gui/ResourceScreen.tsx:298
|
||||
msgid ""
|
||||
@@ -6463,6 +6441,8 @@ msgstr "Catatan ini tidak memiliki riwayat"
|
||||
msgid ""
|
||||
"This note is in HTML format. Convert it to Markdown to edit it more easily."
|
||||
msgstr ""
|
||||
"Catatan ini berada dalam format HTML. Ubah catatan ke Markdown untuk "
|
||||
"menyuntingnya dengan lebih mudah."
|
||||
|
||||
#: packages/lib/services/plugins/PluginService.ts:535
|
||||
msgid "This plugin doesn't support %s."
|
||||
@@ -6567,7 +6547,7 @@ msgstr "Untuk melanjutkan, silakan masukkan kata sandi utama di bawah."
|
||||
|
||||
#: packages/app-mobile/components/ComboBox.tsx:568
|
||||
msgid "To create a new tag, type the name and press enter."
|
||||
msgstr ""
|
||||
msgstr "Untuk membuat sebuah label, ketik nama dan tekan enter."
|
||||
|
||||
#: packages/app-cli/app/app-gui.js:458
|
||||
msgid "To delete a tag, untag the associated notes."
|
||||
@@ -6952,9 +6932,8 @@ msgid "Upgrade the sync target to the latest version."
|
||||
msgstr "Tingkatkan sasaran penyelarasan ke versi terakhir."
|
||||
|
||||
#: packages/app-mobile/components/CameraView/CameraView.web.tsx:45
|
||||
#, fuzzy
|
||||
msgid "Upload photo"
|
||||
msgstr "Ambil foto"
|
||||
msgstr "Unggah foto"
|
||||
|
||||
#: packages/app-desktop/gui/NotePropertiesDialog.tsx:86
|
||||
#: packages/app-mobile/components/NoteEditor/EditLinkDialog.tsx:109
|
||||
|
@@ -7,6 +7,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Joplin-CLI 1.0.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: Mihai Vasiliu <mihai.vasiliu.93@gmail.com>\n"
|
||||
"Language-Team: \n"
|
||||
"Language: ro_MD\n"
|
||||
@@ -437,9 +439,8 @@ msgid "Add recipient:"
|
||||
msgstr "Adaugă destinatar:"
|
||||
|
||||
#: packages/app-mobile/components/TagEditor.tsx:264
|
||||
#, fuzzy
|
||||
msgid "Add tags:"
|
||||
msgstr "Etichete noi:"
|
||||
msgstr "Adaugă etichete:"
|
||||
|
||||
#: packages/app-mobile/components/screens/Note/Note.tsx:1726
|
||||
msgid "Add title"
|
||||
@@ -454,18 +455,16 @@ msgid "Add to note"
|
||||
msgstr "Adaugă la notiță"
|
||||
|
||||
#: packages/app-mobile/components/ComboBox.tsx:89
|
||||
#, fuzzy
|
||||
msgid "Added new: %s"
|
||||
msgstr "Adaugă un nou"
|
||||
msgstr "Adaugă nou: %s"
|
||||
|
||||
#: packages/app-mobile/components/TagEditor.tsx:218
|
||||
#, fuzzy
|
||||
msgid "Added tag: %s"
|
||||
msgstr "Adaugă eticheta %s la notița"
|
||||
msgstr "Etichetă adăugată: %s"
|
||||
|
||||
#: packages/app-mobile/components/TagEditor.tsx:205
|
||||
msgid "Adds tag"
|
||||
msgstr ""
|
||||
msgstr "Adaugă etichete"
|
||||
|
||||
#: packages/server/src/services/MustacheService.ts:162
|
||||
#: packages/server/src/services/MustacheService.ts:286
|
||||
@@ -647,7 +646,7 @@ msgstr "Aritim întunecat"
|
||||
|
||||
#: packages/app-mobile/components/TagEditor.tsx:172
|
||||
msgid "Associated tags:"
|
||||
msgstr ""
|
||||
msgstr "Etichete asociate:"
|
||||
|
||||
#: packages/app-mobile/utils/lockToSingleInstance.ts:15
|
||||
msgid ""
|
||||
@@ -1166,9 +1165,8 @@ msgid "Code View"
|
||||
msgstr "Vizualizare code"
|
||||
|
||||
#: packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.ts:83
|
||||
#, fuzzy
|
||||
msgid "Code:"
|
||||
msgstr "Cod"
|
||||
msgstr "Cod:"
|
||||
|
||||
#: packages/lib/utils/joplinCloud/index.ts:184
|
||||
msgid "Collaborate on a notebook with others"
|
||||
@@ -1342,14 +1340,12 @@ msgid "Control character"
|
||||
msgstr "Caracter de control"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteEditor.tsx:660
|
||||
#, fuzzy
|
||||
msgid "Convert it"
|
||||
msgstr "Convertește în notiță"
|
||||
msgstr "Convertește-o"
|
||||
|
||||
#: packages/app-desktop/commands/convertNoteToMarkdown.ts:11
|
||||
#, fuzzy
|
||||
msgid "Convert note to Markdown"
|
||||
msgstr "Conversie în todo"
|
||||
msgstr "Convertește notița în Markdown"
|
||||
|
||||
#: packages/app-mobile/components/screens/Note/Note.tsx:1332
|
||||
msgid "Convert to note"
|
||||
@@ -1456,9 +1452,8 @@ msgid "Could not connect to plugin repository."
|
||||
msgstr "Nu s-a putut conecta la repozitoriul de plugin-uri."
|
||||
|
||||
#: packages/app-desktop/commands/convertNoteToMarkdown.ts:45
|
||||
#, fuzzy
|
||||
msgid "Could not convert note to Markdown: %s"
|
||||
msgstr "Nu s-au putut exporta notițele: %s"
|
||||
msgstr "Nu s-a putut converti notița în Markdown: %s"
|
||||
|
||||
#: packages/app-desktop/InteropServiceHelper.ts:224
|
||||
msgid "Could not export notes: %s"
|
||||
@@ -1513,9 +1508,8 @@ msgid "Create a notebook"
|
||||
msgstr "Creează un caiet"
|
||||
|
||||
#: packages/app-mobile/components/FolderPicker.tsx:112
|
||||
#, fuzzy
|
||||
msgid "Create new notebook"
|
||||
msgstr "Creează un nou caiet."
|
||||
msgstr "Creează un nou caiet"
|
||||
|
||||
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addProfile.ts:9
|
||||
#: packages/app-mobile/components/ProfileSwitcher/ProfileEditor.tsx:88
|
||||
@@ -1523,9 +1517,8 @@ msgid "Create new profile..."
|
||||
msgstr "Creează un nou profil…"
|
||||
|
||||
#: packages/app-mobile/components/screens/DocumentScanner/NotePreview.tsx:169
|
||||
#, fuzzy
|
||||
msgid "Create note"
|
||||
msgstr "Creează un caiet"
|
||||
msgstr "Creează o notiță"
|
||||
|
||||
#: packages/app-desktop/gui/EditFolderDialog/Dialog.tsx:166
|
||||
msgid "Create notebook"
|
||||
@@ -1595,14 +1588,12 @@ msgid "Creating new to-do..."
|
||||
msgstr "Se creează o nouă listă de făcut…"
|
||||
|
||||
#: packages/app-mobile/components/screens/DocumentScanner/DocumentScanner.tsx:78
|
||||
#, fuzzy
|
||||
msgid "Creating note \"%s\"..."
|
||||
msgstr "Se creează o nouă notiță…"
|
||||
msgstr "Se creează notița „%s”..."
|
||||
|
||||
#: packages/app-mobile/components/screens/DocumentScanner/DocumentScanner.tsx:130
|
||||
#, fuzzy
|
||||
msgid "Creating note."
|
||||
msgstr "Se creează o nouă notiță…"
|
||||
msgstr "Se creează notița."
|
||||
|
||||
#: packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/ExportDebugReportButton.tsx:29
|
||||
msgid "Creating report..."
|
||||
@@ -1704,7 +1695,7 @@ msgstr "Mod implicit"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1924
|
||||
msgid "Default title to use for documents created by the scanner."
|
||||
msgstr ""
|
||||
msgstr "Titlul implicit de folosit pentru documentele create de la scanner."
|
||||
|
||||
#: packages/app-cli/app/help-utils.js:71
|
||||
msgid "Default: %s"
|
||||
@@ -1996,7 +1987,7 @@ msgstr ""
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1923
|
||||
msgid "Document scanner: Title template"
|
||||
msgstr ""
|
||||
msgstr "Scanner documente: Șablon titlu"
|
||||
|
||||
#: packages/app-cli/app/command-share.ts:65
|
||||
msgid ""
|
||||
@@ -2008,7 +1999,7 @@ msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteEditor.tsx:662
|
||||
msgid "Don't show this message again"
|
||||
msgstr ""
|
||||
msgstr "Nu mai afișa acest mesaj"
|
||||
|
||||
#: packages/lib/models/Setting.ts:1299
|
||||
msgid "Donate, website"
|
||||
@@ -2133,14 +2124,12 @@ msgid "Edit"
|
||||
msgstr "Editează"
|
||||
|
||||
#: packages/app-mobile/components/screens/Note/Note.tsx:1368
|
||||
#, fuzzy
|
||||
msgid "Edit as Markdown"
|
||||
msgstr "Markdown"
|
||||
msgstr "Editează ca Markdown"
|
||||
|
||||
#: packages/app-mobile/components/screens/Note/Note.tsx:1368
|
||||
#, fuzzy
|
||||
msgid "Edit as Rich Text"
|
||||
msgstr "Text îmbogățit"
|
||||
msgstr "Editează ca text îmbogățit"
|
||||
|
||||
#: packages/app-mobile/components/NoteEditor/EditLinkDialog.tsx:138
|
||||
msgid "Edit link"
|
||||
@@ -2300,14 +2289,12 @@ msgid "Enable Fountain syntax support"
|
||||
msgstr "Activează suportul pentru sintaxa Fountain"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:564
|
||||
#, fuzzy
|
||||
msgid "Enable handwritten transcription"
|
||||
msgstr "Activează criptarea"
|
||||
msgstr "Activează transcrierea scrisului de mînă"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:741
|
||||
#, fuzzy
|
||||
msgid "Enable HTML-to-Markdown conversion banner"
|
||||
msgstr "Activează bara de instrumente Markdown"
|
||||
msgstr "Activează banner-ul de conversie HTML-la-Markdown"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1063
|
||||
msgid "Enable Linkify"
|
||||
@@ -2921,9 +2908,8 @@ msgid "Hide password"
|
||||
msgstr "Ascunde parola"
|
||||
|
||||
#: packages/app-mobile/components/NoteEditor/WarningBanner.tsx:24
|
||||
#, fuzzy
|
||||
msgid "Hides warning"
|
||||
msgstr "Atenție"
|
||||
msgstr "Ascunde atenționarea"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.ts:14
|
||||
msgid "Highlight"
|
||||
@@ -2975,6 +2961,8 @@ msgid ""
|
||||
"If an image attachment is on its own line and followed by a blank line, it "
|
||||
"will be rendered just below its Markdown source."
|
||||
msgstr ""
|
||||
"Dacă un atașament imagine este pe linia proprie și urmat de o linie goală, "
|
||||
"va fi redat imediat sub sursa lui Markdown."
|
||||
|
||||
#: packages/lib/services/joplinCloudUtils.ts:37
|
||||
msgid ""
|
||||
@@ -3677,13 +3665,12 @@ msgid "Markdown editor"
|
||||
msgstr "Editor Markdown"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1473
|
||||
#, fuzzy
|
||||
msgid "Markdown editor: Render images"
|
||||
msgstr "Editor Markdown"
|
||||
msgstr "Editor Markdown: Redă imaginile"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1463
|
||||
msgid "Markdown editor: Render markup in editor"
|
||||
msgstr ""
|
||||
msgstr "Editor Markdown: Redă markup în editor"
|
||||
|
||||
#: packages/app-cli/app/command-done.ts:15
|
||||
msgid "Marks a to-do as done."
|
||||
@@ -3906,9 +3893,8 @@ msgid ""
|
||||
msgstr "Un nou caiet „%s” va fi creat și fișierul „%s” va fi importat în el"
|
||||
|
||||
#: packages/app-mobile/components/FolderPicker.tsx:71
|
||||
#, fuzzy
|
||||
msgid "New notebook title"
|
||||
msgstr "Titlul agendei:"
|
||||
msgstr "Titlu caiet nou"
|
||||
|
||||
#: packages/app-mobile/setupQuickActions.ts:32
|
||||
msgid "New photo"
|
||||
@@ -4026,9 +4012,8 @@ msgid "No tab selected"
|
||||
msgstr "Nicio filă selectată"
|
||||
|
||||
#: packages/app-mobile/components/TagEditor.tsx:167
|
||||
#, fuzzy
|
||||
msgid "No tags"
|
||||
msgstr "Etichete noi:"
|
||||
msgstr "Fără etichete"
|
||||
|
||||
#: packages/app-cli/app/command-edit.ts:31
|
||||
msgid ""
|
||||
@@ -4146,9 +4131,8 @@ msgid "Note list style"
|
||||
msgstr "Stilul listei de notițe"
|
||||
|
||||
#: packages/app-mobile/components/screens/DocumentScanner/DocumentScanner.tsx:128
|
||||
#, fuzzy
|
||||
msgid "Note preview"
|
||||
msgstr "Vizualizator notiță"
|
||||
msgstr "Previzualizare notiță"
|
||||
|
||||
#: packages/app-desktop/gui/NotePropertiesDialog.tsx:497
|
||||
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showNoteProperties.ts:7
|
||||
@@ -4240,9 +4224,8 @@ msgid "OCR: Language data URL or path"
|
||||
msgstr "OCR: URL sau cale date lingvistice"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:604
|
||||
#, fuzzy
|
||||
msgid "OCR: Search in extracted content"
|
||||
msgstr "Caută în toate notițele"
|
||||
msgstr "OCR: Caută în conținutul extras"
|
||||
|
||||
#: packages/app-cli/app/utils/shimInitCli.ts:12
|
||||
#: packages/app-desktop/bridge.ts:392 packages/app-desktop/bridge.ts:405
|
||||
@@ -4511,7 +4494,7 @@ msgstr "Permisiune necesară"
|
||||
|
||||
#: packages/app-mobile/components/screens/DocumentScanner/DocumentScanner.tsx:93
|
||||
msgid "Photo %d"
|
||||
msgstr ""
|
||||
msgstr "Fotografia %d"
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.tsx:261
|
||||
msgid ""
|
||||
@@ -4859,9 +4842,8 @@ msgid "Re-upload local data to sync target"
|
||||
msgstr "Reîncărcarea datelor locale către destinația de sincronizare"
|
||||
|
||||
#: packages/app-mobile/components/NoteEditor/WarningBanner.tsx:19
|
||||
#, fuzzy
|
||||
msgid "Read more"
|
||||
msgstr "Află mai multe"
|
||||
msgstr "Citește mai multe"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/WarningBanner/WarningBanner.tsx:40
|
||||
msgid "Read more about it"
|
||||
@@ -4872,9 +4854,10 @@ msgid "Read time: %s min"
|
||||
msgstr "Durata lecturii: %s min"
|
||||
|
||||
#: packages/app-cli/app/command-batch.ts:45
|
||||
#, fuzzy
|
||||
msgid "Reading commands from standard input is only available in CLI mode."
|
||||
msgstr "Comanda „%s” este disponibilă doar în modul GUI"
|
||||
msgstr ""
|
||||
"Citirea comenzilor de la intrarea standard este disponibilă doar în modul "
|
||||
"CLI."
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.tsx:314
|
||||
msgid "Recipient has accepted the invitation"
|
||||
@@ -4893,9 +4876,8 @@ msgid "Recipients:"
|
||||
msgstr "Destinatari:"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts:142
|
||||
#, fuzzy
|
||||
msgid "Recognize handwritten image"
|
||||
msgstr "Redimensionează imaginile mari:"
|
||||
msgstr "Recunoaște imagine cu text scris de mînă"
|
||||
|
||||
#: packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/RecommendedBadge.tsx:72
|
||||
msgid "Recommended"
|
||||
@@ -4949,9 +4931,8 @@ msgid "Remove"
|
||||
msgstr "Șterge"
|
||||
|
||||
#: packages/app-mobile/components/TagEditor.tsx:129
|
||||
#, fuzzy
|
||||
msgid "Remove %s"
|
||||
msgstr "Șterge"
|
||||
msgstr "Șterge %s"
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.tsx:331
|
||||
msgid "Remove %s from share"
|
||||
@@ -4970,9 +4951,8 @@ msgid "Removed %s from share."
|
||||
msgstr "S-a eliminat %s din partajare."
|
||||
|
||||
#: packages/app-mobile/components/TagEditor.tsx:230
|
||||
#, fuzzy
|
||||
msgid "Removed tag: %s"
|
||||
msgstr "Redenumește eticheta:"
|
||||
msgstr "Etichetă ștearsă: %s"
|
||||
|
||||
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/renameFolder.ts:8
|
||||
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/renameTag.ts:8
|
||||
@@ -4993,7 +4973,7 @@ msgstr "Redenumește <item> dat (notiță sau caiet) în <name>."
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1464
|
||||
msgid "Renders markup on all lines that don't include the cursor."
|
||||
msgstr ""
|
||||
msgstr "Redă markup pe toate liniile care nu includ cursorul."
|
||||
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.tsx:162
|
||||
msgid "Renew token"
|
||||
@@ -5243,9 +5223,8 @@ msgid "Save geo-location with notes"
|
||||
msgstr "Salvează geo-locația în notițe"
|
||||
|
||||
#: packages/app-mobile/components/screens/Notes/NewNoteButton.tsx:81
|
||||
#, fuzzy
|
||||
msgid "Scan notebook"
|
||||
msgstr "Selectează un caiet"
|
||||
msgstr "Scanează un caiet"
|
||||
|
||||
#: packages/app-mobile/components/CameraView/ScannedBarcodes.tsx:90
|
||||
msgid "Scanned code"
|
||||
@@ -5294,9 +5273,8 @@ msgid "Search shown"
|
||||
msgstr "Căutare afișată"
|
||||
|
||||
#: packages/app-mobile/components/TagEditor.tsx:272
|
||||
#, fuzzy
|
||||
msgid "Search tags"
|
||||
msgstr "Rezultate căutare"
|
||||
msgstr "Caută etichete"
|
||||
|
||||
#: packages/app-cli/app/gui/FolderListWidget.ts:56
|
||||
msgid "Search:"
|
||||
@@ -5345,18 +5323,16 @@ msgid "Select file..."
|
||||
msgstr "Alege o imagine…"
|
||||
|
||||
#: packages/app-mobile/components/screens/DocumentScanner/NotePreview.tsx:147
|
||||
#, fuzzy
|
||||
msgid "Select notebook"
|
||||
msgstr "Selectează un caiet"
|
||||
msgstr "Selectează caiet"
|
||||
|
||||
#: packages/app-mobile/components/screens/folder.js:109
|
||||
msgid "Select parent notebook"
|
||||
msgstr "Selectează caietul părinte"
|
||||
|
||||
#: packages/app-mobile/components/ComboBox.tsx:367
|
||||
#, fuzzy
|
||||
msgid "Selected: %s"
|
||||
msgstr "Creat: %s"
|
||||
msgstr "Selectat: %s"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/localisation.ts:9
|
||||
msgid "Selection deleted"
|
||||
@@ -6198,11 +6174,12 @@ msgid "The note \"%s\" has been successfully restored to the notebook \"%s\"."
|
||||
msgstr "Notița „%s” a fost restaurată cu succes în caietul „%s”."
|
||||
|
||||
#: packages/app-desktop/gui/ConversionNotification/ConversionNotification.tsx:22
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
"The note has been converted to Markdown and the original note has been moved "
|
||||
"to the trash"
|
||||
msgstr "Caietul și conținutul său au fost mutate cu succes în coșul de gunoi."
|
||||
msgstr ""
|
||||
"Notița a fost convertită în Markdown și notița originală a fost mutată în "
|
||||
"coșul de gunoi"
|
||||
|
||||
#: packages/app-desktop/gui/TrashNotification/TrashNotification.tsx:45
|
||||
msgid "The note was successfully moved to the trash."
|
||||
@@ -6441,14 +6418,17 @@ msgid ""
|
||||
"This feature is disabled by default, you need to manually enable it by "
|
||||
"turning on the option to 'Enable handwritten transcription'."
|
||||
msgstr ""
|
||||
"Această funcție este implicit dezactivată. Trebuie să o activezi manual prin "
|
||||
"opțiunea „Activează transcrierea scrisului de mînă”."
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts:146
|
||||
msgid "This feature is only available on Joplin Cloud and Joplin Server."
|
||||
msgstr ""
|
||||
"Această funcție este disponibilă doar pe cloud-ul Joplin și server-ul Joplin."
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts:158
|
||||
msgid "This image type is not supported by the recognition system."
|
||||
msgstr ""
|
||||
msgstr "Acest tip de imagine nu este suportat de sistemul de recunoaștere."
|
||||
|
||||
#: packages/app-desktop/gui/ResourceScreen.tsx:298
|
||||
msgid ""
|
||||
@@ -6506,6 +6486,8 @@ msgstr "Această notă nu are istoric"
|
||||
msgid ""
|
||||
"This note is in HTML format. Convert it to Markdown to edit it more easily."
|
||||
msgstr ""
|
||||
"Această notiță este în format HTML. Convertește-o în Markdown pentru a o "
|
||||
"putea edita mai ușor."
|
||||
|
||||
#: packages/lib/services/plugins/PluginService.ts:535
|
||||
msgid "This plugin doesn't support %s."
|
||||
@@ -6614,6 +6596,7 @@ msgstr "Pentru a continua, te rog să întroduci parola principală mai jos."
|
||||
#: packages/app-mobile/components/ComboBox.tsx:568
|
||||
msgid "To create a new tag, type the name and press enter."
|
||||
msgstr ""
|
||||
"Pentru a crea o etichetă nouă, tastează numele acesteia și apasă enter."
|
||||
|
||||
#: packages/app-cli/app/app-gui.js:458
|
||||
msgid "To delete a tag, untag the associated notes."
|
||||
@@ -6999,9 +6982,8 @@ msgid "Upgrade the sync target to the latest version."
|
||||
msgstr "Actualizează destinația de sincronizare la cea mai recentă versiune."
|
||||
|
||||
#: packages/app-mobile/components/CameraView/CameraView.web.tsx:45
|
||||
#, fuzzy
|
||||
msgid "Upload photo"
|
||||
msgstr "Fotografiază"
|
||||
msgstr "Încarcă fotografie"
|
||||
|
||||
#: packages/app-desktop/gui/NotePropertiesDialog.tsx:86
|
||||
#: packages/app-mobile/components/NoteEditor/EditLinkDialog.tsx:109
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user