1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-08-24 20:19:10 +02:00

Compare commits

...

74 Commits

Author SHA1 Message Date
Joplin Bot
227e41b69a Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-24 12:31:05 +00:00
renovate[bot]
a616e26a0f Update dependency react-native-safe-area-context to v5.4.1 (#13000)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-08-24 13:02:03 +03:00
Joplin Bot
ba0e7e2226 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-23 12:30:39 +00:00
Laurent Cozic
b5a4ba554d Doc: Add sponsor 2025-08-23 13:14:58 +03:00
Arda Kılıçdağı
9037da8f2d All: Translation: Update tr_TR.po (#13019) 2025-08-22 16:07:31 -04:00
renovate[bot]
6998606ec9 Update dependency pg to v8.15.6 (#13021)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-22 17:00:30 +00:00
Laurent Cozic
66d52c90a3 Desktop release v3.4.7 2025-08-22 13:19:27 +03:00
renovate[bot]
f6fb1f7fbf Update dependency pg to v8.15.5 (#13001)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-08-22 13:14:06 +03:00
Henry Heino
3aac6043da Chore: Sync fuzzer: Support testing Joplin Cloud readonly shares (#13003) 2025-08-22 11:33:54 +03:00
Henry Heino
ae170e0aa0 Desktop: Fixes #12998: Fix error logged when rendering a non-existent resource (#13004) 2025-08-22 11:33:16 +03:00
Henry Heino
371f027a24 MacOS: Fix startup failure when unable to access the keychain (#13006) 2025-08-22 11:32:59 +03:00
Henry Heino
37422f316e Desktop: Downgrade to Electron 35.7.5 (#13013) 2025-08-22 11:30:39 +03:00
Henry Heino
a9f284ae45 Desktop: Fixes #13009: Fix custom root CA support (#13018) 2025-08-22 11:29:54 +03:00
Milo Ivir
fd2f69cc73 All: Translation: Update hr_HR.po (#13011) 2025-08-21 18:45:39 -04:00
Joplin Bot
c4eab3c79c Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-21 01:03:33 +00:00
renovate[bot]
a0b9c6376e Update dependency react-native-image-picker to v8 (#12997)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-20 23:37:39 +03:00
Henry Heino
e2fc056369 Desktop,Mobile,Cli: Fixes #12648: Fix unshare action requires two syncs to be reflected locally (#12999) 2025-08-20 23:36:47 +03:00
renovate[bot]
453b4705b1 Update dependency @types/node to v18.19.103 (#12985)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-20 19:25:54 +00:00
Laurent Cozic
4128061e40 Desktop release v3.4.6 2025-08-20 22:22:42 +03:00
Joplin Bot
432b0ca870 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-20 12:32:59 +00:00
renovate[bot]
c484cd2e48 Update dependency sass to v1.87.0 (#12995)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-20 15:06:57 +03:00
Laurent Cozic
58f0725c6b Doc: Add sponsor 2025-08-20 15:05:28 +03:00
Henry Heino
bf8fbec0cd Chore: Sync fuzzer: Add support for adding and removing share participants (#12988) 2025-08-20 09:46:23 +03:00
pedr
f1d452f130 Server: Fixes #12983: Not handling correctly non JSON error responses from Transcribe (#12986) 2025-08-20 09:46:15 +03:00
Henry Heino
26012cd7d5 Cli,Mobile,Desktop: Shared folders: Fix moving shared subfolder to toplevel briefly marks it as a toplevel share (#12964) 2025-08-20 09:39:39 +03:00
mrjo118
a414241541 Mobile: Improve tag screen usability to allow add or remove tag with a single press, when the keyboard is open (#12954) 2025-08-20 09:33:31 +03:00
Henry Heino
0f13bf9d51 Mobile: Rich Text Editor: Support rendering subscript, superscript, and highlighted formatting (#12944) 2025-08-20 09:33:13 +03:00
Henry Heino
c142c5c5c0 Desktop,Mobile: Markdown editor: Toggle checkboxes on ctrl-click (#12927) 2025-08-20 09:32:16 +03:00
Henry Heino
af5c0135dc Mobile: Rich Text Editor: Enable syntax highlighting and auto-indent in the code block editor (#12909) 2025-08-20 09:29:30 +03:00
pedr
8a811b9e78 Doc: Resolves #12861: Add end point documentation for Transcribe (#12870)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-08-20 09:29:12 +03:00
Henry Heino
602484f143 Desktop: Upgrade to Electron v37.3.0 (#12951) 2025-08-20 08:53:50 +03:00
renovate[bot]
dc84db1657 Update dependency sharp to v0.34.2 (#12982)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-08-20 08:33:29 +03:00
Henry Heino
f5882ecfcc Chore: Improve type safety (#12992) 2025-08-20 08:33:10 +03:00
Laurent Cozic
30000c34ec Cli: If no notebook is provided when importing a file, use the default one 2025-08-19 23:33:52 +03:00
renovate[bot]
6e3df1bd90 Update dependency @types/react to v18.3.22 (#12990)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 18:59:17 +03:00
Joplin Bot
67196ac0b2 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-19 12:33:44 +00:00
Laurent Cozic
69646b5522 Doc: Update sponsors 2025-08-19 12:31:13 +03:00
Laurent Cozic
9147afce9a Server v3.4.2 2025-08-18 19:54:35 +03:00
Henry Heino
c92701c52f iOS: Rich Text Editor: Fix the "edit" button for code blocks (#12924) 2025-08-18 18:46:02 +03:00
pedr
ab3e9d1a3e Transcribe: Fixes: Use latest version of joplin/htr-cli available (#12875) 2025-08-18 18:45:52 +03:00
Henry Heino
f9cab8843b Chore: Fix tsc (#12981) 2025-08-18 18:40:58 +03:00
yuudi
c36289c024 Server: Fixes #12947 Skip CORS check for SAML callback (#12948)
Co-authored-by: yuudi <yuudi@users.noreply.github.com>
2025-08-18 16:10:20 +03:00
Henry Heino
60b6db8cd4 Mobile: Rich Text Editor: Add basic support for collapsible <details> blocks (#12946) 2025-08-18 16:10:00 +03:00
Henry Heino
bbd8f6f40e Mobile: Rich Text Editor: Fix adding headings moves the cursor to the next line (#12934) 2025-08-18 16:07:55 +03:00
Henry Heino
34b7f4e1f8 Chore: Sync fuzzer: Fix "DecryptionWorker: Cannot start because..." warning (#12925) 2025-08-18 16:04:26 +03:00
Henry Heino
06b681d897 Chore: Sync fuzzer: Add new possible actions: Adding and syncing a new temporary client on an existing account (#12741) 2025-08-18 15:57:44 +03:00
pedr
f02a94bef5 Transcribe: Fixes #12766: Remove processed files and clean up after a retention period (#12827) 2025-08-18 15:57:34 +03:00
Miguel Matos
ae6b57c5a5 CLI: Add collapsible notebooks functionality (#12718) 2025-08-18 15:55:55 +03:00
Henry Heino
88ab916008 Mobile: Rich Text Editor: Support rendering table of contents blocks (#12949) 2025-08-18 11:35:48 +03:00
pedr
97b0ffc263 Transcribe: #12883: Disable JobProcessor tests by default (#12955) 2025-08-18 11:34:26 +03:00
pedr
ff8848d138 Desktop: Fixes #12315: Clicking Edit URL button in Note properties does not focus in url field (#12970) 2025-08-18 11:20:54 +03:00
renovate[bot]
2b686e6318 Update dependency @playwright/test to v1.52.0 (#12972)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-18 11:17:35 +03:00
renovate[bot]
b913d18882 Update dependency @adobe/css-tools to v4.4.3 (#12979)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-17 22:02:58 +00:00
renovate[bot]
a2c9a01722 Update dependency @types/node to v18.19.101 (#12978)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-17 05:42:21 +00:00
Milo Ivir
000d23c20f All: Translation: Update hr_HR.po (#12961) 2025-08-16 21:46:55 -04:00
Liffindra Angga Zaaldian
9e9f2f2930 All: Translation: Update id_ID.po (#12977) 2025-08-16 20:42:49 -04:00
VortexP
c5a1a759c7 All: Translation: Update fi_FI.po (#12971) 2025-08-15 18:15:58 -04:00
cedecode
0b6a1c75ba All: Translation: Update de_DE.po (#12966) 2025-08-14 22:09:33 -04:00
renovate[bot]
53a0f8ddbc Update dependency python to v3.13.3 (#12965)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-15 01:08:28 +02:00
Laurent Cozic
67eabb5038 Doc: Update recommended Postgres for JSB 2025-08-14 22:32:00 +02:00
Laurent Cozic
983fced410 Doc: Fixed Transcribe graph 2025-08-14 22:32:00 +02:00
Jozef Gaal
4f5bbc1132 All: Translation: Update sk_SK.po (#12950) 2025-08-14 00:22:49 -04:00
ERYpTION
2f10235ecb All: Translation: Update da_DK.po (#12945) 2025-08-13 17:22:50 -04:00
Joplin Bot
cfa7d6cb31 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-13 18:27:34 +00:00
renovate[bot]
f5d62a50fe Update dependency @types/serviceworker to v0.0.135 (#12937)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-13 10:12:25 +00:00
Laurent Cozic
b52f5435aa Doc: Add Transcribe System Architecture documentation 2025-08-12 19:24:27 +01:00
renovate[bot]
bfd5bfc004 Update dependency git to v2.48.1 (#12921)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 15:32:53 +01:00
renovate[bot]
82965fe991 Update dependency jsdom to v26.1.0 (#12922)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 15:32:45 +01:00
summoner
b2c162c25b All: Translation: Update hu_HU.po (#12918) 2025-08-10 16:05:34 -04:00
Mihai Vasiliu
022e76fe8d All: Translation: Update ro_RO.po and ro_MD.po (#12917) 2025-08-10 16:04:28 -04:00
Joplin Bot
4b2d1895fd Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-10 18:26:11 +00:00
Joplin Bot
534507a31f Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-10 12:32:07 +00:00
Laurent Cozic
5b4a300c81 iOS 13.4.1 2025-08-10 10:41:49 +01:00
Laurent Cozic
1de0a59313 Chore: Fix iOS IPHONEOS_DEPLOYMENT_TARGET 2025-08-10 10:41:25 +01:00
125 changed files with 4549 additions and 4833 deletions

View File

@@ -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

View File

@@ -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
View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View 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š"

View File

@@ -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&amp;mtm_kwd=joplinapp&amp;mtm_source=joplinapp-webseite&amp;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

View File

@@ -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": [

View 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>.

View File

@@ -0,0 +1 @@
Program za bilješke i popis zadataka sa sinkronizacijom između Linuxa, macOS-a, Windowsa i mobitela

View File

@@ -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;

View File

@@ -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

View File

@@ -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 = '';

View File

@@ -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,
},

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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",

View File

@@ -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)}`;
},
});

View File

@@ -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
/>
);
}

View File

@@ -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"
}
}

View File

@@ -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/')) {

View File

@@ -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

View File

@@ -540,6 +540,7 @@ const ComboBox: React.FC<Props> = ({
};
const activeId = `${baseId}-${selectedIndex}`;
const searchResults = <NestableFlatList
keyboardShouldPersistTaps="handled"
ref={listRef}
data={results}
{...searchResultProps}

View File

@@ -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}

View File

@@ -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;

View File

@@ -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`);
});
});
});

View File

@@ -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}

View File

@@ -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:

View File

@@ -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;

View File

@@ -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>;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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)",

View File

@@ -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",

View File

@@ -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,
});
},
});

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}),
];
};

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);
},
};

View File

@@ -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;
};

View 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' },
],
},
],
});
});
});

View 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;

View File

@@ -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 {};

View File

@@ -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');
});
});

View File

@@ -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();
}

View File

@@ -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);

View File

@@ -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_({

View File

@@ -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: [{

View File

@@ -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;

View 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;

View File

@@ -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);
};

View File

@@ -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', () => {

View File

@@ -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;

View 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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",

View File

@@ -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;

View File

@@ -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}]`);

View File

@@ -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) {

View File

@@ -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('', [
{

View File

@@ -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 });
}

View File

@@ -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);

View File

@@ -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",

View File

@@ -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();
}
}

View File

@@ -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({});

View File

@@ -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.

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"

View File

@@ -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 : '';

View File

@@ -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');
});
});

View File

@@ -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();

View File

@@ -196,4 +196,7 @@ orderedmap
labelledby
isTextblock
deflt
LOCALAPPDATA
LOCALAPPDATA
Daman
vikasmanimc
traspire

View File

@@ -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;

View File

@@ -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() {

View File

@@ -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();

View File

@@ -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,

View 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),
});
}
}

View File

@@ -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'],
});
},

View File

@@ -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 {

View File

@@ -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) => {

View File

@@ -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 " 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

View File

@@ -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

View File

@@ -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

View File

@@ -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