diff --git a/.eslintignore b/.eslintignore index d683bb7ecc..d22c4db917 100644 --- a/.eslintignore +++ b/.eslintignore @@ -338,6 +338,9 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearch.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useExternalPlugins.d.ts packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useExternalPlugins.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useExternalPlugins.js.map +packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinCommands.d.ts +packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinCommands.js +packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinCommands.js.map packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinMode.d.ts packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinMode.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinMode.js.map diff --git a/.gitignore b/.gitignore index f74547c0a8..997fc70da0 100644 --- a/.gitignore +++ b/.gitignore @@ -324,6 +324,9 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearch.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useExternalPlugins.d.ts packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useExternalPlugins.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useExternalPlugins.js.map +packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinCommands.d.ts +packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinCommands.js +packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinCommands.js.map packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinMode.d.ts packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinMode.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinMode.js.map diff --git a/Assets/WebsiteAssets/images/sponsors/Tranio.png b/Assets/WebsiteAssets/images/sponsors/Tranio.png new file mode 100644 index 0000000000..3ac5813028 Binary files /dev/null and b/Assets/WebsiteAssets/images/sponsors/Tranio.png differ diff --git a/README.md b/README.md index 98159fe45a..c92e75839d 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ The Web Clipper is a browser extension that allows you to save web pages and scr # Sponsors -         + * * * @@ -409,6 +409,12 @@ For more information see [Plugins](https://github.com/laurent22/joplin/blob/dev/ Joplin implements the SQLite Full Text Search (FTS4) extension. It means the content of all the notes is indexed in real time and search queries return results very fast. Both [Simple FTS Queries](https://www.sqlite.org/fts3.html#simple_fts_queries) and [Full-Text Index Queries](https://www.sqlite.org/fts3.html#full_text_index_queries) are supported. See below for the list of supported queries: +One caveat of SQLite FTS is that it does not support languages which do not use Latin word boundaries (spaces, tabs, punctuation). To solve this issue, Joplin has a custom search mode, that does not use FTS, but still has all of its features (multi term search, filters, etc.). One of its drawbacks is that it can get slow on larger note collections. Also, the sorting of the results will be less accurate, as the ranking algorithm (BM25) is, for now, only implemented for FTS. Finally, in this mode there are no restrictions on using the `*` wildcard (`swim*`, `*swim` and `ast*rix` all work). This search mode is currently enabled if one of the following languages are detected: + - Chinese + - Japanese + - Korean + - Thai + ## Supported queries Search type | Description | Example @@ -648,7 +654,7 @@ Thank you to everyone who've contributed to Joplin's source code! MIT License -Copyright (c) 2016-2020 Laurent Cozic +Copyright (c) 2016-2021 Laurent Cozic Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/docs/api/references/plugin_api/classes/joplincommands.html b/docs/api/references/plugin_api/classes/joplincommands.html index 4f74b41d9d..8d02d9f73b 100644 --- a/docs/api/references/plugin_api/classes/joplincommands.html +++ b/docs/api/references/plugin_api/classes/joplincommands.html @@ -86,6 +86,28 @@

To view what arguments are supported, you can open any of these files and look at the execute() command.

+ +

Executing editor commands

+
+

There might be a situation where you want to invoke editor commands + without using a contentScript. For this + reason Joplin provides the built in editor.execCommand command.

+

editor.execCommand should work with any core command in both the + CodeMirror and + TinyMCE editors, + as well as most functions calls directly on a CodeMirror editor object (extensions).

+ +

editor.execCommand supports adding arguments for the commands.

+
await joplin.commands.execute('editor.execCommand', {
+    name: 'madeUpCommand', // CodeMirror and TinyMCE
+    args: [], // CodeMirror and TinyMCE
+    ui: false, // TinyMCE only
+    value: '', // TinyMCE only
+});
+

View the example using the CodeMirror editor

+ - - - + + + + - - - + + + + - - - + + + + - - - + + + + - - + + +

Devon Zuegel

小西 孝宗

Alexander van der Berg

avanderberg

c-nagy

cabottech

chr15m

Nicholas Head

Frank Bloise

Thomas Broussard

chrootlogin

dbrandonjohnson

fbloise

h4sh5

Brandon Johnson

@cnagy

clmntsl

Jesssullivan

joesfer

konishi-t

maxtruxa

mcejp

joesfer

chr15m

mcejp

nicholashead

piccobit

ravenscroftj

piccobit

Jess Sullivan

thismarty

thomasbroussard

wasteisobscene
+

Features🔗

License🔗

MIT License

-

Copyright (c) 2016-2020 Laurent Cozic

+

Copyright (c) 2016-2021 Laurent Cozic

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

diff --git a/docs/schema/settings.json b/docs/schema/settings.json index 0636874f82..700ebde57a 100644 --- a/docs/schema/settings.json +++ b/docs/schema/settings.json @@ -99,17 +99,17 @@ "sync.9.path": { "type": "string", "default": "", - "description": "Joplin Server URL. Attention: If you change this location, make sure you copy all your content to it before syncing, otherwise all files will be removed! See the FAQ for more details: https://joplinapp.org/faq/" + "description": "Joplin Cloud URL. Attention: If you change this location, make sure you copy all your content to it before syncing, otherwise all files will be removed! See the FAQ for more details: https://joplinapp.org/faq/" }, "sync.9.username": { "type": "string", "default": "", - "description": "Joplin Server email" + "description": "Joplin Cloud email" }, "sync.9.password": { "type": "string", "default": "", - "description": "Joplin Server password", + "description": "Joplin Cloud password", "$comment": "private" }, "sync.5.syncTargets": { diff --git a/docs/stats/index.html b/docs/stats/index.html index f5181fd405..5e497c5d38 100644 --- a/docs/stats/index.html +++ b/docs/stats/index.html @@ -415,15 +415,15 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md Total Windows downloads -1,425,567 +1,444,540 Total macOs downloads -554,909 +561,465 Total Linux downloads -463,554 +473,228 Windows % @@ -453,92 +453,100 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md +v2.0.4 (p) +2021-06-02T12:54:17Z +898 +267 +242 +1,407 + + v2.0.2 (p) 2021-05-21T18:07:48Z -594 -179 -448 -1,221 +1,953 +470 +1,554 +3,977 v2.0.1 (p) 2021-05-15T13:22:58Z -770 -243 -984 -1,997 +784 +245 +994 +2,023 v1.8.5 2021-05-10T11:58:14Z -11,870 -6,670 -5,753 -24,293 +27,272 +12,591 +13,983 +53,846 v1.8.4 (p) 2021-05-09T18:05:05Z -623 +656 120 433 -1,176 +1,209 v1.8.3 (p) 2021-05-04T10:38:16Z -1,049 -290 +1,280 +293 912 -2,251 +2,485 v1.8.2 (p) 2021-04-25T10:50:51Z -1,445 +1,473 421 1,261 -3,127 +3,155 v1.8.1 (p) 2021-03-29T10:46:41Z -3,003 +3,025 805 -2,418 -6,226 +2,419 +6,249 v1.7.11 2021-02-03T12:50:01Z -113,794 -42,526 -64,040 -220,360 +113,946 +42,556 +64,079 +220,581 v1.7.10 2021-01-30T13:25:29Z -13,825 +13,830 4,831 -4,425 -23,081 +4,429 +23,090 v1.7.9 (p) 2021-01-28T09:50:21Z -480 +481 123 483 -1,086 +1,087 v1.7.6 (p) 2021-01-27T10:36:05Z -283 +284 82 277 -642 +643 v1.7.5 (p) @@ -559,10 +567,10 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md v1.6.8 2021-01-20T18:11:34Z -18,064 -7,662 -7,578 -33,304 +18,148 +7,665 +7,581 +33,394 v1.7.3 (p) @@ -575,50 +583,50 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md v1.6.7 2021-01-11T23:20:33Z -10,375 -4,617 +10,418 +4,619 4,531 -19,523 +19,568 v1.6.6 2021-01-09T16:15:31Z -12,359 +12,362 3,405 4,776 -20,540 +20,543 v1.6.5 (p) 2021-01-09T01:24:32Z -553 +580 57 300 -910 +937 v1.6.4 (p) 2021-01-07T19:11:32Z 381 -72 +73 197 -650 +651 v1.6.2 (p) 2021-01-04T22:34:55Z -664 +665 221 577 -1,462 +1,463 v1.5.14 2020-12-30T01:48:46Z -10,847 -5,191 +10,889 +5,192 5,512 -21,550 +21,593 v1.6.1 (p) @@ -639,18 +647,18 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md v1.5.12 2020-12-28T15:14:08Z -2,374 +2,378 1,762 911 -5,047 +5,051 v1.5.11 2020-12-27T19:54:07Z -13,999 +14,009 4,605 -4,253 -22,857 +4,254 +22,868 v1.5.10 (p) @@ -664,9 +672,9 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md v1.5.9 (p) 2020-12-23T18:01:08Z 321 -367 -399 -1,087 +368 +400 +1,089 v1.5.8 (p) @@ -695,26 +703,26 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md v1.4.19 2020-12-01T11:11:16Z -25,492 -13,350 -11,610 -50,452 +25,530 +13,358 +11,615 +50,503 v1.4.18 2020-11-28T12:21:41Z -11,082 -3,870 -3,076 -18,028 +11,087 +3,871 +3,081 +18,039 v1.4.16 2020-11-27T19:40:16Z -1,452 +1,457 822 584 -2,858 +2,863 v1.4.15 @@ -727,18 +735,18 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md v1.4.12 2020-11-23T18:58:07Z -2,983 +2,994 1,316 1,287 -5,586 +5,597 v1.4.11 (p) 2020-11-19T23:06:51Z -946 -147 +974 +148 574 -1,667 +1,696 v1.4.10 (p) @@ -751,10 +759,10 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md v1.4.9 (p) 2020-11-11T14:23:17Z -497 +499 133 393 -1,023 +1,025 v1.4.7 (p) @@ -767,10 +775,10 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md v1.3.18 2020-11-06T12:07:02Z -30,609 +30,668 11,316 10,495 -52,420 +52,479 v1.3.17 (p) @@ -783,18 +791,18 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md v1.4.6 (p) 2020-11-05T22:44:12Z -339 +341 86 45 -470 +472 v1.3.15 2020-11-04T12:22:50Z -2,221 +2,223 1,290 836 -4,347 +4,349 v1.3.11 (p) @@ -807,10 +815,10 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md v1.3.10 (p) 2020-10-29T13:27:14Z -368 +369 107 307 -782 +783 v1.3.9 (p) @@ -848,9 +856,9 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md v1.3.3 (p) 2020-10-17T10:56:57Z 113 -36 +38 25 -174 +176 v1.3.2 (p) @@ -864,17 +872,17 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md v1.3.1 (p) 2020-10-11T15:10:18Z 77 -45 -35 -157 +46 +36 +159 v1.2.6 2020-10-09T13:56:59Z -44,164 +44,215 17,713 -14,024 -75,901 +14,026 +75,954 v1.2.4 (p) @@ -895,18 +903,18 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md v1.2.2 (p) 2020-09-22T20:31:55Z -777 +779 199 631 -1,607 +1,609 v1.1.4 2020-09-21T11:20:09Z -27,572 -13,489 +27,592 +13,490 7,740 -48,801 +48,822 v1.1.3 (p) @@ -927,42 +935,42 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md v1.1.1 (p) 2020-09-11T23:32:47Z -519 +521 195 342 -1,056 +1,058 v1.0.245 2020-09-09T12:56:10Z -21,148 +21,177 9,999 5,634 -36,781 +36,810 v1.0.242 2020-09-04T22:00:34Z -12,439 +12,446 6,418 3,015 -21,872 +21,879 v1.0.241 2020-09-04T18:06:00Z -23,628 -5,748 -4,994 -34,370 +23,665 +5,749 +4,995 +34,409 v1.0.239 (p) 2020-09-01T21:56:36Z -599 +601 226 400 -1,225 +1,227 v1.0.237 (p) @@ -983,26 +991,26 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md v1.0.235 (p) 2020-08-18T22:08:01Z -1,671 +1,673 489 920 -3,080 +3,082 v1.0.234 (p) 2020-08-17T23:13:02Z -536 +538 125 100 -761 +763 v1.0.233 2020-08-01T14:51:15Z -43,098 +43,153 18,188 12,358 -73,644 +73,699 v1.0.232 (p) @@ -1015,10 +1023,10 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md v1.0.227 2020-07-07T20:44:54Z -40,384 -15,273 -9,627 -65,284 +40,414 +15,274 +9,629 +65,317 v1.0.226 (p) @@ -1031,10 +1039,10 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md v1.0.224 2020-06-20T22:26:08Z -24,774 +24,788 11,005 6,006 -41,785 +41,799 v1.0.223 (p) @@ -1055,18 +1063,18 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md v1.0.220 2020-06-13T18:26:22Z -31,712 +31,734 9,916 6,411 -48,039 +48,061 v1.0.218 2020-06-07T10:43:34Z -14,535 +14,536 6,968 2,954 -24,457 +24,458 v1.0.217 (p) @@ -1079,18 +1087,18 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md v1.0.216 2020-05-24T14:21:01Z -37,277 -14,268 +37,327 +14,269 10,177 -61,722 +61,773 v1.0.214 (p) 2020-05-21T17:15:15Z -6,529 +6,545 3,466 760 -10,755 +10,771 v1.0.212 (p) @@ -1119,18 +1127,18 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md v1.0.207 (p) 2020-05-10T16:37:35Z -1,187 +1,188 263 1,016 -2,466 +2,467 v1.0.201 2020-04-15T22:55:13Z -53,311 +53,324 20,043 18,180 -91,534 +91,547 v1.0.200 @@ -1143,98 +1151,98 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md v1.0.199 2020-04-10T18:41:58Z -19,339 +19,347 5,884 3,788 -29,011 +29,019 v1.0.197 2020-03-30T17:21:22Z -22,280 +22,290 9,540 -5,726 -37,546 +5,734 +37,564 v1.0.195 2020-03-22T19:56:12Z -18,890 -7,948 +18,892 +7,949 4,506 -31,344 +31,347 v1.0.194 (p) 2020-03-14T00:00:32Z 1,285 -1,375 -511 -3,171 +1,377 +513 +3,175 v1.0.193 2020-03-08T08:58:53Z -28,641 +28,642 10,907 -7,392 -46,940 +7,393 +46,942 v1.0.192 (p) 2020-03-06T23:27:52Z -472 +473 122 89 -683 +684 v1.0.190 (p) 2020-03-06T01:22:22Z -373 +374 90 85 -548 +549 v1.0.189 (p) 2020-03-04T17:27:15Z -342 +343 96 90 -528 +529 v1.0.187 (p) 2020-03-01T12:31:06Z -919 +920 230 263 -1,412 +1,413 v1.0.179 2020-01-24T22:42:41Z -71,023 -28,545 -22,534 -122,102 +71,040 +28,550 +22,535 +122,125 v1.0.178 2020-01-20T19:06:45Z -17,539 +17,540 5,962 2,584 -26,085 +26,086 v1.0.177 (p) 2019-12-30T14:40:40Z -1,943 +1,944 438 -678 -3,059 +679 +3,061 v1.0.176 (p) @@ -1247,42 +1255,42 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md v1.0.175 2019-12-08T11:48:47Z -72,519 -16,905 +72,538 +16,906 16,509 -105,933 +105,953 v1.0.174 2019-11-12T18:20:58Z -30,401 +30,407 11,722 8,221 -50,344 +50,350 v1.0.173 2019-11-11T08:33:35Z -5,072 +5,074 2,077 743 -7,892 +7,894 v1.0.170 2019-10-13T22:13:04Z -27,413 +27,424 8,752 7,675 -43,840 +43,851 v1.0.169 2019-09-27T18:35:13Z -17,097 +17,098 5,921 3,754 -26,772 +26,773 v1.0.168 @@ -1295,10 +1303,10 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md v1.0.167 2019-09-10T08:48:37Z -16,790 +16,791 5,704 3,703 -26,197 +26,198 v1.0.166 @@ -1311,34 +1319,34 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md v1.0.165 2019-08-14T21:46:29Z -18,898 +18,903 6,972 5,462 -31,332 +31,337 v1.0.161 2019-07-13T18:30:00Z -19,285 +19,287 6,352 4,136 -29,773 +29,775 v1.0.160 2019-06-15T00:21:40Z -30,531 -7,745 +30,535 +7,746 8,101 -46,377 +46,382 v1.0.159 2019-06-08T00:00:19Z 5,194 2,178 -1,112 -8,484 +1,113 +8,485 v1.0.158 @@ -1425,8 +1433,8 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md 2019-03-10T20:59:58Z 13,629 4,171 -3,223 -21,023 +3,227 +21,027 v1.0.139 (p) @@ -1440,9 +1448,9 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md v1.0.138 (p) 2019-03-03T17:23:00Z 150 -86 +87 84 -320 +321 v1.0.137 (p) @@ -1455,10 +1463,10 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md v1.0.135 2019-02-27T23:36:57Z -12,514 +12,515 3,958 4,077 -20,549 +20,550 v1.0.134 @@ -1472,17 +1480,17 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md v1.0.132 2019-02-26T23:02:05Z 1,088 -451 +452 95 -1,634 +1,635 v1.0.127 2019-02-14T23:12:48Z -9,785 -3,171 +9,786 +3,172 2,929 -15,885 +15,887 v1.0.126 (p) @@ -1504,9 +1512,9 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md v1.0.120 2019-01-10T21:42:53Z 15,605 -5,201 +5,202 6,517 -27,323 +27,324 v1.0.119 @@ -1536,9 +1544,9 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md v1.0.116 2018-11-20T19:09:24Z 3,474 -1,121 +1,122 714 -5,309 +5,310 v1.0.115 @@ -1559,10 +1567,10 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md v1.0.111 2018-09-30T20:15:09Z -12,041 -3,307 -3,668 -19,016 +12,042 +3,308 +3,669 +19,019 v1.0.110 @@ -1688,9 +1696,9 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md v1.0.93 2018-05-14T11:36:01Z 1,791 -1,157 +1,158 759 -3,707 +3,708 v1.0.91 @@ -1719,10 +1727,10 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md v1.0.83 2018-04-04T19:43:58Z -4,886 +4,892 2,532 2,658 -10,076 +10,082 v1.0.82 @@ -2001,8 +2009,8 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md 2017-11-24T14:27:49Z 150 696 -6,461 -7,307 +6,463 +7,309 v0.10.23 diff --git a/packages/app-desktop/gui/MenuBar.tsx b/packages/app-desktop/gui/MenuBar.tsx index f7a12d8976..8c131b9817 100644 --- a/packages/app-desktop/gui/MenuBar.tsx +++ b/packages/app-desktop/gui/MenuBar.tsx @@ -526,6 +526,14 @@ function useMenu(props: Props) { click: () => { bridge().electronApp().hide(); }, } : noItem, + shim.isMac() ? { + role: 'hideothers', + } : noItem, + + shim.isMac() ? { + role: 'unhide', + } : noItem, + { type: 'separator', }, diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.tsx b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.tsx index 3104481e4b..1a3d424ac1 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.tsx @@ -224,10 +224,12 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) { textHeading: () => addListItem('## ', ''), textHorizontalRule: () => addListItem('* * *'), 'editor.execCommand': (value: CommandValue) => { - if (editorRef.current[value.name]) { - if (!('args' in value)) value.args = []; + if (!('args' in value)) value.args = []; + if (editorRef.current[value.name]) { editorRef.current[value.name](...value.args); + } else if (editorRef.current.commandExists(value.name)) { + editorRef.current.execCommand(value.name); } else { reg.logger().warn('CodeMirror execCommand: unsupported command: ', value.name); } diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Editor.tsx b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Editor.tsx index b113432236..4d6fa64a44 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Editor.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Editor.tsx @@ -20,6 +20,7 @@ import useEditorSearch from './utils/useEditorSearch'; import useJoplinMode from './utils/useJoplinMode'; import useKeymap from './utils/useKeymap'; import useExternalPlugins from './utils/useExternalPlugins'; +import useJoplinCommands from './utils/useJoplinCommands'; import 'codemirror/keymap/emacs'; import 'codemirror/keymap/vim'; @@ -107,6 +108,7 @@ function Editor(props: EditorProps, ref: any) { useJoplinMode(CodeMirror); const pluginOptions: any = useExternalPlugins(CodeMirror, props.plugins); useKeymap(CodeMirror); + useJoplinCommands(CodeMirror); useImperativeHandle(ref, () => { return editor; diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.test.ts b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.test.ts index 037a4906ab..70a4c9d02f 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.test.ts +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.test.ts @@ -2,19 +2,76 @@ import { modifyListLines } from './useCursorUtils'; describe('useCursorUtils', () => { - const listWithDashes = `- item1 -- item2 -- item3`; + const listWithDashes = [ + '- item1', + '- item2', + '- item3', + ]; - const listNoDashes = `item1 -item2 -item3`; + const listWithNoPrefixes = [ + 'item1', + 'item2', + 'item3', + ]; - test('should remove "- " from beggining of each line of input string', () => { - expect(JSON.stringify(modifyListLines(listWithDashes.split('\n'), 0, '- '))).toBe(JSON.stringify(listNoDashes.split('\n'))); + const listWithNumbers = [ + '1. item1', + '2. item2', + '3. item3', + ]; + + const listWithOnes = [ + '1. item1', + '1. item2', + '1. item3', + ]; + + const listWithSomeNumbers = [ + '1. item1', + 'item2', + '2. item3', + ]; + + const numberedListWithEmptyLines = [ + '1. item1', + '2. item2', + '3. ' , + '4. item3', + ]; + + const noPrefixListWithEmptyLines = [ + 'item1', + 'item2', + '' , + 'item3', + ]; + + test('should remove "- " from beginning of each line of input string', () => { + expect(modifyListLines([...listWithDashes], NaN, '- ')).toStrictEqual(listWithNoPrefixes); }); - test('should add "- " at the beggining of each line of the input string', () => { - expect(JSON.stringify(modifyListLines(listNoDashes.split('\n'), 0, '- '))).toBe(JSON.stringify(listWithDashes.split('\n'))); + test('should add "- " at the beginning of each line of the input string', () => { + expect(modifyListLines([...listWithNoPrefixes], NaN, '- ')).toStrictEqual(listWithDashes); + }); + + test('should remove "n. " at the beginning of each line of the input string', () => { + expect(modifyListLines([...listWithNumbers], 4, '1. ')).toStrictEqual(listWithNoPrefixes); + }); + + test('should add "n. " at the beginning of each line of the input string', () => { + expect(modifyListLines([...listWithNoPrefixes], 1, '1. ')).toStrictEqual(listWithNumbers); + }); + + test('should remove "1. " at the beginning of each line of the input string', () => { + expect(modifyListLines([...listWithOnes], 2, '1. ')).toStrictEqual(listWithNoPrefixes); + }); + + test('should remove "n. " from each line that has it, and ignore' + + ' lines which do not', () => { + expect(modifyListLines([...listWithSomeNumbers], 2, '2. ')).toStrictEqual(listWithNoPrefixes); + }); + + test('should add numbers to each line including empty one', () => { + expect(modifyListLines(noPrefixListWithEmptyLines, 1, '1. ')).toStrictEqual(numberedListWithEmptyLines); }); }); diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.ts b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.ts index ad2c2c8a09..f55636325f 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.ts +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.ts @@ -1,20 +1,27 @@ import markdownUtils from '@joplin/lib/markdownUtils'; import Setting from '@joplin/lib/models/Setting'; -export function modifyListLines(lines: string[],num: number,listSymbol: string) { +export function modifyListLines(lines: string[], num: number, listSymbol: string) { + const isNotNumbered = num === 1; for (let j = 0; j < lines.length; j++) { const line = lines[j]; if (!line && j === lines.length - 1) continue; // Only add the list token if it's not already there // if it is, remove it - if (!line.startsWith(listSymbol)) { - if (num) { + if (num) { + const lineNum = markdownUtils.olLineNumber(line); + if (!lineNum && isNotNumbered) { lines[j] = `${num.toString()}. ${line}`; num++; } else { - lines[j] = listSymbol + line; + const listToken = markdownUtils.extractListToken(line); + lines[j] = line.substr(listToken.length, line.length - listToken.length); } } else { - lines[j] = line.substr(listSymbol.length, line.length - listSymbol.length); + if (!line.startsWith(listSymbol)) { + lines[j] = listSymbol + line; + } else { + lines[j] = line.substr(listSymbol.length, line.length - listSymbol.length); + } } } return lines; diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinCommands.ts b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinCommands.ts new file mode 100644 index 0000000000..1056eb619a --- /dev/null +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinCommands.ts @@ -0,0 +1,7 @@ +// Helper commands added to the the CodeMirror instance +export default function useJoplinCommands(CodeMirror: any) { + + CodeMirror.defineExtension('commandExists', function(name: string) { + return !!CodeMirror.commands[name]; + }); +} diff --git a/packages/app-mobile/components/screens/search.js b/packages/app-mobile/components/screens/search.js index 94de65d15b..75dd9bbc9e 100644 --- a/packages/app-mobile/components/screens/search.js +++ b/packages/app-mobile/components/screens/search.js @@ -105,7 +105,7 @@ class SearchScreenComponent extends BaseScreenComponent { if (query) { if (this.props.settings['db.ftsEnabled']) { - notes = await SearchEngineUtils.notesForQuery(query); + notes = await SearchEngineUtils.notesForQuery(query, true); } else { const p = query.split(' '); const temp = []; diff --git a/packages/lib/BaseApplication.ts b/packages/lib/BaseApplication.ts index 1ad933cc04..4cf4fa1ca5 100644 --- a/packages/lib/BaseApplication.ts +++ b/packages/lib/BaseApplication.ts @@ -313,7 +313,7 @@ export default class BaseApplication { notes = await Tag.notes(parentId, options); } else if (parentType === BaseModel.TYPE_SEARCH) { const search = BaseModel.byId(state.searches, parentId); - notes = await SearchEngineUtils.notesForQuery(search.query_pattern); + notes = await SearchEngineUtils.notesForQuery(search.query_pattern, true); const parsedQuery = await SearchEngine.instance().parseQuery(search.query_pattern); highlightedWords = SearchEngine.instance().allParsedQueryTerms(parsedQuery); } else if (parentType === BaseModel.TYPE_SMART_FILTER) { diff --git a/packages/lib/services/plugins/api/JoplinCommands.ts b/packages/lib/services/plugins/api/JoplinCommands.ts index 360b008f06..822501a4d1 100644 --- a/packages/lib/services/plugins/api/JoplinCommands.ts +++ b/packages/lib/services/plugins/api/JoplinCommands.ts @@ -21,6 +21,34 @@ import { Command } from './types'; * * To view what arguments are supported, you can open any of these files * and look at the `execute()` command. + * + * ## Executing editor commands + * + * There might be a situation where you want to invoke editor commands + * without using a {@link JoplinContentScripts | contentScript}. For this + * reason Joplin provides the built in `editor.execCommand` command. + * + * `editor.execCommand` should work with any core command in both the + * [CodeMirror](https://codemirror.net/doc/manual.html#execCommand) and + * [TinyMCE](https://www.tiny.cloud/docs/api/tinymce/tinymce.editorcommands/#execcommand) editors, + * as well as most functions calls directly on a CodeMirror editor object (extensions). + * + * * [CodeMirror commands](https://codemirror.net/doc/manual.html#commands) + * * [TinyMCE core editor commands](https://www.tiny.cloud/docs/advanced/editor-command-identifiers/#coreeditorcommands) + * + * `editor.execCommand` supports adding arguments for the commands. + * + * ```typescript + * await joplin.commands.execute('editor.execCommand', { + * name: 'madeUpCommand', // CodeMirror and TinyMCE + * args: [], // CodeMirror and TinyMCE + * ui: false, // TinyMCE only + * value: '', // TinyMCE only + * }); + * ``` + * + * [View the example using the CodeMirror editor](https://github.com/laurent22/joplin/blob/dev/packages/app-cli/tests/support/plugins/codemirror_content_script/src/index.ts) + * */ export default class JoplinCommands { diff --git a/packages/lib/services/rest/routes/search.ts b/packages/lib/services/rest/routes/search.ts index 71d631c267..e813f2b2d5 100644 --- a/packages/lib/services/rest/routes/search.ts +++ b/packages/lib/services/rest/routes/search.ts @@ -28,7 +28,7 @@ export default async function(request: Request) { options.caseInsensitive = true; results = await ModelClass.all(options); } else { - results = await SearchEngineUtils.notesForQuery(query, defaultLoadOptions(request, ModelType.Note)); + results = await SearchEngineUtils.notesForQuery(query, false, defaultLoadOptions(request, ModelType.Note)); } return collectionToPaginatedResults(modelType, results, request); diff --git a/packages/lib/services/searchengine/SearchEngine.test.js b/packages/lib/services/searchengine/SearchEngine.test.js index 752602f269..b417f6a689 100644 --- a/packages/lib/services/searchengine/SearchEngine.test.js +++ b/packages/lib/services/searchengine/SearchEngine.test.js @@ -386,6 +386,7 @@ describe('services_SearchEngine', function() { expect((await engine.search('测试')).length).toBe(1); expect((await engine.search('测试'))[0].fields).toEqual(['body']); expect((await engine.search('测试*'))[0].fields).toEqual(['body']); + expect((await engine.search('any:1 type:todo 测试')).length).toBe(1); })); it('should support queries with Japanese characters', (async () => { @@ -398,7 +399,7 @@ describe('services_SearchEngine', function() { expect((await engine.search('できません')).length).toBe(1); expect((await engine.search('できません*'))[0].fields.sort()).toEqual(['body', 'title']); // usually assume that keyword was matched in body expect((await engine.search('テスト'))[0].fields.sort()).toEqual(['body']); - + expect((await engine.search('any:1 type:todo テスト')).length).toBe(1); })); it('should support queries with Korean characters', (async () => { @@ -409,6 +410,7 @@ describe('services_SearchEngine', function() { expect((await engine.search('이것은')).length).toBe(1); expect((await engine.search('말')).length).toBe(1); + expect((await engine.search('any:1 type:todo 말')).length).toBe(1); })); it('should support queries with Thai characters', (async () => { @@ -419,28 +421,7 @@ describe('services_SearchEngine', function() { expect((await engine.search('นี่คือค')).length).toBe(1); expect((await engine.search('ไทย')).length).toBe(1); - })); - - it('should support field restricted queries with Chinese characters', (async () => { - let rows; - const n1 = await Note.save({ title: '你好', body: '我是法国人' }); - - await engine.syncTables(); - - expect((await engine.search('title:你好*')).length).toBe(1); - expect((await engine.search('title:你好*'))[0].fields).toEqual(['title']); - expect((await engine.search('body:法国人')).length).toBe(1); - expect((await engine.search('body:法国人'))[0].fields).toEqual(['body']); - expect((await engine.search('body:你好')).length).toBe(0); - expect((await engine.search('title:你好 body:法国人')).length).toBe(1); - expect((await engine.search('title:你好 body:法国人'))[0].fields.sort()).toEqual(['body', 'title']); - expect((await engine.search('title:你好 body:bla')).length).toBe(0); - expect((await engine.search('title:你好 我是')).length).toBe(1); - expect((await engine.search('title:你好 我是'))[0].fields.sort()).toEqual(['body', 'title']); - expect((await engine.search('title:bla 我是')).length).toBe(0); - - // For non-alpha char, only the first field is looked at, the following ones are ignored - // expect((await engine.search('title:你好 title:hello')).length).toBe(1); + expect((await engine.search('any:1 type:todo ไทย')).length).toBe(1); })); it('should parse normal query strings', (async () => { diff --git a/packages/lib/services/searchengine/SearchEngine.ts b/packages/lib/services/searchengine/SearchEngine.ts index c6289967dc..4b38d1ab29 100644 --- a/packages/lib/services/searchengine/SearchEngine.ts +++ b/packages/lib/services/searchengine/SearchEngine.ts @@ -17,6 +17,7 @@ export default class SearchEngine { public static relevantFields = 'id, title, body, user_created_time, user_updated_time, is_todo, todo_completed, todo_due, parent_id, latitude, longitude, altitude, source_url'; public static SEARCH_TYPE_AUTO = 'auto'; public static SEARCH_TYPE_BASIC = 'basic'; + public static SEARCH_TYPE_NONLATIN_SCRIPT = 'nonlatin'; public static SEARCH_TYPE_FTS = 'fts'; public dispatch: Function = (_o: any) => {}; @@ -533,6 +534,7 @@ export default class SearchEngine { determineSearchType_(query: string, preferredSearchType: any) { if (preferredSearchType === SearchEngine.SEARCH_TYPE_BASIC) return SearchEngine.SEARCH_TYPE_BASIC; + if (preferredSearchType === SearchEngine.SEARCH_TYPE_NONLATIN_SCRIPT) return SearchEngine.SEARCH_TYPE_NONLATIN_SCRIPT; // If preferredSearchType is "fts" we auto-detect anyway // because it's not always supported. @@ -547,10 +549,15 @@ export default class SearchEngine { const textQuery = allTerms.filter(x => x.name === 'text' || x.name == 'title' || x.name == 'body').map(x => x.value).join(' '); const st = scriptType(textQuery); - if (!Setting.value('db.ftsEnabled') || ['ja', 'zh', 'ko', 'th'].indexOf(st) >= 0) { + if (!Setting.value('db.ftsEnabled')) { return SearchEngine.SEARCH_TYPE_BASIC; } + // Non-alphabetical languages aren't support by SQLite FTS (except with extensions which are not available in all platforms) + if (['ja', 'zh', 'ko', 'th'].indexOf(st) >= 0) { + return SearchEngine.SEARCH_TYPE_NONLATIN_SCRIPT; + } + return SearchEngine.SEARCH_TYPE_FTS; } @@ -565,7 +572,6 @@ export default class SearchEngine { const parsedQuery = await this.parseQuery(searchString); if (searchType === SearchEngine.SEARCH_TYPE_BASIC) { - // Non-alphabetical languages aren't support by SQLite FTS (except with extensions which are not available in all platforms) searchString = this.normalizeText_(searchString); const rows = await this.basicSearch(searchString); @@ -579,10 +585,11 @@ export default class SearchEngine { // when searching. // https://github.com/laurent22/joplin/issues/1075#issuecomment-459258856 + const useFts = searchType === SearchEngine.SEARCH_TYPE_FTS; try { - const { query, params } = queryBuilder(parsedQuery.allTerms); + const { query, params } = queryBuilder(parsedQuery.allTerms, useFts); const rows = await this.db().selectAll(query, params); - this.processResults_(rows, parsedQuery); + this.processResults_(rows, parsedQuery, !useFts); return rows; } catch (error) { this.logger().warn(`Cannot execute MATCH query: ${searchString}: ${error.message}`); diff --git a/packages/lib/services/searchengine/SearchEngineUtils.test.ts b/packages/lib/services/searchengine/SearchEngineUtils.test.ts index 5c734aee9a..bfc0e8f1be 100644 --- a/packages/lib/services/searchengine/SearchEngineUtils.test.ts +++ b/packages/lib/services/searchengine/SearchEngineUtils.test.ts @@ -26,12 +26,21 @@ describe('services_SearchEngineUtils', function() { Setting.setValue('showCompletedTodos', true); - const rows = await SearchEngineUtils.notesForQuery('abcd', null, searchEngine); + const rows = await SearchEngineUtils.notesForQuery('abcd', true, null, searchEngine); expect(rows.length).toBe(3); expect(rows.map(r=>r.id)).toContain(note1.id); expect(rows.map(r=>r.id)).toContain(todo1.id); expect(rows.map(r=>r.id)).toContain(todo2.id); + + const options: any = {}; + options.fields = ['id', 'title']; + + const rows2 = await SearchEngineUtils.notesForQuery('abcd', true, options, searchEngine); + expect(rows2.length).toBe(3); + expect(rows2.map(r=>r.id)).toContain(note1.id); + expect(rows2.map(r=>r.id)).toContain(todo1.id); + expect(rows2.map(r=>r.id)).toContain(todo2.id); })); it('hide completed', (async () => { @@ -43,11 +52,35 @@ describe('services_SearchEngineUtils', function() { Setting.setValue('showCompletedTodos', false); - const rows = await SearchEngineUtils.notesForQuery('abcd', null, searchEngine); + const rows = await SearchEngineUtils.notesForQuery('abcd', true, null, searchEngine); expect(rows.length).toBe(2); expect(rows.map(r=>r.id)).toContain(note1.id); expect(rows.map(r=>r.id)).toContain(todo1.id); + + const options: any = {}; + options.fields = ['id', 'title']; + const rows2 = await SearchEngineUtils.notesForQuery('abcd', true, options, searchEngine); + expect(rows2.length).toBe(2); + expect(rows2.map(r=>r.id)).toContain(note1.id); + expect(rows2.map(r=>r.id)).toContain(todo1.id); + })); + + it('show completed (!applyUserSettings)', (async () => { + const note1 = await Note.save({ title: 'abcd', body: 'body 1' }); + const todo1 = await Note.save({ title: 'abcd', body: 'todo 1', is_todo: 1 }); + await Note.save({ title: 'qwer', body: 'body 2' }); + const todo2 = await Note.save({ title: 'abcd', body: 'todo 2', is_todo: 1, todo_completed: 1590085027710 }); + await searchEngine.syncTables(); + + Setting.setValue('showCompletedTodos', false); + + const rows = await SearchEngineUtils.notesForQuery('abcd', false, null, searchEngine); + + expect(rows.length).toBe(3); + expect(rows.map(r=>r.id)).toContain(note1.id); + expect(rows.map(r=>r.id)).toContain(todo1.id); + expect(rows.map(r=>r.id)).toContain(todo2.id); })); }); }); diff --git a/packages/lib/services/searchengine/SearchEngineUtils.ts b/packages/lib/services/searchengine/SearchEngineUtils.ts index 2357622fa5..387b08d8c9 100644 --- a/packages/lib/services/searchengine/SearchEngineUtils.ts +++ b/packages/lib/services/searchengine/SearchEngineUtils.ts @@ -3,7 +3,7 @@ import Note from '../../models/Note'; import Setting from '../../models/Setting'; export default class SearchEngineUtils { - static async notesForQuery(query: string, options: any = null, searchEngine: SearchEngine = null) { + static async notesForQuery(query: string, applyUserSettings: boolean, options: any = null, searchEngine: SearchEngine = null) { if (!options) options = {}; if (!searchEngine) { @@ -30,6 +30,20 @@ export default class SearchEngineUtils { idWasAutoAdded = true; } + // Add fields is_todo and todo_completed for showCompletedTodos filtering. + // Also remember that the field was auto-added so that it can be removed afterwards. + let isTodoAutoAdded = false; + if (fields.indexOf('is_todo') < 0) { + fields.push('is_todo'); + isTodoAutoAdded = true; + } + + let isTodoCompletedAutoAdded = false; + if (fields.indexOf('todo_completed') < 0) { + fields.push('todo_completed'); + isTodoCompletedAutoAdded = true; + } + const previewOptions = Object.assign({}, { order: [], fields: fields, @@ -38,20 +52,22 @@ export default class SearchEngineUtils { const notes = await Note.previews(null, previewOptions); + // Filter completed todos + let filteredNotes = [...notes]; + if (applyUserSettings && !Setting.value('showCompletedTodos')) { + filteredNotes = notes.filter(note => note.is_todo === 0 || (note.is_todo === 1 && note.todo_completed === 0)); + } + // By default, the notes will be returned in reverse order // or maybe random order so sort them here in the correct order // (search engine returns the results in order of relevance). const sortedNotes = []; - for (let i = 0; i < notes.length; i++) { - const idx = noteIds.indexOf(notes[i].id); - sortedNotes[idx] = notes[i]; + for (let i = 0; i < filteredNotes.length; i++) { + const idx = noteIds.indexOf(filteredNotes[i].id); + sortedNotes[idx] = filteredNotes[i]; if (idWasAutoAdded) delete sortedNotes[idx].id; - } - - // Filter completed todos - let filteredNotes = [...sortedNotes]; - if (!Setting.value('showCompletedTodos')) { - filteredNotes = sortedNotes.filter(note => note.is_todo === 0 || (note.is_todo === 1 && note.todo_completed === 0)); + if (isTodoCompletedAutoAdded) delete sortedNotes[idx].is_todo; + if (isTodoAutoAdded) delete sortedNotes[idx].todo_completed; } // Note that when the search engine index is somehow corrupted, it might contain @@ -60,9 +76,9 @@ export default class SearchEngineUtils { // issue: https://discourse.joplinapp.org/t/how-to-recover-corrupted-database/9367 if (noteIds.length !== notes.length) { // remove null objects - return filteredNotes.filter(n => n); + return sortedNotes.filter(n => n); } else { - return filteredNotes; + return sortedNotes; } } diff --git a/packages/lib/services/searchengine/SearchFilter.test.js b/packages/lib/services/searchengine/SearchFilter.test.js index decc9e0736..9cf8a8505a 100644 --- a/packages/lib/services/searchengine/SearchFilter.test.js +++ b/packages/lib/services/searchengine/SearchFilter.test.js @@ -25,704 +25,7 @@ describe('services_SearchFilter', function() { done(); }); - - it('should return note matching title', (async () => { - let rows; - const n1 = await Note.save({ title: 'abcd', body: 'body 1' }); - const n2 = await Note.save({ title: 'efgh', body: 'body 2' }); - - await engine.syncTables(); - rows = await engine.search('title: abcd'); - - expect(rows.length).toBe(1); - expect(rows[0].id).toBe(n1.id); - })); - - it('should return note matching negated title', (async () => { - let rows; - const n1 = await Note.save({ title: 'abcd', body: 'body 1' }); - const n2 = await Note.save({ title: 'efgh', body: 'body 2' }); - - await engine.syncTables(); - rows = await engine.search('-title: abcd'); - - expect(rows.length).toBe(1); - expect(rows[0].id).toBe(n2.id); - })); - - it('should return note matching body', (async () => { - let rows; - const n1 = await Note.save({ title: 'abcd', body: 'body1' }); - const n2 = await Note.save({ title: 'efgh', body: 'body2' }); - - await engine.syncTables(); - rows = await engine.search('body: body1'); - - expect(rows.length).toBe(1); - expect(rows[0].id).toBe(n1.id); - })); - - it('should return note matching negated body', (async () => { - let rows; - const n1 = await Note.save({ title: 'abcd', body: 'body1' }); - const n2 = await Note.save({ title: 'efgh', body: 'body2' }); - - await engine.syncTables(); - rows = await engine.search('-body: body1'); - - expect(rows.length).toBe(1); - expect(rows[0].id).toBe(n2.id); - })); - - it('should return note matching title containing multiple words', (async () => { - let rows; - const n1 = await Note.save({ title: 'abcd xyz', body: 'body1' }); - const n2 = await Note.save({ title: 'efgh ijk', body: 'body2' }); - - await engine.syncTables(); - rows = await engine.search('title: "abcd xyz"'); - - expect(rows.length).toBe(1); - expect(rows[0].id).toBe(n1.id); - })); - - it('should return note matching body containing multiple words', (async () => { - let rows; - const n1 = await Note.save({ title: 'abcd', body: 'ho ho ho' }); - const n2 = await Note.save({ title: 'efgh', body: 'foo bar' }); - - await engine.syncTables(); - rows = await engine.search('body: "foo bar"'); - - expect(rows.length).toBe(1); - expect(rows[0].id).toBe(n2.id); - })); - - it('should return note matching title AND body', (async () => { - let rows; - const n1 = await Note.save({ title: 'abcd', body: 'ho ho ho' }); - const n2 = await Note.save({ title: 'efgh', body: 'foo bar' }); - - await engine.syncTables(); - rows = await engine.search('title: efgh body: "foo bar"'); - expect(rows.length).toBe(1); - expect(rows[0].id).toBe(n2.id); - - rows = await engine.search('title: abcd body: "foo bar"'); - expect(rows.length).toBe(0); - })); - - it('should return note matching title OR body', (async () => { - let rows; - const n1 = await Note.save({ title: 'abcd', body: 'ho ho ho' }); - const n2 = await Note.save({ title: 'efgh', body: 'foo bar' }); - - await engine.syncTables(); - rows = await engine.search('any:1 title: abcd body: "foo bar"'); - expect(rows.length).toBe(2); - expect(rows.map(r=>r.id)).toContain(n1.id); - expect(rows.map(r=>r.id)).toContain(n2.id); - - rows = await engine.search('any:1 title: wxyz body: "blah blah"'); - expect(rows.length).toBe(0); - })); - - it('should return notes matching text', (async () => { - let rows; - const n1 = await Note.save({ title: 'foo beef', body: 'dead bar' }); - const n2 = await Note.save({ title: 'bar efgh', body: 'foo dog' }); - const n3 = await Note.save({ title: 'foo ho', body: 'ho ho ho' }); - await engine.syncTables(); - - // Interpretation: Match with notes containing foo in title/body and bar in title/body - // Note: This is NOT saying to match notes containing foo bar in title/body - rows = await engine.search('foo bar'); - expect(rows.length).toBe(2); - expect(rows.map(r=>r.id)).toContain(n1.id); - expect(rows.map(r=>r.id)).toContain(n2.id); - - rows = await engine.search('foo -bar'); - expect(rows.length).toBe(1); - expect(rows.map(r=>r.id)).toContain(n3.id); - - rows = await engine.search('foo efgh'); - expect(rows.length).toBe(1); - expect(rows[0].id).toBe(n2.id); - - rows = await engine.search('zebra'); - expect(rows.length).toBe(0); - })); - - it('should return notes matching any negated text', (async () => { - let rows; - const n1 = await Note.save({ title: 'abc', body: 'def' }); - const n2 = await Note.save({ title: 'def', body: 'ghi' }); - const n3 = await Note.save({ title: 'ghi', body: 'jkl' }); - await engine.syncTables(); - - rows = await engine.search('any:1 -abc -ghi'); - expect(rows.length).toBe(3); - expect(rows.map(r=>r.id)).toContain(n1.id); - expect(rows.map(r=>r.id)).toContain(n2.id); - expect(rows.map(r=>r.id)).toContain(n3.id); - })); - - it('should return notes matching any negated title', (async () => { - let rows; - const n1 = await Note.save({ title: 'abc', body: 'def' }); - const n2 = await Note.save({ title: 'def', body: 'ghi' }); - const n3 = await Note.save({ title: 'ghi', body: 'jkl' }); - await engine.syncTables(); - - rows = await engine.search('any:1 -title:abc -title:ghi'); - expect(rows.length).toBe(3); - expect(rows.map(r=>r.id)).toContain(n1.id); - expect(rows.map(r=>r.id)).toContain(n2.id); - expect(rows.map(r=>r.id)).toContain(n3.id); - })); - - it('should return notes matching any negated body', (async () => { - let rows; - const n1 = await Note.save({ title: 'abc', body: 'def' }); - const n2 = await Note.save({ title: 'def', body: 'ghi' }); - const n3 = await Note.save({ title: 'ghi', body: 'jkl' }); - await engine.syncTables(); - - rows = await engine.search('any:1 -body:xyz -body:ghi'); - expect(rows.length).toBe(3); - expect(rows.map(r=>r.id)).toContain(n1.id); - expect(rows.map(r=>r.id)).toContain(n2.id); - expect(rows.map(r=>r.id)).toContain(n3.id); - })); - - it('should support phrase search', (async () => { - let rows; - const n1 = await Note.save({ title: 'foo beef', body: 'bar dog' }); - const n2 = await Note.save({ title: 'bar efgh', body: 'foo dog' }); - await engine.syncTables(); - - rows = await engine.search('"bar dog"'); - expect(rows.length).toBe(1); - expect(rows[0].id).toBe(n1.id); - })); - - it('should support prefix search', (async () => { - let rows; - const n1 = await Note.save({ title: 'foo beef', body: 'bar dog' }); - const n2 = await Note.save({ title: 'bar efgh', body: 'foo dog' }); - await engine.syncTables(); - - rows = await engine.search('"bar*"'); - expect(rows.length).toBe(2); - expect(ids(rows)).toContain(n1.id); - expect(ids(rows)).toContain(n2.id); - })); - - it('should support filtering by tags', (async () => { - let rows; - const n1 = await Note.save({ title: 'But I would', body: 'walk 500 miles' }); - const n2 = await Note.save({ title: 'And I would', body: 'walk 500 more' }); - const n3 = await Note.save({ title: 'Just to be', body: 'the man who' }); - const n4 = await Note.save({ title: 'walked a thousand', body: 'miles to fall' }); - const n5 = await Note.save({ title: 'down at your', body: 'door' }); - - await Tag.setNoteTagsByTitles(n1.id, ['Da', 'da', 'lat', 'da']); - await Tag.setNoteTagsByTitles(n2.id, ['Da', 'da', 'lat', 'da']); - - await engine.syncTables(); - - rows = await engine.search('tag:*'); - expect(rows.length).toBe(2); - expect(ids(rows)).toContain(n1.id); - expect(ids(rows)).toContain(n2.id); - - rows = await engine.search('-tag:*'); - expect(rows.length).toBe(3); - expect(ids(rows)).toContain(n3.id); - expect(ids(rows)).toContain(n4.id); - expect(ids(rows)).toContain(n5.id); - })); - - - it('should support filtering by tags', (async () => { - let rows; - const n1 = await Note.save({ title: 'peace talks', body: 'battle ground' }); - const n2 = await Note.save({ title: 'mouse', body: 'mister' }); - const n3 = await Note.save({ title: 'dresden files', body: 'harry dresden' }); - - await Tag.setNoteTagsByTitles(n1.id, ['tag1', 'tag2']); - await Tag.setNoteTagsByTitles(n2.id, ['tag2', 'tag3']); - await Tag.setNoteTagsByTitles(n3.id, ['tag3', 'tag4', 'space travel']); - - await engine.syncTables(); - - rows = await engine.search('tag:tag2'); - expect(rows.length).toBe(2); - expect(ids(rows)).toContain(n1.id); - expect(ids(rows)).toContain(n2.id); - - rows = await engine.search('tag:tag2 tag:tag3'); - expect(rows.length).toBe(1); - expect(ids(rows)).toContain(n2.id); - - rows = await engine.search('any:1 tag:tag1 tag:tag2 tag:tag3'); - expect(rows.length).toBe(3); - expect(ids(rows)).toContain(n1.id); - expect(ids(rows)).toContain(n2.id); - expect(ids(rows)).toContain(n3.id); - - rows = await engine.search('tag:tag2 tag:tag3 tag:tag4'); - expect(rows.length).toBe(0); - - rows = await engine.search('-tag:tag2'); - expect(rows.length).toBe(1); - expect(ids(rows)).toContain(n3.id); - - rows = await engine.search('-tag:tag2 -tag:tag3'); - expect(rows.length).toBe(0); - - rows = await engine.search('-tag:tag2 -tag:tag3'); - expect(rows.length).toBe(0); - - rows = await engine.search('any:1 -tag:tag2 -tag:tag3'); - expect(rows.length).toBe(2); - expect(ids(rows)).toContain(n1.id); - expect(ids(rows)).toContain(n3.id); - - rows = await engine.search('tag:"space travel"'); - expect(rows.length).toBe(1); - expect(ids(rows)).toContain(n3.id); - })); - - it('should support filtering by notebook', (async () => { - let rows; - const folder0 = await Folder.save({ title: 'notebook0' }); - const folder1 = await Folder.save({ title: 'notebook1' }); - const notes0 = await createNTestNotes(5, folder0); - const notes1 = await createNTestNotes(5, folder1); - - await engine.syncTables(); - - rows = await engine.search('notebook:notebook0'); - expect(rows.length).toBe(5); - expect(ids(rows).sort()).toEqual(ids(notes0).sort()); - - })); - - it('should support filtering by nested notebook', (async () => { - let rows; - const folder0 = await Folder.save({ title: 'notebook0' }); - const folder00 = await Folder.save({ title: 'notebook00', parent_id: folder0.id }); - const folder1 = await Folder.save({ title: 'notebook1' }); - const notes0 = await createNTestNotes(5, folder0); - const notes00 = await createNTestNotes(5, folder00); - const notes1 = await createNTestNotes(5, folder1); - - await engine.syncTables(); - - rows = await engine.search('notebook:notebook0'); - expect(rows.length).toBe(10); - expect(ids(rows).sort()).toEqual(ids(notes0.concat(notes00)).sort()); - })); - - it('should support filtering by multiple notebooks', (async () => { - let rows; - const folder0 = await Folder.save({ title: 'notebook0' }); - const folder00 = await Folder.save({ title: 'notebook00', parent_id: folder0.id }); - const folder1 = await Folder.save({ title: 'notebook1' }); - const folder2 = await Folder.save({ title: 'notebook2' }); - const notes0 = await createNTestNotes(5, folder0); - const notes00 = await createNTestNotes(5, folder00); - const notes1 = await createNTestNotes(5, folder1); - const notes2 = await createNTestNotes(5, folder2); - - await engine.syncTables(); - - rows = await engine.search('notebook:notebook0 notebook:notebook1'); - expect(rows.length).toBe(15); - expect(ids(rows).sort()).toEqual(ids(notes0).concat(ids(notes00).concat(ids(notes1))).sort()); - })); - - it('should support filtering by created date', (async () => { - let rows; - const n1 = await Note.save({ title: 'I made this on', body: 'May 20 2020', user_created_time: Date.parse('2020-05-20') }); - const n2 = await Note.save({ title: 'I made this on', body: 'May 19 2020', user_created_time: Date.parse('2020-05-19') }); - const n3 = await Note.save({ title: 'I made this on', body: 'May 18 2020', user_created_time: Date.parse('2020-05-18') }); - - await engine.syncTables(); - - rows = await engine.search('created:20200520'); - expect(rows.length).toBe(1); - expect(ids(rows)).toContain(n1.id); - - rows = await engine.search('created:20200519'); - expect(rows.length).toBe(2); - expect(ids(rows)).toContain(n1.id); - expect(ids(rows)).toContain(n2.id); - - rows = await engine.search('-created:20200519'); - expect(rows.length).toBe(1); - expect(ids(rows)).toContain(n3.id); - - })); - - it('should support filtering by between two dates', (async () => { - let rows; - const n1 = await Note.save({ title: 'January 01 2020', body: 'January 01 2020', user_created_time: Date.parse('2020-01-01') }); - const n2 = await Note.save({ title: 'February 15 2020', body: 'February 15 2020', user_created_time: Date.parse('2020-02-15') }); - const n3 = await Note.save({ title: 'March 25 2019', body: 'March 25 2019', user_created_time: Date.parse('2019-03-25') }); - const n4 = await Note.save({ title: 'March 01 2018', body: 'March 01 2018', user_created_time: Date.parse('2018-03-01') }); - - await engine.syncTables(); - - rows = await engine.search('created:20200101 -created:20200220'); - expect(rows.length).toBe(2); - expect(ids(rows)).toContain(n1.id); - expect(ids(rows)).toContain(n2.id); - - rows = await engine.search('created:201901 -created:202002'); - expect(rows.length).toBe(2); - expect(ids(rows)).toContain(n3.id); - expect(ids(rows)).toContain(n1.id); - - rows = await engine.search('created:2018 -created:2019'); - expect(rows.length).toBe(1); - expect(ids(rows)).toContain(n4.id); - })); - - it('should support filtering by created with smart value: day', (async () => { - let rows; - const n1 = await Note.save({ title: 'I made this', body: 'today', user_created_time: parseInt(time.goBackInTime(Date.now(), 0, 'day'), 10) }); - const n2 = await Note.save({ title: 'I made this', body: 'yesterday', user_created_time: parseInt(time.goBackInTime(Date.now(), 1, 'day'), 10) }); - const n3 = await Note.save({ title: 'I made this', body: 'day before yesterday', user_created_time: parseInt(time.goBackInTime(Date.now(), 2, 'day'), 10) }); - - await engine.syncTables(); - - rows = await engine.search('created:day-0'); - expect(rows.length).toBe(1); - expect(ids(rows)).toContain(n1.id); - - rows = await engine.search('created:day-1'); - expect(rows.length).toBe(2); - expect(ids(rows)).toContain(n1.id); - expect(ids(rows)).toContain(n2.id); - - rows = await engine.search('created:day-2'); - expect(rows.length).toBe(3); - expect(ids(rows)).toContain(n1.id); - expect(ids(rows)).toContain(n2.id); - expect(ids(rows)).toContain(n3.id); - })); - - it('should support filtering by created with smart value: week', (async () => { - let rows; - const n1 = await Note.save({ title: 'I made this', body: 'this week', user_created_time: parseInt(time.goBackInTime(Date.now(), 0, 'week'), 10) }); - const n2 = await Note.save({ title: 'I made this', body: 'the week before', user_created_time: parseInt(time.goBackInTime(Date.now(), 1, 'week'), 10) }); - const n3 = await Note.save({ title: 'I made this', body: 'before before week', user_created_time: parseInt(time.goBackInTime(Date.now(), 2, 'week'), 10) }); - - await engine.syncTables(); - - rows = await engine.search('created:week-0'); - expect(rows.length).toBe(1); - expect(ids(rows)).toContain(n1.id); - - rows = await engine.search('created:week-1'); - expect(rows.length).toBe(2); - expect(ids(rows)).toContain(n1.id); - expect(ids(rows)).toContain(n2.id); - - rows = await engine.search('created:week-2'); - expect(rows.length).toBe(3); - expect(ids(rows)).toContain(n1.id); - expect(ids(rows)).toContain(n2.id); - expect(ids(rows)).toContain(n3.id); - })); - - it('should support filtering by created with smart value: month', (async () => { - let rows; - const n1 = await Note.save({ title: 'I made this', body: 'this month', user_created_time: parseInt(time.goBackInTime(Date.now(), 0, 'month'), 10) }); - const n2 = await Note.save({ title: 'I made this', body: 'the month before', user_created_time: parseInt(time.goBackInTime(Date.now(), 1, 'month'), 10) }); - const n3 = await Note.save({ title: 'I made this', body: 'before before month', user_created_time: parseInt(time.goBackInTime(Date.now(), 2, 'month'), 10) }); - - await engine.syncTables(); - - rows = await engine.search('created:month-0'); - expect(rows.length).toBe(1); - expect(ids(rows)).toContain(n1.id); - - rows = await engine.search('created:month-1'); - expect(rows.length).toBe(2); - expect(ids(rows)).toContain(n1.id); - expect(ids(rows)).toContain(n2.id); - - rows = await engine.search('created:month-2'); - expect(rows.length).toBe(3); - expect(ids(rows)).toContain(n1.id); - expect(ids(rows)).toContain(n2.id); - expect(ids(rows)).toContain(n3.id); - })); - - it('should support filtering by created with smart value: year', (async () => { - let rows; - const n1 = await Note.save({ title: 'I made this', body: 'this year', user_created_time: parseInt(time.goBackInTime(Date.now(), 0, 'year'), 10) }); - const n2 = await Note.save({ title: 'I made this', body: 'the year before', user_created_time: parseInt(time.goBackInTime(Date.now(), 1, 'year'), 10) }); - const n3 = await Note.save({ title: 'I made this', body: 'before before year', user_created_time: parseInt(time.goBackInTime(Date.now(), 2, 'year'), 10) }); - - await engine.syncTables(); - - rows = await engine.search('created:year-0'); - expect(rows.length).toBe(1); - expect(ids(rows)).toContain(n1.id); - - rows = await engine.search('created:year-1'); - expect(rows.length).toBe(2); - expect(ids(rows)).toContain(n1.id); - expect(ids(rows)).toContain(n2.id); - - rows = await engine.search('created:year-2'); - expect(rows.length).toBe(3); - expect(ids(rows)).toContain(n1.id); - expect(ids(rows)).toContain(n2.id); - expect(ids(rows)).toContain(n3.id); - })); - - it('should support filtering by updated date', (async () => { - let rows; - const n1 = await Note.save({ title: 'I updated this on', body: 'May 20 2020', updated_time: Date.parse('2020-05-20'), user_updated_time: Date.parse('2020-05-20') }, { autoTimestamp: false }); - const n2 = await Note.save({ title: 'I updated this on', body: 'May 19 2020', updated_time: Date.parse('2020-05-19'), user_updated_time: Date.parse('2020-05-19') }, { autoTimestamp: false }); - - await engine.syncTables(); - - rows = await engine.search('updated:20200520'); - expect(rows.length).toBe(1); - expect(ids(rows)).toContain(n1.id); - - rows = await engine.search('updated:20200519'); - expect(rows.length).toBe(2); - expect(ids(rows)).toContain(n1.id); - expect(ids(rows)).toContain(n2.id); - })); - - it('should support filtering by updated with smart value: day', (async () => { - let rows; - const today = parseInt(time.goBackInTime(Date.now(), 0, 'day'), 10); - const yesterday = parseInt(time.goBackInTime(Date.now(), 1, 'day'), 10); - const dayBeforeYesterday = parseInt(time.goBackInTime(Date.now(), 2, 'day'), 10); - const n1 = await Note.save({ title: 'I made this', body: 'today', updated_time: today, user_updated_time: today }, { autoTimestamp: false }); - const n11 = await Note.save({ title: 'I also made this', body: 'today', updated_time: today, user_updated_time: today }, { autoTimestamp: false }); - - const n2 = await Note.save({ title: 'I made this', body: 'yesterday', updated_time: yesterday, user_updated_time: yesterday }, { autoTimestamp: false }); - const n3 = await Note.save({ title: 'I made this', body: 'day before yesterday', updated_time: dayBeforeYesterday ,user_updated_time: dayBeforeYesterday }, { autoTimestamp: false }); - - await engine.syncTables(); - - rows = await engine.search('updated:day-0'); - expect(rows.length).toBe(2); - expect(ids(rows)).toContain(n1.id); - expect(ids(rows)).toContain(n11.id); - - rows = await engine.search('updated:day-1'); - expect(rows.length).toBe(3); - expect(ids(rows)).toContain(n1.id); - expect(ids(rows)).toContain(n11.id); - expect(ids(rows)).toContain(n2.id); - - rows = await engine.search('updated:day-2'); - expect(rows.length).toBe(4); - expect(ids(rows)).toContain(n1.id); - expect(ids(rows)).toContain(n11.id); - expect(ids(rows)).toContain(n2.id); - expect(ids(rows)).toContain(n3.id); - })); - - it('should support filtering by type todo', (async () => { - let rows; - const t1 = await Note.save({ title: 'This is a ', body: 'todo', is_todo: 1 }); - const t2 = await Note.save({ title: 'This is another', body: 'todo but completed', is_todo: 1, todo_completed: 1590085027710 }); - const t3 = await Note.save({ title: 'This is NOT a ', body: 'todo' }); - - await engine.syncTables(); - - rows = await engine.search('type:todo'); - expect(rows.length).toBe(2); - expect(ids(rows)).toContain(t1.id); - expect(ids(rows)).toContain(t2.id); - - rows = await engine.search('any:1 type:todo'); - expect(rows.length).toBe(2); - expect(ids(rows)).toContain(t1.id); - expect(ids(rows)).toContain(t2.id); - - rows = await engine.search('iscompleted:1'); - expect(rows.length).toBe(1); - expect(ids(rows)).toContain(t2.id); - - rows = await engine.search('iscompleted:0'); - expect(rows.length).toBe(1); - expect(ids(rows)).toContain(t1.id); - })); - - it('should support filtering by type note', (async () => { - let rows; - const t1 = await Note.save({ title: 'This is a ', body: 'todo', is_todo: 1 }); - const t2 = await Note.save({ title: 'This is another', body: 'todo but completed', is_todo: 1, todo_completed: 1590085027710 }); - const t3 = await Note.save({ title: 'This is NOT a ', body: 'todo' }); - - await engine.syncTables(); - - rows = await engine.search('type:note'); - expect(rows.length).toBe(1); - expect(ids(rows)).toContain(t3.id); - })); - - it('should support filtering by due date', (async () => { - let rows; - const toDo1 = await Note.save({ title: 'ToDo 1', body: 'todo', is_todo: 1, todo_due: Date.parse('2021-04-27') }); - const toDo2 = await Note.save({ title: 'ToDo 2', body: 'todo', is_todo: 1, todo_due: Date.parse('2021-03-17') }); - const note1 = await Note.save({ title: 'Note 1', body: 'Note' }); - - await engine.syncTables(); - - rows = await engine.search('due:20210425'); - expect(rows.length).toBe(1); - expect(ids(rows)).toContain(toDo1.id); - - rows = await engine.search('-due:20210425'); - expect(rows.length).toBe(1); - expect(ids(rows)).toContain(toDo2.id); - })); - - it('should support filtering by due with smart value: day', (async () => { - let rows; - - const inThreeDays = parseInt(time.goForwardInTime(Date.now(), 3, 'day'), 10); - const inSevenDays = parseInt(time.goForwardInTime(Date.now(), 7, 'day'), 10); - const threeDaysAgo = parseInt(time.goBackInTime(Date.now(), 3, 'day'), 10); - const sevenDaysAgo = parseInt(time.goBackInTime(Date.now(), 7, 'day'), 10); - - const toDo1 = await Note.save({ title: 'ToDo + 3 day', body: 'toto', is_todo: 1, todo_due: inThreeDays }); - const toDo2 = await Note.save({ title: 'ToDo + 7 day', body: 'toto', is_todo: 1, todo_due: inSevenDays }); - const toDo3 = await Note.save({ title: 'ToDo - 3 day', body: 'toto', is_todo: 1, todo_due: threeDaysAgo }); - const toDo4 = await Note.save({ title: 'ToDo - 7 day', body: 'toto', is_todo: 1, todo_due: sevenDaysAgo }); - - await engine.syncTables(); - - rows = await engine.search('due:day-4'); - expect(rows.length).toBe(3); - expect(ids(rows)).toContain(toDo1.id); - expect(ids(rows)).toContain(toDo2.id); - expect(ids(rows)).toContain(toDo3.id); - - rows = await engine.search('-due:day-4'); - expect(rows.length).toBe(1); - expect(ids(rows)).toContain(toDo4.id); - - rows = await engine.search('-due:day+4'); - expect(rows.length).toBe(3); - expect(ids(rows)).toContain(toDo1.id); - expect(ids(rows)).toContain(toDo3.id); - expect(ids(rows)).toContain(toDo4.id); - - rows = await engine.search('due:day+4'); - expect(rows.length).toBe(1); - expect(ids(rows)).toContain(toDo2.id); - - rows = await engine.search('due:day-4 -due:day+4'); - expect(rows.length).toBe(2); - expect(ids(rows)).toContain(toDo1.id); - expect(ids(rows)).toContain(toDo3.id); - })); - - it('should support filtering by latitude, longitude, altitude', (async () => { - let rows; - const n1 = await Note.save({ title: 'I made this', body: 'this week', latitude: 12.97, longitude: 88.88, altitude: 69.96 }); - const n2 = await Note.save({ title: 'I made this', body: 'the week before', latitude: 42.11, longitude: 77.77, altitude: 42.00 }); - const n3 = await Note.save({ title: 'I made this', body: 'before before week', latitude: 82.01, longitude: 66.66, altitude: 13.13 }); - - await engine.syncTables(); - - rows = await engine.search('latitude:13.5'); - expect(rows.length).toBe(2); - expect(ids(rows)).toContain(n2.id); - expect(ids(rows)).toContain(n3.id); - - rows = await engine.search('-latitude:40'); - expect(rows.length).toBe(1); - expect(ids(rows)).toContain(n1.id); - - rows = await engine.search('latitude:13 -latitude:80'); - expect(rows.length).toBe(1); - expect(ids(rows)).toContain(n2.id); - - rows = await engine.search('altitude:13.5'); - expect(rows.length).toBe(2); - expect(ids(rows)).toContain(n1.id); - expect(ids(rows)).toContain(n2.id); - - rows = await engine.search('-altitude:80.12'); - expect(rows.length).toBe(3); - expect(ids(rows)).toContain(n1.id); - expect(ids(rows)).toContain(n2.id); - expect(ids(rows)).toContain(n3.id); - - rows = await engine.search('longitude:70 -longitude:80'); - expect(rows.length).toBe(1); - expect(ids(rows)).toContain(n2.id); - - rows = await engine.search('latitude:20 longitude:50 altitude:40'); - expect(rows.length).toBe(1); - expect(ids(rows)).toContain(n2.id); - - rows = await engine.search('any:1 latitude:20 longitude:50 altitude:40'); - expect(rows.length).toBe(3); - expect(ids(rows)).toContain(n1.id); - expect(ids(rows)).toContain(n2.id); - expect(ids(rows)).toContain(n3.id); - })); - - it('should support filtering by resource MIME type', (async () => { - let rows; - const service = new ResourceService(); - // console.log(testImagePath) - const folder1 = await Folder.save({ title: 'folder1' }); - let n1 = await Note.save({ title: 'I have a picture', body: 'Im awesome', parent_id: folder1.id }); - const n2 = await Note.save({ title: 'Boring note 1', body: 'I just have text', parent_id: folder1.id }); - const n3 = await Note.save({ title: 'Boring note 2', body: 'me too', parent_id: folder1.id }); - let n4 = await Note.save({ title: 'A picture?', body: 'pfff, I have a pdf', parent_id: folder1.id }); - await engine.syncTables(); - - // let note1 = await Note.save({ title: 'ma note', parent_id: folder1.id }); - n1 = await shim.attachFileToNote(n1, `${supportDir}/photo.jpg`); - // const resource1 = (await Resource.all())[0]; - - n4 = await shim.attachFileToNote(n4, `${supportDir}/welcome.pdf`); - - await service.indexNoteResources(); - - rows = await engine.search('resource:image/jpeg'); - expect(rows.length).toBe(1); - expect(ids(rows)).toContain(n1.id); - - rows = await engine.search('resource:image/*'); - expect(rows.length).toBe(1); - expect(ids(rows)).toContain(n1.id); - - rows = await engine.search('resource:application/pdf'); - expect(rows.length).toBe(1); - expect(ids(rows)).toContain(n4.id); - - rows = await engine.search('-resource:image/jpeg'); - expect(rows.length).toBe(3); - expect(ids(rows)).toContain(n2.id); - expect(ids(rows)).toContain(n3.id); - expect(ids(rows)).toContain(n4.id); - - rows = await engine.search('any:1 resource:application/pdf resource:image/jpeg'); - expect(rows.length).toBe(2); - expect(ids(rows)).toContain(n1.id); - expect(ids(rows)).toContain(n4.id); - })); - + // Outside of for loop because this does not apply to to SEARCH_TYPE_NONLATIN_SCRIPT it('should ignore dashes in a word', (async () => { const n0 = await Note.save({ title: 'doesnotwork' }); const n1 = await Note.save({ title: 'does not work' }); @@ -761,111 +64,817 @@ describe('services_SearchFilter', function() { })); - it('should support filtering by sourceurl', (async () => { - const n0 = await Note.save({ title: 'n0', source_url: 'https://discourse.joplinapp.org' }); - const n1 = await Note.save({ title: 'n1', source_url: 'https://google.com' }); - const n2 = await Note.save({ title: 'n2', source_url: 'https://reddit.com' }); - const n3 = await Note.save({ title: 'n3', source_url: 'https://joplinapp.org' }); + for (const searchType of [SearchEngine.SEARCH_TYPE_FTS, SearchEngine.SEARCH_TYPE_NONLATIN_SCRIPT]) { - await engine.syncTables(); + describe(`search type ${searchType}`, () => { + it('should return note matching title', (async () => { + let rows; + const n1 = await Note.save({ title: 'abcd', body: 'body 1' }); + const n2 = await Note.save({ title: 'efgh', body: 'body 2' }); - let rows = await engine.search('sourceurl:https://joplinapp.org'); - expect(rows.length).toBe(1); - expect(ids(rows)).toContain(n3.id); + await engine.syncTables(); + rows = await engine.search('title: abcd', { searchType }); - rows = await engine.search('sourceurl:https://google.com'); - expect(rows.length).toBe(1); - expect(ids(rows)).toContain(n1.id); + expect(rows.length).toBe(1); + expect(rows[0].id).toBe(n1.id); + })); - rows = await engine.search('any:1 sourceurl:https://google.com sourceurl:https://reddit.com'); - expect(rows.length).toBe(2); - expect(ids(rows)).toContain(n1.id); - expect(ids(rows)).toContain(n2.id); + it('should return note matching negated title', (async () => { + let rows; + const n1 = await Note.save({ title: 'abcd', body: 'body 1' }); + const n2 = await Note.save({ title: 'efgh', body: 'body 2' }); - rows = await engine.search('-sourceurl:https://google.com'); - expect(rows.length).toBe(3); - expect(ids(rows)).toContain(n0.id); - expect(ids(rows)).toContain(n2.id); - expect(ids(rows)).toContain(n3.id); + await engine.syncTables(); + rows = await engine.search('-title: abcd', { searchType }); - rows = await engine.search('sourceurl:*joplinapp.org'); - expect(rows.length).toBe(2); - expect(ids(rows)).toContain(n0.id); - expect(ids(rows)).toContain(n3.id); + expect(rows.length).toBe(1); - })); + expect(rows[0].id).toBe(n2.id); + })); - it('should support negating notebooks', (async () => { + it('should return note matching body', (async () => { + let rows; + const n1 = await Note.save({ title: 'abcd', body: 'body1' }); + const n2 = await Note.save({ title: 'efgh', body: 'body2' }); - const folder1 = await Folder.save({ title: 'folder1' }); - const n1 = await Note.save({ title: 'task1', body: 'foo', parent_id: folder1.id }); - const n2 = await Note.save({ title: 'task2', body: 'bar', parent_id: folder1.id }); + await engine.syncTables(); + rows = await engine.search('body: body1', { searchType }); + + expect(rows.length).toBe(1); + + expect(rows[0].id).toBe(n1.id); + })); + + it('should return note matching negated body', (async () => { + let rows; + const n1 = await Note.save({ title: 'abcd', body: 'body1' }); + const n2 = await Note.save({ title: 'efgh', body: 'body2' }); + + await engine.syncTables(); + rows = await engine.search('-body: body1', { searchType }); + + expect(rows.length).toBe(1); + expect(rows[0].id).toBe(n2.id); + })); + + it('should return note matching title containing multiple words', (async () => { + let rows; + const n1 = await Note.save({ title: 'abcd xyz', body: 'body1' }); + const n2 = await Note.save({ title: 'efgh ijk', body: 'body2' }); + + await engine.syncTables(); + rows = await engine.search('title: "abcd xyz"', { searchType }); + + expect(rows.length).toBe(1); + expect(rows[0].id).toBe(n1.id); + })); + + it('should return note matching body containing multiple words', (async () => { + let rows; + const n1 = await Note.save({ title: 'abcd', body: 'ho ho ho' }); + const n2 = await Note.save({ title: 'efgh', body: 'foo bar' }); + + await engine.syncTables(); + rows = await engine.search('body: "foo bar"', { searchType }); + + expect(rows.length).toBe(1); + expect(rows[0].id).toBe(n2.id); + })); + + it('should return note matching title AND body', (async () => { + let rows; + const n1 = await Note.save({ title: 'abcd', body: 'ho ho ho' }); + const n2 = await Note.save({ title: 'efgh', body: 'foo bar' }); + + await engine.syncTables(); + rows = await engine.search('title: efgh body: "foo bar"', { searchType }); + expect(rows.length).toBe(1); + expect(rows[0].id).toBe(n2.id); + + rows = await engine.search('title: abcd body: "foo bar"', { searchType }); + expect(rows.length).toBe(0); + })); + + it('should return note matching title OR body', (async () => { + let rows; + const n1 = await Note.save({ title: 'abcd', body: 'ho ho ho' }); + const n2 = await Note.save({ title: 'efgh', body: 'foo bar' }); + + await engine.syncTables(); + rows = await engine.search('any:1 title: abcd body: "foo bar"', { searchType }); + expect(rows.length).toBe(2); + expect(rows.map(r=>r.id)).toContain(n1.id); + expect(rows.map(r=>r.id)).toContain(n2.id); + + rows = await engine.search('any:1 title: wxyz body: "blah blah"', { searchType }); + expect(rows.length).toBe(0); + })); + + it('should return notes matching text', (async () => { + let rows; + const n1 = await Note.save({ title: 'foo beef', body: 'dead bar' }); + const n2 = await Note.save({ title: 'bar efgh', body: 'foo dog' }); + const n3 = await Note.save({ title: 'foo ho', body: 'ho ho ho' }); + await engine.syncTables(); + + // Interpretation: Match with notes containing foo in title/body and bar in title/body + // Note: This is NOT saying to match notes containing foo bar in title/body + rows = await engine.search('foo bar', { searchType }); + expect(rows.length).toBe(2); + expect(rows.map(r=>r.id)).toContain(n1.id); + expect(rows.map(r=>r.id)).toContain(n2.id); + + rows = await engine.search('foo -bar', { searchType }); + expect(rows.length).toBe(1); + expect(rows.map(r=>r.id)).toContain(n3.id); + + rows = await engine.search('foo efgh', { searchType }); + expect(rows.length).toBe(1); + expect(rows[0].id).toBe(n2.id); + + rows = await engine.search('zebra', { searchType }); + expect(rows.length).toBe(0); + })); + + it('should return notes matching any negated text', (async () => { + let rows; + const n1 = await Note.save({ title: 'abc', body: 'def' }); + const n2 = await Note.save({ title: 'def', body: 'ghi' }); + const n3 = await Note.save({ title: 'ghi', body: 'jkl' }); + await engine.syncTables(); + + rows = await engine.search('any:1 -abc -ghi', { searchType }); + expect(rows.length).toBe(3); + expect(rows.map(r=>r.id)).toContain(n1.id); + expect(rows.map(r=>r.id)).toContain(n2.id); + expect(rows.map(r=>r.id)).toContain(n3.id); + })); + + it('should return notes matching any negated title', (async () => { + let rows; + const n1 = await Note.save({ title: 'abc', body: 'def' }); + const n2 = await Note.save({ title: 'def', body: 'ghi' }); + const n3 = await Note.save({ title: 'ghi', body: 'jkl' }); + await engine.syncTables(); + + rows = await engine.search('any:1 -title:abc -title:ghi', { searchType }); + expect(rows.length).toBe(3); + expect(rows.map(r=>r.id)).toContain(n1.id); + expect(rows.map(r=>r.id)).toContain(n2.id); + expect(rows.map(r=>r.id)).toContain(n3.id); + })); + + it('should return notes matching any negated body', (async () => { + let rows; + const n1 = await Note.save({ title: 'abc', body: 'def' }); + const n2 = await Note.save({ title: 'def', body: 'ghi' }); + const n3 = await Note.save({ title: 'ghi', body: 'jkl' }); + await engine.syncTables(); + + rows = await engine.search('any:1 -body:xyz -body:ghi', { searchType }); + expect(rows.length).toBe(3); + expect(rows.map(r=>r.id)).toContain(n1.id); + expect(rows.map(r=>r.id)).toContain(n2.id); + expect(rows.map(r=>r.id)).toContain(n3.id); + })); + + it('should support phrase search', (async () => { + let rows; + const n1 = await Note.save({ title: 'foo beef', body: 'bar dog' }); + const n2 = await Note.save({ title: 'bar efgh', body: 'foo dog' }); + await engine.syncTables(); + + rows = await engine.search('"bar dog"', { searchType }); + expect(rows.length).toBe(1); + expect(rows[0].id).toBe(n1.id); + })); + + it('should support prefix search', (async () => { + let rows; + const n1 = await Note.save({ title: 'foo beef', body: 'bar dog' }); + const n2 = await Note.save({ title: 'bar efgh', body: 'foo dog' }); + await engine.syncTables(); + + rows = await engine.search('"bar*"', { searchType }); + expect(rows.length).toBe(2); + expect(ids(rows)).toContain(n1.id); + expect(ids(rows)).toContain(n2.id); + })); + + it('should support filtering by tags', (async () => { + let rows; + const n1 = await Note.save({ title: 'But I would', body: 'walk 500 miles' }); + const n2 = await Note.save({ title: 'And I would', body: 'walk 500 more' }); + const n3 = await Note.save({ title: 'Just to be', body: 'the man who' }); + const n4 = await Note.save({ title: 'walked a thousand', body: 'miles to fall' }); + const n5 = await Note.save({ title: 'down at your', body: 'door' }); + + await Tag.setNoteTagsByTitles(n1.id, ['Da', 'da', 'lat', 'da']); + await Tag.setNoteTagsByTitles(n2.id, ['Da', 'da', 'lat', 'da']); + + await engine.syncTables(); + + rows = await engine.search('tag:*', { searchType }); + expect(rows.length).toBe(2); + expect(ids(rows)).toContain(n1.id); + expect(ids(rows)).toContain(n2.id); + + rows = await engine.search('-tag:*', { searchType }); + expect(rows.length).toBe(3); + expect(ids(rows)).toContain(n3.id); + expect(ids(rows)).toContain(n4.id); + expect(ids(rows)).toContain(n5.id); + })); - const folder2 = await Folder.save({ title: 'folder2' }); - const n3 = await Note.save({ title: 'task3', body: 'baz', parent_id: folder2.id }); - const n4 = await Note.save({ title: 'task4', body: 'blah', parent_id: folder2.id }); + it('should support filtering by tags', (async () => { + let rows; + const n1 = await Note.save({ title: 'peace talks', body: 'battle ground' }); + const n2 = await Note.save({ title: 'mouse', body: 'mister' }); + const n3 = await Note.save({ title: 'dresden files', body: 'harry dresden' }); + + await Tag.setNoteTagsByTitles(n1.id, ['tag1', 'tag2']); + await Tag.setNoteTagsByTitles(n2.id, ['tag2', 'tag3']); + await Tag.setNoteTagsByTitles(n3.id, ['tag3', 'tag4', 'space travel']); + + await engine.syncTables(); + + rows = await engine.search('tag:tag2', { searchType }); + expect(rows.length).toBe(2); + expect(ids(rows)).toContain(n1.id); + expect(ids(rows)).toContain(n2.id); + + rows = await engine.search('tag:tag2 tag:tag3', { searchType }); + expect(rows.length).toBe(1); + expect(ids(rows)).toContain(n2.id); + + rows = await engine.search('any:1 tag:tag1 tag:tag2 tag:tag3', { searchType }); + expect(rows.length).toBe(3); + expect(ids(rows)).toContain(n1.id); + expect(ids(rows)).toContain(n2.id); + expect(ids(rows)).toContain(n3.id); + + rows = await engine.search('tag:tag2 tag:tag3 tag:tag4', { searchType }); + expect(rows.length).toBe(0); + + rows = await engine.search('-tag:tag2', { searchType }); + expect(rows.length).toBe(1); + expect(ids(rows)).toContain(n3.id); + + rows = await engine.search('-tag:tag2 -tag:tag3', { searchType }); + expect(rows.length).toBe(0); + + rows = await engine.search('-tag:tag2 -tag:tag3', { searchType }); + expect(rows.length).toBe(0); + + rows = await engine.search('any:1 -tag:tag2 -tag:tag3', { searchType }); + expect(rows.length).toBe(2); + expect(ids(rows)).toContain(n1.id); + expect(ids(rows)).toContain(n3.id); + + rows = await engine.search('tag:"space travel"', { searchType }); + expect(rows.length).toBe(1); + expect(ids(rows)).toContain(n3.id); + })); + + it('should support filtering by notebook', (async () => { + let rows; + const folder0 = await Folder.save({ title: 'notebook0' }); + const folder1 = await Folder.save({ title: 'notebook1' }); + const notes0 = await createNTestNotes(5, folder0); + const notes1 = await createNTestNotes(5, folder1); + + await engine.syncTables(); + + rows = await engine.search('notebook:notebook0', { searchType }); + expect(rows.length).toBe(5); + expect(ids(rows).sort()).toEqual(ids(notes0).sort()); + + })); + + it('should support filtering by nested notebook', (async () => { + let rows; + const folder0 = await Folder.save({ title: 'notebook0' }); + const folder00 = await Folder.save({ title: 'notebook00', parent_id: folder0.id }); + const folder1 = await Folder.save({ title: 'notebook1' }); + const notes0 = await createNTestNotes(5, folder0); + const notes00 = await createNTestNotes(5, folder00); + const notes1 = await createNTestNotes(5, folder1); + + await engine.syncTables(); + + rows = await engine.search('notebook:notebook0', { searchType }); + expect(rows.length).toBe(10); + expect(ids(rows).sort()).toEqual(ids(notes0.concat(notes00)).sort()); + })); + + it('should support filtering by multiple notebooks', (async () => { + let rows; + const folder0 = await Folder.save({ title: 'notebook0' }); + const folder00 = await Folder.save({ title: 'notebook00', parent_id: folder0.id }); + const folder1 = await Folder.save({ title: 'notebook1' }); + const folder2 = await Folder.save({ title: 'notebook2' }); + const notes0 = await createNTestNotes(5, folder0); + const notes00 = await createNTestNotes(5, folder00); + const notes1 = await createNTestNotes(5, folder1); + const notes2 = await createNTestNotes(5, folder2); + + await engine.syncTables(); + + rows = await engine.search('notebook:notebook0 notebook:notebook1', { searchType }); + expect(rows.length).toBe(15); + expect(ids(rows).sort()).toEqual(ids(notes0).concat(ids(notes00).concat(ids(notes1))).sort()); + })); + + it('should support filtering by created date', (async () => { + let rows; + const n1 = await Note.save({ title: 'I made this on', body: 'May 20 2020', user_created_time: Date.parse('2020-05-20') }); + const n2 = await Note.save({ title: 'I made this on', body: 'May 19 2020', user_created_time: Date.parse('2020-05-19') }); + const n3 = await Note.save({ title: 'I made this on', body: 'May 18 2020', user_created_time: Date.parse('2020-05-18') }); + + await engine.syncTables(); + + rows = await engine.search('created:20200520', { searchType }); + expect(rows.length).toBe(1); + expect(ids(rows)).toContain(n1.id); + + rows = await engine.search('created:20200519', { searchType }); + expect(rows.length).toBe(2); + expect(ids(rows)).toContain(n1.id); + expect(ids(rows)).toContain(n2.id); + + rows = await engine.search('-created:20200519', { searchType }); + expect(rows.length).toBe(1); + expect(ids(rows)).toContain(n3.id); + + })); + + it('should support filtering by between two dates', (async () => { + let rows; + const n1 = await Note.save({ title: 'January 01 2020', body: 'January 01 2020', user_created_time: Date.parse('2020-01-01') }); + const n2 = await Note.save({ title: 'February 15 2020', body: 'February 15 2020', user_created_time: Date.parse('2020-02-15') }); + const n3 = await Note.save({ title: 'March 25 2019', body: 'March 25 2019', user_created_time: Date.parse('2019-03-25') }); + const n4 = await Note.save({ title: 'March 01 2018', body: 'March 01 2018', user_created_time: Date.parse('2018-03-01') }); + + await engine.syncTables(); + + rows = await engine.search('created:20200101 -created:20200220', { searchType }); + expect(rows.length).toBe(2); + expect(ids(rows)).toContain(n1.id); + expect(ids(rows)).toContain(n2.id); + + rows = await engine.search('created:201901 -created:202002', { searchType }); + expect(rows.length).toBe(2); + expect(ids(rows)).toContain(n3.id); + expect(ids(rows)).toContain(n1.id); + + rows = await engine.search('created:2018 -created:2019', { searchType }); + expect(rows.length).toBe(1); + expect(ids(rows)).toContain(n4.id); + })); + + it('should support filtering by created with smart value: day', (async () => { + let rows; + const n1 = await Note.save({ title: 'I made this', body: 'today', user_created_time: parseInt(time.goBackInTime(Date.now(), 0, 'day'), 10) }); + const n2 = await Note.save({ title: 'I made this', body: 'yesterday', user_created_time: parseInt(time.goBackInTime(Date.now(), 1, 'day'), 10) }); + const n3 = await Note.save({ title: 'I made this', body: 'day before yesterday', user_created_time: parseInt(time.goBackInTime(Date.now(), 2, 'day'), 10) }); + + await engine.syncTables(); + + rows = await engine.search('created:day-0', { searchType }); + expect(rows.length).toBe(1); + expect(ids(rows)).toContain(n1.id); + + rows = await engine.search('created:day-1', { searchType }); + expect(rows.length).toBe(2); + expect(ids(rows)).toContain(n1.id); + expect(ids(rows)).toContain(n2.id); + + rows = await engine.search('created:day-2', { searchType }); + expect(rows.length).toBe(3); + expect(ids(rows)).toContain(n1.id); + expect(ids(rows)).toContain(n2.id); + expect(ids(rows)).toContain(n3.id); + })); + + it('should support filtering by created with smart value: week', (async () => { + let rows; + const n1 = await Note.save({ title: 'I made this', body: 'this week', user_created_time: parseInt(time.goBackInTime(Date.now(), 0, 'week'), 10) }); + const n2 = await Note.save({ title: 'I made this', body: 'the week before', user_created_time: parseInt(time.goBackInTime(Date.now(), 1, 'week'), 10) }); + const n3 = await Note.save({ title: 'I made this', body: 'before before week', user_created_time: parseInt(time.goBackInTime(Date.now(), 2, 'week'), 10) }); + + await engine.syncTables(); + + rows = await engine.search('created:week-0', { searchType }); + expect(rows.length).toBe(1); + expect(ids(rows)).toContain(n1.id); + + rows = await engine.search('created:week-1', { searchType }); + expect(rows.length).toBe(2); + expect(ids(rows)).toContain(n1.id); + expect(ids(rows)).toContain(n2.id); + + rows = await engine.search('created:week-2', { searchType }); + expect(rows.length).toBe(3); + expect(ids(rows)).toContain(n1.id); + expect(ids(rows)).toContain(n2.id); + expect(ids(rows)).toContain(n3.id); + })); + + it('should support filtering by created with smart value: month', (async () => { + let rows; + const n1 = await Note.save({ title: 'I made this', body: 'this month', user_created_time: parseInt(time.goBackInTime(Date.now(), 0, 'month'), 10) }); + const n2 = await Note.save({ title: 'I made this', body: 'the month before', user_created_time: parseInt(time.goBackInTime(Date.now(), 1, 'month'), 10) }); + const n3 = await Note.save({ title: 'I made this', body: 'before before month', user_created_time: parseInt(time.goBackInTime(Date.now(), 2, 'month'), 10) }); + + await engine.syncTables(); + + rows = await engine.search('created:month-0', { searchType }); + expect(rows.length).toBe(1); + expect(ids(rows)).toContain(n1.id); + + rows = await engine.search('created:month-1', { searchType }); + expect(rows.length).toBe(2); + expect(ids(rows)).toContain(n1.id); + expect(ids(rows)).toContain(n2.id); + + rows = await engine.search('created:month-2', { searchType }); + expect(rows.length).toBe(3); + expect(ids(rows)).toContain(n1.id); + expect(ids(rows)).toContain(n2.id); + expect(ids(rows)).toContain(n3.id); + })); + + it('should support filtering by created with smart value: year', (async () => { + let rows; + const n1 = await Note.save({ title: 'I made this', body: 'this year', user_created_time: parseInt(time.goBackInTime(Date.now(), 0, 'year'), 10) }); + const n2 = await Note.save({ title: 'I made this', body: 'the year before', user_created_time: parseInt(time.goBackInTime(Date.now(), 1, 'year'), 10) }); + const n3 = await Note.save({ title: 'I made this', body: 'before before year', user_created_time: parseInt(time.goBackInTime(Date.now(), 2, 'year'), 10) }); + + await engine.syncTables(); + + rows = await engine.search('created:year-0', { searchType }); + expect(rows.length).toBe(1); + expect(ids(rows)).toContain(n1.id); + + rows = await engine.search('created:year-1', { searchType }); + expect(rows.length).toBe(2); + expect(ids(rows)).toContain(n1.id); + expect(ids(rows)).toContain(n2.id); + + rows = await engine.search('created:year-2', { searchType }); + expect(rows.length).toBe(3); + expect(ids(rows)).toContain(n1.id); + expect(ids(rows)).toContain(n2.id); + expect(ids(rows)).toContain(n3.id); + })); + + it('should support filtering by updated date', (async () => { + let rows; + const n1 = await Note.save({ title: 'I updated this on', body: 'May 20 2020', updated_time: Date.parse('2020-05-20'), user_updated_time: Date.parse('2020-05-20') }, { autoTimestamp: false }); + const n2 = await Note.save({ title: 'I updated this on', body: 'May 19 2020', updated_time: Date.parse('2020-05-19'), user_updated_time: Date.parse('2020-05-19') }, { autoTimestamp: false }); + + await engine.syncTables(); + + rows = await engine.search('updated:20200520', { searchType }); + expect(rows.length).toBe(1); + expect(ids(rows)).toContain(n1.id); + + rows = await engine.search('updated:20200519', { searchType }); + expect(rows.length).toBe(2); + expect(ids(rows)).toContain(n1.id); + expect(ids(rows)).toContain(n2.id); + })); + + it('should support filtering by updated with smart value: day', (async () => { + let rows; + const today = parseInt(time.goBackInTime(Date.now(), 0, 'day'), 10); + const yesterday = parseInt(time.goBackInTime(Date.now(), 1, 'day'), 10); + const dayBeforeYesterday = parseInt(time.goBackInTime(Date.now(), 2, 'day'), 10); + const n1 = await Note.save({ title: 'I made this', body: 'today', updated_time: today, user_updated_time: today }, { autoTimestamp: false }); + const n11 = await Note.save({ title: 'I also made this', body: 'today', updated_time: today, user_updated_time: today }, { autoTimestamp: false }); + + const n2 = await Note.save({ title: 'I made this', body: 'yesterday', updated_time: yesterday, user_updated_time: yesterday }, { autoTimestamp: false }); + const n3 = await Note.save({ title: 'I made this', body: 'day before yesterday', updated_time: dayBeforeYesterday ,user_updated_time: dayBeforeYesterday }, { autoTimestamp: false }); + + await engine.syncTables(); + + rows = await engine.search('updated:day-0', { searchType }); + expect(rows.length).toBe(2); + expect(ids(rows)).toContain(n1.id); + expect(ids(rows)).toContain(n11.id); + + rows = await engine.search('updated:day-1', { searchType }); + expect(rows.length).toBe(3); + expect(ids(rows)).toContain(n1.id); + expect(ids(rows)).toContain(n11.id); + expect(ids(rows)).toContain(n2.id); + + rows = await engine.search('updated:day-2', { searchType }); + expect(rows.length).toBe(4); + expect(ids(rows)).toContain(n1.id); + expect(ids(rows)).toContain(n11.id); + expect(ids(rows)).toContain(n2.id); + expect(ids(rows)).toContain(n3.id); + })); + + it('should support filtering by type todo', (async () => { + let rows; + const t1 = await Note.save({ title: 'This is a ', body: 'todo', is_todo: 1 }); + const t2 = await Note.save({ title: 'This is another', body: 'todo but completed', is_todo: 1, todo_completed: 1590085027710 }); + const t3 = await Note.save({ title: 'This is NOT a ', body: 'todo' }); + + await engine.syncTables(); + + rows = await engine.search('type:todo', { searchType }); + expect(rows.length).toBe(2); + expect(ids(rows)).toContain(t1.id); + expect(ids(rows)).toContain(t2.id); + + rows = await engine.search('any:1 type:todo', { searchType }); + expect(rows.length).toBe(2); + expect(ids(rows)).toContain(t1.id); + expect(ids(rows)).toContain(t2.id); + + rows = await engine.search('iscompleted:1', { searchType }); + expect(rows.length).toBe(1); + expect(ids(rows)).toContain(t2.id); + + rows = await engine.search('iscompleted:0', { searchType }); + expect(rows.length).toBe(1); + expect(ids(rows)).toContain(t1.id); + })); + + it('should support filtering by type note', (async () => { + let rows; + const t1 = await Note.save({ title: 'This is a ', body: 'todo', is_todo: 1 }); + const t2 = await Note.save({ title: 'This is another', body: 'todo but completed', is_todo: 1, todo_completed: 1590085027710 }); + const t3 = await Note.save({ title: 'This is NOT a ', body: 'todo' }); + + await engine.syncTables(); + + rows = await engine.search('type:note', { searchType }); + expect(rows.length).toBe(1); + expect(ids(rows)).toContain(t3.id); + })); + + it('should support filtering by due date', (async () => { + let rows; + const toDo1 = await Note.save({ title: 'ToDo 1', body: 'todo', is_todo: 1, todo_due: Date.parse('2021-04-27') }); + const toDo2 = await Note.save({ title: 'ToDo 2', body: 'todo', is_todo: 1, todo_due: Date.parse('2021-03-17') }); + const note1 = await Note.save({ title: 'Note 1', body: 'Note' }); + + await engine.syncTables(); + + rows = await engine.search('due:20210425', { searchType }); + expect(rows.length).toBe(1); + expect(ids(rows)).toContain(toDo1.id); + + rows = await engine.search('-due:20210425', { searchType }); + expect(rows.length).toBe(1); + expect(ids(rows)).toContain(toDo2.id); + })); + + it('should support filtering by due with smart value: day', (async () => { + let rows; + + const inThreeDays = parseInt(time.goForwardInTime(Date.now(), 3, 'day'), 10); + const inSevenDays = parseInt(time.goForwardInTime(Date.now(), 7, 'day'), 10); + const threeDaysAgo = parseInt(time.goBackInTime(Date.now(), 3, 'day'), 10); + const sevenDaysAgo = parseInt(time.goBackInTime(Date.now(), 7, 'day'), 10); + + const toDo1 = await Note.save({ title: 'ToDo + 3 day', body: 'toto', is_todo: 1, todo_due: inThreeDays }); + const toDo2 = await Note.save({ title: 'ToDo + 7 day', body: 'toto', is_todo: 1, todo_due: inSevenDays }); + const toDo3 = await Note.save({ title: 'ToDo - 3 day', body: 'toto', is_todo: 1, todo_due: threeDaysAgo }); + const toDo4 = await Note.save({ title: 'ToDo - 7 day', body: 'toto', is_todo: 1, todo_due: sevenDaysAgo }); + + await engine.syncTables(); + + rows = await engine.search('due:day-4', { searchType }); + expect(rows.length).toBe(3); + expect(ids(rows)).toContain(toDo1.id); + expect(ids(rows)).toContain(toDo2.id); + expect(ids(rows)).toContain(toDo3.id); + + rows = await engine.search('-due:day-4', { searchType }); + expect(rows.length).toBe(1); + expect(ids(rows)).toContain(toDo4.id); + + rows = await engine.search('-due:day+4', { searchType }); + expect(rows.length).toBe(3); + expect(ids(rows)).toContain(toDo1.id); + expect(ids(rows)).toContain(toDo3.id); + expect(ids(rows)).toContain(toDo4.id); + + rows = await engine.search('due:day+4', { searchType }); + expect(rows.length).toBe(1); + expect(ids(rows)).toContain(toDo2.id); + + rows = await engine.search('due:day-4 -due:day+4', { searchType }); + expect(rows.length).toBe(2); + expect(ids(rows)).toContain(toDo1.id); + expect(ids(rows)).toContain(toDo3.id); + })); + + it('should support filtering by latitude, longitude, altitude', (async () => { + let rows; + const n1 = await Note.save({ title: 'I made this', body: 'this week', latitude: 12.97, longitude: 88.88, altitude: 69.96 }); + const n2 = await Note.save({ title: 'I made this', body: 'the week before', latitude: 42.11, longitude: 77.77, altitude: 42.00 }); + const n3 = await Note.save({ title: 'I made this', body: 'before before week', latitude: 82.01, longitude: 66.66, altitude: 13.13 }); + + await engine.syncTables(); + + rows = await engine.search('latitude:13.5', { searchType }); + expect(rows.length).toBe(2); + expect(ids(rows)).toContain(n2.id); + expect(ids(rows)).toContain(n3.id); + + rows = await engine.search('-latitude:40', { searchType }); + expect(rows.length).toBe(1); + expect(ids(rows)).toContain(n1.id); + + rows = await engine.search('latitude:13 -latitude:80', { searchType }); + expect(rows.length).toBe(1); + expect(ids(rows)).toContain(n2.id); + + rows = await engine.search('altitude:13.5', { searchType }); + expect(rows.length).toBe(2); + expect(ids(rows)).toContain(n1.id); + expect(ids(rows)).toContain(n2.id); + + rows = await engine.search('-altitude:80.12', { searchType }); + expect(rows.length).toBe(3); + expect(ids(rows)).toContain(n1.id); + expect(ids(rows)).toContain(n2.id); + expect(ids(rows)).toContain(n3.id); + + rows = await engine.search('longitude:70 -longitude:80', { searchType }); + expect(rows.length).toBe(1); + expect(ids(rows)).toContain(n2.id); + + rows = await engine.search('latitude:20 longitude:50 altitude:40', { searchType }); + expect(rows.length).toBe(1); + expect(ids(rows)).toContain(n2.id); + + rows = await engine.search('any:1 latitude:20 longitude:50 altitude:40', { searchType }); + expect(rows.length).toBe(3); + expect(ids(rows)).toContain(n1.id); + expect(ids(rows)).toContain(n2.id); + expect(ids(rows)).toContain(n3.id); + })); + + it('should support filtering by resource MIME type', (async () => { + let rows; + const service = new ResourceService(); + // console.log(testImagePath) + const folder1 = await Folder.save({ title: 'folder1' }); + let n1 = await Note.save({ title: 'I have a picture', body: 'Im awesome', parent_id: folder1.id }); + const n2 = await Note.save({ title: 'Boring note 1', body: 'I just have text', parent_id: folder1.id }); + const n3 = await Note.save({ title: 'Boring note 2', body: 'me too', parent_id: folder1.id }); + let n4 = await Note.save({ title: 'A picture?', body: 'pfff, I have a pdf', parent_id: folder1.id }); + await engine.syncTables(); + + // let note1 = await Note.save({ title: 'ma note', parent_id: folder1.id }); + n1 = await shim.attachFileToNote(n1, `${supportDir}/photo.jpg`); + // const resource1 = (await Resource.all())[0]; + + n4 = await shim.attachFileToNote(n4, `${supportDir}/welcome.pdf`); + + await service.indexNoteResources(); + + rows = await engine.search('resource:image/jpeg', { searchType }); + expect(rows.length).toBe(1); + expect(ids(rows)).toContain(n1.id); + + rows = await engine.search('resource:image/*', { searchType }); + expect(rows.length).toBe(1); + expect(ids(rows)).toContain(n1.id); + + rows = await engine.search('resource:application/pdf', { searchType }); + expect(rows.length).toBe(1); + expect(ids(rows)).toContain(n4.id); + + rows = await engine.search('-resource:image/jpeg', { searchType }); + expect(rows.length).toBe(3); + expect(ids(rows)).toContain(n2.id); + expect(ids(rows)).toContain(n3.id); + expect(ids(rows)).toContain(n4.id); + + rows = await engine.search('any:1 resource:application/pdf resource:image/jpeg', { searchType }); + expect(rows.length).toBe(2); + expect(ids(rows)).toContain(n1.id); + expect(ids(rows)).toContain(n4.id); + })); - await engine.syncTables(); + it('should support filtering by sourceurl', (async () => { + const n0 = await Note.save({ title: 'n0', source_url: 'https://discourse.joplinapp.org' }); + const n1 = await Note.save({ title: 'n1', source_url: 'https://google.com' }); + const n2 = await Note.save({ title: 'n2', source_url: 'https://reddit.com' }); + const n3 = await Note.save({ title: 'n3', source_url: 'https://joplinapp.org' }); - let rows = await engine.search('-notebook:folder1'); - expect(rows.length).toBe(2); - expect(ids(rows)).toContain(n3.id); - expect(ids(rows)).toContain(n4.id); + await engine.syncTables(); + + let rows = await engine.search('sourceurl:https://joplinapp.org', { searchType }); + expect(rows.length).toBe(1); + expect(ids(rows)).toContain(n3.id); + + rows = await engine.search('sourceurl:https://google.com', { searchType }); + expect(rows.length).toBe(1); + expect(ids(rows)).toContain(n1.id); + + rows = await engine.search('any:1 sourceurl:https://google.com sourceurl:https://reddit.com', { searchType }); + expect(rows.length).toBe(2); + expect(ids(rows)).toContain(n1.id); + expect(ids(rows)).toContain(n2.id); + + rows = await engine.search('-sourceurl:https://google.com', { searchType }); + expect(rows.length).toBe(3); + expect(ids(rows)).toContain(n0.id); + expect(ids(rows)).toContain(n2.id); + expect(ids(rows)).toContain(n3.id); + + rows = await engine.search('sourceurl:*joplinapp.org', { searchType }); + expect(rows.length).toBe(2); + expect(ids(rows)).toContain(n0.id); + expect(ids(rows)).toContain(n3.id); + + })); + + it('should support negating notebooks', (async () => { + + const folder1 = await Folder.save({ title: 'folder1' }); + const n1 = await Note.save({ title: 'task1', body: 'foo', parent_id: folder1.id }); + const n2 = await Note.save({ title: 'task2', body: 'bar', parent_id: folder1.id }); - rows = await engine.search('-notebook:folder2'); - expect(rows.length).toBe(2); - expect(ids(rows)).toContain(n1.id); - expect(ids(rows)).toContain(n2.id); - - })); - - it('should support both inclusion and exclusion of notebooks together', (async () => { - - const parentFolder = await Folder.save({ title: 'parent' }); - const n1 = await Note.save({ title: 'task1', body: 'foo', parent_id: parentFolder.id }); - const n2 = await Note.save({ title: 'task2', body: 'bar', parent_id: parentFolder.id }); + const folder2 = await Folder.save({ title: 'folder2' }); + const n3 = await Note.save({ title: 'task3', body: 'baz', parent_id: folder2.id }); + const n4 = await Note.save({ title: 'task4', body: 'blah', parent_id: folder2.id }); - const subFolder = await Folder.save({ title: 'child', parent_id: parentFolder.id }); - const n3 = await Note.save({ title: 'task3', body: 'baz', parent_id: subFolder.id }); - const n4 = await Note.save({ title: 'task4', body: 'blah', parent_id: subFolder.id }); + await engine.syncTables(); + + let rows = await engine.search('-notebook:folder1', { searchType }); + expect(rows.length).toBe(2); + expect(ids(rows)).toContain(n3.id); + expect(ids(rows)).toContain(n4.id); - await engine.syncTables(); + rows = await engine.search('-notebook:folder2', { searchType }); + expect(rows.length).toBe(2); + expect(ids(rows)).toContain(n1.id); + expect(ids(rows)).toContain(n2.id); - const rows = await engine.search('notebook:parent -notebook:child'); - expect(rows.length).toBe(2); - expect(ids(rows)).toContain(n1.id); - expect(ids(rows)).toContain(n2.id); + })); - })); + it('should support both inclusion and exclusion of notebooks together', (async () => { - it('should support filtering by note id', (async () => { - let rows; - const note1 = await Note.save({ title: 'Note 1', body: 'body' }); - const note2 = await Note.save({ title: 'Note 2', body: 'body' }); - const note3 = await Note.save({ title: 'Note 3', body: 'body' }); - await engine.syncTables(); + const parentFolder = await Folder.save({ title: 'parent' }); + const n1 = await Note.save({ title: 'task1', body: 'foo', parent_id: parentFolder.id }); + const n2 = await Note.save({ title: 'task2', body: 'bar', parent_id: parentFolder.id }); - rows = await engine.search(`id:${note1.id}`); - expect(rows.length).toBe(1); - expect(rows.map(r=>r.id)).toContain(note1.id); - rows = await engine.search(`any:1 id:${note1.id} id:${note2.id}`); - expect(rows.length).toBe(2); - expect(rows.map(r=>r.id)).toContain(note1.id); - expect(rows.map(r=>r.id)).toContain(note2.id); + const subFolder = await Folder.save({ title: 'child', parent_id: parentFolder.id }); + const n3 = await Note.save({ title: 'task3', body: 'baz', parent_id: subFolder.id }); + const n4 = await Note.save({ title: 'task4', body: 'blah', parent_id: subFolder.id }); - rows = await engine.search(`any:0 id:${note1.id} id:${note2.id}`); - expect(rows.length).toBe(0); - rows = await engine.search(`-id:${note2.id}`); - expect(rows.length).toBe(2); - expect(rows.map(r=>r.id)).toContain(note1.id); - expect(rows.map(r=>r.id)).toContain(note3.id); - })); + await engine.syncTables(); + + const rows = await engine.search('notebook:parent -notebook:child', { searchType }); + expect(rows.length).toBe(2); + expect(ids(rows)).toContain(n1.id); + expect(ids(rows)).toContain(n2.id); + + })); + + it('should support filtering by note id', (async () => { + let rows; + const note1 = await Note.save({ title: 'Note 1', body: 'body' }); + const note2 = await Note.save({ title: 'Note 2', body: 'body' }); + const note3 = await Note.save({ title: 'Note 3', body: 'body' }); + await engine.syncTables(); + + rows = await engine.search(`id:${note1.id}`, { searchType }); + expect(rows.length).toBe(1); + expect(rows.map(r=>r.id)).toContain(note1.id); + + rows = await engine.search(`any:1 id:${note1.id} id:${note2.id}`, { searchType }); + expect(rows.length).toBe(2); + expect(rows.map(r=>r.id)).toContain(note1.id); + expect(rows.map(r=>r.id)).toContain(note2.id); + + rows = await engine.search(`any:0 id:${note1.id} id:${note2.id}`, { searchType }); + expect(rows.length).toBe(0); + + rows = await engine.search(`-id:${note2.id}`, { searchType }); + expect(rows.length).toBe(2); + expect(rows.map(r=>r.id)).toContain(note1.id); + expect(rows.map(r=>r.id)).toContain(note3.id); + })); + + }); + } }); diff --git a/packages/lib/services/searchengine/queryBuilder.ts b/packages/lib/services/searchengine/queryBuilder.ts index 8474cb0510..bbe7d5fbe2 100644 --- a/packages/lib/services/searchengine/queryBuilder.ts +++ b/packages/lib/services/searchengine/queryBuilder.ts @@ -21,7 +21,7 @@ enum Requirement { INCLUSION = 'INCLUSION', } -const _notebookFilter = (notebooks: string[], requirement: Requirement, conditions: string[], params: string[], withs: string[]) => { +const _notebookFilter = (notebooks: string[], requirement: Requirement, conditions: string[], params: string[], withs: string[], useFts: boolean) => { if (notebooks.length === 0) return; const likes = []; @@ -50,12 +50,13 @@ const _notebookFilter = (notebooks: string[], requirement: Requirement, conditio ON folders.parent_id=${viewName}.id )`; + const tableName = useFts ? 'notes_normalized' : 'notes'; const where = ` AND ROWID ${requirement === Requirement.EXCLUSION ? 'NOT' : ''} IN ( - SELECT notes_normalized.ROWID + SELECT ${tableName}.ROWID FROM ${viewName} - JOIN notes_normalized - ON ${viewName}.id=notes_normalized.parent_id + JOIN ${tableName} + ON ${viewName}.id=${tableName}.parent_id )`; @@ -65,12 +66,12 @@ const _notebookFilter = (notebooks: string[], requirement: Requirement, conditio }; -const notebookFilter = (terms: Term[], conditions: string[], params: string[], withs: string[]) => { +const notebookFilter = (terms: Term[], conditions: string[], params: string[], withs: string[], useFts: boolean) => { const notebooksToInclude = terms.filter(x => x.name === 'notebook' && !x.negated).map(x => x.value); - _notebookFilter(notebooksToInclude, Requirement.INCLUSION, conditions, params, withs); + _notebookFilter(notebooksToInclude, Requirement.INCLUSION, conditions, params, withs, useFts); const notebooksToExclude = terms.filter(x => x.name === 'notebook' && x.negated).map(x => x.value); - _notebookFilter(notebooksToExclude, Requirement.EXCLUSION, conditions, params, withs); + _notebookFilter(notebooksToExclude, Requirement.EXCLUSION, conditions, params, withs, useFts); }; @@ -87,7 +88,8 @@ const filterByTableName = ( noteIDs: string, requirement: Requirement, withs: string[], - tableName: string + tableName: string, + useFts: boolean ) => { const operator: Operation = getOperator(requirement, relation); @@ -144,13 +146,14 @@ const filterByTableName = ( } // Get the ROWIDs that satisfy the condition so we can filter the result + const targetTableName = useFts ? 'notes_normalized' : 'notes'; const whereCondition = ` ${relation} ROWID ${(relation === 'AND' && requirement === 'EXCLUSION') ? 'NOT' : ''} IN ( - SELECT notes_normalized.ROWID + SELECT ${targetTableName}.ROWID FROM notes_with_${requirement}_${tableName} - JOIN notes_normalized - ON notes_with_${requirement}_${tableName}.id=notes_normalized.id + JOIN ${targetTableName} + ON notes_with_${requirement}_${tableName}.id=${targetTableName}.id )`; withs.push(withCondition); @@ -159,7 +162,7 @@ const filterByTableName = ( }; -const resourceFilter = (terms: Term[], conditions: string[], params: string[], relation: Relation, withs: string[]) => { +const resourceFilter = (terms: Term[], conditions: string[], params: string[], relation: Relation, withs: string[], useFts: boolean) => { const tableName = 'resources'; const resourceIDs = ` @@ -177,15 +180,15 @@ const resourceFilter = (terms: Term[], conditions: string[], params: string[], r const excludedResources = terms.filter(x => x.name === 'resource' && x.negated); if (requiredResources.length > 0) { - filterByTableName(requiredResources, conditions, params, relation, noteIDsWithResource, Requirement.INCLUSION, withs, tableName); + filterByTableName(requiredResources, conditions, params, relation, noteIDsWithResource, Requirement.INCLUSION, withs, tableName, useFts); } if (excludedResources.length > 0) { - filterByTableName(excludedResources, conditions, params, relation, noteIDsWithResource, Requirement.EXCLUSION, withs, tableName); + filterByTableName(excludedResources, conditions, params, relation, noteIDsWithResource, Requirement.EXCLUSION, withs, tableName, useFts); } }; -const tagFilter = (terms: Term[], conditions: string[], params: string[], relation: Relation, withs: string[]) => { +const tagFilter = (terms: Term[], conditions: string[], params: string[], relation: Relation, withs: string[], useFts: boolean) => { const tableName = 'tags'; const tagIDs = ` @@ -203,30 +206,32 @@ const tagFilter = (terms: Term[], conditions: string[], params: string[], relati const excludedTags = terms.filter(x => x.name === 'tag' && x.negated); if (requiredTags.length > 0) { - filterByTableName(requiredTags, conditions, params, relation, noteIDsWithTag, Requirement.INCLUSION, withs, tableName); + filterByTableName(requiredTags, conditions, params, relation, noteIDsWithTag, Requirement.INCLUSION, withs, tableName, useFts); } if (excludedTags.length > 0) { - filterByTableName(excludedTags, conditions, params, relation, noteIDsWithTag, Requirement.EXCLUSION, withs, tableName); + filterByTableName(excludedTags, conditions, params, relation, noteIDsWithTag, Requirement.EXCLUSION, withs, tableName, useFts); } }; -const genericFilter = (terms: Term[], conditions: string[], params: string[], relation: Relation, fieldName: string) => { +const genericFilter = (terms: Term[], conditions: string[], params: string[], relation: Relation, fieldName: string, useFts: boolean) => { if (fieldName === 'iscompleted' || fieldName === 'type') { // Faster query when values can only take two distinct values - biConditionalFilter(terms, conditions, relation, fieldName); + biConditionalFilter(terms, conditions, relation, fieldName, useFts); return; } + const tableName = useFts ? 'notes_normalized' : 'notes'; + const getCondition = (term: Term) => { if (fieldName === 'sourceurl') { - return `notes_normalized.source_url ${term.negated ? 'NOT' : ''} LIKE ?`; + return `${tableName}.source_url ${term.negated ? 'NOT' : ''} LIKE ?`; } else if (fieldName === 'date' && term.name === 'due') { return `todo_due ${term.negated ? '<' : '>='} ?`; } else if (fieldName === 'id') { return `id ${term.negated ? 'NOT' : ''} LIKE ?`; } else { - return `notes_normalized.${fieldName === 'date' ? `user_${term.name}_time` : `${term.name}`} ${term.negated ? '<' : '>='} ?`; + return `${tableName}.${fieldName === 'date' ? `user_${term.name}_time` : `${term.name}`} ${term.negated ? '<' : '>='} ?`; } }; @@ -234,16 +239,16 @@ const genericFilter = (terms: Term[], conditions: string[], params: string[], re conditions.push(` ${relation} ( ${term.name === 'due' ? 'is_todo IS 1 AND ' : ''} ROWID IN ( SELECT ROWID - FROM notes_normalized + FROM ${tableName} WHERE ${getCondition(term)} ))`); params.push(term.value); }); }; -const biConditionalFilter = (terms: Term[], conditions: string[], relation: Relation, filterName: string) => { +const biConditionalFilter = (terms: Term[], conditions: string[], relation: Relation, filterName: string, useFts: boolean) => { const getCondition = (filterName: string , value: string, relation: Relation) => { - const tableName = (relation === 'AND') ? 'notes_fts' : 'notes_normalized'; + const tableName = useFts ? (relation === 'AND' ? 'notes_fts' : 'notes_normalized') : 'notes'; if (filterName === 'type') { return `${tableName}.is_todo IS ${value === 'todo' ? 1 : 0}`; } else if (filterName === 'iscompleted') { @@ -262,39 +267,44 @@ const biConditionalFilter = (terms: Term[], conditions: string[], relation: Rela AND ${getCondition(filterName, value, relation)}`); } if (relation === 'OR') { - conditions.push(` - OR ROWID IN ( - SELECT ROWID - FROM notes_normalized - WHERE ${getCondition(filterName, value, relation)} - )`); + if (useFts) { + conditions.push(` + OR ROWID IN ( + SELECT ROWID + FROM notes_normalized + WHERE ${getCondition(filterName, value, relation)} + )`); + } else { + conditions.push(` + OR ${getCondition(filterName, value, relation)}`); + } } }); }; -const noteIdFilter = (terms: Term[], conditions: string[], params: string[], relation: Relation) => { +const noteIdFilter = (terms: Term[], conditions: string[], params: string[], relation: Relation, useFts: boolean) => { const noteIdTerms = terms.filter(x => x.name === 'id'); - genericFilter(noteIdTerms, conditions, params, relation, 'id'); + genericFilter(noteIdTerms, conditions, params, relation, 'id', useFts); }; -const typeFilter = (terms: Term[], conditions: string[], params: string[], relation: Relation) => { +const typeFilter = (terms: Term[], conditions: string[], params: string[], relation: Relation, useFts: boolean) => { const typeTerms = terms.filter(x => x.name === 'type'); - genericFilter(typeTerms, conditions, params, relation, 'type'); + genericFilter(typeTerms, conditions, params, relation, 'type', useFts); }; -const completedFilter = (terms: Term[], conditions: string[], params: string[], relation: Relation) => { +const completedFilter = (terms: Term[], conditions: string[], params: string[], relation: Relation, useFts: boolean) => { const completedTerms = terms.filter(x => x.name === 'iscompleted'); - genericFilter(completedTerms, conditions, params, relation, 'iscompleted'); + genericFilter(completedTerms, conditions, params, relation, 'iscompleted', useFts); }; -const locationFilter = (terms: Term[], conditons: string[], params: string[], relation: Relation) => { +const locationFilter = (terms: Term[], conditons: string[], params: string[], relation: Relation, useFts: boolean) => { const locationTerms = terms.filter(x => x.name === 'latitude' || x.name === 'longitude' || x.name === 'altitude'); - genericFilter(locationTerms, conditons, params, relation, 'location'); + genericFilter(locationTerms, conditons, params, relation, 'location', useFts); }; -const dateFilter = (terms: Term[], conditons: string[], params: string[], relation: Relation) => { +const dateFilter = (terms: Term[], conditons: string[], params: string[], relation: Relation, useFts: boolean) => { const getUnixMs = (date: string): string => { const yyyymmdd = /^[0-9]{8}$/; const yyyymm = /^[0-9]{6}$/; @@ -321,44 +331,61 @@ const dateFilter = (terms: Term[], conditons: string[], params: string[], relati const dateTerms = terms.filter(x => x.name === 'created' || x.name === 'updated' || x.name === 'due'); const unixDateTerms = dateTerms.map(term => { return { ...term, value: getUnixMs(term.value) }; }); - genericFilter(unixDateTerms, conditons, params, relation, 'date'); + genericFilter(unixDateTerms, conditons, params, relation, 'date', useFts); }; -const sourceUrlFilter = (terms: Term[], conditons: string[], params: string[], relation: Relation) => { +const sourceUrlFilter = (terms: Term[], conditons: string[], params: string[], relation: Relation, useFts: boolean) => { const urlTerms = terms.filter(x => x.name === 'sourceurl'); - genericFilter(urlTerms, conditons, params, relation, 'sourceurl'); + genericFilter(urlTerms, conditons, params, relation, 'sourceurl', useFts); }; +const trimQuotes = (str: string) => str.startsWith('"') && str.endsWith('"') ? str.substr(1, str.length - 2) : str; + +const textFilter = (terms: Term[], conditions: string[], params: string[], relation: Relation, useFts: boolean) => { + const createLikeMatch = (term: Term, negate: boolean) => { + const query = `${relation} ${negate ? 'NOT' : ''} ( + ${(term.name === 'text' || term.name === 'body') ? 'notes.body LIKE ? ' : ''} + ${term.name === 'text' ? 'OR' : ''} + ${(term.name === 'text' || term.name === 'title') ? 'notes.title LIKE ? ' : ''})`; + + conditions.push(query); + const param = `%${trimQuotes(term.value).replace(/\*/, '%')}%`; + params.push(param); + if (term.name === 'text') params.push(param); + }; -const textFilter = (terms: Term[], conditions: string[], params: string[], relation: Relation) => { const addExcludeTextConditions = (excludedTerms: Term[], conditions: string[], params: string[], relation: Relation) => { - const type = excludedTerms[0].name === 'text' ? '' : `.${excludedTerms[0].name}`; - - if (relation === 'AND') { - conditions.push(` - AND ROWID NOT IN ( - SELECT ROWID - FROM notes_fts - WHERE notes_fts${type} MATCH ? - )`); - params.push(excludedTerms.map(x => x.value).join(' OR ')); - } - - if (relation === 'OR') { - excludedTerms.forEach(term => { + if (useFts) { + const type = excludedTerms[0].name === 'text' ? '' : `.${excludedTerms[0].name}`; + if (relation === 'AND') { conditions.push(` - OR ROWID IN ( - SELECT * - FROM ( - SELECT ROWID - FROM notes_fts - EXCEPT - SELECT ROWID - FROM notes_fts - WHERE notes_fts${type} MATCH ? - ) + AND ROWID NOT IN ( + SELECT ROWID + FROM notes_fts + WHERE notes_fts${type} MATCH ? )`); - params.push(term.value); + params.push(excludedTerms.map(x => x.value).join(' OR ')); + } + if (relation === 'OR') { + excludedTerms.forEach(term => { + conditions.push(` + OR ROWID IN ( + SELECT * + FROM ( + SELECT ROWID + FROM notes_fts + EXCEPT + SELECT ROWID + FROM notes_fts + WHERE notes_fts${type} MATCH ? + ) + )`); + params.push(term.value); + }); + } + } else { + excludedTerms.forEach(term => { + createLikeMatch(term, true); }); } }; @@ -367,13 +394,19 @@ const textFilter = (terms: Term[], conditions: string[], params: string[], relat const includedTerms = allTerms.filter(x => !x.negated); if (includedTerms.length > 0) { - conditions.push(`${relation} notes_fts MATCH ?`); - const termsToMatch = includedTerms.map(term => { - if (term.name === 'text') return term.value; - else return `${term.name}:${term.value}`; - }); - const matchQuery = (relation === 'OR') ? termsToMatch.join(' OR ') : termsToMatch.join(' '); - params.push(matchQuery); + if (useFts) { + conditions.push(`${relation} notes_fts MATCH ?`); + const termsToMatch = includedTerms.map(term => { + if (term.name === 'text') return term.value; + else return `${term.name}:${term.value}`; + }); + const matchQuery = (relation === 'OR') ? termsToMatch.join(' OR ') : termsToMatch.join(' '); + params.push(matchQuery); + } else { + includedTerms.forEach(term => { + createLikeMatch(term, false); + }); + } } const excludedTextTerms = allTerms.filter(x => x.name === 'text' && x.negated); @@ -404,47 +437,48 @@ const getConnective = (terms: Term[], relation: Relation): string => { return (!notebookTerm && (relation === 'OR')) ? 'ROWID=-1' : '1'; // ROWID=-1 acts as 0 (something always false) }; -export default function queryBuilder(terms: Term[]) { +export default function queryBuilder(terms: Term[], useFts: boolean) { const queryParts: string[] = []; const params: string[] = []; const withs: string[] = []; const relation: Relation = getDefaultRelation(terms); + const tableName = useFts ? 'notes_fts' : 'notes'; + queryParts.push(` SELECT - notes_fts.id, - notes_fts.title, - offsets(notes_fts) AS offsets, - matchinfo(notes_fts, 'pcnalx') AS matchinfo, - notes_fts.user_created_time, - notes_fts.user_updated_time, - notes_fts.is_todo, - notes_fts.todo_completed, - notes_fts.parent_id - FROM notes_fts + ${tableName}.id, + ${tableName}.title, + ${useFts ? 'offsets(notes_fts) AS offsets, matchinfo(notes_fts, \'pcnalx\') AS matchinfo,' : ''} + ${tableName}.user_created_time, + ${tableName}.user_updated_time, + ${tableName}.is_todo, + ${tableName}.todo_completed, + ${tableName}.parent_id + FROM ${tableName} WHERE ${getConnective(terms, relation)}`); - noteIdFilter(terms, queryParts, params, relation); + noteIdFilter(terms, queryParts, params, relation, useFts); - notebookFilter(terms, queryParts, params, withs); + notebookFilter(terms, queryParts, params, withs, useFts); - tagFilter(terms, queryParts, params, relation, withs); + tagFilter(terms, queryParts, params, relation, withs, useFts); - resourceFilter(terms, queryParts, params, relation, withs); + resourceFilter(terms, queryParts, params, relation, withs, useFts); - textFilter(terms, queryParts, params, relation); + textFilter(terms, queryParts, params, relation, useFts); - typeFilter(terms, queryParts, params, relation); + typeFilter(terms, queryParts, params, relation, useFts); - completedFilter(terms, queryParts, params, relation); + completedFilter(terms, queryParts, params, relation, useFts); - dateFilter(terms, queryParts, params, relation); + dateFilter(terms, queryParts, params, relation, useFts); - locationFilter(terms, queryParts, params, relation); + locationFilter(terms, queryParts, params, relation, useFts); - sourceUrlFilter(terms, queryParts, params, relation); + sourceUrlFilter(terms, queryParts, params, relation, useFts); let query; if (withs.length > 0) { diff --git a/packages/lib/shim-init-node.js b/packages/lib/shim-init-node.js index 81a57aef6e..84a435f7b8 100644 --- a/packages/lib/shim-init-node.js +++ b/packages/lib/shim-init-node.js @@ -487,13 +487,12 @@ function shimInit(sharp = null, keytar = null, React = null, appVersion = null) maxSockets: 1, keepAliveMsecs: 5000, }; - if (url.startsWith('https')) { - shim.httpAgent_ = new https.Agent(AgentSettings); - } else { - shim.httpAgent_ = new http.Agent(AgentSettings); - } + shim.httpAgent_ = { + http: new http.Agent(AgentSettings), + https: new https.Agent(AgentSettings), + }; } - return shim.httpAgent_; + return url.startsWith('https') ? shim.httpAgent_.https : shim.httpAgent_.http; }; shim.openOrCreateFile = (filepath, defaultContents) => { diff --git a/readme/api/references/rest_api.md b/readme/api/references/rest_api.md index 332f6caee5..beee9ee31a 100644 --- a/readme/api/references/rest_api.md +++ b/readme/api/references/rest_api.md @@ -148,38 +148,38 @@ command | 16 ## Properties -Name | Type | Description ---- | --- | --- -id | text | -parent_id | text | ID of the notebook that contains this note. Change this ID to move the note to a different notebook. -title | text | The note title. -body | text | The note body, in Markdown. May also contain HTML. -created_time | int | When the note was created. -updated_time | int | When the note was last updated. -is_conflict | int | Tells whether the note is a conflict or not. -latitude | numeric | -longitude | numeric | -altitude | numeric | -author | text | -source_url | text | The full URL where the note comes from. -is_todo | int | Tells whether this note is a todo or not. -todo_due | int | When the todo is due. An alarm will be triggered on that date. -todo_completed | int | Tells whether todo is completed or not. This is a timestamp in milliseconds. -source | text | -source_application | text | -application_data | text | -order | numeric | -user_created_time | int | When the note was created. It may differ from created_time as it can be manually set by the user. -user_updated_time | int | When the note was last updated. It may differ from updated_time as it can be manually set by the user. -encryption_cipher_text | text | -encryption_applied | int | -markup_language | int | -is_shared | int | -share_id | text | -body_html | text | Note body, in HTML format -base_url | text | If `body_html` is provided and contains relative URLs, provide the `base_url` parameter too so that all the URLs can be converted to absolute ones. The base URL is basically where the HTML was fetched from, minus the query (everything after the '?'). For example if the original page was `https://stackoverflow.com/search?q=%5Bjava%5D+test`, the base URL is `https://stackoverflow.com/search`. -image_data_url | text | An image to attach to the note, in [Data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) format. -crop_rect | text | If an image is provided, you can also specify an optional rectangle that will be used to crop the image. In format `{ x: x, y: y, width: width, height: height }` +| Name | Type | Description | +| ----- | ----- | ----- | +| id | text | | +| parent_id | text | ID of the notebook that contains this note. Change this ID to move the note to a different notebook. | +| title | text | The note title. | +| body | text | The note body, in Markdown. May also contain HTML. | +| created_time | int | When the note was created. | +| updated_time | int | When the note was last updated. | +| is_conflict | int | Tells whether the note is a conflict or not. | +| latitude | numeric | | +| longitude | numeric | | +| altitude | numeric | | +| author | text | | +| source_url | text | The full URL where the note comes from. | +| is_todo | int | Tells whether this note is a todo or not. | +| todo_due | int | When the todo is due. An alarm will be triggered on that date. | +| todo_completed | int | Tells whether todo is completed or not. This is a timestamp in milliseconds. | +| source | text | | +| source_application | text | | +| application_data | text | | +| order | numeric | | +| user_created_time | int | When the note was created. It may differ from created_time as it can be manually set by the user. | +| user_updated_time | int | When the note was last updated. It may differ from updated_time as it can be manually set by the user. | +| encryption_cipher_text | text | | +| encryption_applied | int | | +| markup_language | int | | +| is_shared | int | | +| share_id | text | | +| body_html | text | Note body, in HTML format | +| base_url | text | If `body_html` is provided and contains relative URLs, provide the `base_url` parameter too so that all the URLs can be converted to absolute ones. The base URL is basically where the HTML was fetched from, minus the query (everything after the '?'). For example if the original page was `https://stackoverflow.com/search?q=%5Bjava%5D+test`, the base URL is `https://stackoverflow.com/search`. | +| image_data_url | text | An image to attach to the note, in [Data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) format. | +| crop_rect | text | If an image is provided, you can also specify an optional rectangle that will be used to crop the image. In format `{ x: x, y: y, width: width, height: height }` | ## GET /notes @@ -237,19 +237,19 @@ This is actually a notebook. Internally notebooks are called "folders". ## Properties -Name | Type | Description ---- | --- | --- -id | text | -title | text | The folder title. -created_time | int | When the folder was created. -updated_time | int | When the folder was last updated. -user_created_time | int | When the folder was created. It may differ from created_time as it can be manually set by the user. -user_updated_time | int | When the folder was last updated. It may differ from updated_time as it can be manually set by the user. -encryption_cipher_text | text | -encryption_applied | int | -parent_id | text | -is_shared | int | -share_id | text | +| Name | Type | Description | +| ----- | ----- | ----- | +| id | text | | +| title | text | The folder title. | +| created_time | int | When the folder was created. | +| updated_time | int | When the folder was last updated. | +| user_created_time | int | When the folder was created. It may differ from created_time as it can be manually set by the user. | +| user_updated_time | int | When the folder was last updated. It may differ from updated_time as it can be manually set by the user. | +| encryption_cipher_text | text | | +| encryption_applied | int | | +| parent_id | text | | +| is_shared | int | | +| share_id | text | | ## GET /folders @@ -281,23 +281,23 @@ Deletes the folder with ID :id ## Properties -Name | Type | Description ---- | --- | --- -id | text | -title | text | The resource title. -mime | text | -filename | text | -created_time | int | When the resource was created. -updated_time | int | When the resource was last updated. -user_created_time | int | When the resource was created. It may differ from created_time as it can be manually set by the user. -user_updated_time | int | When the resource was last updated. It may differ from updated_time as it can be manually set by the user. -file_extension | text | -encryption_cipher_text | text | -encryption_applied | int | -encryption_blob_encrypted | int | -size | int | -is_shared | int | -share_id | text | +| Name | Type | Description | +| ----- | ----- | ----- | +| id | text | | +| title | text | The resource title. | +| mime | text | | +| filename | text | | +| created_time | int | When the resource was created. | +| updated_time | int | When the resource was last updated. | +| user_created_time | int | When the resource was created. It may differ from created_time as it can be manually set by the user. | +| user_updated_time | int | When the resource was last updated. It may differ from updated_time as it can be manually set by the user. | +| file_extension | text | | +| encryption_cipher_text | text | | +| encryption_applied | int | | +| encryption_blob_encrypted | int | | +| size | int | | +| is_shared | int | | +| share_id | text | | ## GET /resources @@ -351,18 +351,18 @@ Deletes the resource with ID :id ## Properties -Name | Type | Description ---- | --- | --- -id | text | -title | text | The tag title. -created_time | int | When the tag was created. -updated_time | int | When the tag was last updated. -user_created_time | int | When the tag was created. It may differ from created_time as it can be manually set by the user. -user_updated_time | int | When the tag was last updated. It may differ from updated_time as it can be manually set by the user. -encryption_cipher_text | text | -encryption_applied | int | -is_shared | int | -parent_id | text | +| Name | Type | Description | +| ----- | ----- | ----- | +| id | text | | +| title | text | The tag title. | +| created_time | int | When the tag was created. | +| updated_time | int | When the tag was last updated. | +| user_created_time | int | When the tag was created. It may differ from created_time as it can be manually set by the user. | +| user_updated_time | int | When the tag was last updated. It may differ from updated_time as it can be manually set by the user. | +| encryption_cipher_text | text | | +| encryption_applied | int | | +| is_shared | int | | +| parent_id | text | | ## GET /tags diff --git a/readme/changelog.md b/readme/changelog.md index 95a903dd0c..9bedf4d9d5 100644 --- a/readme/changelog.md +++ b/readme/changelog.md @@ -1,5 +1,11 @@ # Joplin changelog +## [v2.0.4](https://github.com/laurent22/joplin/releases/tag/v2.0.4) (Pre-release) - 2021-06-02T12:54:17Z + +- Improved: Download plugins from GitHub release (8f6a475) +- Fixed: Count tags based on showCompletedTodos setting ([#4957](https://github.com/laurent22/joplin/issues/4957)) ([#4411](https://github.com/laurent22/joplin/issues/4411) by [@JackGruber](https://github.com/JackGruber)) +- Fixed: Fixes panels overflowing window ([#4991](https://github.com/laurent22/joplin/issues/4991)) ([#4864](https://github.com/laurent22/joplin/issues/4864) by [@mablin7](https://github.com/mablin7)) + ## [v2.0.2](https://github.com/laurent22/joplin/releases/tag/v2.0.2) (Pre-release) - 2021-05-21T18:07:48Z - New: Add Share Notebook menu item (6f2f241) diff --git a/readme/stats.md b/readme/stats.md index d495a80e7e..5d3aa4375e 100644 --- a/readme/stats.md +++ b/readme/stats.md @@ -1,13 +1,13 @@ # Joplin statistics -Name | Value ---- | --- -Total Windows downloads | 1,425,567 -Total macOs downloads | 554,909 -Total Linux downloads | 463,554 -Windows % | 58% -macOS % | 23% -Linux % | 19% +| Name | Value | +| ----- | ----- | +| Total Windows downloads | 1,444,540 | +| Total macOs downloads | 561,465 | +| Total Linux downloads | 473,228 | +| Windows % | 58% | +| macOS % | 23% | +| Linux % | 19% | @@ -15,204 +15,205 @@ Linux % | 19% -Version | Date | Windows | macOS | Linux | Total ---- | --- | --- | --- | --- | --- -[v2.0.2](https://github.com/laurent22/joplin/releases/tag/v2.0.2) (p) | 2021-05-21T18:07:48Z | 594 | 179 | 448 | 1,221 -[v2.0.1](https://github.com/laurent22/joplin/releases/tag/v2.0.1) (p) | 2021-05-15T13:22:58Z | 770 | 243 | 984 | 1,997 -[v1.8.5](https://github.com/laurent22/joplin/releases/tag/v1.8.5) | 2021-05-10T11:58:14Z | 11,870 | 6,670 | 5,753 | 24,293 -[v1.8.4](https://github.com/laurent22/joplin/releases/tag/v1.8.4) (p) | 2021-05-09T18:05:05Z | 623 | 120 | 433 | 1,176 -[v1.8.3](https://github.com/laurent22/joplin/releases/tag/v1.8.3) (p) | 2021-05-04T10:38:16Z | 1,049 | 290 | 912 | 2,251 -[v1.8.2](https://github.com/laurent22/joplin/releases/tag/v1.8.2) (p) | 2021-04-25T10:50:51Z | 1,445 | 421 | 1,261 | 3,127 -[v1.8.1](https://github.com/laurent22/joplin/releases/tag/v1.8.1) (p) | 2021-03-29T10:46:41Z | 3,003 | 805 | 2,418 | 6,226 -[v1.7.11](https://github.com/laurent22/joplin/releases/tag/v1.7.11) | 2021-02-03T12:50:01Z | 113,794 | 42,526 | 64,040 | 220,360 -[v1.7.10](https://github.com/laurent22/joplin/releases/tag/v1.7.10) | 2021-01-30T13:25:29Z | 13,825 | 4,831 | 4,425 | 23,081 -[v1.7.9](https://github.com/laurent22/joplin/releases/tag/v1.7.9) (p) | 2021-01-28T09:50:21Z | 480 | 123 | 483 | 1,086 -[v1.7.6](https://github.com/laurent22/joplin/releases/tag/v1.7.6) (p) | 2021-01-27T10:36:05Z | 283 | 82 | 277 | 642 -[v1.7.5](https://github.com/laurent22/joplin/releases/tag/v1.7.5) (p) | 2021-01-26T09:53:05Z | 364 | 195 | 444 | 1,003 -[v1.7.4](https://github.com/laurent22/joplin/releases/tag/v1.7.4) (p) | 2021-01-22T17:58:38Z | 673 | 195 | 613 | 1,481 -[v1.6.8](https://github.com/laurent22/joplin/releases/tag/v1.6.8) | 2021-01-20T18:11:34Z | 18,064 | 7,662 | 7,578 | 33,304 -[v1.7.3](https://github.com/laurent22/joplin/releases/tag/v1.7.3) (p) | 2021-01-20T11:23:50Z | 334 | 68 | 436 | 838 -[v1.6.7](https://github.com/laurent22/joplin/releases/tag/v1.6.7) | 2021-01-11T23:20:33Z | 10,375 | 4,617 | 4,531 | 19,523 -[v1.6.6](https://github.com/laurent22/joplin/releases/tag/v1.6.6) | 2021-01-09T16:15:31Z | 12,359 | 3,405 | 4,776 | 20,540 -[v1.6.5](https://github.com/laurent22/joplin/releases/tag/v1.6.5) (p) | 2021-01-09T01:24:32Z | 553 | 57 | 300 | 910 -[v1.6.4](https://github.com/laurent22/joplin/releases/tag/v1.6.4) (p) | 2021-01-07T19:11:32Z | 381 | 72 | 197 | 650 -[v1.6.2](https://github.com/laurent22/joplin/releases/tag/v1.6.2) (p) | 2021-01-04T22:34:55Z | 664 | 221 | 577 | 1,462 -[v1.5.14](https://github.com/laurent22/joplin/releases/tag/v1.5.14) | 2020-12-30T01:48:46Z | 10,847 | 5,191 | 5,512 | 21,550 -[v1.6.1](https://github.com/laurent22/joplin/releases/tag/v1.6.1) (p) | 2020-12-29T19:37:45Z | 162 | 32 | 156 | 350 -[v1.5.13](https://github.com/laurent22/joplin/releases/tag/v1.5.13) | 2020-12-29T18:29:15Z | 609 | 212 | 190 | 1,011 -[v1.5.12](https://github.com/laurent22/joplin/releases/tag/v1.5.12) | 2020-12-28T15:14:08Z | 2,374 | 1,762 | 911 | 5,047 -[v1.5.11](https://github.com/laurent22/joplin/releases/tag/v1.5.11) | 2020-12-27T19:54:07Z | 13,999 | 4,605 | 4,253 | 22,857 -[v1.5.10](https://github.com/laurent22/joplin/releases/tag/v1.5.10) (p) | 2020-12-26T12:35:36Z | 288 | 102 | 255 | 645 -[v1.5.9](https://github.com/laurent22/joplin/releases/tag/v1.5.9) (p) | 2020-12-23T18:01:08Z | 321 | 367 | 399 | 1,087 -[v1.5.8](https://github.com/laurent22/joplin/releases/tag/v1.5.8) (p) | 2020-12-20T09:45:19Z | 559 | 158 | 631 | 1,348 -[v1.5.7](https://github.com/laurent22/joplin/releases/tag/v1.5.7) (p) | 2020-12-10T12:58:33Z | 880 | 248 | 982 | 2,110 -[v1.5.4](https://github.com/laurent22/joplin/releases/tag/v1.5.4) (p) | 2020-12-05T12:07:49Z | 686 | 161 | 623 | 1,470 -[v1.4.19](https://github.com/laurent22/joplin/releases/tag/v1.4.19) | 2020-12-01T11:11:16Z | 25,492 | 13,350 | 11,610 | 50,452 -[v1.4.18](https://github.com/laurent22/joplin/releases/tag/v1.4.18) | 2020-11-28T12:21:41Z | 11,082 | 3,870 | 3,076 | 18,028 -[v1.4.16](https://github.com/laurent22/joplin/releases/tag/v1.4.16) | 2020-11-27T19:40:16Z | 1,452 | 822 | 584 | 2,858 -[v1.4.15](https://github.com/laurent22/joplin/releases/tag/v1.4.15) | 2020-11-27T13:25:43Z | 878 | 482 | 262 | 1,622 -[v1.4.12](https://github.com/laurent22/joplin/releases/tag/v1.4.12) | 2020-11-23T18:58:07Z | 2,983 | 1,316 | 1,287 | 5,586 -[v1.4.11](https://github.com/laurent22/joplin/releases/tag/v1.4.11) (p) | 2020-11-19T23:06:51Z | 946 | 147 | 574 | 1,667 -[v1.4.10](https://github.com/laurent22/joplin/releases/tag/v1.4.10) (p) | 2020-11-14T09:53:15Z | 614 | 186 | 676 | 1,476 -[v1.4.9](https://github.com/laurent22/joplin/releases/tag/v1.4.9) (p) | 2020-11-11T14:23:17Z | 497 | 133 | 393 | 1,023 -[v1.4.7](https://github.com/laurent22/joplin/releases/tag/v1.4.7) (p) | 2020-11-07T18:23:29Z | 511 | 166 | 506 | 1,183 -[v1.3.18](https://github.com/laurent22/joplin/releases/tag/v1.3.18) | 2020-11-06T12:07:02Z | 30,609 | 11,316 | 10,495 | 52,420 -[v1.3.17](https://github.com/laurent22/joplin/releases/tag/v1.3.17) (p) | 2020-11-06T11:35:15Z | 44 | 16 | 15 | 75 -[v1.4.6](https://github.com/laurent22/joplin/releases/tag/v1.4.6) (p) | 2020-11-05T22:44:12Z | 339 | 86 | 45 | 470 -[v1.3.15](https://github.com/laurent22/joplin/releases/tag/v1.3.15) | 2020-11-04T12:22:50Z | 2,221 | 1,290 | 836 | 4,347 -[v1.3.11](https://github.com/laurent22/joplin/releases/tag/v1.3.11) (p) | 2020-10-31T13:22:20Z | 693 | 177 | 471 | 1,341 -[v1.3.10](https://github.com/laurent22/joplin/releases/tag/v1.3.10) (p) | 2020-10-29T13:27:14Z | 368 | 107 | 307 | 782 -[v1.3.9](https://github.com/laurent22/joplin/releases/tag/v1.3.9) (p) | 2020-10-23T16:04:26Z | 830 | 233 | 624 | 1,687 -[v1.3.8](https://github.com/laurent22/joplin/releases/tag/v1.3.8) (p) | 2020-10-21T18:46:29Z | 510 | 104 | 321 | 935 -[v1.3.7](https://github.com/laurent22/joplin/releases/tag/v1.3.7) (p) | 2020-10-20T11:35:55Z | 291 | 76 | 334 | 701 -[v1.3.5](https://github.com/laurent22/joplin/releases/tag/v1.3.5) (p) | 2020-10-17T14:26:35Z | 464 | 126 | 397 | 987 -[v1.3.3](https://github.com/laurent22/joplin/releases/tag/v1.3.3) (p) | 2020-10-17T10:56:57Z | 113 | 36 | 25 | 174 -[v1.3.2](https://github.com/laurent22/joplin/releases/tag/v1.3.2) (p) | 2020-10-11T20:39:49Z | 659 | 173 | 556 | 1,388 -[v1.3.1](https://github.com/laurent22/joplin/releases/tag/v1.3.1) (p) | 2020-10-11T15:10:18Z | 77 | 45 | 35 | 157 -[v1.2.6](https://github.com/laurent22/joplin/releases/tag/v1.2.6) | 2020-10-09T13:56:59Z | 44,164 | 17,713 | 14,024 | 75,901 -[v1.2.4](https://github.com/laurent22/joplin/releases/tag/v1.2.4) (p) | 2020-09-30T07:34:29Z | 808 | 240 | 791 | 1,839 -[v1.2.3](https://github.com/laurent22/joplin/releases/tag/v1.2.3) (p) | 2020-09-29T15:13:02Z | 212 | 61 | 72 | 345 -[v1.2.2](https://github.com/laurent22/joplin/releases/tag/v1.2.2) (p) | 2020-09-22T20:31:55Z | 777 | 199 | 631 | 1,607 -[v1.1.4](https://github.com/laurent22/joplin/releases/tag/v1.1.4) | 2020-09-21T11:20:09Z | 27,572 | 13,489 | 7,740 | 48,801 -[v1.1.3](https://github.com/laurent22/joplin/releases/tag/v1.1.3) (p) | 2020-09-17T10:30:37Z | 557 | 147 | 457 | 1,161 -[v1.1.2](https://github.com/laurent22/joplin/releases/tag/v1.1.2) (p) | 2020-09-15T12:58:38Z | 372 | 112 | 244 | 728 -[v1.1.1](https://github.com/laurent22/joplin/releases/tag/v1.1.1) (p) | 2020-09-11T23:32:47Z | 519 | 195 | 342 | 1,056 -[v1.0.245](https://github.com/laurent22/joplin/releases/tag/v1.0.245) | 2020-09-09T12:56:10Z | 21,148 | 9,999 | 5,634 | 36,781 -[v1.0.242](https://github.com/laurent22/joplin/releases/tag/v1.0.242) | 2020-09-04T22:00:34Z | 12,439 | 6,418 | 3,015 | 21,872 -[v1.0.241](https://github.com/laurent22/joplin/releases/tag/v1.0.241) | 2020-09-04T18:06:00Z | 23,628 | 5,748 | 4,994 | 34,370 -[v1.0.239](https://github.com/laurent22/joplin/releases/tag/v1.0.239) (p) | 2020-09-01T21:56:36Z | 599 | 226 | 400 | 1,225 -[v1.0.237](https://github.com/laurent22/joplin/releases/tag/v1.0.237) (p) | 2020-08-29T15:38:04Z | 588 | 923 | 338 | 1,849 -[v1.0.236](https://github.com/laurent22/joplin/releases/tag/v1.0.236) (p) | 2020-08-28T09:16:54Z | 315 | 110 | 104 | 529 -[v1.0.235](https://github.com/laurent22/joplin/releases/tag/v1.0.235) (p) | 2020-08-18T22:08:01Z | 1,671 | 489 | 920 | 3,080 -[v1.0.234](https://github.com/laurent22/joplin/releases/tag/v1.0.234) (p) | 2020-08-17T23:13:02Z | 536 | 125 | 100 | 761 -[v1.0.233](https://github.com/laurent22/joplin/releases/tag/v1.0.233) | 2020-08-01T14:51:15Z | 43,098 | 18,188 | 12,358 | 73,644 -[v1.0.232](https://github.com/laurent22/joplin/releases/tag/v1.0.232) (p) | 2020-07-28T22:34:40Z | 652 | 222 | 178 | 1,052 -[v1.0.227](https://github.com/laurent22/joplin/releases/tag/v1.0.227) | 2020-07-07T20:44:54Z | 40,384 | 15,273 | 9,627 | 65,284 -[v1.0.226](https://github.com/laurent22/joplin/releases/tag/v1.0.226) (p) | 2020-07-04T10:21:26Z | 4,905 | 2,252 | 688 | 7,845 -[v1.0.224](https://github.com/laurent22/joplin/releases/tag/v1.0.224) | 2020-06-20T22:26:08Z | 24,774 | 11,005 | 6,006 | 41,785 -[v1.0.223](https://github.com/laurent22/joplin/releases/tag/v1.0.223) (p) | 2020-06-20T11:51:27Z | 186 | 112 | 78 | 376 -[v1.0.221](https://github.com/laurent22/joplin/releases/tag/v1.0.221) (p) | 2020-06-20T01:44:20Z | 856 | 205 | 210 | 1,271 -[v1.0.220](https://github.com/laurent22/joplin/releases/tag/v1.0.220) | 2020-06-13T18:26:22Z | 31,712 | 9,916 | 6,411 | 48,039 -[v1.0.218](https://github.com/laurent22/joplin/releases/tag/v1.0.218) | 2020-06-07T10:43:34Z | 14,535 | 6,968 | 2,954 | 24,457 -[v1.0.217](https://github.com/laurent22/joplin/releases/tag/v1.0.217) (p) | 2020-06-06T15:17:27Z | 226 | 93 | 54 | 373 -[v1.0.216](https://github.com/laurent22/joplin/releases/tag/v1.0.216) | 2020-05-24T14:21:01Z | 37,277 | 14,268 | 10,177 | 61,722 -[v1.0.214](https://github.com/laurent22/joplin/releases/tag/v1.0.214) (p) | 2020-05-21T17:15:15Z | 6,529 | 3,466 | 760 | 10,755 -[v1.0.212](https://github.com/laurent22/joplin/releases/tag/v1.0.212) (p) | 2020-05-21T07:48:39Z | 210 | 66 | 46 | 322 -[v1.0.211](https://github.com/laurent22/joplin/releases/tag/v1.0.211) (p) | 2020-05-20T08:59:16Z | 300 | 131 | 86 | 517 -[v1.0.209](https://github.com/laurent22/joplin/releases/tag/v1.0.209) (p) | 2020-05-17T18:32:51Z | 1,393 | 851 | 147 | 2,391 -[v1.0.207](https://github.com/laurent22/joplin/releases/tag/v1.0.207) (p) | 2020-05-10T16:37:35Z | 1,187 | 263 | 1,016 | 2,466 -[v1.0.201](https://github.com/laurent22/joplin/releases/tag/v1.0.201) | 2020-04-15T22:55:13Z | 53,311 | 20,043 | 18,180 | 91,534 -[v1.0.200](https://github.com/laurent22/joplin/releases/tag/v1.0.200) | 2020-04-12T12:17:46Z | 9,552 | 4,892 | 1,903 | 16,347 -[v1.0.199](https://github.com/laurent22/joplin/releases/tag/v1.0.199) | 2020-04-10T18:41:58Z | 19,339 | 5,884 | 3,788 | 29,011 -[v1.0.197](https://github.com/laurent22/joplin/releases/tag/v1.0.197) | 2020-03-30T17:21:22Z | 22,280 | 9,540 | 5,726 | 37,546 -[v1.0.195](https://github.com/laurent22/joplin/releases/tag/v1.0.195) | 2020-03-22T19:56:12Z | 18,890 | 7,948 | 4,506 | 31,344 -[v1.0.194](https://github.com/laurent22/joplin/releases/tag/v1.0.194) (p) | 2020-03-14T00:00:32Z | 1,285 | 1,375 | 511 | 3,171 -[v1.0.193](https://github.com/laurent22/joplin/releases/tag/v1.0.193) | 2020-03-08T08:58:53Z | 28,641 | 10,907 | 7,392 | 46,940 -[v1.0.192](https://github.com/laurent22/joplin/releases/tag/v1.0.192) (p) | 2020-03-06T23:27:52Z | 472 | 122 | 89 | 683 -[v1.0.190](https://github.com/laurent22/joplin/releases/tag/v1.0.190) (p) | 2020-03-06T01:22:22Z | 373 | 90 | 85 | 548 -[v1.0.189](https://github.com/laurent22/joplin/releases/tag/v1.0.189) (p) | 2020-03-04T17:27:15Z | 342 | 96 | 90 | 528 -[v1.0.187](https://github.com/laurent22/joplin/releases/tag/v1.0.187) (p) | 2020-03-01T12:31:06Z | 919 | 230 | 263 | 1,412 -[v1.0.179](https://github.com/laurent22/joplin/releases/tag/v1.0.179) | 2020-01-24T22:42:41Z | 71,023 | 28,545 | 22,534 | 122,102 -[v1.0.178](https://github.com/laurent22/joplin/releases/tag/v1.0.178) | 2020-01-20T19:06:45Z | 17,539 | 5,962 | 2,584 | 26,085 -[v1.0.177](https://github.com/laurent22/joplin/releases/tag/v1.0.177) (p) | 2019-12-30T14:40:40Z | 1,943 | 438 | 678 | 3,059 -[v1.0.176](https://github.com/laurent22/joplin/releases/tag/v1.0.176) (p) | 2019-12-14T10:36:44Z | 3,124 | 2,534 | 467 | 6,125 -[v1.0.175](https://github.com/laurent22/joplin/releases/tag/v1.0.175) | 2019-12-08T11:48:47Z | 72,519 | 16,905 | 16,509 | 105,933 -[v1.0.174](https://github.com/laurent22/joplin/releases/tag/v1.0.174) | 2019-11-12T18:20:58Z | 30,401 | 11,722 | 8,221 | 50,344 -[v1.0.173](https://github.com/laurent22/joplin/releases/tag/v1.0.173) | 2019-11-11T08:33:35Z | 5,072 | 2,077 | 743 | 7,892 -[v1.0.170](https://github.com/laurent22/joplin/releases/tag/v1.0.170) | 2019-10-13T22:13:04Z | 27,413 | 8,752 | 7,675 | 43,840 -[v1.0.169](https://github.com/laurent22/joplin/releases/tag/v1.0.169) | 2019-09-27T18:35:13Z | 17,097 | 5,921 | 3,754 | 26,772 -[v1.0.168](https://github.com/laurent22/joplin/releases/tag/v1.0.168) | 2019-09-25T21:21:38Z | 5,332 | 2,273 | 717 | 8,322 -[v1.0.167](https://github.com/laurent22/joplin/releases/tag/v1.0.167) | 2019-09-10T08:48:37Z | 16,790 | 5,704 | 3,703 | 26,197 -[v1.0.166](https://github.com/laurent22/joplin/releases/tag/v1.0.166) | 2019-09-09T17:35:54Z | 1,956 | 560 | 236 | 2,752 -[v1.0.165](https://github.com/laurent22/joplin/releases/tag/v1.0.165) | 2019-08-14T21:46:29Z | 18,898 | 6,972 | 5,462 | 31,332 -[v1.0.161](https://github.com/laurent22/joplin/releases/tag/v1.0.161) | 2019-07-13T18:30:00Z | 19,285 | 6,352 | 4,136 | 29,773 -[v1.0.160](https://github.com/laurent22/joplin/releases/tag/v1.0.160) | 2019-06-15T00:21:40Z | 30,531 | 7,745 | 8,101 | 46,377 -[v1.0.159](https://github.com/laurent22/joplin/releases/tag/v1.0.159) | 2019-06-08T00:00:19Z | 5,194 | 2,178 | 1,112 | 8,484 -[v1.0.158](https://github.com/laurent22/joplin/releases/tag/v1.0.158) | 2019-05-27T19:01:18Z | 9,815 | 3,538 | 1,936 | 15,289 -[v1.0.157](https://github.com/laurent22/joplin/releases/tag/v1.0.157) | 2019-05-26T17:55:53Z | 2,179 | 844 | 291 | 3,314 -[v1.0.153](https://github.com/laurent22/joplin/releases/tag/v1.0.153) (p) | 2019-05-15T06:27:29Z | 850 | 102 | 106 | 1,058 -[v1.0.152](https://github.com/laurent22/joplin/releases/tag/v1.0.152) | 2019-05-13T09:08:07Z | 13,873 | 4,427 | 4,061 | 22,361 -[v1.0.151](https://github.com/laurent22/joplin/releases/tag/v1.0.151) | 2019-05-12T15:14:32Z | 1,954 | 533 | 957 | 3,444 -[v1.0.150](https://github.com/laurent22/joplin/releases/tag/v1.0.150) | 2019-05-12T11:27:48Z | 423 | 136 | 68 | 627 -[v1.0.148](https://github.com/laurent22/joplin/releases/tag/v1.0.148) (p) | 2019-05-08T19:12:24Z | 133 | 58 | 96 | 287 -[v1.0.145](https://github.com/laurent22/joplin/releases/tag/v1.0.145) | 2019-05-03T09:16:53Z | 7,008 | 2,861 | 1,437 | 11,306 -[v1.0.143](https://github.com/laurent22/joplin/releases/tag/v1.0.143) | 2019-04-22T10:51:38Z | 11,918 | 3,550 | 2,779 | 18,247 -[v1.0.142](https://github.com/laurent22/joplin/releases/tag/v1.0.142) | 2019-04-02T16:44:51Z | 14,663 | 4,565 | 4,727 | 23,955 -[v1.0.140](https://github.com/laurent22/joplin/releases/tag/v1.0.140) | 2019-03-10T20:59:58Z | 13,629 | 4,171 | 3,223 | 21,023 -[v1.0.139](https://github.com/laurent22/joplin/releases/tag/v1.0.139) (p) | 2019-03-09T10:06:48Z | 123 | 63 | 46 | 232 -[v1.0.138](https://github.com/laurent22/joplin/releases/tag/v1.0.138) (p) | 2019-03-03T17:23:00Z | 150 | 86 | 84 | 320 -[v1.0.137](https://github.com/laurent22/joplin/releases/tag/v1.0.137) (p) | 2019-03-03T01:12:51Z | 591 | 58 | 83 | 732 -[v1.0.135](https://github.com/laurent22/joplin/releases/tag/v1.0.135) | 2019-02-27T23:36:57Z | 12,514 | 3,958 | 4,077 | 20,549 -[v1.0.134](https://github.com/laurent22/joplin/releases/tag/v1.0.134) | 2019-02-27T10:21:44Z | 1,468 | 568 | 219 | 2,255 -[v1.0.132](https://github.com/laurent22/joplin/releases/tag/v1.0.132) | 2019-02-26T23:02:05Z | 1,088 | 451 | 95 | 1,634 -[v1.0.127](https://github.com/laurent22/joplin/releases/tag/v1.0.127) | 2019-02-14T23:12:48Z | 9,785 | 3,171 | 2,929 | 15,885 -[v1.0.126](https://github.com/laurent22/joplin/releases/tag/v1.0.126) (p) | 2019-02-09T19:46:16Z | 932 | 73 | 117 | 1,122 -[v1.0.125](https://github.com/laurent22/joplin/releases/tag/v1.0.125) | 2019-01-26T18:14:33Z | 10,251 | 3,559 | 1,703 | 15,513 -[v1.0.120](https://github.com/laurent22/joplin/releases/tag/v1.0.120) | 2019-01-10T21:42:53Z | 15,605 | 5,201 | 6,517 | 27,323 -[v1.0.119](https://github.com/laurent22/joplin/releases/tag/v1.0.119) | 2018-12-18T12:40:22Z | 8,906 | 3,262 | 2,014 | 14,182 -[v1.0.118](https://github.com/laurent22/joplin/releases/tag/v1.0.118) | 2019-01-11T08:34:13Z | 718 | 248 | 89 | 1,055 -[v1.0.117](https://github.com/laurent22/joplin/releases/tag/v1.0.117) | 2018-11-24T12:05:24Z | 16,259 | 4,896 | 6,381 | 27,536 -[v1.0.116](https://github.com/laurent22/joplin/releases/tag/v1.0.116) | 2018-11-20T19:09:24Z | 3,474 | 1,121 | 714 | 5,309 -[v1.0.115](https://github.com/laurent22/joplin/releases/tag/v1.0.115) | 2018-11-16T16:52:02Z | 3,658 | 1,303 | 799 | 5,760 -[v1.0.114](https://github.com/laurent22/joplin/releases/tag/v1.0.114) | 2018-10-24T20:14:10Z | 11,397 | 3,500 | 3,830 | 18,727 -[v1.0.111](https://github.com/laurent22/joplin/releases/tag/v1.0.111) | 2018-09-30T20:15:09Z | 12,041 | 3,307 | 3,668 | 19,016 -[v1.0.110](https://github.com/laurent22/joplin/releases/tag/v1.0.110) | 2018-09-29T12:29:21Z | 962 | 409 | 118 | 1,489 -[v1.0.109](https://github.com/laurent22/joplin/releases/tag/v1.0.109) | 2018-09-27T18:01:41Z | 2,102 | 706 | 328 | 3,136 -[v1.0.108](https://github.com/laurent22/joplin/releases/tag/v1.0.108) (p) | 2018-09-29T18:49:29Z | 31 | 22 | 14 | 67 -[v1.0.107](https://github.com/laurent22/joplin/releases/tag/v1.0.107) | 2018-09-16T19:51:07Z | 7,151 | 2,137 | 1,708 | 10,996 -[v1.0.106](https://github.com/laurent22/joplin/releases/tag/v1.0.106) | 2018-09-08T15:23:40Z | 4,559 | 1,458 | 318 | 6,335 -[v1.0.105](https://github.com/laurent22/joplin/releases/tag/v1.0.105) | 2018-09-05T11:29:36Z | 4,657 | 1,590 | 1,455 | 7,702 -[v1.0.104](https://github.com/laurent22/joplin/releases/tag/v1.0.104) | 2018-06-28T20:25:36Z | 15,055 | 4,702 | 7,345 | 27,102 -[v1.0.103](https://github.com/laurent22/joplin/releases/tag/v1.0.103) | 2018-06-21T19:38:13Z | 2,054 | 888 | 680 | 3,622 -[v1.0.101](https://github.com/laurent22/joplin/releases/tag/v1.0.101) | 2018-06-17T18:35:11Z | 1,311 | 608 | 409 | 2,328 -[v1.0.100](https://github.com/laurent22/joplin/releases/tag/v1.0.100) | 2018-06-14T17:41:43Z | 882 | 435 | 246 | 1,563 -[v1.0.99](https://github.com/laurent22/joplin/releases/tag/v1.0.99) | 2018-06-10T13:18:23Z | 1,256 | 598 | 380 | 2,234 -[v1.0.97](https://github.com/laurent22/joplin/releases/tag/v1.0.97) | 2018-06-09T19:23:34Z | 315 | 159 | 61 | 535 -[v1.0.96](https://github.com/laurent22/joplin/releases/tag/v1.0.96) | 2018-05-26T16:36:39Z | 2,721 | 1,225 | 1,700 | 5,646 -[v1.0.95](https://github.com/laurent22/joplin/releases/tag/v1.0.95) | 2018-05-25T13:04:30Z | 420 | 220 | 120 | 760 -[v1.0.94](https://github.com/laurent22/joplin/releases/tag/v1.0.94) | 2018-05-21T20:52:59Z | 1,134 | 586 | 397 | 2,117 -[v1.0.93](https://github.com/laurent22/joplin/releases/tag/v1.0.93) | 2018-05-14T11:36:01Z | 1,791 | 1,157 | 759 | 3,707 -[v1.0.91](https://github.com/laurent22/joplin/releases/tag/v1.0.91) | 2018-05-10T14:48:04Z | 828 | 552 | 307 | 1,687 -[v1.0.89](https://github.com/laurent22/joplin/releases/tag/v1.0.89) | 2018-05-09T13:05:05Z | 495 | 232 | 111 | 838 -[v1.0.85](https://github.com/laurent22/joplin/releases/tag/v1.0.85) | 2018-05-01T21:08:24Z | 1,654 | 951 | 633 | 3,238 -[v1.0.83](https://github.com/laurent22/joplin/releases/tag/v1.0.83) | 2018-04-04T19:43:58Z | 4,886 | 2,532 | 2,658 | 10,076 -[v1.0.82](https://github.com/laurent22/joplin/releases/tag/v1.0.82) | 2018-03-31T19:16:31Z | 694 | 406 | 122 | 1,222 -[v1.0.81](https://github.com/laurent22/joplin/releases/tag/v1.0.81) | 2018-03-28T08:13:58Z | 1,001 | 597 | 783 | 2,381 -[v1.0.79](https://github.com/laurent22/joplin/releases/tag/v1.0.79) | 2018-03-23T18:00:11Z | 932 | 539 | 381 | 1,852 -[v1.0.78](https://github.com/laurent22/joplin/releases/tag/v1.0.78) | 2018-03-17T15:27:18Z | 1,313 | 870 | 872 | 3,055 -[v1.0.77](https://github.com/laurent22/joplin/releases/tag/v1.0.77) | 2018-03-16T15:12:35Z | 179 | 105 | 46 | 330 -[v1.0.72](https://github.com/laurent22/joplin/releases/tag/v1.0.72) | 2018-03-14T09:44:35Z | 407 | 258 | 57 | 722 -[v1.0.70](https://github.com/laurent22/joplin/releases/tag/v1.0.70) | 2018-02-28T20:04:30Z | 1,855 | 1,052 | 1,255 | 4,162 -[v1.0.67](https://github.com/laurent22/joplin/releases/tag/v1.0.67) | 2018-02-19T22:51:08Z | 1,816 | 605 | 0 | 2,421 -[v1.0.66](https://github.com/laurent22/joplin/releases/tag/v1.0.66) | 2018-02-18T23:09:09Z | 329 | 136 | 86 | 551 -[v1.0.65](https://github.com/laurent22/joplin/releases/tag/v1.0.65) | 2018-02-17T20:02:25Z | 195 | 129 | 134 | 458 -[v1.0.64](https://github.com/laurent22/joplin/releases/tag/v1.0.64) | 2018-02-16T00:58:20Z | 1,086 | 545 | 1,124 | 2,755 -[v1.0.63](https://github.com/laurent22/joplin/releases/tag/v1.0.63) | 2018-02-14T19:40:36Z | 302 | 161 | 94 | 557 -[v1.0.62](https://github.com/laurent22/joplin/releases/tag/v1.0.62) | 2018-02-12T20:19:58Z | 561 | 300 | 369 | 1,230 -[v0.10.61](https://github.com/laurent22/joplin/releases/tag/v0.10.61) | 2018-02-08T18:27:39Z | 973 | 633 | 964 | 2,570 -[v0.10.60](https://github.com/laurent22/joplin/releases/tag/v0.10.60) | 2018-02-06T13:09:56Z | 723 | 522 | 553 | 1,798 -[v0.10.54](https://github.com/laurent22/joplin/releases/tag/v0.10.54) | 2018-01-31T20:21:30Z | 1,821 | 1,460 | 324 | 3,605 -[v0.10.52](https://github.com/laurent22/joplin/releases/tag/v0.10.52) | 2018-01-31T19:25:18Z | 48 | 634 | 16 | 698 -[v0.10.51](https://github.com/laurent22/joplin/releases/tag/v0.10.51) | 2018-01-28T18:47:02Z | 1,329 | 1,600 | 328 | 3,257 -[v0.10.48](https://github.com/laurent22/joplin/releases/tag/v0.10.48) | 2018-01-23T11:19:51Z | 1,966 | 1,752 | 32 | 3,750 -[v0.10.47](https://github.com/laurent22/joplin/releases/tag/v0.10.47) | 2018-01-16T17:27:17Z | 1,231 | 1,270 | 68 | 2,569 -[v0.10.43](https://github.com/laurent22/joplin/releases/tag/v0.10.43) | 2018-01-08T10:12:10Z | 3,442 | 2,357 | 1,209 | 7,008 -[v0.10.41](https://github.com/laurent22/joplin/releases/tag/v0.10.41) | 2018-01-05T20:38:12Z | 1,038 | 1,549 | 243 | 2,830 -[v0.10.40](https://github.com/laurent22/joplin/releases/tag/v0.10.40) | 2018-01-02T23:16:57Z | 1,596 | 1,790 | 339 | 3,725 -[v0.10.39](https://github.com/laurent22/joplin/releases/tag/v0.10.39) | 2017-12-11T21:19:44Z | 5,824 | 4,294 | 3,195 | 13,313 -[v0.10.38](https://github.com/laurent22/joplin/releases/tag/v0.10.38) | 2017-12-08T10:12:06Z | 1,050 | 1,231 | 307 | 2,588 -[v0.10.37](https://github.com/laurent22/joplin/releases/tag/v0.10.37) | 2017-12-07T19:38:05Z | 266 | 845 | 82 | 1,193 -[v0.10.36](https://github.com/laurent22/joplin/releases/tag/v0.10.36) | 2017-12-05T09:34:40Z | 1,016 | 1,356 | 439 | 2,811 -[v0.10.35](https://github.com/laurent22/joplin/releases/tag/v0.10.35) | 2017-12-02T15:56:08Z | 1,578 | 1,548 | 745 | 3,871 -[v0.10.34](https://github.com/laurent22/joplin/releases/tag/v0.10.34) | 2017-12-02T14:50:28Z | 91 | 670 | 60 | 821 -[v0.10.33](https://github.com/laurent22/joplin/releases/tag/v0.10.33) | 2017-12-02T13:20:39Z | 62 | 659 | 22 | 743 -[v0.10.31](https://github.com/laurent22/joplin/releases/tag/v0.10.31) | 2017-12-01T09:56:44Z | 893 | 1,451 | 407 | 2,751 -[v0.10.30](https://github.com/laurent22/joplin/releases/tag/v0.10.30) | 2017-11-30T20:28:16Z | 724 | 1,369 | 420 | 2,513 -[v0.10.28](https://github.com/laurent22/joplin/releases/tag/v0.10.28) | 2017-11-30T01:07:46Z | 1,342 | 1,701 | 874 | 3,917 -[v0.10.26](https://github.com/laurent22/joplin/releases/tag/v0.10.26) | 2017-11-29T16:02:17Z | 188 | 701 | 261 | 1,150 -[v0.10.25](https://github.com/laurent22/joplin/releases/tag/v0.10.25) | 2017-11-24T14:27:49Z | 150 | 696 | 6,461 | 7,307 -[v0.10.23](https://github.com/laurent22/joplin/releases/tag/v0.10.23) | 2017-11-21T19:38:41Z | 134 | 647 | 28 | 809 -[v0.10.22](https://github.com/laurent22/joplin/releases/tag/v0.10.22) | 2017-11-20T21:45:57Z | 86 | 645 | 19 | 750 -[v0.10.21](https://github.com/laurent22/joplin/releases/tag/v0.10.21) | 2017-11-18T00:53:15Z | 53 | 638 | 13 | 704 -[v0.10.20](https://github.com/laurent22/joplin/releases/tag/v0.10.20) | 2017-11-17T17:18:25Z | 34 | 649 | 22 | 705 -[v0.10.19](https://github.com/laurent22/joplin/releases/tag/v0.10.19) | 2017-11-20T18:59:48Z | 19 | 645 | 13 | 677 \ No newline at end of file +| Version | Date | Windows | macOS | Linux | Total | +| ----- | ----- | ----- | ----- | ----- | ----- | +| [v2.0.4](https://github.com/laurent22/joplin/releases/tag/v2.0.4) (p) | 2021-06-02T12:54:17Z | 898 | 267 | 242 | 1,407 | +| [v2.0.2](https://github.com/laurent22/joplin/releases/tag/v2.0.2) (p) | 2021-05-21T18:07:48Z | 1,953 | 470 | 1,554 | 3,977 | +| [v2.0.1](https://github.com/laurent22/joplin/releases/tag/v2.0.1) (p) | 2021-05-15T13:22:58Z | 784 | 245 | 994 | 2,023 | +| [v1.8.5](https://github.com/laurent22/joplin/releases/tag/v1.8.5) | 2021-05-10T11:58:14Z | 27,272 | 12,591 | 13,983 | 53,846 | +| [v1.8.4](https://github.com/laurent22/joplin/releases/tag/v1.8.4) (p) | 2021-05-09T18:05:05Z | 656 | 120 | 433 | 1,209 | +| [v1.8.3](https://github.com/laurent22/joplin/releases/tag/v1.8.3) (p) | 2021-05-04T10:38:16Z | 1,280 | 293 | 912 | 2,485 | +| [v1.8.2](https://github.com/laurent22/joplin/releases/tag/v1.8.2) (p) | 2021-04-25T10:50:51Z | 1,473 | 421 | 1,261 | 3,155 | +| [v1.8.1](https://github.com/laurent22/joplin/releases/tag/v1.8.1) (p) | 2021-03-29T10:46:41Z | 3,025 | 805 | 2,419 | 6,249 | +| [v1.7.11](https://github.com/laurent22/joplin/releases/tag/v1.7.11) | 2021-02-03T12:50:01Z | 113,946 | 42,556 | 64,079 | 220,581 | +| [v1.7.10](https://github.com/laurent22/joplin/releases/tag/v1.7.10) | 2021-01-30T13:25:29Z | 13,830 | 4,831 | 4,429 | 23,090 | +| [v1.7.9](https://github.com/laurent22/joplin/releases/tag/v1.7.9) (p) | 2021-01-28T09:50:21Z | 481 | 123 | 483 | 1,087 | +| [v1.7.6](https://github.com/laurent22/joplin/releases/tag/v1.7.6) (p) | 2021-01-27T10:36:05Z | 284 | 82 | 277 | 643 | +| [v1.7.5](https://github.com/laurent22/joplin/releases/tag/v1.7.5) (p) | 2021-01-26T09:53:05Z | 364 | 195 | 444 | 1,003 | +| [v1.7.4](https://github.com/laurent22/joplin/releases/tag/v1.7.4) (p) | 2021-01-22T17:58:38Z | 673 | 195 | 613 | 1,481 | +| [v1.6.8](https://github.com/laurent22/joplin/releases/tag/v1.6.8) | 2021-01-20T18:11:34Z | 18,148 | 7,665 | 7,581 | 33,394 | +| [v1.7.3](https://github.com/laurent22/joplin/releases/tag/v1.7.3) (p) | 2021-01-20T11:23:50Z | 334 | 68 | 436 | 838 | +| [v1.6.7](https://github.com/laurent22/joplin/releases/tag/v1.6.7) | 2021-01-11T23:20:33Z | 10,418 | 4,619 | 4,531 | 19,568 | +| [v1.6.6](https://github.com/laurent22/joplin/releases/tag/v1.6.6) | 2021-01-09T16:15:31Z | 12,362 | 3,405 | 4,776 | 20,543 | +| [v1.6.5](https://github.com/laurent22/joplin/releases/tag/v1.6.5) (p) | 2021-01-09T01:24:32Z | 580 | 57 | 300 | 937 | +| [v1.6.4](https://github.com/laurent22/joplin/releases/tag/v1.6.4) (p) | 2021-01-07T19:11:32Z | 381 | 73 | 197 | 651 | +| [v1.6.2](https://github.com/laurent22/joplin/releases/tag/v1.6.2) (p) | 2021-01-04T22:34:55Z | 665 | 221 | 577 | 1,463 | +| [v1.5.14](https://github.com/laurent22/joplin/releases/tag/v1.5.14) | 2020-12-30T01:48:46Z | 10,889 | 5,192 | 5,512 | 21,593 | +| [v1.6.1](https://github.com/laurent22/joplin/releases/tag/v1.6.1) (p) | 2020-12-29T19:37:45Z | 162 | 32 | 156 | 350 | +| [v1.5.13](https://github.com/laurent22/joplin/releases/tag/v1.5.13) | 2020-12-29T18:29:15Z | 609 | 212 | 190 | 1,011 | +| [v1.5.12](https://github.com/laurent22/joplin/releases/tag/v1.5.12) | 2020-12-28T15:14:08Z | 2,378 | 1,762 | 911 | 5,051 | +| [v1.5.11](https://github.com/laurent22/joplin/releases/tag/v1.5.11) | 2020-12-27T19:54:07Z | 14,009 | 4,605 | 4,254 | 22,868 | +| [v1.5.10](https://github.com/laurent22/joplin/releases/tag/v1.5.10) (p) | 2020-12-26T12:35:36Z | 288 | 102 | 255 | 645 | +| [v1.5.9](https://github.com/laurent22/joplin/releases/tag/v1.5.9) (p) | 2020-12-23T18:01:08Z | 321 | 368 | 400 | 1,089 | +| [v1.5.8](https://github.com/laurent22/joplin/releases/tag/v1.5.8) (p) | 2020-12-20T09:45:19Z | 559 | 158 | 631 | 1,348 | +| [v1.5.7](https://github.com/laurent22/joplin/releases/tag/v1.5.7) (p) | 2020-12-10T12:58:33Z | 880 | 248 | 982 | 2,110 | +| [v1.5.4](https://github.com/laurent22/joplin/releases/tag/v1.5.4) (p) | 2020-12-05T12:07:49Z | 686 | 161 | 623 | 1,470 | +| [v1.4.19](https://github.com/laurent22/joplin/releases/tag/v1.4.19) | 2020-12-01T11:11:16Z | 25,530 | 13,358 | 11,615 | 50,503 | +| [v1.4.18](https://github.com/laurent22/joplin/releases/tag/v1.4.18) | 2020-11-28T12:21:41Z | 11,087 | 3,871 | 3,081 | 18,039 | +| [v1.4.16](https://github.com/laurent22/joplin/releases/tag/v1.4.16) | 2020-11-27T19:40:16Z | 1,457 | 822 | 584 | 2,863 | +| [v1.4.15](https://github.com/laurent22/joplin/releases/tag/v1.4.15) | 2020-11-27T13:25:43Z | 878 | 482 | 262 | 1,622 | +| [v1.4.12](https://github.com/laurent22/joplin/releases/tag/v1.4.12) | 2020-11-23T18:58:07Z | 2,994 | 1,316 | 1,287 | 5,597 | +| [v1.4.11](https://github.com/laurent22/joplin/releases/tag/v1.4.11) (p) | 2020-11-19T23:06:51Z | 974 | 148 | 574 | 1,696 | +| [v1.4.10](https://github.com/laurent22/joplin/releases/tag/v1.4.10) (p) | 2020-11-14T09:53:15Z | 614 | 186 | 676 | 1,476 | +| [v1.4.9](https://github.com/laurent22/joplin/releases/tag/v1.4.9) (p) | 2020-11-11T14:23:17Z | 499 | 133 | 393 | 1,025 | +| [v1.4.7](https://github.com/laurent22/joplin/releases/tag/v1.4.7) (p) | 2020-11-07T18:23:29Z | 511 | 166 | 506 | 1,183 | +| [v1.3.18](https://github.com/laurent22/joplin/releases/tag/v1.3.18) | 2020-11-06T12:07:02Z | 30,668 | 11,316 | 10,495 | 52,479 | +| [v1.3.17](https://github.com/laurent22/joplin/releases/tag/v1.3.17) (p) | 2020-11-06T11:35:15Z | 44 | 16 | 15 | 75 | +| [v1.4.6](https://github.com/laurent22/joplin/releases/tag/v1.4.6) (p) | 2020-11-05T22:44:12Z | 341 | 86 | 45 | 472 | +| [v1.3.15](https://github.com/laurent22/joplin/releases/tag/v1.3.15) | 2020-11-04T12:22:50Z | 2,223 | 1,290 | 836 | 4,349 | +| [v1.3.11](https://github.com/laurent22/joplin/releases/tag/v1.3.11) (p) | 2020-10-31T13:22:20Z | 693 | 177 | 471 | 1,341 | +| [v1.3.10](https://github.com/laurent22/joplin/releases/tag/v1.3.10) (p) | 2020-10-29T13:27:14Z | 369 | 107 | 307 | 783 | +| [v1.3.9](https://github.com/laurent22/joplin/releases/tag/v1.3.9) (p) | 2020-10-23T16:04:26Z | 830 | 233 | 624 | 1,687 | +| [v1.3.8](https://github.com/laurent22/joplin/releases/tag/v1.3.8) (p) | 2020-10-21T18:46:29Z | 510 | 104 | 321 | 935 | +| [v1.3.7](https://github.com/laurent22/joplin/releases/tag/v1.3.7) (p) | 2020-10-20T11:35:55Z | 291 | 76 | 334 | 701 | +| [v1.3.5](https://github.com/laurent22/joplin/releases/tag/v1.3.5) (p) | 2020-10-17T14:26:35Z | 464 | 126 | 397 | 987 | +| [v1.3.3](https://github.com/laurent22/joplin/releases/tag/v1.3.3) (p) | 2020-10-17T10:56:57Z | 113 | 38 | 25 | 176 | +| [v1.3.2](https://github.com/laurent22/joplin/releases/tag/v1.3.2) (p) | 2020-10-11T20:39:49Z | 659 | 173 | 556 | 1,388 | +| [v1.3.1](https://github.com/laurent22/joplin/releases/tag/v1.3.1) (p) | 2020-10-11T15:10:18Z | 77 | 46 | 36 | 159 | +| [v1.2.6](https://github.com/laurent22/joplin/releases/tag/v1.2.6) | 2020-10-09T13:56:59Z | 44,215 | 17,713 | 14,026 | 75,954 | +| [v1.2.4](https://github.com/laurent22/joplin/releases/tag/v1.2.4) (p) | 2020-09-30T07:34:29Z | 808 | 240 | 791 | 1,839 | +| [v1.2.3](https://github.com/laurent22/joplin/releases/tag/v1.2.3) (p) | 2020-09-29T15:13:02Z | 212 | 61 | 72 | 345 | +| [v1.2.2](https://github.com/laurent22/joplin/releases/tag/v1.2.2) (p) | 2020-09-22T20:31:55Z | 779 | 199 | 631 | 1,609 | +| [v1.1.4](https://github.com/laurent22/joplin/releases/tag/v1.1.4) | 2020-09-21T11:20:09Z | 27,592 | 13,490 | 7,740 | 48,822 | +| [v1.1.3](https://github.com/laurent22/joplin/releases/tag/v1.1.3) (p) | 2020-09-17T10:30:37Z | 557 | 147 | 457 | 1,161 | +| [v1.1.2](https://github.com/laurent22/joplin/releases/tag/v1.1.2) (p) | 2020-09-15T12:58:38Z | 372 | 112 | 244 | 728 | +| [v1.1.1](https://github.com/laurent22/joplin/releases/tag/v1.1.1) (p) | 2020-09-11T23:32:47Z | 521 | 195 | 342 | 1,058 | +| [v1.0.245](https://github.com/laurent22/joplin/releases/tag/v1.0.245) | 2020-09-09T12:56:10Z | 21,177 | 9,999 | 5,634 | 36,810 | +| [v1.0.242](https://github.com/laurent22/joplin/releases/tag/v1.0.242) | 2020-09-04T22:00:34Z | 12,446 | 6,418 | 3,015 | 21,879 | +| [v1.0.241](https://github.com/laurent22/joplin/releases/tag/v1.0.241) | 2020-09-04T18:06:00Z | 23,665 | 5,749 | 4,995 | 34,409 | +| [v1.0.239](https://github.com/laurent22/joplin/releases/tag/v1.0.239) (p) | 2020-09-01T21:56:36Z | 601 | 226 | 400 | 1,227 | +| [v1.0.237](https://github.com/laurent22/joplin/releases/tag/v1.0.237) (p) | 2020-08-29T15:38:04Z | 588 | 923 | 338 | 1,849 | +| [v1.0.236](https://github.com/laurent22/joplin/releases/tag/v1.0.236) (p) | 2020-08-28T09:16:54Z | 315 | 110 | 104 | 529 | +| [v1.0.235](https://github.com/laurent22/joplin/releases/tag/v1.0.235) (p) | 2020-08-18T22:08:01Z | 1,673 | 489 | 920 | 3,082 | +| [v1.0.234](https://github.com/laurent22/joplin/releases/tag/v1.0.234) (p) | 2020-08-17T23:13:02Z | 538 | 125 | 100 | 763 | +| [v1.0.233](https://github.com/laurent22/joplin/releases/tag/v1.0.233) | 2020-08-01T14:51:15Z | 43,153 | 18,188 | 12,358 | 73,699 | +| [v1.0.232](https://github.com/laurent22/joplin/releases/tag/v1.0.232) (p) | 2020-07-28T22:34:40Z | 652 | 222 | 178 | 1,052 | +| [v1.0.227](https://github.com/laurent22/joplin/releases/tag/v1.0.227) | 2020-07-07T20:44:54Z | 40,414 | 15,274 | 9,629 | 65,317 | +| [v1.0.226](https://github.com/laurent22/joplin/releases/tag/v1.0.226) (p) | 2020-07-04T10:21:26Z | 4,905 | 2,252 | 688 | 7,845 | +| [v1.0.224](https://github.com/laurent22/joplin/releases/tag/v1.0.224) | 2020-06-20T22:26:08Z | 24,788 | 11,005 | 6,006 | 41,799 | +| [v1.0.223](https://github.com/laurent22/joplin/releases/tag/v1.0.223) (p) | 2020-06-20T11:51:27Z | 186 | 112 | 78 | 376 | +| [v1.0.221](https://github.com/laurent22/joplin/releases/tag/v1.0.221) (p) | 2020-06-20T01:44:20Z | 856 | 205 | 210 | 1,271 | +| [v1.0.220](https://github.com/laurent22/joplin/releases/tag/v1.0.220) | 2020-06-13T18:26:22Z | 31,734 | 9,916 | 6,411 | 48,061 | +| [v1.0.218](https://github.com/laurent22/joplin/releases/tag/v1.0.218) | 2020-06-07T10:43:34Z | 14,536 | 6,968 | 2,954 | 24,458 | +| [v1.0.217](https://github.com/laurent22/joplin/releases/tag/v1.0.217) (p) | 2020-06-06T15:17:27Z | 226 | 93 | 54 | 373 | +| [v1.0.216](https://github.com/laurent22/joplin/releases/tag/v1.0.216) | 2020-05-24T14:21:01Z | 37,327 | 14,269 | 10,177 | 61,773 | +| [v1.0.214](https://github.com/laurent22/joplin/releases/tag/v1.0.214) (p) | 2020-05-21T17:15:15Z | 6,545 | 3,466 | 760 | 10,771 | +| [v1.0.212](https://github.com/laurent22/joplin/releases/tag/v1.0.212) (p) | 2020-05-21T07:48:39Z | 210 | 66 | 46 | 322 | +| [v1.0.211](https://github.com/laurent22/joplin/releases/tag/v1.0.211) (p) | 2020-05-20T08:59:16Z | 300 | 131 | 86 | 517 | +| [v1.0.209](https://github.com/laurent22/joplin/releases/tag/v1.0.209) (p) | 2020-05-17T18:32:51Z | 1,393 | 851 | 147 | 2,391 | +| [v1.0.207](https://github.com/laurent22/joplin/releases/tag/v1.0.207) (p) | 2020-05-10T16:37:35Z | 1,188 | 263 | 1,016 | 2,467 | +| [v1.0.201](https://github.com/laurent22/joplin/releases/tag/v1.0.201) | 2020-04-15T22:55:13Z | 53,324 | 20,043 | 18,180 | 91,547 | +| [v1.0.200](https://github.com/laurent22/joplin/releases/tag/v1.0.200) | 2020-04-12T12:17:46Z | 9,552 | 4,892 | 1,903 | 16,347 | +| [v1.0.199](https://github.com/laurent22/joplin/releases/tag/v1.0.199) | 2020-04-10T18:41:58Z | 19,347 | 5,884 | 3,788 | 29,019 | +| [v1.0.197](https://github.com/laurent22/joplin/releases/tag/v1.0.197) | 2020-03-30T17:21:22Z | 22,290 | 9,540 | 5,734 | 37,564 | +| [v1.0.195](https://github.com/laurent22/joplin/releases/tag/v1.0.195) | 2020-03-22T19:56:12Z | 18,892 | 7,949 | 4,506 | 31,347 | +| [v1.0.194](https://github.com/laurent22/joplin/releases/tag/v1.0.194) (p) | 2020-03-14T00:00:32Z | 1,285 | 1,377 | 513 | 3,175 | +| [v1.0.193](https://github.com/laurent22/joplin/releases/tag/v1.0.193) | 2020-03-08T08:58:53Z | 28,642 | 10,907 | 7,393 | 46,942 | +| [v1.0.192](https://github.com/laurent22/joplin/releases/tag/v1.0.192) (p) | 2020-03-06T23:27:52Z | 473 | 122 | 89 | 684 | +| [v1.0.190](https://github.com/laurent22/joplin/releases/tag/v1.0.190) (p) | 2020-03-06T01:22:22Z | 374 | 90 | 85 | 549 | +| [v1.0.189](https://github.com/laurent22/joplin/releases/tag/v1.0.189) (p) | 2020-03-04T17:27:15Z | 343 | 96 | 90 | 529 | +| [v1.0.187](https://github.com/laurent22/joplin/releases/tag/v1.0.187) (p) | 2020-03-01T12:31:06Z | 920 | 230 | 263 | 1,413 | +| [v1.0.179](https://github.com/laurent22/joplin/releases/tag/v1.0.179) | 2020-01-24T22:42:41Z | 71,040 | 28,550 | 22,535 | 122,125 | +| [v1.0.178](https://github.com/laurent22/joplin/releases/tag/v1.0.178) | 2020-01-20T19:06:45Z | 17,540 | 5,962 | 2,584 | 26,086 | +| [v1.0.177](https://github.com/laurent22/joplin/releases/tag/v1.0.177) (p) | 2019-12-30T14:40:40Z | 1,944 | 438 | 679 | 3,061 | +| [v1.0.176](https://github.com/laurent22/joplin/releases/tag/v1.0.176) (p) | 2019-12-14T10:36:44Z | 3,124 | 2,534 | 467 | 6,125 | +| [v1.0.175](https://github.com/laurent22/joplin/releases/tag/v1.0.175) | 2019-12-08T11:48:47Z | 72,538 | 16,906 | 16,509 | 105,953 | +| [v1.0.174](https://github.com/laurent22/joplin/releases/tag/v1.0.174) | 2019-11-12T18:20:58Z | 30,407 | 11,722 | 8,221 | 50,350 | +| [v1.0.173](https://github.com/laurent22/joplin/releases/tag/v1.0.173) | 2019-11-11T08:33:35Z | 5,074 | 2,077 | 743 | 7,894 | +| [v1.0.170](https://github.com/laurent22/joplin/releases/tag/v1.0.170) | 2019-10-13T22:13:04Z | 27,424 | 8,752 | 7,675 | 43,851 | +| [v1.0.169](https://github.com/laurent22/joplin/releases/tag/v1.0.169) | 2019-09-27T18:35:13Z | 17,098 | 5,921 | 3,754 | 26,773 | +| [v1.0.168](https://github.com/laurent22/joplin/releases/tag/v1.0.168) | 2019-09-25T21:21:38Z | 5,332 | 2,273 | 717 | 8,322 | +| [v1.0.167](https://github.com/laurent22/joplin/releases/tag/v1.0.167) | 2019-09-10T08:48:37Z | 16,791 | 5,704 | 3,703 | 26,198 | +| [v1.0.166](https://github.com/laurent22/joplin/releases/tag/v1.0.166) | 2019-09-09T17:35:54Z | 1,956 | 560 | 236 | 2,752 | +| [v1.0.165](https://github.com/laurent22/joplin/releases/tag/v1.0.165) | 2019-08-14T21:46:29Z | 18,903 | 6,972 | 5,462 | 31,337 | +| [v1.0.161](https://github.com/laurent22/joplin/releases/tag/v1.0.161) | 2019-07-13T18:30:00Z | 19,287 | 6,352 | 4,136 | 29,775 | +| [v1.0.160](https://github.com/laurent22/joplin/releases/tag/v1.0.160) | 2019-06-15T00:21:40Z | 30,535 | 7,746 | 8,101 | 46,382 | +| [v1.0.159](https://github.com/laurent22/joplin/releases/tag/v1.0.159) | 2019-06-08T00:00:19Z | 5,194 | 2,178 | 1,113 | 8,485 | +| [v1.0.158](https://github.com/laurent22/joplin/releases/tag/v1.0.158) | 2019-05-27T19:01:18Z | 9,815 | 3,538 | 1,936 | 15,289 | +| [v1.0.157](https://github.com/laurent22/joplin/releases/tag/v1.0.157) | 2019-05-26T17:55:53Z | 2,179 | 844 | 291 | 3,314 | +| [v1.0.153](https://github.com/laurent22/joplin/releases/tag/v1.0.153) (p) | 2019-05-15T06:27:29Z | 850 | 102 | 106 | 1,058 | +| [v1.0.152](https://github.com/laurent22/joplin/releases/tag/v1.0.152) | 2019-05-13T09:08:07Z | 13,873 | 4,427 | 4,061 | 22,361 | +| [v1.0.151](https://github.com/laurent22/joplin/releases/tag/v1.0.151) | 2019-05-12T15:14:32Z | 1,954 | 533 | 957 | 3,444 | +| [v1.0.150](https://github.com/laurent22/joplin/releases/tag/v1.0.150) | 2019-05-12T11:27:48Z | 423 | 136 | 68 | 627 | +| [v1.0.148](https://github.com/laurent22/joplin/releases/tag/v1.0.148) (p) | 2019-05-08T19:12:24Z | 133 | 58 | 96 | 287 | +| [v1.0.145](https://github.com/laurent22/joplin/releases/tag/v1.0.145) | 2019-05-03T09:16:53Z | 7,008 | 2,861 | 1,437 | 11,306 | +| [v1.0.143](https://github.com/laurent22/joplin/releases/tag/v1.0.143) | 2019-04-22T10:51:38Z | 11,918 | 3,550 | 2,779 | 18,247 | +| [v1.0.142](https://github.com/laurent22/joplin/releases/tag/v1.0.142) | 2019-04-02T16:44:51Z | 14,663 | 4,565 | 4,727 | 23,955 | +| [v1.0.140](https://github.com/laurent22/joplin/releases/tag/v1.0.140) | 2019-03-10T20:59:58Z | 13,629 | 4,171 | 3,227 | 21,027 | +| [v1.0.139](https://github.com/laurent22/joplin/releases/tag/v1.0.139) (p) | 2019-03-09T10:06:48Z | 123 | 63 | 46 | 232 | +| [v1.0.138](https://github.com/laurent22/joplin/releases/tag/v1.0.138) (p) | 2019-03-03T17:23:00Z | 150 | 87 | 84 | 321 | +| [v1.0.137](https://github.com/laurent22/joplin/releases/tag/v1.0.137) (p) | 2019-03-03T01:12:51Z | 591 | 58 | 83 | 732 | +| [v1.0.135](https://github.com/laurent22/joplin/releases/tag/v1.0.135) | 2019-02-27T23:36:57Z | 12,515 | 3,958 | 4,077 | 20,550 | +| [v1.0.134](https://github.com/laurent22/joplin/releases/tag/v1.0.134) | 2019-02-27T10:21:44Z | 1,468 | 568 | 219 | 2,255 | +| [v1.0.132](https://github.com/laurent22/joplin/releases/tag/v1.0.132) | 2019-02-26T23:02:05Z | 1,088 | 452 | 95 | 1,635 | +| [v1.0.127](https://github.com/laurent22/joplin/releases/tag/v1.0.127) | 2019-02-14T23:12:48Z | 9,786 | 3,172 | 2,929 | 15,887 | +| [v1.0.126](https://github.com/laurent22/joplin/releases/tag/v1.0.126) (p) | 2019-02-09T19:46:16Z | 932 | 73 | 117 | 1,122 | +| [v1.0.125](https://github.com/laurent22/joplin/releases/tag/v1.0.125) | 2019-01-26T18:14:33Z | 10,251 | 3,559 | 1,703 | 15,513 | +| [v1.0.120](https://github.com/laurent22/joplin/releases/tag/v1.0.120) | 2019-01-10T21:42:53Z | 15,605 | 5,202 | 6,517 | 27,324 | +| [v1.0.119](https://github.com/laurent22/joplin/releases/tag/v1.0.119) | 2018-12-18T12:40:22Z | 8,906 | 3,262 | 2,014 | 14,182 | +| [v1.0.118](https://github.com/laurent22/joplin/releases/tag/v1.0.118) | 2019-01-11T08:34:13Z | 718 | 248 | 89 | 1,055 | +| [v1.0.117](https://github.com/laurent22/joplin/releases/tag/v1.0.117) | 2018-11-24T12:05:24Z | 16,259 | 4,896 | 6,381 | 27,536 | +| [v1.0.116](https://github.com/laurent22/joplin/releases/tag/v1.0.116) | 2018-11-20T19:09:24Z | 3,474 | 1,122 | 714 | 5,310 | +| [v1.0.115](https://github.com/laurent22/joplin/releases/tag/v1.0.115) | 2018-11-16T16:52:02Z | 3,658 | 1,303 | 799 | 5,760 | +| [v1.0.114](https://github.com/laurent22/joplin/releases/tag/v1.0.114) | 2018-10-24T20:14:10Z | 11,397 | 3,500 | 3,830 | 18,727 | +| [v1.0.111](https://github.com/laurent22/joplin/releases/tag/v1.0.111) | 2018-09-30T20:15:09Z | 12,042 | 3,308 | 3,669 | 19,019 | +| [v1.0.110](https://github.com/laurent22/joplin/releases/tag/v1.0.110) | 2018-09-29T12:29:21Z | 962 | 409 | 118 | 1,489 | +| [v1.0.109](https://github.com/laurent22/joplin/releases/tag/v1.0.109) | 2018-09-27T18:01:41Z | 2,102 | 706 | 328 | 3,136 | +| [v1.0.108](https://github.com/laurent22/joplin/releases/tag/v1.0.108) (p) | 2018-09-29T18:49:29Z | 31 | 22 | 14 | 67 | +| [v1.0.107](https://github.com/laurent22/joplin/releases/tag/v1.0.107) | 2018-09-16T19:51:07Z | 7,151 | 2,137 | 1,708 | 10,996 | +| [v1.0.106](https://github.com/laurent22/joplin/releases/tag/v1.0.106) | 2018-09-08T15:23:40Z | 4,559 | 1,458 | 318 | 6,335 | +| [v1.0.105](https://github.com/laurent22/joplin/releases/tag/v1.0.105) | 2018-09-05T11:29:36Z | 4,657 | 1,590 | 1,455 | 7,702 | +| [v1.0.104](https://github.com/laurent22/joplin/releases/tag/v1.0.104) | 2018-06-28T20:25:36Z | 15,055 | 4,702 | 7,345 | 27,102 | +| [v1.0.103](https://github.com/laurent22/joplin/releases/tag/v1.0.103) | 2018-06-21T19:38:13Z | 2,054 | 888 | 680 | 3,622 | +| [v1.0.101](https://github.com/laurent22/joplin/releases/tag/v1.0.101) | 2018-06-17T18:35:11Z | 1,311 | 608 | 409 | 2,328 | +| [v1.0.100](https://github.com/laurent22/joplin/releases/tag/v1.0.100) | 2018-06-14T17:41:43Z | 882 | 435 | 246 | 1,563 | +| [v1.0.99](https://github.com/laurent22/joplin/releases/tag/v1.0.99) | 2018-06-10T13:18:23Z | 1,256 | 598 | 380 | 2,234 | +| [v1.0.97](https://github.com/laurent22/joplin/releases/tag/v1.0.97) | 2018-06-09T19:23:34Z | 315 | 159 | 61 | 535 | +| [v1.0.96](https://github.com/laurent22/joplin/releases/tag/v1.0.96) | 2018-05-26T16:36:39Z | 2,721 | 1,225 | 1,700 | 5,646 | +| [v1.0.95](https://github.com/laurent22/joplin/releases/tag/v1.0.95) | 2018-05-25T13:04:30Z | 420 | 220 | 120 | 760 | +| [v1.0.94](https://github.com/laurent22/joplin/releases/tag/v1.0.94) | 2018-05-21T20:52:59Z | 1,134 | 586 | 397 | 2,117 | +| [v1.0.93](https://github.com/laurent22/joplin/releases/tag/v1.0.93) | 2018-05-14T11:36:01Z | 1,791 | 1,158 | 759 | 3,708 | +| [v1.0.91](https://github.com/laurent22/joplin/releases/tag/v1.0.91) | 2018-05-10T14:48:04Z | 828 | 552 | 307 | 1,687 | +| [v1.0.89](https://github.com/laurent22/joplin/releases/tag/v1.0.89) | 2018-05-09T13:05:05Z | 495 | 232 | 111 | 838 | +| [v1.0.85](https://github.com/laurent22/joplin/releases/tag/v1.0.85) | 2018-05-01T21:08:24Z | 1,654 | 951 | 633 | 3,238 | +| [v1.0.83](https://github.com/laurent22/joplin/releases/tag/v1.0.83) | 2018-04-04T19:43:58Z | 4,892 | 2,532 | 2,658 | 10,082 | +| [v1.0.82](https://github.com/laurent22/joplin/releases/tag/v1.0.82) | 2018-03-31T19:16:31Z | 694 | 406 | 122 | 1,222 | +| [v1.0.81](https://github.com/laurent22/joplin/releases/tag/v1.0.81) | 2018-03-28T08:13:58Z | 1,001 | 597 | 783 | 2,381 | +| [v1.0.79](https://github.com/laurent22/joplin/releases/tag/v1.0.79) | 2018-03-23T18:00:11Z | 932 | 539 | 381 | 1,852 | +| [v1.0.78](https://github.com/laurent22/joplin/releases/tag/v1.0.78) | 2018-03-17T15:27:18Z | 1,313 | 870 | 872 | 3,055 | +| [v1.0.77](https://github.com/laurent22/joplin/releases/tag/v1.0.77) | 2018-03-16T15:12:35Z | 179 | 105 | 46 | 330 | +| [v1.0.72](https://github.com/laurent22/joplin/releases/tag/v1.0.72) | 2018-03-14T09:44:35Z | 407 | 258 | 57 | 722 | +| [v1.0.70](https://github.com/laurent22/joplin/releases/tag/v1.0.70) | 2018-02-28T20:04:30Z | 1,855 | 1,052 | 1,255 | 4,162 | +| [v1.0.67](https://github.com/laurent22/joplin/releases/tag/v1.0.67) | 2018-02-19T22:51:08Z | 1,816 | 605 | 0 | 2,421 | +| [v1.0.66](https://github.com/laurent22/joplin/releases/tag/v1.0.66) | 2018-02-18T23:09:09Z | 329 | 136 | 86 | 551 | +| [v1.0.65](https://github.com/laurent22/joplin/releases/tag/v1.0.65) | 2018-02-17T20:02:25Z | 195 | 129 | 134 | 458 | +| [v1.0.64](https://github.com/laurent22/joplin/releases/tag/v1.0.64) | 2018-02-16T00:58:20Z | 1,086 | 545 | 1,124 | 2,755 | +| [v1.0.63](https://github.com/laurent22/joplin/releases/tag/v1.0.63) | 2018-02-14T19:40:36Z | 302 | 161 | 94 | 557 | +| [v1.0.62](https://github.com/laurent22/joplin/releases/tag/v1.0.62) | 2018-02-12T20:19:58Z | 561 | 300 | 369 | 1,230 | +| [v0.10.61](https://github.com/laurent22/joplin/releases/tag/v0.10.61) | 2018-02-08T18:27:39Z | 973 | 633 | 964 | 2,570 | +| [v0.10.60](https://github.com/laurent22/joplin/releases/tag/v0.10.60) | 2018-02-06T13:09:56Z | 723 | 522 | 553 | 1,798 | +| [v0.10.54](https://github.com/laurent22/joplin/releases/tag/v0.10.54) | 2018-01-31T20:21:30Z | 1,821 | 1,460 | 324 | 3,605 | +| [v0.10.52](https://github.com/laurent22/joplin/releases/tag/v0.10.52) | 2018-01-31T19:25:18Z | 48 | 634 | 16 | 698 | +| [v0.10.51](https://github.com/laurent22/joplin/releases/tag/v0.10.51) | 2018-01-28T18:47:02Z | 1,329 | 1,600 | 328 | 3,257 | +| [v0.10.48](https://github.com/laurent22/joplin/releases/tag/v0.10.48) | 2018-01-23T11:19:51Z | 1,966 | 1,752 | 32 | 3,750 | +| [v0.10.47](https://github.com/laurent22/joplin/releases/tag/v0.10.47) | 2018-01-16T17:27:17Z | 1,231 | 1,270 | 68 | 2,569 | +| [v0.10.43](https://github.com/laurent22/joplin/releases/tag/v0.10.43) | 2018-01-08T10:12:10Z | 3,442 | 2,357 | 1,209 | 7,008 | +| [v0.10.41](https://github.com/laurent22/joplin/releases/tag/v0.10.41) | 2018-01-05T20:38:12Z | 1,038 | 1,549 | 243 | 2,830 | +| [v0.10.40](https://github.com/laurent22/joplin/releases/tag/v0.10.40) | 2018-01-02T23:16:57Z | 1,596 | 1,790 | 339 | 3,725 | +| [v0.10.39](https://github.com/laurent22/joplin/releases/tag/v0.10.39) | 2017-12-11T21:19:44Z | 5,824 | 4,294 | 3,195 | 13,313 | +| [v0.10.38](https://github.com/laurent22/joplin/releases/tag/v0.10.38) | 2017-12-08T10:12:06Z | 1,050 | 1,231 | 307 | 2,588 | +| [v0.10.37](https://github.com/laurent22/joplin/releases/tag/v0.10.37) | 2017-12-07T19:38:05Z | 266 | 845 | 82 | 1,193 | +| [v0.10.36](https://github.com/laurent22/joplin/releases/tag/v0.10.36) | 2017-12-05T09:34:40Z | 1,016 | 1,356 | 439 | 2,811 | +| [v0.10.35](https://github.com/laurent22/joplin/releases/tag/v0.10.35) | 2017-12-02T15:56:08Z | 1,578 | 1,548 | 745 | 3,871 | +| [v0.10.34](https://github.com/laurent22/joplin/releases/tag/v0.10.34) | 2017-12-02T14:50:28Z | 91 | 670 | 60 | 821 | +| [v0.10.33](https://github.com/laurent22/joplin/releases/tag/v0.10.33) | 2017-12-02T13:20:39Z | 62 | 659 | 22 | 743 | +| [v0.10.31](https://github.com/laurent22/joplin/releases/tag/v0.10.31) | 2017-12-01T09:56:44Z | 893 | 1,451 | 407 | 2,751 | +| [v0.10.30](https://github.com/laurent22/joplin/releases/tag/v0.10.30) | 2017-11-30T20:28:16Z | 724 | 1,369 | 420 | 2,513 | +| [v0.10.28](https://github.com/laurent22/joplin/releases/tag/v0.10.28) | 2017-11-30T01:07:46Z | 1,342 | 1,701 | 874 | 3,917 | +| [v0.10.26](https://github.com/laurent22/joplin/releases/tag/v0.10.26) | 2017-11-29T16:02:17Z | 188 | 701 | 261 | 1,150 | +| [v0.10.25](https://github.com/laurent22/joplin/releases/tag/v0.10.25) | 2017-11-24T14:27:49Z | 150 | 696 | 6,463 | 7,309 | +| [v0.10.23](https://github.com/laurent22/joplin/releases/tag/v0.10.23) | 2017-11-21T19:38:41Z | 134 | 647 | 28 | 809 | +| [v0.10.22](https://github.com/laurent22/joplin/releases/tag/v0.10.22) | 2017-11-20T21:45:57Z | 86 | 645 | 19 | 750 | +| [v0.10.21](https://github.com/laurent22/joplin/releases/tag/v0.10.21) | 2017-11-18T00:53:15Z | 53 | 638 | 13 | 704 | +| [v0.10.20](https://github.com/laurent22/joplin/releases/tag/v0.10.20) | 2017-11-17T17:18:25Z | 34 | 649 | 22 | 705 | +| [v0.10.19](https://github.com/laurent22/joplin/releases/tag/v0.10.19) | 2017-11-20T18:59:48Z | 19 | 645 | 13 | 677 | \ No newline at end of file