1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-09-05 20:56:22 +02:00

Compare commits

...

29 Commits

Author SHA1 Message Date
Laurent Cozic
6e143aef5c Android release v1.0.277 2019-06-26 18:40:43 +01:00
Laurent Cozic
bf16aa6192 All: Better logging in case of error while indexing search 2019-06-26 18:36:42 +01:00
Laurent Cozic
d96c58d192 Mobile: Edit and delete notebooks by long-pressing them, and removed context menu on note lists 2019-06-26 18:28:09 +01:00
Laurent Cozic
e7e0264411 Mobile: Improved side menu: Made button panel fixed at the bottom, and added dark overlay over right side content 2019-06-26 18:05:37 +00:00
Laurent Cozic
430a11282b Mobile: Moved 'New notebook' button to sidebar 2019-06-26 01:10:15 +01:00
Laurent Cozic
9957b2798c Mobile: Moved config menu item to button on side bar 2019-06-26 00:35:26 +01:00
Laurent Cozic
2c5b0010bf Mobile: Removed concept of Advanced Options and move tools to Config screen to clean up context menu 2019-06-26 00:13:13 +01:00
Laurent Cozic
1e3c6ed98c Desktop: When doing local search do not split query into words 2019-06-25 23:09:53 +01:00
Laurent Cozic
484f290eb0 Clipper: Improved clipping selection by removing unecessary elements 2019-06-25 22:11:12 +01:00
Helmut K. C. Tessarek
06ad539941 Clipper release v1.0.17 2019-06-23 23:23:07 -04:00
Laurent Cozic
5b84e80ac4 Clipper: Fixes #1214: Include data from form fields when clipping forms 2019-06-24 00:57:39 +01:00
Laurent Cozic
ca0f349348 Merge branch 'master' of github.com:laurent22/joplin 2019-06-24 00:00:24 +01:00
Laurent Cozic
d79089aea3 Clipper: Fixes #1682: Do not clip elements that should be hidden 2019-06-24 00:00:11 +01:00
Eugene Odeluga
03611ad5ca Desktop: For Ubuntu users, added unity to if condition for desktop icon creation (#1683) 2019-06-23 22:24:58 +01:00
Helmut K. C. Tessarek
c78c1cd3cf Clipper release v1.0.16 2019-06-23 03:06:34 -04:00
水货
55afa7b5b7 Update zh_CN.po (#1681) 2019-06-23 03:00:41 -04:00
Laurent Cozic
a6c407b62b Doc: Mentioned Goto Anything feature 2019-06-22 19:06:54 +01:00
Laurent Cozic
21897a3cd4 Clipper: Resolves #1669: Handle special case of code block used on Microsoft website 2019-06-22 18:57:41 +01:00
Laurent Cozic
5796dd2098 Update translations 2019-06-22 13:10:13 +01:00
abonte
d050071437 update it_IT.po (#1680) 2019-06-22 12:45:35 +01:00
Laurent Cozic
eaf8510f49 Doc: Added requirement for unit tests 2019-06-22 12:44:31 +01:00
Laurent Cozic
6ee2595dce Desktop: Fixes #1676: Preserve user timestamps when adding note via API 2019-06-22 12:31:04 +01:00
Laurent Cozic
0ecf2d6d9a Merge branch 'master' of github.com:laurent22/joplin 2019-06-22 11:23:35 +01:00
Laurent Cozic
50fd075168 Desktop, CLI: Fixes #1672: Fix line break issue when importing certain notes from Evernotes 2019-06-22 11:23:22 +01:00
Helmut K. C. Tessarek
6fa76bb83a fix minor typo in README.md 2019-06-21 23:58:44 -04:00
Laurent Cozic
b175c1fc94 Desktop: Resolves #1649: Cache code blocks in notes to speed up rendering 2019-06-21 08:28:59 +01:00
Caleb John
b461625518 Desktop: Fixed issue with issue with watching file on Linux (#1659)
Watch for rename events in the external editor and re-watch file
2019-06-20 00:44:51 +01:00
Laurent Cozic
3819897ba1 Merge branch 'master' of github.com:laurent22/joplin 2019-06-20 00:02:29 +01:00
Laurent Cozic
6a031857ba Desktop: Fixes #1664: Disable certain menu items when no note or multiple notes are selected, and fixed menu item to set tag 2019-06-20 00:02:13 +01:00
44 changed files with 740 additions and 314 deletions

View File

@@ -16,10 +16,12 @@ File bugs in the [Github Issue Tracker](https://github.com/laurent22/joplin/issu
Please check that your request has not already been posted in the [Github Issue Tracker](https://github.com/laurent22/joplin/issues?utf8=%E2%9C%93&q=is%3Aissue). If it has, **up-voting the issue** increases the chances it'll be noticed and implemented in the future. "+1" comments are not tracked.
As a general rule, suggestions to _improve Joplin_ should be posted first in the [Joplin Forum](https://discourse.joplinapp.org/) for discussion.
As a general rule, suggestions to *improve Joplin* should be posted first in the [Joplin Forum](https://discourse.joplinapp.org/) for discussion.
Avoid listing multiple requests in one report in the [Github Issue Tracker](https://github.com/laurent22/joplin/issues?utf8=%E2%9C%93&q=is%3Aissue). One issue per request makes it easier to track and discuss it.
Finally, when submitting a pull request, don't forget to [test your code](#unit-tests).
# Contribute to the project
## Contributing to Joplin's translation
@@ -42,3 +44,20 @@ There are only two rules, but not following them means the pull request will not
- **Please use tabs, NOT spaces.**
- **Please do not add or remove optional characters, such as spaces or colons.** Please setup your editor so that it only changes what you are working on and is not making automated changes elsewhere. The reason for this is that small white space changes make diff hard to read and can cause needless conflicts.
## Unit tests
When submitting a pull request for a new feature or bug fix, please add unit tests for your code. Unit testing GUI changes is not always possible so it is not required, but any change in a file under /lib for example should be unit tested.
The tests are under CliClient/tests. To get them running, you first need to build the CLI app:
cd CliClient
npm i
Then to run all the test units:
./run_test.sh
To run just one particular file:
./run_test.sh markdownUtils # Don't add the .js extension

View File

@@ -1439,9 +1439,9 @@ msgid ""
"the attachments are downloaded whether you open the note or not."
msgstr ""
"En mode \"manuel\", les ressources sont téléchargées uniquement lorsque vous "
"cliquez dessus. En mode \"auto\", elle sont téléchargée lorsque vous ouvrez "
"la note. En mode \"toujours\", toutes les ressources sont téléchargées, que "
"vous ayez ouvert la note ou pas."
"cliquez dessus. En mode \"auto\", elles sont téléchargées lorsque vous "
"ouvrez la note. En mode \"toujours\", toutes les ressources sont "
"téléchargées, que vous ayez ouvert la note ou pas."
msgid "Always"
msgstr "Toujours"
@@ -1963,7 +1963,7 @@ msgstr ""
#, javascript-format
msgid "Links with protocol \"%s\" are not supported"
msgstr ""
msgstr "Le schéma d'URI \"%s\" n'est pas supporté"
#, javascript-format
msgid "Unsupported image type: %s"

View File

@@ -735,7 +735,7 @@ msgid "Make a donation"
msgstr "Fai una donazione"
msgid "Toggle development tools"
msgstr ""
msgstr "Attiva / disattiva strumenti di sviluppo"
#, javascript-format
msgid "Open %s"
@@ -755,7 +755,7 @@ msgstr "La versione attuale è aggiornata."
#, javascript-format
msgid "%s (pre-release)"
msgstr ""
msgstr "%s (pre-rilascio)"
msgid "An update is available, do you want to download it now?"
msgstr "È disponibile un aggiornamento, vuoi scaricarlo ora?"
@@ -991,9 +991,8 @@ msgstr "Alcuni elementi non possono essere sincronizzati."
msgid "View them now"
msgstr "Mostrali ora"
#, fuzzy
msgid "One or more master keys need a password."
msgstr "Inserisci password principale:"
msgstr "Una o più chiavi master necessitano di una password."
msgid "Set the password"
msgstr "Imposta la password"
@@ -1185,18 +1184,19 @@ msgstr "Taccuini"
msgid "Decrypting items: %d/%d"
msgstr "Decrittografia Elementi: %d/%d"
#, fuzzy, javascript-format
#, javascript-format
msgid "Fetching resources: %d/%d"
msgstr "Risorse: %d/%d"
msgstr "Recupero risorse: %d/%d"
msgid "Please select where the sync status should be exported to"
msgstr ""
"Prego selezionare dove lo stato della sincronizzazione deve essere esportato"
msgid "Retry"
msgstr ""
msgstr "Riprova"
msgid "Add or remove tags"
msgstr "Aggiungi o rimuovi etichetta"
msgstr "Aggiungi o rimuovi etichette"
msgid "Duplicate"
msgstr "Duplicare"
@@ -1229,9 +1229,11 @@ msgid ""
"Type a note title to jump to it. Or type # followed by a tag name, or @ "
"followed by a notebook name."
msgstr ""
"Scrivi il titolo di una nota per saltare ad essa. Oppure digita # seguito "
"dal nome di una etichetta, oppure @ seguito dal nome di un taccuino."
msgid "Goto Anything..."
msgstr ""
msgstr "Vai a..."
#, javascript-format
msgid "Usage: %s"
@@ -1348,6 +1350,8 @@ msgstr "La sincronizzazione è già in corso. Stato: %s"
msgid ""
"Unknown item type downloaded - please upgrade Joplin to the latest version"
msgstr ""
"Tipo elemento scaricato sconosciuto - prego aggiornare Joplin all’ultima "
"versione"
msgid "Encrypted"
msgstr "Crittografato"
@@ -1391,6 +1395,9 @@ msgid ""
"to it before syncing, otherwise all files will be removed! See the FAQ for "
"more details: %s"
msgstr ""
"Attenzione: se si cambia questa posizione, accertarsi di copiare tutto il "
"contenuto prima di sincronizzare, altrimenti tutti i file saranno rimossi! "
"Vedi le FAQ per maggiori dettagli: %s"
msgid "Synchronisation target"
msgstr "Destinazione di sincronizzazione"
@@ -1424,13 +1431,16 @@ msgid "WebDAV password"
msgstr "Password WebDAV"
msgid "Attachment download behaviour"
msgstr ""
msgstr "Comportamento scaricamento allegati"
msgid ""
"In \"Manual\" mode, attachments are downloaded only when you click on them. "
"In \"Auto\", they are downloaded when you open the note. In \"Always\", all "
"the attachments are downloaded whether you open the note or not."
msgstr ""
"In modalità \"Manuale\", gli allegati sono scaricati solo quando si clicca su "
"di essi. In \"Auto\" sono scaricati quando si apre la nota. In \"Sempre\" tutti "
"gli allegati sono scaricati sia che si apra o no la nota."
msgid "Always"
msgstr "Sempre"
@@ -1442,7 +1452,7 @@ msgid "Auto"
msgstr "Auto"
msgid "Max concurrent connections"
msgstr ""
msgstr "Massimo numero di connessioni concorrenti"
msgid "Language"
msgstr "Linguaggio"
@@ -1499,7 +1509,7 @@ msgid "Enable math expressions"
msgstr "Attiva espressioni matematiche"
msgid "Enable ==mark== syntax"
msgstr ""
msgstr "Attiva sintassi ==mark=="
msgid "Enable footnotes"
msgstr "Attiva note a piè pagina"
@@ -1508,10 +1518,10 @@ msgid "Enable table of contents extension"
msgstr ""
msgid "Enable ~sub~ syntax"
msgstr ""
msgstr "Attiva sintassi ~sub~"
msgid "Enable ^sup^ syntax"
msgstr ""
msgstr "Attiva sintassi ^sup^"
msgid "Enable deflist syntax"
msgstr ""
@@ -1523,7 +1533,7 @@ msgid "Enable markdown emoji"
msgstr ""
msgid "Enable ++insert++ syntax"
msgstr ""
msgstr "Attiva sintassi ++insert++"
msgid "Enable multimarkdown table extension"
msgstr ""
@@ -1544,7 +1554,7 @@ msgstr ""
"costantemente le tue note e quindi ridurre il numero di conflitti."
msgid "Start application minimised in the tray icon"
msgstr ""
msgstr "Avvia applicazione minimizzata nell’icona del vassoio"
msgid "Global zoom percentage"
msgstr "Percentuale di zoom globale"
@@ -1567,11 +1577,11 @@ msgid "Automatically update the application"
msgstr "Aggiorna automaticamente l'applicazione"
msgid "Get pre-releases when checking for updates"
msgstr ""
msgstr "Ottieni pre-rilasci durante controllo aggiornamenti"
#, javascript-format
msgid "See the pre-release page for more details: %s"
msgstr ""
msgstr "Vedi la pagina di pre-rilascio per maggiori dettagli: %s"
msgid "Synchronisation interval"
msgstr "Intervallo di sincronizzazione"
@@ -1631,7 +1641,7 @@ msgid "%d days"
msgstr "%d giorni"
msgid "Keep note history for"
msgstr ""
msgstr "Mantieni cronologia nota per"
#, javascript-format
msgid "Invalid option value: \"%s\". Possible values are: %s."
@@ -1650,7 +1660,7 @@ msgid "Note"
msgstr "Nota"
msgid "Plugins"
msgstr ""
msgstr "Plugins"
msgid "Application"
msgstr "Applicazione"
@@ -1725,15 +1735,17 @@ msgstr "%s (%s) non può essere caricato: %s"
msgid "Item \"%s\" could not be downloaded: %s"
msgstr "Elemento \"%s\" non può essere scaricato: %s"
#, fuzzy
msgid "Items that cannot be decrypted"
msgstr "Elementi che non possono essere sincronizzati"
msgstr "Elementi che non possono essere decriptati"
msgid ""
"Joplin failed to decrypt these items multiple times, possibly because they "
"are corrupted or too large. These items will remain on the device but Joplin "
"will no longer attempt to decrypt them."
msgstr ""
"Joplin ha fallito la decriptazione di questi elementi più volte, forse "
"perché sono corrotti o troppo grandi. Questi elementi rimarranno sul "
"dispositivo, ma Joplin non proverà più a decriptarli."
msgid "Sync status (synced items / total items)"
msgstr "Stato di sincronizzazione (Elementi sincronizzati / Elementi totali)"
@@ -1828,6 +1840,9 @@ msgid ""
"Error. Please check that URL, username, password, etc. are correct and that "
"the sync target is accessible. The reported error was:"
msgstr ""
"Errore. Prego controllare che URL, nome utente, password, etc. siano corretti "
"e che la destinazione di sincronizzazione sia accessibile. L’errore "
"riportato era:"
msgid "The application has been authorised!"
msgstr "L'applicazione è stata autorizzata con successo!"
@@ -1840,6 +1855,11 @@ msgid ""
"\n"
"Please try again."
msgstr ""
"Non è stato possibile autorizzare l’applicazione:\n"
"\n"
"%s\n"
"\n"
"Riprovare prego."
#, javascript-format
msgid "Decrypted items: %s / %s"
@@ -1851,9 +1871,8 @@ msgstr "Nuovi tag:"
msgid "Type new tags or select from list"
msgstr "Digita nuovi tag o seleziona dalla lista"
#, fuzzy
msgid "More information"
msgstr "Configurazione"
msgstr "Maggiori informazioni"
msgid ""
"To work correctly, the app needs the following permissions. Please enable "
@@ -1946,7 +1965,7 @@ msgstr ""
#, javascript-format
msgid "Links with protocol \"%s\" are not supported"
msgstr ""
msgstr "Collegamenti con protocollo \"%s\" non sono supportati"
#, javascript-format
msgid "Unsupported image type: %s"

View File

@@ -14,6 +14,8 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 2.2.1\n"
"POT-Creation-Date: \n"
"PO-Revision-Date: \n"
msgid "To delete a tag, untag the associated notes."
msgstr "移除相关笔记的标签后才可删除此标签。"
@@ -22,7 +24,7 @@ msgid "Please select the note or notebook to be deleted first."
msgstr "请先选择需要删除的笔记或笔记本。"
msgid "Press Ctrl+D or type \"exit\" to exit the application"
msgstr "按 Ctrl+D 或输入 \"exit\" 退出程序"
msgstr "按 Ctrl+D 或输入exit退出程序"
#, javascript-format
msgid "More than one item match \"%s\". Please narrow down your query."
@@ -55,7 +57,7 @@ msgstr "不存在该命令:%s"
#, javascript-format
msgid "The command \"%s\" is only available in GUI mode"
msgstr "命令 \"%s\" 仅在GUI模式下可用"
msgstr "命令“%s”仅在 GUI 模式下可用"
msgid "Cannot change encrypted item"
msgstr "无法更改加密项目"
@@ -80,7 +82,7 @@ msgstr "将选定文件添加到笔记中。"
#, javascript-format
msgid "Cannot find \"%s\"."
msgstr "无法找到 \"%s\"。"
msgstr "无法找到“%s”。"
msgid "Displays the given note."
msgstr "显示选定笔记。"
@@ -119,7 +121,7 @@ msgstr "标记待办事项为完成。"
#, javascript-format
msgid "Note is not a to-do: \"%s\""
msgstr "笔记非待办事项:\"%s\""
msgstr "笔记非待办事项:“%s”"
msgid ""
"Manages E2EE configuration. Commands are `enable`, `disable`, `decrypt`, "
@@ -237,7 +239,7 @@ msgid ""
msgstr "通过方向键与 page up/down 键来滚动列表与文本区域(包含此控制台)。"
msgid "To maximise/minimise the console, press \"tc\"."
msgstr "按 \"TC\" 最大化/最小化控制台。"
msgstr "按“TC”最大化/最小化控制台。"
msgid "To enter command line mode, press \":\""
msgstr "按“:”键进入命令行模式"
@@ -314,7 +316,7 @@ msgstr ""
"待办事项。"
msgid "Either \"text\" or \"json\""
msgstr "\"text\" 或 \"json\""
msgstr "text”或“json"
msgid ""
"Use long list format. Format is ID, NOTE_COUNT (for notebook), DATE, "
@@ -418,8 +420,8 @@ msgid ""
"taking place, you may delete the lock file at \"%s\" and resume the "
"operation."
msgstr ""
"锁定文件已被保存。如果您确认当前未在进行任何同步,可删除锁定文件 \"%s\" 后继"
"续上一部操作。"
"锁定文件已被保存。如果您确认当前未在进行任何同步,可删除锁定文件“%s”后继续上"
"一部操作。"
#, javascript-format
msgid "Synchronisation target: %s (%s)"
@@ -442,13 +444,13 @@ msgid ""
"[tag] from [note], or to list the notes associated with [tag]. The command "
"`tag list` can be used to list all the tags (use -l for long option)."
msgstr ""
"<tag-command> 可以是 \"add\"、\"remove\" 或者 \"list\", 用于从 [note] 中赋值"
"或删除 [tag],或者列出与 [tag] 相关的笔记。`tag list` 命令可以用于列出所有的"
"标签(对于过长选项请使用 -l 参数)。"
"<tag-command> 可以是“add”、“remove”或者“list, 用于从 [note] 中赋值或删除 "
"[tag],或者列出与 [tag] 相关的笔记。`tag list` 命令可以用于列出所有的标签(对"
"于过长选项请使用 -l 参数)。"
#, javascript-format
msgid "Invalid command: \"%s\""
msgstr "无效命令:\"%s\""
msgstr "无效命令:“%s”"
msgid ""
"<todo-command> can either be \"toggle\" or \"clear\". Use \"toggle\" to "
@@ -456,9 +458,9 @@ msgid ""
"target is a regular note it will be converted to a to-do). Use \"clear\" to "
"convert the to-do back to a regular note."
msgstr ""
"<todo-command> 可以是 \"toggle\" 或者 \"clear\"。使用 \"toggle\" 命令来切换待"
"办事项的完成状态(若目标为普通笔记则将会转换成待办事项)。使用 \"clear\" 命令"
"来把待办事项转换到普通笔记。"
"<todo-command> 可以是toggle”或者“clear。使用toggle命令来切换待办事项的完"
"成状态(若目标为普通笔记则将会转换成待办事项)。使用clear”命令来把待办事项转"
"换到普通笔记。"
msgid "Marks a to-do as non-completed."
msgstr "标记待办事项为未完成。"
@@ -513,9 +515,9 @@ msgid ""
"any files outside this directory nor to any other personal data. No data "
"will be shared with any third party."
msgstr ""
"请在浏览器中打开以下链接激活该应用程序。该应用会建立 \"Apps/Joplin\" 文件目"
"录,并只会读写该目录中的文件。它没有任何权限访问此目录以外的任何文件或个人信"
"息。也不会与第三方分享任何数据。"
"请在浏览器中打开以下链接激活该应用程序。该应用会建立Apps/Joplin”文件目录,并"
"只会读写该目录中的文件。它没有任何权限访问此目录以外的任何文件或个人信息。也"
"不会与第三方分享任何数据。"
msgid "Search:"
msgstr "搜索:"
@@ -545,7 +547,7 @@ msgstr ""
#, javascript-format
msgid "Exporting to \"%s\" as \"%s\" format. Please wait..."
msgstr "从 \"%s\" 导出,导出格式为 \"%s\"。请稍等…"
msgstr "从“%s”导出,导出格式为“%s”。请稍等…"
msgid "Sidebar"
msgstr "边栏"
@@ -561,7 +563,7 @@ msgstr "笔记正文"
#, javascript-format
msgid "Importing from \"%s\" as \"%s\" format. Please wait..."
msgstr "从 \"%s\" 导入,导入格式为 \"%s\" 。请稍等…"
msgstr "从“%s”导入,导入格式为“%s”。请稍等…"
msgid "PDF File"
msgstr "PDF 文件"
@@ -692,7 +694,7 @@ msgid "Make a donation"
msgstr "捐赠"
msgid "Toggle development tools"
msgstr ""
msgstr "切换开发者工具"
#, javascript-format
msgid "Open %s"
@@ -899,7 +901,7 @@ msgstr "返回"
#, javascript-format
msgid ""
"New notebook \"%s\" will be created and file \"%s\" will be imported into it"
msgstr "将新建的笔记本“%s”,并将文件 \"%s\" 导入其中"
msgstr "将新建的笔记本“%s”,并将文件“%s”导入其中"
msgid "Please create a notebook first."
msgstr "请先创建笔记本。"
@@ -934,19 +936,18 @@ msgstr "一些项目无法被同步。"
msgid "View them now"
msgstr "立刻查看"
#, fuzzy
msgid "One or more master keys need a password."
msgstr "输入主密码:"
msgstr "一个或多个主密钥需要密码。"
msgid "Set the password"
msgstr "设置密码"
msgid "No notes in here. Create one by clicking on \"New note\"."
msgstr "此处没有任何笔记。点击\"新建笔记\"创建。"
msgstr "此处没有任何笔记。点击新建笔记创建。"
msgid ""
"There is currently no notebook. Create one by clicking on \"New notebook\"."
msgstr "此处没有任何笔记本。点击\"新建笔记本\"创建。"
msgstr "此处没有任何笔记本。点击新建笔记本创建。"
msgid "Location"
msgstr "位置"
@@ -965,7 +966,7 @@ msgstr "笔记属性"
#, javascript-format
msgid "The note \"%s\" has been successfully restored to the notebook \"%s\"."
msgstr "笔记\"%s\"已成功恢复到笔记本\"%s\"中。"
msgstr "笔记“%s”已成功恢复到笔记本“%s”中。"
msgid "This note has no history"
msgstr "此笔记没有历史记录"
@@ -978,8 +979,8 @@ msgid ""
"Click \"%s\" to restore the note. It will be copied in the notebook named "
"\"%s\". The current version of the note will not be replaced or modified."
msgstr ""
"单击 \"%s\" 以恢复笔记。它将会被复制到名为 \"%s\" 的笔记本中。笔记的当前版本"
"不会被替换或修改。"
"单击“%s”以恢复笔记。它将会被复制到名为“%s”的笔记本中。笔记的当前版本不会被替"
"换或修改。"
msgid "Open..."
msgstr "打开…"
@@ -1131,7 +1132,7 @@ msgid "Please select where the sync status should be exported to"
msgstr "请选择同步状态的导出位置"
msgid "Retry"
msgstr ""
msgstr "重试"
msgid "Add or remove tags"
msgstr "添加或删除标签"
@@ -1306,7 +1307,7 @@ msgstr "无法移动笔记本到该位置"
#, javascript-format
msgid "Notebooks cannot be named \"%s\", which is a reserved title."
msgstr "笔记本无法被命名为 \"%s\",这个标题被留作他用。"
msgstr "笔记本无法被命名为“%s”,这个标题被留作他用。"
msgid "created date"
msgstr "创建日期"
@@ -1362,25 +1363,27 @@ msgid "WebDAV password"
msgstr "WebDAV 密码"
msgid "Attachment download behaviour"
msgstr ""
msgstr "附件下载行为"
msgid ""
"In \"Manual\" mode, attachments are downloaded only when you click on them. "
"In \"Auto\", they are downloaded when you open the note. In \"Always\", all "
"the attachments are downloaded whether you open the note or not."
msgstr ""
"在“手动”模式下,只有单击附件时才会下载它们。在“自动”中,当你打开笔记时,它们"
"就会被下载下来。在“总是”中,无论你是否打开笔记,所有的附件都会被下载。"
msgid "Always"
msgstr ""
msgstr "总是"
msgid "Manual"
msgstr ""
msgstr "手动"
msgid "Auto"
msgstr ""
msgstr "自动"
msgid "Max concurrent connections"
msgstr ""
msgstr "最大并发连接数"
msgid "Language"
msgstr "语言"
@@ -1564,11 +1567,11 @@ msgid "%d days"
msgstr ""
msgid "Keep note history for"
msgstr ""
msgstr "保留笔记历史记录"
#, javascript-format
msgid "Invalid option value: \"%s\". Possible values are: %s."
msgstr "无效的选项值:\"%s\"。可用值有:%s。"
msgstr "无效的选项值:“%s”。可用值有:%s。"
msgid "General"
msgstr "通用选项"
@@ -1590,7 +1593,7 @@ msgstr "应用程序"
#, javascript-format
msgid "The tag \"%s\" already exists. Please choose a different name."
msgstr "标签 \"%s\" 已存在。请选择一个不同的名称。"
msgstr "标签“%s”已存在。请选择一个不同的名称。"
msgid "Joplin Export File"
msgstr "Joplin 导出文件"
@@ -1615,7 +1618,7 @@ msgstr "文件目录"
#, javascript-format
msgid "Cannot load \"%s\" module for format \"%s\""
msgstr "无法加载 \"%s\" 模块用于读取 \"%s\" 格式"
msgstr "无法加载“%s”模块用于读取“%s”格式"
#, javascript-format
msgid "Please specify import format for %s"
@@ -1625,7 +1628,7 @@ msgstr "请指定 %s 的导入格式"
msgid ""
"This item is currently encrypted: %s \"%s\". Please wait for all items to be "
"decrypted and try again."
msgstr "该项目当前已加密:%s \"%s\"。请等待所有项目解密后再重试。"
msgstr "该项目当前已加密:%s“%s”。请等待所有项目解密后再重试。"
msgid "There is no data to export."
msgstr "没有可导出的数据。"
@@ -1653,17 +1656,18 @@ msgstr "%s (%s) 无法上传到:%s"
#, javascript-format
msgid "Item \"%s\" could not be downloaded: %s"
msgstr "项目 \"%s\" 无法从 %s 中下载"
msgstr "项目“%s”无法从 %s 中下载"
#, fuzzy
msgid "Items that cannot be decrypted"
msgstr "无法同步项目"
msgstr "无法解密的项目"
msgid ""
"Joplin failed to decrypt these items multiple times, possibly because they "
"are corrupted or too large. These items will remain on the device but Joplin "
"will no longer attempt to decrypt them."
msgstr ""
"Joplin 多次解密这些项目均已失败,可能是它们太大或已经损坏导致的。这些项目会保"
"留再设备上,但 Joplin 不会再尝试对它们进行解密。"
msgid "Sync status (synced items / total items)"
msgstr "同步状态(已同步项目/项目总数)"
@@ -1787,9 +1791,8 @@ msgstr "新建标签:"
msgid "Type new tags or select from list"
msgstr "输入新的标签或从列表中选择"
#, fuzzy
msgid "More information"
msgstr "配置"
msgstr "更多信息"
msgid ""
"To work correctly, the app needs the following permissions. Please enable "
@@ -1875,7 +1878,7 @@ msgstr "Joplin 手机应用目前不支持这种类型的链接:%s"
#, javascript-format
msgid "Links with protocol \"%s\" are not supported"
msgstr ""
msgstr "不支持“%s”协议链接"
#, javascript-format
msgid "Unsupported image type: %s"

View File

@@ -165,6 +165,11 @@
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k="
},
"atob": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
"integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg=="
},
"aws-sign2": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz",
@@ -453,6 +458,24 @@
}
}
},
"css": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/css/-/css-2.2.4.tgz",
"integrity": "sha512-oUnjmWpy0niI3x/mPL8dVEI1l7MnG3+HHyRPHf+YFSbK+svOhXpmSOcDURUh2aOCgl2grzrOPt1nHLuCVFULLw==",
"requires": {
"inherits": "^2.0.3",
"source-map": "^0.6.1",
"source-map-resolve": "^0.5.2",
"urix": "^0.1.0"
},
"dependencies": {
"source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
}
}
},
"cssom": {
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.6.tgz",
@@ -517,6 +540,11 @@
"ms": "2.0.0"
}
},
"decode-uri-component": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
"integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU="
},
"decompress-response": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz",
@@ -1437,10 +1465,11 @@
"dev": true
},
"joplin-turndown": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/joplin-turndown/-/joplin-turndown-4.0.12.tgz",
"integrity": "sha512-HlxkcIiNFSMLBvYktoXqLLHFGuwQYlcPclo0Peeatw3cPe6iFqSsEgEGY/0bYM/fubA/zpPULrJcjST99BO9wQ==",
"version": "4.0.15",
"resolved": "https://registry.npmjs.org/joplin-turndown/-/joplin-turndown-4.0.15.tgz",
"integrity": "sha512-68ukx19XFbKtJ5hfPfPX6IDLFZ1+NI+CpxJZyDEXAN5rPkyGXDw9xnEfo1IYRd+fq56upjo5Fn7J1hTCQTVTIA==",
"requires": {
"css": "^2.2.4",
"html-entities": "^1.2.1",
"jsdom": "^11.9.0"
}
@@ -2441,6 +2470,11 @@
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8="
},
"resolve-url": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz",
"integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo="
},
"retry": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.10.1.tgz",
@@ -2598,6 +2632,23 @@
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
"integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
},
"source-map-resolve": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.2.tgz",
"integrity": "sha512-MjqsvNwyz1s0k81Goz/9vRBe9SZdB09Bdw+/zYyO+3CuPk6fouTaxscHkgtE8jKvf01kVfl8riHzERQ/kefaSA==",
"requires": {
"atob": "^2.1.1",
"decode-uri-component": "^0.2.0",
"resolve-url": "^0.2.1",
"source-map-url": "^0.4.0",
"urix": "^0.1.0"
}
},
"source-map-url": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.0.tgz",
"integrity": "sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM="
},
"split-skip": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/split-skip/-/split-skip-0.0.2.tgz",
@@ -3114,6 +3165,11 @@
}
}
},
"urix": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz",
"integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI="
},
"url-parse": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.2.0.tgz",

View File

@@ -43,7 +43,7 @@
"html-minifier": "^3.5.15",
"image-data-uri": "^2.0.0",
"image-type": "^3.0.0",
"joplin-turndown": "^4.0.12",
"joplin-turndown": "^4.0.15",
"joplin-turndown-plugin-gfm": "^1.0.8",
"jssha": "^2.3.0",
"levenshtein": "^1.0.5",

View File

@@ -35,7 +35,7 @@ describe('EnexToMd', function() {
const htmlPath = basePath + '/' + htmlFilename;
const mdPath = basePath + '/' + filename(htmlFilename) + '.md';
// if (htmlFilename !== 'list5.html') continue;
// if (htmlFilename !== 'multiline_inner_text.html') continue;
const html = await shim.fsDriver().readFile(htmlPath);
let expectedMd = await shim.fsDriver().readFile(mdPath);

View File

@@ -0,0 +1,7 @@
<div>Sometimes Evernote
wraps lines inside blocks</div>
<div>Sometimes it doesn't wrap them</div>
<pre>But
careful
with
pre tags</pre>

View File

@@ -0,0 +1,6 @@
Sometimes Evernote wraps lines inside blocks
Sometimes it doesn't wrap them
But
careful
with
pre tags

View File

@@ -1,5 +1,7 @@
def ma_fonction():
"""
C'est une super fonction
"""
pass
```
def ma_fonction():
"""
C'est une super fonction
"""
pass
```

View File

@@ -0,0 +1,2 @@
<pre style="font-family: Menlo, Monaco, Consolas, &quot;Courier New&quot;, monospace;"><strong><font color="#008080">thatsCode();</font></strong></pre>
<pre>thatsJustPre(); // In that case we do not have enough info to know if it is a codeblock or not, so we leave it as plain text</pre>

View File

@@ -0,0 +1,5 @@
```
thatsCode();
```
thatsJustPre(); // In that case we do not have enough info to know if it is a codeblock or not, so we leave it as plain text

View File

@@ -6,4 +6,6 @@ Some text, not an image, so it should remain escaped:
But this is code so it can be unescaped:
<img src="http://test.com/image.png" />
```
<img src="http://test.com/image.png" />
```

View File

@@ -156,6 +156,25 @@ describe('services_rest_Api', function() {
done();
});
it('should preserve user timestamps when creating notes', async (done) => {
let response = null;
const f = await Folder.save({ title: "mon carnet" });
const updatedTime = Date.now() - 1000;
const createdTime = Date.now() - 10000;
response = await api.route('POST', 'notes', null, JSON.stringify({
parent_id: f.id,
user_updated_time: updatedTime,
user_created_time: createdTime,
}));
expect(response.user_updated_time).toBe(updatedTime);
expect(response.user_created_time).toBe(createdTime);
done();
});
it('should create notes with supplied ID', async (done) => {
let response = null;
const f = await Folder.save({ title: "mon carnet" });

View File

@@ -87,20 +87,31 @@
}
// Cleans up element by removing all its invisible children (which we don't want to render as Markdown)
// And hard-code the image dimensions so that the information can be used by the clipper server to
// display them at the right sizes in the notes.
function cleanUpElement(element, imageSizes) {
const childNodes = element.childNodes;
for (let i = 0; i < childNodes.length; i++) {
for (let i = childNodes.length - 1; i >= 0; i--) {
const node = childNodes[i];
const nodeName = node.nodeName.toLowerCase();
let isVisible = node.nodeType === 1 ? window.getComputedStyle(node).display !== 'none' : true;
if (isVisible && ['input', 'textarea', 'script', 'noscript', 'style', 'select', 'option', 'button'].indexOf(node.nodeName.toLowerCase()) >= 0) isVisible = false;
const isHidden = node && node.classList && node.classList.contains('joplin-clipper-hidden');
if (!isVisible) {
if (isHidden) {
element.removeChild(node);
} else {
if (node.nodeName.toLowerCase() === 'img') {
// If the data-joplin-clipper-value has been set earlier, create a new DIV element
// to replace the input or text area, so that it can be exported.
if (node.getAttribute && node.getAttribute('data-joplin-clipper-value')) {
const div = document.createElement('div');
div.innerText = node.getAttribute('data-joplin-clipper-value');
node.parentNode.insertBefore(div, node.nextSibling);
element.removeChild(node);
}
if (nodeName === 'img') {
node.src = absoluteUrl(node.src);
const imageSize = imageSizes[node.src];
if (imageSize) {
@@ -114,6 +125,70 @@
}
}
// When we clone the document before cleaning it, we lose some of the information that might have been set via CSS or
// JavaScript, in particular whether an element was hidden or not. This function pre-process the document by
// adding a "joplin-clipper-hidden" class to all currently hidden elements in the current document.
// This class is then used in cleanUpElement() on the cloned document to find an element should be visible or not.
function preProcessDocument(element) {
const childNodes = element.childNodes;
for (let i = 0; i < childNodes.length; i++) {
const node = childNodes[i];
const nodeName = node.nodeName.toLowerCase();
let isVisible = node.nodeType === 1 ? window.getComputedStyle(node).display !== 'none' : true;
if (isVisible && ['script', 'noscript', 'style', 'select', 'option', 'button'].indexOf(nodeName) >= 0) isVisible = false;
// If it's a text input or a textarea and it has a value, save
// that value to data-joplin-clipper-value. This is then used
// when cleaning up the document to export the value.
if (['input', 'textarea'].indexOf(nodeName) >= 0) {
isVisible = !!node.value;
if (nodeName === 'input' && node.getAttribute('type') !== 'text') isVisible = false;
if (isVisible) node.setAttribute('data-joplin-clipper-value', node.value);
}
if (!isVisible) {
node.classList.add('joplin-clipper-hidden');
} else {
preProcessDocument(node);
}
}
}
// This sets the PRE elements computed style to the style attribute, so that
// the info can be exported and later processed by the htmlToMd converter
// to detect code blocks.
function hardcodePreStyles(doc) {
const preElements = doc.getElementsByTagName('pre');
for (const preElement of preElements) {
const fontFamily = getComputedStyle(preElement).getPropertyValue('font-family');
const fontFamilyArray = fontFamily.split(',').map(f => f.toLowerCase().trim());
if (fontFamilyArray.indexOf('monospace') >= 0) {
preElement.style.fontFamily = fontFamily;
}
}
}
// Given a document, return a <style> tag that contains all the styles
// required to render the page. Not currently used but could be as an
// option to clip pages as HTML.
function getStyleTag(doc) {
const styleText = [];
for (var i=0; i<doc.styleSheets.length; i++) {
try {
var sheet = doc.styleSheets[i];
for (const cssRule of sheet.cssRules) {
styleText.push(cssRule.cssText);
}
} catch (error) {
console.warn(error);
}
}
return '<style>' + styleText.join('\n') + '</style>';
}
function documentForReadability() {
// Readability directly change the passed document so clone it so as
// to preserve the original web page.
@@ -180,6 +255,10 @@
} else if (command.name === "completePageHtml") {
hardcodePreStyles(document);
preProcessDocument(document);
// Because cleanUpElement is going to modify the DOM and remove elements we don't want to work
// directly on the document, so we make a copy of it first.
const cleanDocument = document.body.cloneNode(true);
const imageSizes = getImageSizes(document, true);
cleanUpElement(cleanDocument, imageSizes);
@@ -187,10 +266,14 @@
} else if (command.name === "selectedHtml") {
const range = window.getSelection().getRangeAt(0);
const container = document.createElement('div');
container.appendChild(range.cloneContents());
return clippedContentResponse(pageTitle(), container.innerHTML, getImageSizes(document), getAnchorNames(document));
hardcodePreStyles(document);
preProcessDocument(document);
const range = window.getSelection().getRangeAt(0);
const container = document.createElement('div');
container.appendChild(range.cloneContents());
const imageSizes = getImageSizes(document, true);
cleanUpElement(container, imageSizes);
return clippedContentResponse(pageTitle(), container.innerHTML, getImageSizes(document), getAnchorNames(document));
} else if (command.name === 'screenshot') {

View File

@@ -1,7 +1,7 @@
{
"manifest_version": 2,
"name": "Joplin Web Clipper [DEV]",
"version": "1.0.15",
"version": "1.0.17",
"description": "Capture and save web pages and screenshots from your browser to Joplin.",
"homepage_url": "https://joplinapp.org",
"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'",

View File

@@ -239,6 +239,10 @@ class Application extends BaseApplication {
Setting.setValue('sidebarVisibility', newState.sidebarVisibility);
}
if (action.type.indexOf('NOTE_SELECT') === 0 || action.type.indexOf('FOLDER_SELECT') === 0) {
this.updateMenuItemStates();
}
return result;
}
@@ -618,20 +622,25 @@ class Application extends BaseApplication {
const rootMenus = {
edit: {
id: 'edit',
label: _('&Edit'),
submenu: [{
id: 'edit:copy',
label: _('Copy'),
role: 'copy',
accelerator: 'CommandOrControl+C',
}, {
id: 'edit:cut',
label: _('Cut'),
role: 'cut',
accelerator: 'CommandOrControl+X',
}, {
id: 'edit:paste',
label: _('Paste'),
role: 'paste',
accelerator: 'CommandOrControl+V',
}, {
id: 'edit:selectAll',
label: _('Select all'),
role: 'selectall',
accelerator: 'CommandOrControl+A',
@@ -639,6 +648,7 @@ class Application extends BaseApplication {
type: 'separator',
screens: ['Main'],
}, {
id: 'edit:bold',
label: _('Bold'),
screens: ['Main'],
accelerator: 'CommandOrControl+B',
@@ -649,6 +659,7 @@ class Application extends BaseApplication {
});
},
}, {
id: 'edit:italic',
label: _('Italic'),
screens: ['Main'],
accelerator: 'CommandOrControl+I',
@@ -659,6 +670,7 @@ class Application extends BaseApplication {
});
},
}, {
id: 'edit:link',
label: _('Link'),
screens: ['Main'],
accelerator: 'CommandOrControl+K',
@@ -669,6 +681,7 @@ class Application extends BaseApplication {
});
},
}, {
id: 'edit:code',
label: _('Code'),
screens: ['Main'],
accelerator: 'CommandOrControl+`',
@@ -682,6 +695,7 @@ class Application extends BaseApplication {
type: 'separator',
screens: ['Main'],
}, {
id: 'edit:insertDateTime',
label: _('Insert Date Time'),
screens: ['Main'],
accelerator: 'CommandOrControl+Shift+T',
@@ -695,6 +709,7 @@ class Application extends BaseApplication {
type: 'separator',
screens: ['Main'],
}, {
id: 'edit:commandStartExternalEditing',
label: _('Edit in external editor'),
screens: ['Main'],
accelerator: 'CommandOrControl+E',
@@ -705,29 +720,36 @@ class Application extends BaseApplication {
});
},
}, {
id: 'edit:setTags',
label: _('Tags'),
screens: ['Main'],
accelerator: 'CommandOrControl+Alt+T',
click: () => {
const selectedNoteIds = this.store().getState().selectedNoteIds;
if (selectedNoteIds.length !== 1) return;
this.dispatch({
type: 'WINDOW_COMMAND',
name: 'setTags',
noteId: selectedNoteIds[0],
});
},
}, {
type: 'separator',
screens: ['Main'],
}, {
id: 'edit:focusSearch',
label: _('Search in all the notes'),
screens: ['Main'],
accelerator: shim.isMac() ? 'Shift+Command+F' : 'F6',
click: () => {
this.dispatch({
type: 'WINDOW_COMMAND',
name: 'focus_search',
name: 'focusSearch',
});
},
}, {
id: 'edit:showLocalSearch',
label: _('Search in current note'),
screens: ['Main'],
accelerator: 'CommandOrControl+F',
@@ -924,6 +946,19 @@ class Application extends BaseApplication {
this.lastMenuScreen_ = screen;
}
updateMenuItemStates() {
if (!this.lastMenuScreen_) return;
if (!this.store()) return;
const selectedNoteIds = this.store().getState().selectedNoteIds;
for (const itemId of ['copy', 'paste', 'cut', 'selectAll', 'bold', 'italic', 'link', 'code', 'insertDateTime', 'commandStartExternalEditing', 'setTags', 'showLocalSearch']) {
const menuItem = Menu.getApplicationMenu().getMenuItemById('edit:' + itemId);
if (!menuItem) continue;
menuItem.enabled = selectedNoteIds.length === 1;
}
}
updateTray() {
const app = bridge().electronApp();
@@ -1092,6 +1127,8 @@ class Application extends BaseApplication {
RevisionService.instance().runInBackground();
this.updateMenuItemStates();
// Make it available to the console window - useful to call revisionService.collectRevisions()
window.revisionService = RevisionService.instance();
window.migrationService = MigrationService.instance();

View File

@@ -96,7 +96,7 @@ class HeaderComponent extends React.Component {
let commandProcessed = true;
if (command.name === 'focus_search' && this.searchElement_) {
if (command.name === 'focusSearch' && this.searchElement_) {
this.searchElement_.focus();
} else {
commandProcessed = false;

View File

@@ -954,6 +954,7 @@ class NoteTextComponent extends React.Component {
postMessageSyntax: 'ipcProxySendToHost',
userCss: options.useCustomCss ? this.props.customCss : '',
resources: await shared.attachedResources(bodyToRender),
codeHighlightCacheKey: this.state.note ? this.state.note.id : null,
};
let bodyHtml = '';
@@ -1794,6 +1795,7 @@ class NoteTextComponent extends React.Component {
accuracy: 'partially',
}]
markerOptions.selectedIndex = this.state.localSearch.selectedIndex;
markerOptions.separateWordSearch = false;
} else {
const search = BaseModel.byId(this.props.searches, this.props.selectedSearchId);
if (search) {
@@ -1917,7 +1919,6 @@ const mapStateToProps = (state) => {
itemType: state.selectedItemType,
folders: state.folders,
theme: state.settings.theme,
showAdvancedOptions: state.settings.showAdvancedOptions,
syncStarted: state.syncStarted,
newNote: state.newNote,
windowCommand: state.windowCommand,

View File

@@ -205,15 +205,19 @@
elementIndex++;
}
const markKeywordOptions = {
each: onEachElement,
};
if ('separateWordSearch' in options) markKeywordOptions.separateWordSearch = options.separateWordSearch;
for (let i = 0; i < keywords.length; i++) {
let keyword = keywords[i];
markJsUtils.markKeyword(mark_, keyword, {
pregQuote: pregQuote,
replaceRegexDiacritics: replaceRegexDiacritics,
}, {
each: onEachElement,
});
}, markKeywordOptions);
}
ipcProxySendToHost('setMarkerCount', elementIndex);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -101,7 +101,7 @@
"highlight.js": "^9.15.6",
"html-entities": "^1.2.1",
"image-type": "^3.0.0",
"joplin-turndown": "^4.0.12",
"joplin-turndown": "^4.0.15",
"joplin-turndown-plugin-gfm": "^1.0.8",
"jssha": "^2.3.1",
"katex": "^0.10.0",

View File

@@ -71,7 +71,7 @@ if [[ ! -e ~/.joplin/VERSION ]] || [[ $(< ~/.joplin/VERSION) != "$version" ]]; t
# Create icon for Gnome
echo 'Create Desktop icon.'
if [[ $desktop =~ .*gnome.*|.*kde.*|.*xfce.*|.*mate.*|.*lxqt.* ]]
if [[ $desktop =~ .*gnome.*|.*kde.*|.*xfce.*|.*mate.*|.*lxqt.*|.*unity.* ]]
then
: "${TMPDIR:=/tmp}"
# This command extracts to squashfs-root by default and can't be changed...

View File

@@ -8,7 +8,7 @@ Notes exported from Evernote via .enex files [can be imported](#importing) into
The notes can be [synchronised](#synchronisation) with various cloud services including [Nextcloud](https://nextcloud.com/), Dropbox, OneDrive, WebDAV or the file system (for example with a network directory). When synchronising the notes, notebooks, tags and other metadata are saved to plain text files which can be easily inspected, backed up and moved around.
The application is available for Windows, Linux, macOS, Android and iOS (the terminal app also work on FreeBSD). A [Web Clipper](https://github.com/laurent22/joplin/blob/master/readme/clipper.md), to save web pages and screenshots from your browser, is also available for [Firefox](https://addons.mozilla.org/en-US/firefox/addon/joplin-web-clipper/) and [Chrome](https://chrome.google.com/webstore/detail/joplin-web-clipper/alofnhikmmkdbbbgpnglcpdollgjjfek?hl=en-GB).
The application is available for Windows, Linux, macOS, Android and iOS (the terminal app also works on FreeBSD). A [Web Clipper](https://github.com/laurent22/joplin/blob/master/readme/clipper.md), to save web pages and screenshots from your browser, is also available for [Firefox](https://addons.mozilla.org/en-US/firefox/addon/joplin-web-clipper/) and [Chrome](https://chrome.google.com/webstore/detail/joplin-web-clipper/alofnhikmmkdbbbgpnglcpdollgjjfek?hl=en-GB).
<div class="top-screenshot"><img src="https://joplinapp.org/images/AllClients.jpg" style="max-width: 100%; max-height: 35em;"></div>
@@ -28,7 +28,7 @@ Linux | <a href='https://github.com/laurent22/joplin/releases/download/
Operating System | Download | Alt. Download
-----------------|----------|----------------
Android | <a href='https://play.google.com/store/apps/details?id=net.cozic.joplin&utm_source=GitHub&utm_campaign=README&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' height="40px" src='https://joplinapp.org/images/BadgeAndroid.png'/></a> | or [Download APK File](https://github.com/laurent22/joplin-android/releases/download/android-v1.0.276/joplin-v1.0.276.apk)
Android | <a href='https://play.google.com/store/apps/details?id=net.cozic.joplin&utm_source=GitHub&utm_campaign=README&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' height="40px" src='https://joplinapp.org/images/BadgeAndroid.png'/></a> | or [Download APK File](https://github.com/laurent22/joplin-android/releases/download/android-v1.0.277/joplin-v1.0.277.apk)
iOS | <a href='https://itunes.apple.com/us/app/joplin/id1315599797'><img alt='Get it on the App Store' height="40px" src='https://joplinapp.org/images/BadgeIOS.png'/></a> | -
## Terminal application
@@ -84,6 +84,7 @@ The Web Clipper is a browser extension that allows you to save web pages and scr
- Import Enex files (Evernote export format) and Markdown files.
- Export JEX files (Joplin Export format) and raw files.
- Support notes, to-dos, tags and notebooks.
- Goto Anything feature.
- Sort notes by multiple criteria - title, updated time, etc.
- Support for alarms (notifications) in mobile and desktop applications.
- Offline first, so the entire data is always available on the device even without an internet connection.
@@ -329,6 +330,10 @@ Field restricted | Add either `title:` or `body:` before a note to restrict your
Notes are sorted by "relevance". Currently it means the notes that contain the requested terms the most times are on top. For queries with multiple terms, it also matter how close to each others are the terms. This is a bit experimental so if you notice a search query that returns unexpected results, please report it in the forum, providing as much details as possible to replicate the issue.
# Goto Anything
In the desktop application, press Ctrl+G or Cmd+G and type the title of a note to jump directly to it. You can also type `#` followed by a tag or `@` followed by a notebook title.
# Donations
Donations to Joplin support the development of the project. Developing quality applications mostly takes time, but there are also some expenses, such as digital certificates to sign the applications, app store fees, hosting, etc. Most of all, your donation will make it possible to keep up the current development standard.
@@ -376,9 +381,9 @@ Current translations:
![](https://joplinapp.org/images/flags/country-4x3/gb.png) | English (UK) | [en_GB](https://github.com/laurent22/joplin/blob/master/CliClient/locales/en_GB.po) | | 100%
![](https://joplinapp.org/images/flags/country-4x3/us.png) | English (US) | [en_US](https://github.com/laurent22/joplin/blob/master/CliClient/locales/en_US.po) | | 100%
![](https://joplinapp.org/images/flags/country-4x3/es.png) | Español | [es_ES](https://github.com/laurent22/joplin/blob/master/CliClient/locales/es_ES.po) | Andros Fenollosa (andros@fenollosa.email) | 97%
![](https://joplinapp.org/images/flags/country-4x3/fr.png) | Français | [fr_FR](https://github.com/laurent22/joplin/blob/master/CliClient/locales/fr_FR.po) | Laurent Cozic | 99%
![](https://joplinapp.org/images/flags/country-4x3/fr.png) | Français | [fr_FR](https://github.com/laurent22/joplin/blob/master/CliClient/locales/fr_FR.po) | Laurent Cozic | 100%
![](https://joplinapp.org/images/flags/es/galicia.png) | Galician | [gl_ES](https://github.com/laurent22/joplin/blob/master/CliClient/locales/gl_ES.po) | Marcos Lans (marcoslansgarza@gmail.com) | 65%
![](https://joplinapp.org/images/flags/country-4x3/it.png) | Italiano | [it_IT](https://github.com/laurent22/joplin/blob/master/CliClient/locales/it_IT.po) | | 92%
![](https://joplinapp.org/images/flags/country-4x3/it.png) | Italiano | [it_IT](https://github.com/laurent22/joplin/blob/master/CliClient/locales/it_IT.po) | | 98%
![](https://joplinapp.org/images/flags/country-4x3/be.png) | Nederlands | [nl_BE](https://github.com/laurent22/joplin/blob/master/CliClient/locales/nl_BE.po) | | 51%
![](https://joplinapp.org/images/flags/country-4x3/nl.png) | Nederlands | [nl_NL](https://github.com/laurent22/joplin/blob/master/CliClient/locales/nl_NL.po) | Heimen Stoffels (vistausss@outlook.com) | 79%
![](https://joplinapp.org/images/flags/country-4x3/no.png) | Norwegian | [nb_NO](https://github.com/laurent22/joplin/blob/master/CliClient/locales/nb_NO.po) | Mats Estensen (code@mxe.no) | 91%

View File

@@ -94,8 +94,8 @@ android {
applicationId "net.cozic.joplin"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2097512
versionName "1.0.276"
versionCode 2097513
versionName "1.0.277"
ndk {
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
}

View File

@@ -8,6 +8,7 @@ class HtmlToMd {
const turndown = new TurndownService({
headingStyle: 'atx',
anchorNames: options.anchorNames ? options.anchorNames.map(n => n.trim().toLowerCase()) : [],
codeBlockStyle: 'fenced',
})
turndown.use(turndownPluginGfm)
turndown.remove('script');

View File

@@ -22,7 +22,7 @@ const hljs = require('highlight.js');
const markdownItAnchor = require('markdown-it-anchor');
// The keys must match the corresponding entry in Setting.js
const plugins = {
mark: {module: require('markdown-it-mark')},
mark: {module: require('markdown-it-mark')},
footnote: {module: require('markdown-it-footnote')},
sub: {module: require('markdown-it-sub')},
sup: {module: require('markdown-it-sup')},
@@ -43,6 +43,9 @@ class MdToHtml {
this.resourceBaseUrl_ = ('resourceBaseUrl' in options) ? options.resourceBaseUrl : null;
this.cachedOutputs_ = {};
this.lastCodeHighlightCacheKey_ = null;
this.cachedHighlightedCode_ = {};
}
render(body, style, options = null) {
@@ -51,6 +54,15 @@ class MdToHtml {
if (!options.paddingBottom) options.paddingBottom = '0';
if (!options.highlightedKeywords) options.highlightedKeywords = [];
// The "codeHighlightCacheKey" option indicates what set of cached object should be
// associated with this particular Markdown body. It is only used to allow us to
// clear the cache whenever switching to a different note.
// If "codeHighlightCacheKey" is not specified, code highlighting won't be cached.
if (options.codeHighlightCacheKey !== this.lastCodeHighlightCacheKey_ || !options.codeHighlightCacheKey) {
this.cachedHighlightedCode_ = {};
this.lastCodeHighlightCacheKey_ = options.codeHighlightCacheKey;
}
const breaks_ = Setting.value('markdown.softbreaks') ? false : true;
const cacheKey = md5(escape(body + JSON.stringify(options) + JSON.stringify(style)));
@@ -67,14 +79,21 @@ class MdToHtml {
breaks: breaks_,
linkify: true,
html: true,
highlight: function(str, lang) {
highlight: (str, lang) => {
try {
let hlCode = '';
if (lang && hljs.getLanguage(lang)) {
hlCode = hljs.highlight(lang, str, true).value;
const cacheKey = md5(str + '_' + lang);
if (options.codeHighlightCacheKey && this.cachedHighlightedCode_[cacheKey]) {
hlCode = this.cachedHighlightedCode_[cacheKey];
} else {
hlCode = hljs.highlightAuto(str).value;
if (lang && hljs.getLanguage(lang)) {
hlCode = hljs.highlight(lang, str, true).value;
} else {
hlCode = hljs.highlightAuto(str).value;
}
this.cachedHighlightedCode_[cacheKey] = hlCode;
}
if (shim.isReactNative()) {
@@ -124,15 +143,13 @@ class MdToHtml {
markdownIt.use(rules.checkbox(context, ruleOptions));
markdownIt.use(rules.link_open(context, ruleOptions));
markdownIt.use(rules.html_image(context, ruleOptions));
if (Setting.value('markdown.plugin.katex'))
markdownIt.use(rules.katex(context, ruleOptions));
if (Setting.value('markdown.plugin.katex')) markdownIt.use(rules.katex(context, ruleOptions));
markdownIt.use(rules.highlight_keywords(context, ruleOptions));
markdownIt.use(rules.code_inline(context, ruleOptions));
markdownIt.use(markdownItAnchor)
for (let key in plugins) {
if (Setting.value('markdown.plugin.' + key))
markdownIt.use(plugins[key].module, plugins[key].options);
if (Setting.value('markdown.plugin.' + key)) markdownIt.use(plugins[key].module, plugins[key].options);
}
setupLinkify(markdownIt);

View File

@@ -52,14 +52,6 @@ class ActionButtonComponent extends React.Component {
});
}
newFolder_press() {
this.props.dispatch({
type: 'NAV_GO',
routeName: 'Folder',
folderId: null,
});
}
render() {
let buttons = this.props.buttons ? this.props.buttons : [];
@@ -79,13 +71,6 @@ class ActionButtonComponent extends React.Component {
icon: 'md-document',
});
}
buttons.push({
title: _('New notebook'),
onPress: () => { this.newFolder_press() },
color: '#3498db',
icon: 'md-folder',
});
}
let buttonComps = [];

View File

@@ -4,7 +4,6 @@ const { Platform, View, Text, Button, StyleSheet, TouchableOpacity, Image, Scrol
const Icon = require('react-native-vector-icons/Ionicons').default;
const { BackButtonService } = require('lib/services/back-button.js');
const NavService = require('lib/services/NavService.js');
const { ReportService } = require('lib/services/report.js');
const { Menu, MenuOptions, MenuOption, MenuTrigger } = require('react-native-popup-menu');
const { _ } = require('lib/locale.js');
const Setting = require('lib/models/Setting.js');
@@ -191,44 +190,10 @@ class ScreenHeaderComponent extends Component {
NavService.go('Status');
}
config_press() {
NavService.go('Config');
}
encryptionConfig_press() {
NavService.go('EncryptionConfig');
}
warningBox_press() {
NavService.go('EncryptionConfig');
}
async debugReport_press() {
const service = new ReportService();
const logItems = await reg.logger().lastEntries(null);
const logItemRows = [
['Date','Level','Message']
];
for (let i = 0; i < logItems.length; i++) {
const item = logItems[i];
logItemRows.push([
time.formatMsToLocal(item.timestamp, 'MM-DDTHH:mm:ss'),
item.level,
item.message
]);
}
const logItemCsv = service.csvCreate(logItemRows);
const itemListCsv = await service.basicItemList({ format: 'csv' });
const filePath = RNFS.ExternalDirectoryPath + '/syncReport-' + (new Date()).getTime() + '.txt';
const finalText = [logItemCsv, itemListCsv].join("\n================================================================================\n");
await RNFS.writeFile(filePath, finalText);
alert('Debug report exported to ' + filePath);
}
render() {
function sideMenuButton(styles, onPress) {
@@ -312,42 +277,9 @@ class ScreenHeaderComponent extends Component {
}
}
if (this.props.showAdvancedOptions) {
if (menuOptionComponents.length) {
menuOptionComponents.push(<View key={'menuOption_showAdvancedOptions'} style={this.styles().divider}/>);
}
menuOptionComponents.push(
<MenuOption value={() => this.log_press()} key={'menuOption_log'} style={this.styles().contextMenuItem}>
<Text style={this.styles().contextMenuItemText}>{_('Log')}</Text>
</MenuOption>);
menuOptionComponents.push(
<MenuOption value={() => this.status_press()} key={'menuOption_status'} style={this.styles().contextMenuItem}>
<Text style={this.styles().contextMenuItemText}>{_('Status')}</Text>
</MenuOption>);
if (Platform.OS === 'android') {
menuOptionComponents.push(
<MenuOption value={() => this.debugReport_press()} key={'menuOption_debugReport'} style={this.styles().contextMenuItem}>
<Text style={this.styles().contextMenuItemText}>{_('Export Debug Report')}</Text>
</MenuOption>);
}
}
if (menuOptionComponents.length) {
menuOptionComponents.push(<View key={'menuOption_' + key++} style={this.styles().divider}/>);
}
menuOptionComponents.push(
<MenuOption value={() => this.encryptionConfig_press()} key={'menuOption_encryptionConfig'} style={this.styles().contextMenuItem}>
<Text style={this.styles().contextMenuItemText}>{_('Encryption Config')}</Text>
</MenuOption>);
menuOptionComponents.push(
<MenuOption value={() => this.config_press()} key={'menuOption_config'} style={this.styles().contextMenuItem}>
<Text style={this.styles().contextMenuItemText}>{_('Configuration')}</Text>
</MenuOption>);
} else {
menuOptionComponents.push(
<MenuOption value={() => this.deleteButton_press()} key={'menuOption_delete'} style={this.styles().contextMenuItem}>
@@ -453,7 +385,7 @@ class ScreenHeaderComponent extends Component {
const sortButtonComp = this.props.sortButton_press ? sortButton(this.styles(), () => this.props.sortButton_press()) : null;
const windowHeight = Dimensions.get('window').height - 50;
const menuComp = !showContextMenuButton ? null : (
const menuComp = !menuOptionComponents.length || !showContextMenuButton ? null : (
<Menu onSelect={(value) => this.menu_select(value)} style={this.styles().contextMenu}>
<MenuTrigger style={{ paddingTop: PADDING_V, paddingBottom: PADDING_V }}>
<Icon name='md-more' style={this.styles().contextMenuTrigger} />
@@ -497,7 +429,6 @@ const ScreenHeader = connect(
locale: state.settings.locale,
folders: state.folders,
theme: state.settings.theme,
showAdvancedOptions: state.settings.showAdvancedOptions,
noteSelectionEnabled: state.noteSelectionEnabled,
selectedNoteIds: state.selectedNoteIds,
showMissingMasterKeyMessage: state.notLoadedMasterKeys.length && state.masterKeys.length,

View File

@@ -10,7 +10,11 @@ const Setting = require('lib/models/Setting.js');
const shared = require('lib/components/shared/config-shared.js');
const SyncTargetRegistry = require('lib/SyncTargetRegistry');
const { reg } = require('lib/registry.js');
const NavService = require('lib/services/NavService.js');
const VersionInfo = require('react-native-version-info').default;
const { ReportService } = require('lib/services/report.js');
const { time } = require('lib/time-utils');
const RNFS = require('react-native-fs');
import Slider from '@react-native-community/slider';
@@ -24,15 +28,59 @@ class ConfigScreenComponent extends BaseScreenComponent {
super();
this.styles_ = {};
this.state = {
creatingReport: false,
};
shared.init(this);
this.checkSyncConfig_ = async () => {
await shared.checkSyncConfig(this, this.state.settings);
}
this.e2eeConfig_ = () => {
NavService.go('EncryptionConfig');
}
this.saveButton_press = () => {
return shared.saveSettings(this);
};
this.syncStatusButtonPress_ = () => {
NavService.go('Status');
}
this.exportDebugButtonPress_ = async () => {
this.setState({ creatingReport: true });
const service = new ReportService();
const logItems = await reg.logger().lastEntries(null);
const logItemRows = [
['Date','Level','Message']
];
for (let i = 0; i < logItems.length; i++) {
const item = logItems[i];
logItemRows.push([
time.formatMsToLocal(item.timestamp, 'MM-DDTHH:mm:ss'),
item.level,
item.message
]);
}
const logItemCsv = service.csvCreate(logItemRows);
const itemListCsv = await service.basicItemList({ format: 'csv' });
const filePath = RNFS.ExternalDirectoryPath + '/syncReport-' + (new Date()).getTime() + '.txt';
const finalText = [logItemCsv, itemListCsv].join("\n================================================================================\n");
await RNFS.writeFile(filePath, finalText);
alert('Debug report exported to ' + filePath);
this.setState({ creatingReport: false });
}
this.logButtonPress_ = () => {
NavService.go('Log');
}
}
UNSAFE_componentWillMount() {
@@ -139,6 +187,21 @@ class ConfigScreenComponent extends BaseScreenComponent {
);
}
renderButton(key, title, clickHandler, options = null) {
if (!options) options = {};
return (
<View key={key} style={this.styles().settingContainer}>
<View style={{flex:1, flexDirection: 'column'}}>
<View style={{flex:1}}>
<Button title={title} onPress={clickHandler} disabled={!!options.disabled}/>
</View>
{ options.statusComp }
</View>
</View>
);
}
sectionToComponent(key, section, settings) {
const theme = themeStyle(this.props.theme);
const settingComps = [];
@@ -157,15 +220,7 @@ class ConfigScreenComponent extends BaseScreenComponent {
{messages.length >= 1 ? (<View style={{marginTop:10}}><Text style={this.styles().descriptionText}>{messages[1]}</Text></View>) : null}
</View>);
settingComps.push(
<View key="check_sync_config_button" style={this.styles().settingContainer}>
<View style={{flex:1, flexDirection: 'column'}}>
<View style={{flex:1}}>
<Button title={_('Check synchronisation configuration')} onPress={this.checkSyncConfig_}/>
</View>
{ statusComp }
</View>
</View>);
settingComps.push(this.renderButton('check_sync_config_button', _('Check synchronisation configuration'), this.checkSyncConfig_, { statusComp: statusComp }));
}
}
@@ -173,6 +228,10 @@ class ConfigScreenComponent extends BaseScreenComponent {
settingComps.push(settingComp);
}
if (section.name === 'sync') {
settingComps.push(this.renderButton('e2ee_config_button', _('Encryption Config') + ' ▶', this.e2eeConfig_));
}
const headerWrapperStyle = this.styles().headerWrapperStyle;
return (
@@ -273,6 +332,12 @@ class ConfigScreenComponent extends BaseScreenComponent {
const settingComps = shared.settingsToComponents2(this, 'mobile', settings);
settingComps.push(this.renderHeader('moreInfo', _('Tools')));
settingComps.push(this.renderButton('status_button', _('Sync Status') + ' ▶', this.syncStatusButtonPress_));
settingComps.push(this.renderButton('log_button', _('Log') + ' ▶', this.logButtonPress_));
settingComps.push(this.renderButton('export_report_button', this.state.creatingReport ? _('Creating report...') : _('Export Debug Report'), this.exportDebugButtonPress_, { disabled: this.state.creatingReport }));
settingComps.push(this.renderHeader('moreInfo', _('More information')));
if (Platform.OS === 'android' && Platform.Version >= 23) {

View File

@@ -580,7 +580,7 @@ class NoteScreenComponent extends BaseScreenComponent {
output.push({ title: isTodo ? _('Convert to note') : _('Convert to todo'), onPress: () => { this.toggleIsTodo_onPress(); } });
if (isSaved) output.push({ title: _('Copy Markdown link'), onPress: () => { this.copyMarkdownLink_onPress(); } });
output.push({ isDivider: true });
if (this.props.showAdvancedOptions) output.push({ title: this.state.showNoteMetadata ? _('Hide metadata') : _('Show metadata'), onPress: () => { this.showMetadata_onPress(); } });
output.push({ title: this.state.showNoteMetadata ? _('Hide metadata') : _('Show metadata'), onPress: () => { this.showMetadata_onPress(); } });
output.push({ title: _('View on map'), onPress: () => { this.showOnMap_onPress(); } });
if (hasSource) output.push({ title: _('Go to source URL'), onPress: () => { this.showSource_onPress(); } });
output.push({ isDivider: true });
@@ -805,7 +805,6 @@ const NoteScreen = connect(
theme: state.settings.theme,
ftsEnabled: state.settings['db.ftsEnabled'],
sharedData: state.sharedData,
showAdvancedOptions: state.settings.showAdvancedOptions,
};
}
)(NoteScreenComponent)

View File

@@ -155,8 +155,8 @@ class NotesScreenComponent extends BaseScreenComponent {
if (!folder) return [];
let output = [];
if (!folder.encryption_applied) output.push({ title: _('Edit notebook'), onPress: () => { this.editFolder_onPress(this.props.selectedFolderId); } });
output.push({ title: _('Delete notebook'), onPress: () => { this.deleteFolder_onPress(this.props.selectedFolderId); } });
// if (!folder.encryption_applied) output.push({ title: _('Edit notebook'), onPress: () => { this.editFolder_onPress(this.props.selectedFolderId); } });
// output.push({ title: _('Delete notebook'), onPress: () => { this.deleteFolder_onPress(this.props.selectedFolderId); } });
return output;
} else {

View File

@@ -1,5 +1,5 @@
const React = require('react'); const Component = React.Component;
const { TouchableOpacity , Button, Text, Image, StyleSheet, ScrollView, View } = require('react-native');
const { TouchableOpacity , Button, Text, Image, StyleSheet, ScrollView, View, Alert } = require('react-native');
const { connect } = require('react-redux');
const Icon = require('react-native-vector-icons/Ionicons').default;
const Tag = require('lib/models/Tag.js');
@@ -8,21 +8,27 @@ const Folder = require('lib/models/Folder.js');
const Setting = require('lib/models/Setting.js');
const { FoldersScreenUtils } = require('lib/folders-screen-utils.js');
const { Synchronizer } = require('lib/synchronizer.js');
const NavService = require('lib/services/NavService.js');
const { reg } = require('lib/registry.js');
const { _ } = require('lib/locale.js');
const { globalStyle, themeStyle } = require('lib/components/global-style.js');
const shared = require('lib/components/shared/side-menu-shared.js');
const { ActionButton } = require('lib/components/action-button.js');
class SideMenuContentComponent extends Component {
constructor() {
super();
this.state = { syncReportText: '',
//width: 0,
this.state = {
syncReportText: '',
};
this.styles_ = {};
this.tagButton_press = this.tagButton_press.bind(this);
this.newFolderButton_press = this.newFolderButton_press.bind(this);
this.synchronize_press = this.synchronize_press.bind(this);
this.configButton_press = this.configButton_press.bind(this);
this.renderFolderItem = this.renderFolderItem.bind(this);
}
styles() {
@@ -55,6 +61,11 @@ class SideMenuContentComponent extends Component {
paddingRight: theme.marginRight,
color: theme.colorFaded,
fontSize: theme.fontSizeSmaller,
flex: 0,
},
sidebarIcon: {
fontSize: 22,
color: theme.color,
},
};
@@ -67,7 +78,7 @@ class SideMenuContentComponent extends Component {
styles.folderIcon.color = theme.colorFaded;//'#0072d5';
styles.folderIcon.paddingTop = 3;
styles.sideButton = Object.assign({}, styles.button);
styles.sideButton = Object.assign({}, styles.button, { flex: 0 });
styles.sideButtonText = Object.assign({}, styles.buttonText);
this.styles_[this.props.theme] = StyleSheet.create(styles);
@@ -84,6 +95,59 @@ class SideMenuContentComponent extends Component {
});
}
async folder_longPress(folder) {
const buttons = [];
Alert.alert(
'',
_('Notebook: %s', folder.title), [
{
text: _('Rename'),
onPress: () => {
if (folder.encryption_applied) {
alert(_('Encrypted notebooks cannot be renamed'));
return;
}
this.props.dispatch({ type: 'SIDE_MENU_CLOSE' });
this.props.dispatch({
type: 'NAV_GO',
routeName: 'Folder',
folderId: folder.id,
});
}
},
{
text: _('Delete'),
onPress: () => {
Alert.alert('', _('Delete notebook "%s"?\n\nAll notes and sub-notebooks within this notebook will also be deleted.', folder.title), [
{
text: _('OK'),
onPress: () => {
Folder.delete(folder.id);
},
},
{
text: _('Cancel'),
onPress: () => {},
style: 'cancel',
},
]);
},
style: 'destructive',
},
{
text: _('Cancel'),
onPress: () => {},
style: 'cancel',
}
], {
cancelable: false
}
);
}
folder_togglePress(folder) {
this.props.dispatch({
type: 'FOLDER_TOGGLE',
@@ -100,12 +164,27 @@ class SideMenuContentComponent extends Component {
});
}
configButton_press() {
this.props.dispatch({ type: 'SIDE_MENU_CLOSE' });
NavService.go('Config');
}
newFolderButton_press() {
this.props.dispatch({ type: 'SIDE_MENU_CLOSE' });
this.props.dispatch({
type: 'NAV_GO',
routeName: 'Folder',
folderId: null,
});
}
async synchronize_press() {
const actionDone = await shared.synchronize_press(this);
if (actionDone === 'auth') this.props.dispatch({ type: 'SIDE_MENU_CLOSE' });
}
folderItem(folder, selected, hasChildren, depth) {
renderFolderItem(folder, selected, hasChildren, depth) {
const theme = themeStyle(this.props.theme);
const folderButtonStyle = {
@@ -133,7 +212,7 @@ class SideMenuContentComponent extends Component {
return (
<View key={folder.id} style={{ flex: 1, flexDirection: 'row' }}>
<TouchableOpacity style={{ flex: 1 }} onPress={() => { this.folder_press(folder) }}>
<TouchableOpacity style={{ flex: 1 }} onPress={() => { this.folder_press(folder) }} onLongPress={() => { this.folder_longPress(folder) }}>
<View style={folderButtonStyle}>
<Text numberOfLines={1} style={this.styles().folderButtonText}>{Folder.displayTitle(folder)}</Text>
</View>
@@ -143,59 +222,37 @@ class SideMenuContentComponent extends Component {
);
}
synchronizeButton(state) {
renderSideBarButton(key, title, iconName, onPressHandler) {
const theme = themeStyle(this.props.theme);
const title = state == 'sync' ? _('Synchronise') : _('Cancel synchronisation');
const iconComp = state == 'sync' ? <Icon name='md-sync' style={theme.icon} /> : <Icon name='md-close' style={theme.icon} />;
return (
<TouchableOpacity key={'synchronize_button'} onPress={() => { this.synchronize_press() }}>
<TouchableOpacity key={key} onPress={onPressHandler}>
<View style={this.styles().sideButton}>
{ iconComp }
<Icon name={iconName} style={this.styles().sidebarIcon} />
<Text style={this.styles().sideButtonText}>{title}</Text>
</View>
</TouchableOpacity>
);
}
tagButton() {
const theme = themeStyle(this.props.theme);
return (
<TouchableOpacity key={'tag_button'} onPress={this.tagButton_press}>
<View style={this.styles().sideButton}>
<Icon name='md-pricetag' style={theme.icon} />
<Text style={this.styles().sideButtonText}>Tags</Text>
</View>
</TouchableOpacity>
);
}
makeDivider(key) {
return <View style={{ marginTop: 15, marginBottom: 15, flex: -1, borderBottomWidth: 1, borderBottomColor: globalStyle.dividerColor }} key={key}></View>
}
render() {
let items = [];
renderBottomPanel() {
const theme = themeStyle(this.props.theme);
// HACK: inner height of ScrollView doesn't appear to be calculated correctly when
// using padding. So instead creating blank elements for padding bottom and top.
items.push(<View style={{ height: globalStyle.marginTop }} key='bottom_top_hack'/>);
if (this.props.folders.length) {
const result = shared.renderFolders(this.props, this.folderItem.bind(this));
const folderItems = result.items;
items = items.concat(folderItems);
}
const items = [];
items.push(this.makeDivider('divider_1'));
items.push(this.tagButton());
items.push(this.makeDivider('divider_tag_bottom'));
items.push(this.renderSideBarButton('newFolder_button', _('New Notebook'), 'md-folder-open', this.newFolderButton_press));
items.push(this.renderSideBarButton('tag_button', _('Tags'), 'md-pricetag', this.tagButton_press));
items.push(this.renderSideBarButton('config_button', _('Configuration'), 'md-settings', this.configButton_press));
items.push(this.makeDivider('divider_2'));
let lines = Synchronizer.reportToLines(this.props.syncReport);
const syncReportText = lines.join("\n");
@@ -212,17 +269,39 @@ class SideMenuContentComponent extends Component {
let fullReport = [];
if (syncReportText) fullReport.push(syncReportText);
// if (fullReport.length) fullReport.push('');
if (resourceFetcherText) fullReport.push(resourceFetcherText);
if (decryptionReportText) fullReport.push(decryptionReportText);
while (fullReport.length < 12) fullReport.push(''); // Add blank lines so that height of report text is fixed and doesn't affect scrolling
items.push(this.renderSideBarButton(
'synchronize_button',
!this.props.syncStarted ? _('Synchronise') : _('Cancel synchronisation'),
!this.props.syncStarted ? 'md-sync' : 'md-close',
this.synchronize_press
));
items.push(this.synchronizeButton(this.props.syncStarted ? 'cancel' : 'sync'));
if (fullReport.length) items.push(<Text key='sync_report' style={this.styles().syncStatus}>{fullReport.join('\n')}</Text>);
items.push(<Text key='sync_report' style={this.styles().syncStatus}>{fullReport.join('\n')}</Text>);
return (
<View style={{ flex: 0, flexDirection: 'column', paddingBottom: theme.marginBottom }}>
{ items }
</View>
);
}
items.push(<View style={{ height: globalStyle.marginBottom }} key='bottom_padding_hack'/>);
render() {
let items = [];
const theme = themeStyle(this.props.theme);
// HACK: inner height of ScrollView doesn't appear to be calculated correctly when
// using padding. So instead creating blank elements for padding bottom and top.
items.push(<View style={{ height: globalStyle.marginTop }} key='bottom_top_hack'/>);
if (this.props.folders.length) {
const result = shared.renderFolders(this.props, this.renderFolderItem);
const folderItems = result.items;
items = items.concat(folderItems);
}
let style = {
flex:1,
@@ -237,6 +316,7 @@ class SideMenuContentComponent extends Component {
<ScrollView scrollsToTop={false} style={this.styles().menu}>
{ items }
</ScrollView>
{ this.renderBottomPanel() }
</View>
</View>
);

View File

@@ -430,8 +430,34 @@ function enexXmlToMdArray(stream, resources) {
//reject(e);
})
const unwrapInnerText = text => {
const lines = text.split('\n');
let output = '';
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const nextLine = i < lines.length - 1 ? lines[i+1] : '';
if (!line) {
output += '\n';
continue;
}
if (nextLine) {
output += line + ' ';
} else {
output += line;
}
}
return output;
}
saxStream.on('text', function(text) {
if (['table', 'tr', 'tbody'].indexOf(section.type) >= 0) return;
text = !state.inPre ? unwrapInnerText(text) : text;
section.lines = collapseWhiteSpaceAndAppend(section.lines, state, text);
})

View File

@@ -194,7 +194,6 @@ class Setting extends BaseModel {
'tagHeaderIsExpanded': { value: true, type: Setting.TYPE_BOOL, public: false, appTypes: ['desktop'] },
'folderHeaderIsExpanded': { value: true, type: Setting.TYPE_BOOL, public: false, appTypes: ['desktop'] },
'editor': { value: '', type: Setting.TYPE_STRING, subType: 'file_path_and_args', public: true, appTypes: ['cli', 'desktop'], label: () => _('Text editor command'), description: () => _('The editor command (may include arguments) that will be used to open a note. If none is provided it will try to auto-detect the default editor.') },
'showAdvancedOptions': { value: false, type: Setting.TYPE_BOOL, public: true, appTypes: ['mobile' ], label: () => _('Show advanced options') },
'net.customCertificates': { value: '', type: Setting.TYPE_STRING, section:'sync', show: (settings) => { return [SyncTargetRegistry.nameToId('nextcloud'), SyncTargetRegistry.nameToId('webdav')].indexOf(settings['sync.target']) >= 0 }, public: true, appTypes: ['desktop', 'cli'], label: () => _('Custom TLS certificates'), description: () => _('Comma-separated list of paths to directories to load the certificates from, or path to individual cert files. For example: /my/cert_dir, /other/custom.pem. Note that if you make changes to the TLS settings, you must save your changes before clicking on "Check synchronisation configuration".') },
'net.ignoreTlsErrors': { value: false, type: Setting.TYPE_BOOL, section:'sync', show: (settings) => { return [SyncTargetRegistry.nameToId('nextcloud'), SyncTargetRegistry.nameToId('webdav')].indexOf(settings['sync.target']) >= 0 }, public: true, appTypes: ['desktop', 'cli'], label: () => _('Ignore TLS certificate errors') },

View File

@@ -102,7 +102,7 @@ class DecryptionWorker {
if (!('errorHandler' in options)) options.errorHandler = 'log';
if (this.state_ !== 'idle') {
this.logger().info('DecryptionWorker: cannot start because state is "' + this.state_ + '"');
this.logger().debug('DecryptionWorker: cannot start because state is "' + this.state_ + '"');
return;
}

View File

@@ -87,6 +87,14 @@ class ExternalEditWatcher {
this.logger().error(error)
}
});
// Hack to support external watcher on some linux applications (gedit, gvim, etc)
// taken from https://github.com/paulmillr/chokidar/issues/591
this.watcher_.on('raw', async (event, path, {watchedPath}) => {
if (event === 'rename') {
this.watcher_.unwatch(watchedPath);
this.watcher_.add(watchedPath);
}
});
} else {
this.watcher_.add(fileToWatch);
}
@@ -297,4 +305,4 @@ class ExternalEditWatcher {
}
module.exports = ExternalEditWatcher;
module.exports = ExternalEditWatcher;

View File

@@ -82,7 +82,11 @@ class SearchEngine {
if (this.scheduleSyncTablesIID_) return;
this.scheduleSyncTablesIID_ = setTimeout(async () => {
await this.syncTables();
try {
await this.syncTables();
} catch (error) {
this.logger().error('SearchEngine::scheduleSyncTables: Error while syncing tables:', error);
}
this.scheduleSyncTablesIID_ = null;
}, 10000);
}
@@ -107,44 +111,48 @@ class SearchEngine {
let lastChangeId = Setting.value('searchEngine.lastProcessedChangeId');
while (true) {
const changes = await ItemChange.modelSelectAll(`
SELECT id, item_id, type
FROM item_changes
WHERE item_type = ?
AND id > ?
ORDER BY id ASC
LIMIT 100
`, [BaseModel.TYPE_NOTE, lastChangeId]);
try {
while (true) {
const changes = await ItemChange.modelSelectAll(`
SELECT id, item_id, type
FROM item_changes
WHERE item_type = ?
AND id > ?
ORDER BY id ASC
LIMIT 100
`, [BaseModel.TYPE_NOTE, lastChangeId]);
if (!changes.length) break;
if (!changes.length) break;
const noteIds = changes.map(a => a.item_id);
const notes = await Note.modelSelectAll('SELECT id, title, body FROM notes WHERE id IN ("' + noteIds.join('","') + '") AND is_conflict = 0 AND encryption_applied = 0');
const queries = [];
const noteIds = changes.map(a => a.item_id);
const notes = await Note.modelSelectAll('SELECT id, title, body FROM notes WHERE id IN ("' + noteIds.join('","') + '") AND is_conflict = 0 AND encryption_applied = 0');
const queries = [];
for (let i = 0; i < changes.length; i++) {
const change = changes[i];
for (let i = 0; i < changes.length; i++) {
const change = changes[i];
if (change.type === ItemChange.TYPE_CREATE || change.type === ItemChange.TYPE_UPDATE) {
queries.push({ sql: 'DELETE FROM notes_normalized WHERE id = ?', params: [change.item_id] });
const note = this.noteById_(notes, change.item_id);
if (note) {
const n = this.normalizeNote_(note);
queries.push({ sql: 'INSERT INTO notes_normalized(id, title, body) VALUES (?, ?, ?)', params: [change.item_id, n.title, n.body] });
if (change.type === ItemChange.TYPE_CREATE || change.type === ItemChange.TYPE_UPDATE) {
queries.push({ sql: 'DELETE FROM notes_normalized WHERE id = ?', params: [change.item_id] });
const note = this.noteById_(notes, change.item_id);
if (note) {
const n = this.normalizeNote_(note);
queries.push({ sql: 'INSERT INTO notes_normalized(id, title, body) VALUES (?, ?, ?)', params: [change.item_id, n.title, n.body] });
}
} else if (change.type === ItemChange.TYPE_DELETE) {
queries.push({ sql: 'DELETE FROM notes_normalized WHERE id = ?', params: [change.item_id] });
} else {
throw new Error('Invalid change type: ' + change.type);
}
} else if (change.type === ItemChange.TYPE_DELETE) {
queries.push({ sql: 'DELETE FROM notes_normalized WHERE id = ?', params: [change.item_id] });
} else {
throw new Error('Invalid change type: ' + change.type);
lastChangeId = change.id;
}
lastChangeId = change.id;
await this.db().transactionExecBatch(queries);
Setting.setValue('searchEngine.lastProcessedChangeId', lastChangeId);
await Setting.saveAll();
}
await this.db().transactionExecBatch(queries);
Setting.setValue('searchEngine.lastProcessedChangeId', lastChangeId);
await Setting.saveAll();
} catch (error) {
this.logger().error('SearchEngine: Error while processing changes:', error);
}
await ItemChangeUtils.deleteProcessedChanges();

View File

@@ -1,4 +1,5 @@
const { ltrimSlashes } = require('lib/path-utils.js');
const { Database } = require('lib/database.js');
const Folder = require('lib/models/Folder');
const Note = require('lib/models/Note');
const Tag = require('lib/models/Tag');
@@ -383,6 +384,11 @@ class Api {
this.logger().info('Request (' + requestId + '): Saving note...');
const saveOptions = this.defaultSaveOptions_(note, 'POST');
saveOptions.autoTimestamp = false; // No auto-timestamp because user may have provided them
const timestamp = Date.now();
note.updated_time = timestamp;
note.created_time = timestamp;
note = await Note.save(note, saveOptions);
if (requestNote.tags) {
@@ -432,6 +438,10 @@ class Api {
baseUrl: requestNote.base_url ? requestNote.base_url : '',
anchorNames: requestNote.anchor_names ? requestNote.anchor_names : [],
});
// Note: to save the note as HTML, use the code below:
// const minify = require('html-minifier').minify;
// output.body = minify(requestNote.body_html, { collapseWhitespace: true });
}
if (requestNote.parent_id) {
@@ -442,8 +452,10 @@ class Api {
output.parent_id = folder.id;
}
if (requestNote.source_url) output.source_url = requestNote.source_url;
if (requestNote.author) output.author = requestNote.author;
if ('source_url' in requestNote) output.source_url = requestNote.source_url;
if ('author' in requestNote) output.author = requestNote.author;
if ('user_updated_time' in requestNote) output.user_updated_time = Database.formatValue(Database.TYPE_INT, requestNote.user_updated_time);
if ('user_created_time' in requestNote) output.user_created_time = Database.formatValue(Database.TYPE_INT, requestNote.user_created_time);
return output;
}
@@ -498,7 +510,7 @@ class Api {
// If we could not find the file extension from the URL, try to get it
// now based on the Content-Type header.
if (!fileExt) imagePath = this.tryToGuessImageExtFromMimeType_(response, imagePath);
if (!fileExt) imagePath = await this.tryToGuessImageExtFromMimeType_(response, imagePath);
}
return imagePath;
} catch (error) {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
const React = require('react'); const Component = React.Component;
const { AppState, Keyboard, NativeModules, BackHandler, Platform } = require('react-native');
const { AppState, Keyboard, NativeModules, BackHandler, Platform, View, Animated } = require('react-native');
const { SafeAreaView } = require('react-navigation');
const { connect, Provider } = require('react-redux');
const { BackButtonService } = require('lib/services/back-button.js');
@@ -552,6 +552,11 @@ class AppComponent extends React.Component {
constructor() {
super();
this.state = {
sideMenuContentOpacity: new Animated.Value(0),
};
this.lastSyncStarted_ = defaultState.syncStarted;
this.backButtonHandler_ = () => {
@@ -630,6 +635,15 @@ class AppComponent extends React.Component {
AppState.removeEventListener('change', this.onAppStateChange_);
}
componentDidUpdate(prevProps) {
if (this.props.showSideMenu !== prevProps.showSideMenu) {
Animated.timing(this.state.sideMenuContentOpacity, {
toValue: this.props.showSideMenu ? 0.5 : 0,
duration: 600,
}).start();
}
}
async backButtonHandler() {
if (this.props.noteSelectionEnabled) {
this.props.dispatch({ type: 'NOTE_SELECTION_END' });
@@ -704,6 +718,7 @@ class AppComponent extends React.Component {
<AppNav screens={appNavInit} />
</SafeAreaView>
<DropdownAlert ref={ref => this.dropdownAlert_ = ref} tapToCloseEnabled={true} />
<Animated.View pointerEvents='none' style={{position:'absolute', backgroundColor:'black', opacity: this.state.sideMenuContentOpacity, width: '100%', height: '100%'}}/>
</MenuContext>
</SideMenu>
);

View File

@@ -273,7 +273,7 @@
<p>Joplin is a free, open source note taking and to-do application, which can handle a large number of notes organised into notebooks. The notes are searchable, can be copied, tagged and modified either from the applications directly or from your own text editor. The notes are in <a href="#markdown">Markdown format</a>.</p>
<p>Notes exported from Evernote via .enex files <a href="#importing">can be imported</a> into Joplin, including the formatted content (which is converted to Markdown), resources (images, attachments, etc.) and complete metadata (geolocation, updated time, created time, etc.). Plain Markdown files can also be imported.</p>
<p>The notes can be <a href="#synchronisation">synchronised</a> with various cloud services including <a href="https://nextcloud.com/">Nextcloud</a>, Dropbox, OneDrive, WebDAV or the file system (for example with a network directory). When synchronising the notes, notebooks, tags and other metadata are saved to plain text files which can be easily inspected, backed up and moved around.</p>
<p>The application is available for Windows, Linux, macOS, Android and iOS (the terminal app also work on FreeBSD). A <a href="https://github.com/laurent22/joplin/blob/master/readme/clipper.md">Web Clipper</a>, to save web pages and screenshots from your browser, is also available for <a href="https://addons.mozilla.org/en-US/firefox/addon/joplin-web-clipper/">Firefox</a> and <a href="https://chrome.google.com/webstore/detail/joplin-web-clipper/alofnhikmmkdbbbgpnglcpdollgjjfek?hl=en-GB">Chrome</a>.</p>
<p>The application is available for Windows, Linux, macOS, Android and iOS (the terminal app also works on FreeBSD). A <a href="https://github.com/laurent22/joplin/blob/master/readme/clipper.md">Web Clipper</a>, to save web pages and screenshots from your browser, is also available for <a href="https://addons.mozilla.org/en-US/firefox/addon/joplin-web-clipper/">Firefox</a> and <a href="https://chrome.google.com/webstore/detail/joplin-web-clipper/alofnhikmmkdbbbgpnglcpdollgjjfek?hl=en-GB">Chrome</a>.</p>
<div class="top-screenshot"><img src="https://joplinapp.org/images/AllClients.jpg" style="max-width: 100%; max-height: 35em;"></div>
<h1><a name="installation" href="#installation" class="heading-anchor">🔗</a>Installation</h1>
<p>Three types of applications are available: for the <strong>desktop</strong> (Windows, macOS and Linux), for <strong>mobile</strong> (Android and iOS) and for <strong>terminal</strong> (Windows, macOS, Linux and FreeBSD). All applications have similar user interfaces and can synchronise with each other.</p>
@@ -317,7 +317,7 @@
<tr>
<td>Android</td>
<td><a href='https://play.google.com/store/apps/details?id=net.cozic.joplin&utm_source=GitHub&utm_campaign=README&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' height="40px" src='https://joplinapp.org/images/BadgeAndroid.png'/></a></td>
<td>or <a href="https://github.com/laurent22/joplin-android/releases/download/android-v1.0.276/joplin-v1.0.276.apk">Download APK File</a></td>
<td>or <a href="https://github.com/laurent22/joplin-android/releases/download/android-v1.0.277/joplin-v1.0.277.apk">Download APK File</a></td>
</tr>
<tr>
<td>iOS</td>
@@ -363,6 +363,7 @@
<li>Import Enex files (Evernote export format) and Markdown files.</li>
<li>Export JEX files (Joplin Export format) and raw files.</li>
<li>Support notes, to-dos, tags and notebooks.</li>
<li>Goto Anything feature.</li>
<li>Sort notes by multiple criteria - title, updated time, etc.</li>
<li>Support for alarms (notifications) in mobile and desktop applications.</li>
<li>Offline first, so the entire data is always available on the device even without an internet connection.</li>
@@ -629,6 +630,8 @@ $$
</tbody>
</table>
<p>Notes are sorted by &quot;relevance&quot;. Currently it means the notes that contain the requested terms the most times are on top. For queries with multiple terms, it also matter how close to each others are the terms. This is a bit experimental so if you notice a search query that returns unexpected results, please report it in the forum, providing as much details as possible to replicate the issue.</p>
<h1><a name="goto-anything" href="#goto-anything" class="heading-anchor">🔗</a>Goto Anything</h1>
<p>In the desktop application, press Ctrl+G or Cmd+G and type the title of a note to jump directly to it. You can also type <code>#</code> followed by a tag or <code>@</code> followed by a notebook title.</p>
<h1><a name="donations" href="#donations" class="heading-anchor">🔗</a>Donations</h1>
<p>Donations to Joplin support the development of the project. Developing quality applications mostly takes time, but there are also some expenses, such as digital certificates to sign the applications, app store fees, hosting, etc. Most of all, your donation will make it possible to keep up the current development standard.</p>
<p>Please see the <a href="https://joplinapp.org/donate/">donation page</a> for information on how to support the development of Joplin.</p>
@@ -670,7 +673,7 @@ $$
<td>Arabic</td>
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/ar.po">ar</a></td>
<td>عبد الناصر سعيد (<a href="mailto:as@althobaity.com">as@althobaity.com</a>)</td>
<td>91%</td>
<td>90%</td>
</tr>
<tr>
<td><img src="https://joplinapp.org/images/flags/es/basque_country.png" alt=""></td>
@@ -680,6 +683,13 @@ $$
<td>51%</td>
</tr>
<tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/bg.png" alt=""></td>
<td>Bulgarian</td>
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/bg_BG.po">bg_BG</a></td>
<td></td>
<td>99%</td>
</tr>
<tr>
<td><img src="https://joplinapp.org/images/flags/es/catalonia.png" alt=""></td>
<td>Catalan</td>
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/ca.po">ca</a></td>
@@ -712,7 +722,7 @@ $$
<td>Deutsch</td>
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/de_DE.po">de_DE</a></td>
<td>Michael Sonntag (<a href="mailto:ms@editorei.de">ms@editorei.de</a>)</td>
<td>98%</td>
<td>99%</td>
</tr>
<tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/gb.png" alt=""></td>
@@ -733,7 +743,7 @@ $$
<td>Español</td>
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/es_ES.po">es_ES</a></td>
<td>Andros Fenollosa (andros@fenollosa.email)</td>
<td>98%</td>
<td>97%</td>
</tr>
<tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/fr.png" alt=""></td>
@@ -754,14 +764,14 @@ $$
<td>Italiano</td>
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/it_IT.po">it_IT</a></td>
<td></td>
<td>92%</td>
<td>98%</td>
</tr>
<tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/be.png" alt=""></td>
<td>Nederlands</td>
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/nl_BE.po">nl_BE</a></td>
<td></td>
<td>52%</td>
<td>51%</td>
</tr>
<tr>
<td><img src="https://joplinapp.org/images/flags/country-4x3/nl.png" alt=""></td>