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

Compare commits

..

36 Commits

Author SHA1 Message Date
Laurent Cozic
5ef5888bfb Android release v2.1.2 2021-06-20 19:35:28 +01:00
Laurent Cozic
12aae48ce6 Server: Fixed transaction locking issue when a sub-transaction fails 2021-06-20 19:30:33 +01:00
Laurent Cozic
5bb68ba65d All: Fixed issue when trying to sync an item associated with a share that no longer exists 2021-06-20 19:29:59 +01:00
Laurent Cozic
112157e33f Server: Fixed handling of max item size for encrypted items 2021-06-20 16:29:35 +01:00
Laurent Cozic
d129744138 Merge branch 'dev' of github.com:laurent22/joplin into dev 2021-06-20 14:35:56 +01:00
Laurent Cozic
975951ccbc Doc: Clarified logo and icon license 2021-06-20 14:33:33 +01:00
Denys Golubiev
efb6aaf8cc Linux: Fixes #5105: Use --no-sandbox option for Linux Mint LDME 4 Debbie as well. (#5106) 2021-06-20 14:19:12 +01:00
Laurent Cozic
9323caf2f1 Fixed revision issue for old revisions 2021-06-20 13:59:58 +01:00
Laurent Cozic
d13b1f96ba fixed tests 2021-06-20 13:46:50 +01:00
Laurent Cozic
8e55fe31ee fix tests 2021-06-20 12:30:45 +01:00
Laurent Cozic
50ecdc2ff1 Plugins: Add support for read and writing text, HTML and images from/to clipboard 2021-06-20 12:03:18 +01:00
Laurent Cozic
8a2ca0535d Merge branch 'dev' of github.com:laurent22/joplin into dev 2021-06-20 11:20:51 +01:00
Laurent Cozic
097e49d797 All: Fixes #5051: Fixed error that could prevent a revision from being created, and that would prevent the revision service from processing the rest of the notes 2021-06-20 11:19:59 +01:00
Longhao.Chen
11ffbe7d66 Update zh_CN.po (#5101) 2021-06-20 09:50:43 +01:00
Laurent Cozic
e79f965e5d Android 2.1.1 2021-06-19 18:27:27 +01:00
Laurent Cozic
8cc103a062 Android release v2.1.1 2021-06-19 17:42:08 +01:00
Laurent Cozic
7f05efb789 Merge branch 'dev' of github.com:laurent22/joplin into dev 2021-06-19 17:35:19 +01:00
Laurent Cozic
5b368e39ca Mobile: Allow enabling and disabling feature flags 2021-06-19 17:32:36 +01:00
Laurent Cozic
b88bbc74a2 Desktop release v2.1.3 2021-06-19 16:26:39 +01:00
Laurent Cozic
fc132216cb Merge branch 'dev' of github.com:laurent22/joplin into dev 2021-06-19 16:25:53 +01:00
Laurent Cozic
1dc49c7673 Desktop release v2.1.2 2021-06-19 16:25:19 +01:00
Laurent Cozic
ab9bbcbff2 Plugins: Fixed saving secure settings to the keychain, and added way to store plugin settings to settings.json 2021-06-19 15:56:37 +01:00
Laurent Cozic
b7b12f9369 Server v2.1.3 2021-06-19 15:15:25 +01:00
Laurent Cozic
d8752ab889 Tools: Fixed Docker image tagging for pre-releases 2021-06-19 15:14:19 +01:00
Laurent Cozic
372495afd3 Desktop release v2.1.1 2021-06-19 14:28:27 +01:00
Laurent Cozic
2320934853 Server v2.1.2 2021-06-19 14:25:15 +01:00
Laurent Cozic
326fef486c All: Added feature flags to disable Joplin Server sync optimisations by default, so that it still work with server 2.0 2021-06-19 14:22:53 +01:00
Laurent Cozic
7aff6d271d Cli: Added flag to disable local lock when synchronising 2021-06-19 11:41:36 +01:00
Laurent Cozic
a86a80b21c Fixed column type for Postgres 2021-06-19 11:05:06 +01:00
Laurent Cozic
4dc1210eb5 All: Improved first sync speed when synchronising with Joplin Server 2021-06-19 10:34:44 +01:00
Laurent Cozic
0222c0f0a6 All: Allow uploading items in batch when synchronising with Joplin Server 2021-06-18 17:17:25 +01:00
Laurent Cozic
63f7349c17 Merge branch 'dev' of github.com:laurent22/joplin into dev 2021-06-18 17:15:01 +01:00
Laurent Cozic
3b9c02e92d Server: Add support for uploading multiple items in one request 2021-06-18 11:50:06 +01:00
Helmut K. C. Tessarek
cef5834d73 All: Update Mermaid: 8.8.4 -> 8.10.2 (#5092) 2021-06-18 08:53:16 +01:00
Francisco Mora
b341caf287 All: Translation: Update es_ES.po (#5096) 2021-06-17 22:15:48 -04:00
小骏
fc063fe68e All: Translation: Update zh_CN.po (#5079)
* update zh_CN.po

* update zh_CN.po
2021-06-17 20:36:11 +01:00
99 changed files with 8879 additions and 660 deletions

View File

@@ -701,6 +701,9 @@ packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js.map
packages/app-mobile/components/SelectDateTimeDialog.d.ts
packages/app-mobile/components/SelectDateTimeDialog.js
packages/app-mobile/components/SelectDateTimeDialog.js.map
packages/app-mobile/components/screens/ConfigScreen.d.ts
packages/app-mobile/components/screens/ConfigScreen.js
packages/app-mobile/components/screens/ConfigScreen.js.map
packages/app-mobile/components/screens/Note.d.ts
packages/app-mobile/components/screens/Note.js
packages/app-mobile/components/screens/Note.js.map
@@ -872,6 +875,9 @@ packages/lib/eventManager.js.map
packages/lib/file-api-driver-joplinServer.d.ts
packages/lib/file-api-driver-joplinServer.js
packages/lib/file-api-driver-joplinServer.js.map
packages/lib/file-api-driver-memory.d.ts
packages/lib/file-api-driver-memory.js
packages/lib/file-api-driver-memory.js.map
packages/lib/file-api-driver.test.d.ts
packages/lib/file-api-driver.test.js
packages/lib/file-api-driver.test.js.map
@@ -962,6 +968,9 @@ packages/lib/models/ResourceLocalState.js.map
packages/lib/models/Revision.d.ts
packages/lib/models/Revision.js
packages/lib/models/Revision.js.map
packages/lib/models/Revision.test.d.ts
packages/lib/models/Revision.test.js
packages/lib/models/Revision.test.js.map
packages/lib/models/Search.d.ts
packages/lib/models/Search.js
packages/lib/models/Search.js.map
@@ -1082,6 +1091,9 @@ packages/lib/services/ResourceService.test.js.map
packages/lib/services/RevisionService.d.ts
packages/lib/services/RevisionService.js
packages/lib/services/RevisionService.js.map
packages/lib/services/RevisionService.test.d.ts
packages/lib/services/RevisionService.test.js
packages/lib/services/RevisionService.test.js.map
packages/lib/services/SettingUtils.d.ts
packages/lib/services/SettingUtils.js
packages/lib/services/SettingUtils.js.map
@@ -1226,6 +1238,9 @@ packages/lib/services/plugins/api/Global.js.map
packages/lib/services/plugins/api/Joplin.d.ts
packages/lib/services/plugins/api/Joplin.js
packages/lib/services/plugins/api/Joplin.js.map
packages/lib/services/plugins/api/JoplinClipboard.d.ts
packages/lib/services/plugins/api/JoplinClipboard.js
packages/lib/services/plugins/api/JoplinClipboard.js.map
packages/lib/services/plugins/api/JoplinCommands.d.ts
packages/lib/services/plugins/api/JoplinCommands.js
packages/lib/services/plugins/api/JoplinCommands.js.map
@@ -1391,6 +1406,12 @@ packages/lib/services/spellChecker/SpellCheckerService.js.map
packages/lib/services/spellChecker/SpellCheckerServiceDriverBase.d.ts
packages/lib/services/spellChecker/SpellCheckerServiceDriverBase.js
packages/lib/services/spellChecker/SpellCheckerServiceDriverBase.js.map
packages/lib/services/synchronizer/ItemUploader.d.ts
packages/lib/services/synchronizer/ItemUploader.js
packages/lib/services/synchronizer/ItemUploader.js.map
packages/lib/services/synchronizer/ItemUploader.test.d.ts
packages/lib/services/synchronizer/ItemUploader.test.js
packages/lib/services/synchronizer/ItemUploader.test.js.map
packages/lib/services/synchronizer/LockHandler.d.ts
packages/lib/services/synchronizer/LockHandler.js
packages/lib/services/synchronizer/LockHandler.js.map

21
.gitignore vendored
View File

@@ -687,6 +687,9 @@ packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js.map
packages/app-mobile/components/SelectDateTimeDialog.d.ts
packages/app-mobile/components/SelectDateTimeDialog.js
packages/app-mobile/components/SelectDateTimeDialog.js.map
packages/app-mobile/components/screens/ConfigScreen.d.ts
packages/app-mobile/components/screens/ConfigScreen.js
packages/app-mobile/components/screens/ConfigScreen.js.map
packages/app-mobile/components/screens/Note.d.ts
packages/app-mobile/components/screens/Note.js
packages/app-mobile/components/screens/Note.js.map
@@ -858,6 +861,9 @@ packages/lib/eventManager.js.map
packages/lib/file-api-driver-joplinServer.d.ts
packages/lib/file-api-driver-joplinServer.js
packages/lib/file-api-driver-joplinServer.js.map
packages/lib/file-api-driver-memory.d.ts
packages/lib/file-api-driver-memory.js
packages/lib/file-api-driver-memory.js.map
packages/lib/file-api-driver.test.d.ts
packages/lib/file-api-driver.test.js
packages/lib/file-api-driver.test.js.map
@@ -948,6 +954,9 @@ packages/lib/models/ResourceLocalState.js.map
packages/lib/models/Revision.d.ts
packages/lib/models/Revision.js
packages/lib/models/Revision.js.map
packages/lib/models/Revision.test.d.ts
packages/lib/models/Revision.test.js
packages/lib/models/Revision.test.js.map
packages/lib/models/Search.d.ts
packages/lib/models/Search.js
packages/lib/models/Search.js.map
@@ -1068,6 +1077,9 @@ packages/lib/services/ResourceService.test.js.map
packages/lib/services/RevisionService.d.ts
packages/lib/services/RevisionService.js
packages/lib/services/RevisionService.js.map
packages/lib/services/RevisionService.test.d.ts
packages/lib/services/RevisionService.test.js
packages/lib/services/RevisionService.test.js.map
packages/lib/services/SettingUtils.d.ts
packages/lib/services/SettingUtils.js
packages/lib/services/SettingUtils.js.map
@@ -1212,6 +1224,9 @@ packages/lib/services/plugins/api/Global.js.map
packages/lib/services/plugins/api/Joplin.d.ts
packages/lib/services/plugins/api/Joplin.js
packages/lib/services/plugins/api/Joplin.js.map
packages/lib/services/plugins/api/JoplinClipboard.d.ts
packages/lib/services/plugins/api/JoplinClipboard.js
packages/lib/services/plugins/api/JoplinClipboard.js.map
packages/lib/services/plugins/api/JoplinCommands.d.ts
packages/lib/services/plugins/api/JoplinCommands.js
packages/lib/services/plugins/api/JoplinCommands.js.map
@@ -1377,6 +1392,12 @@ packages/lib/services/spellChecker/SpellCheckerService.js.map
packages/lib/services/spellChecker/SpellCheckerServiceDriverBase.d.ts
packages/lib/services/spellChecker/SpellCheckerServiceDriverBase.js
packages/lib/services/spellChecker/SpellCheckerServiceDriverBase.js.map
packages/lib/services/synchronizer/ItemUploader.d.ts
packages/lib/services/synchronizer/ItemUploader.js
packages/lib/services/synchronizer/ItemUploader.js.map
packages/lib/services/synchronizer/ItemUploader.test.d.ts
packages/lib/services/synchronizer/ItemUploader.test.js
packages/lib/services/synchronizer/ItemUploader.test.js.map
packages/lib/services/synchronizer/LockHandler.d.ts
packages/lib/services/synchronizer/LockHandler.js
packages/lib/services/synchronizer/LockHandler.js.map

View File

@@ -162,11 +162,13 @@ DESKTOP=${DESKTOP,,} # convert to lower case
# Detect distribution environment
DISTVER=$(lsb_release -is) && DISTVER=$DISTVER$(lsb_release -rs)
DISTCODENAME=$(lsb_release -cs)
#-----------------------------------------------------
echo 'Create Desktop icon...'
# Check for "The SUID sandbox helper binary was found, but is not configured correctly" problem.
# It is present in Debian 10 Buster. A (temporary) patch will be applied at .desktop file
if [ "$DISTVER" = "Debian10" ]
# Linux Mint 4 Debbie is based on Debian 10 and requires the same param handling.
if [ "$DISTVER" = "Debian10" ] || [ "$DISTVER" = "Linuxmint4" ] && [ "$DISTCODENAME" = "debbie" ]
then
SANDBOXPARAM=" --no-sandbox"
else

41
LICENSE
View File

@@ -2,32 +2,39 @@ All code in this repository is licensed under the MIT License **unless a
directory contains a LICENSE file**, in which case that LICENSE file applies to
the code in that sub-directory.
For example, packages/fork-sax contains a ISC LICENSE file, thus all files
under the packages/fork-sax directory are licensed under ISC.
For example, packages/fork-sax contains a ISC LICENSE file, thus all code under
the packages/fork-sax directory is licensed under ISC.
For example, packages/app-cli does NOT contain a LICENSE file, thus all files
under that directory are licensed under the default license, which is MIT.
For example, packages/app-cli does NOT contain a LICENSE file, thus all code
under that directory is licensed under the default license, which is MIT.
* * *
Logo and Icon License
The Joplin logos and icons are copyright (c) Laurent Cozic, all rights reserved,
and may not be used without a permission. If you would like to use the logos or
icons please contact the author in order to get a permission.
* * *
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:
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.
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.

View File

@@ -33,6 +33,7 @@ class Command extends BaseCommand {
return [
['--target <target>', _('Sync to provided target (defaults to sync.target config value)')],
['--upgrade', _('Upgrade the sync target to the latest version.')],
['--use-lock <value>', 'Disable local locks that prevent multiple clients from synchronizing at the same time (Default = 1)'],
];
}
@@ -124,17 +125,21 @@ class Command extends BaseCommand {
const lockFilePath = `${require('os').tmpdir()}/synclock_${md5(escape(Setting.value('profileDir')))}`; // https://github.com/pvorb/node-md5/issues/41
if (!(await fs.pathExists(lockFilePath))) await fs.writeFile(lockFilePath, 'synclock');
try {
if (await Command.isLocked(lockFilePath)) throw new Error(_('Synchronisation is already in progress.'));
const useLock = args.options.useLock !== 0;
this.releaseLockFn_ = await Command.lockFile(lockFilePath);
} catch (error) {
if (error.code == 'ELOCKED') {
const msg = _('Lock file is already being hold. If you know that no synchronisation is taking place, you may delete the lock file at "%s" and resume the operation.', error.file);
this.stdout(msg);
return;
if (useLock) {
try {
if (await Command.isLocked(lockFilePath)) throw new Error(_('Synchronisation is already in progress.'));
this.releaseLockFn_ = await Command.lockFile(lockFilePath);
} catch (error) {
if (error.code == 'ELOCKED') {
const msg = _('Lock file is already being hold. If you know that no synchronisation is taking place, you may delete the lock file at "%s" and resume the operation.', error.file);
this.stdout(msg);
return;
}
throw error;
}
throw error;
}
const cleanUp = () => {

View File

@@ -0,0 +1,742 @@
# Reserved Strings
#
# Strings which may be used elsewhere in code
undefined
undef
null
NULL
(null)
nil
NIL
true
false
True
False
TRUE
FALSE
None
hasOwnProperty
then
constructor
\
\\
# Numeric Strings
#
# Strings which can be interpreted as numeric
0
1
1.00
$1.00
1/2
1E2
1E02
1E+02
-1
-1.00
-$1.00
-1/2
-1E2
-1E02
-1E+02
1/0
0/0
-2147483648/-1
-9223372036854775808/-1
-0
-0.0
+0
+0.0
0.00
0..0
.
0.0.0
0,00
0,,0
,
0,0,0
0.0/0
1.0/0.0
0.0/0.0
1,0/0,0
0,0/0,0
--1
-
-.
-,
999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999
NaN
Infinity
-Infinity
INF
1#INF
-1#IND
1#QNAN
1#SNAN
1#IND
0x0
0xffffffff
0xffffffffffffffff
0xabad1dea
123456789012345678901234567890123456789
1,000.00
1 000.00
1'000.00
1,000,000.00
1 000 000.00
1'000'000.00
1.000,00
1 000,00
1'000,00
1.000.000,00
1 000 000,00
1'000'000,00
01000
08
09
2.2250738585072011e-308
# Special Characters
#
# ASCII punctuation. All of these characters may need to be escaped in some
# contexts. Divided into three groups based on (US-layout) keyboard position.
,./;'[]\-=
<>?:"{}|_+
!@#$%^&*()`~
# Non-whitespace C0 controls: U+0001 through U+0008, U+000E through U+001F,
# and U+007F (DEL)
# Often forbidden to appear in various text-based file formats (e.g. XML),
# or reused for internal delimiters on the theory that they should never
# appear in input.
# The next line may appear to be blank or mojibake in some viewers.

# Non-whitespace C1 controls: U+0080 through U+0084 and U+0086 through U+009F.
# Commonly misinterpreted as additional graphic characters.
# The next line may appear to be blank, mojibake, or dingbats in some viewers.
€‚ƒ„†‡ˆ‰Š‹ŒŽ‘’“”•–—˜™š›œžŸ
# Whitespace: all of the characters with category Zs, Zl, or Zp (in Unicode
# version 8.0.0), plus U+0009 (HT), U+000B (VT), U+000C (FF), U+0085 (NEL),
# and U+200B (ZERO WIDTH SPACE), which are in the C categories but are often
# treated as whitespace in some contexts.
# This file unfortunately cannot express strings containing
# U+0000, U+000A, or U+000D (NUL, LF, CR).
# The next line may appear to be blank or mojibake in some viewers.
# The next line may be flagged for "trailing whitespace" in some viewers.
…             ​

   
# Unicode additional control characters: all of the characters with
# general category Cf (in Unicode 8.0.0).
# The next line may appear to be blank or mojibake in some viewers.
­؀؁؂؃؄؅؜۝܏᠎​‌‍‎‏‪‫‬‭‮⁠⁡⁢⁣⁤⁦⁧⁨⁩𑂽𛲠𛲡𛲢𛲣𝅳𝅴𝅵𝅶𝅷𝅸𝅹𝅺󠀁󠀠󠀡󠀢󠀣󠀤󠀥󠀦󠀧󠀨󠀩󠀪󠀫󠀬󠀭󠀮󠀯󠀰󠀱󠀲󠀳󠀴󠀵󠀶󠀷󠀸󠀹󠀺󠀻󠀼󠀽󠀾󠀿󠁀󠁁󠁂󠁃󠁄󠁅󠁆󠁇󠁈󠁉󠁊󠁋󠁌󠁍󠁎󠁏󠁐󠁑󠁒󠁓󠁔󠁕󠁖󠁗󠁘󠁙󠁚󠁛󠁜󠁝󠁞󠁟󠁠󠁡󠁢󠁣󠁤󠁥󠁦󠁧󠁨󠁩󠁪󠁫󠁬󠁭󠁮󠁯󠁰󠁱󠁲󠁳󠁴󠁵󠁶󠁷󠁸󠁹󠁺󠁻󠁼󠁽󠁾󠁿
# "Byte order marks", U+FEFF and U+FFFE, each on its own line.
# The next two lines may appear to be blank or mojibake in some viewers.

# Unicode Symbols
#
# Strings which contain common unicode symbols (e.g. smart quotes)
Ω≈ç√∫˜µ≤≥÷
åß∂ƒ©˙∆˚¬…æ
œ∑´®†¥¨ˆøπ“‘
¡™£¢∞§¶•ªº–≠
¸˛Ç◊ı˜Â¯˘¿
ÅÍÎÏ˝ÓÔÒÚÆ☃
Œ„´‰ˇÁ¨ˆØ∏”’
`⁄€‹›fifl‡°·‚—±
⅛⅜⅝⅞
ЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыьэюя
٠١٢٣٤٥٦٧٨٩
# Unicode Subscript/Superscript/Accents
#
# Strings which contain unicode subscripts/superscripts; can cause rendering issues
⁰⁴⁵
₀₁₂
⁰⁴⁵₀₁₂
ด้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็ ด้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็ ด้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็
# Quotation Marks
#
# Strings which contain misplaced quotation marks; can cause encoding errors
'
"
''
""
'"'
"''''"'"
"'"'"''''"
<foo val=“bar” />
<foo val=“bar” />
<foo val=”bar“ />
<foo val=`bar' />
# Two-Byte Characters
#
# Strings which contain two-byte characters: can cause rendering issues or character-length issues
田中さんにあげて下さい
パーティーへ行かないか
和製漢語
部落格
사회과학원 어학연구소
찦차를 타고 온 펲시맨과 쑛다리 똠방각하
社會科學院語學研究所
울란바토르
𠜎𠜱𠝹𠱓𠱸𠲖𠳏
# Strings which contain two-byte letters: can cause issues with naïve UTF-16 capitalizers which think that 16 bits == 1 character
𐐜 𐐔𐐇𐐝𐐀𐐡𐐇𐐓 𐐙𐐊𐐡𐐝𐐓/𐐝𐐇𐐗𐐊𐐤𐐔 𐐒𐐋𐐗 𐐒𐐌 𐐜 𐐡𐐀𐐖𐐇𐐤𐐓𐐝 𐐱𐑂 𐑄 𐐔𐐇𐐝𐐀𐐡𐐇𐐓 𐐏𐐆𐐅𐐤𐐆𐐚𐐊𐐡𐐝𐐆𐐓𐐆
# Special Unicode Characters Union
#
# A super string recommended by VMware Inc. Globalization Team: can effectively cause rendering issues or character-length issues to validate product globalization readiness.
#
# 表 CJK_UNIFIED_IDEOGRAPHS (U+8868)
# ポ KATAKANA LETTER PO (U+30DD)
# あ HIRAGANA LETTER A (U+3042)
# A LATIN CAPITAL LETTER A (U+0041)
# 鷗 CJK_UNIFIED_IDEOGRAPHS (U+9DD7)
# Œ LATIN SMALL LIGATURE OE (U+0153)
# é LATIN SMALL LETTER E WITH ACUTE (U+00E9)
# B FULLWIDTH LATIN CAPITAL LETTER B (U+FF22)
# 逍 CJK_UNIFIED_IDEOGRAPHS (U+900D)
# Ü LATIN SMALL LETTER U WITH DIAERESIS (U+00FC)
# ß LATIN SMALL LETTER SHARP S (U+00DF)
# ª FEMININE ORDINAL INDICATOR (U+00AA)
# ą LATIN SMALL LETTER A WITH OGONEK (U+0105)
# ñ LATIN SMALL LETTER N WITH TILDE (U+00F1)
# 丂 CJK_UNIFIED_IDEOGRAPHS (U+4E02)
# 㐀 CJK Ideograph Extension A, First (U+3400)
# 𠀀 CJK Ideograph Extension B, First (U+20000)
表ポあA鷗ŒéB逍Üߪąñ丂㐀𠀀
# Changing length when lowercased
#
# Characters which increase in length (2 to 3 bytes) when lowercased
# Credit: https://twitter.com/jifa/status/625776454479970304
Ⱥ
Ⱦ
# Japanese Emoticons
#
# Strings which consists of Japanese-style emoticons which are popular on the web
ヽ༼ຈل͜ຈ༽ノ ヽ༼ຈل͜ຈ༽ノ
(。◕ ∀ ◕。)
`ィ(´∀`∩
__ロ(,_,*)
・( ̄∀ ̄)・:*:
゚・✿ヾ╲(。◕‿◕。)╱✿・゚
,。・:*:・゜’( ☻ ω ☻ )。・:*:・゜’
(╯°□°)╯︵ ┻━┻)
(ノಥ益ಥ)ノ ┻━┻
┬─┬ノ( º _ ºノ)
( ͡° ͜ʖ ͡°)
¯\_(ツ)_/¯
# Emoji
#
# Strings which contain Emoji; should be the same behavior as two-byte characters, but not always
😍
👩🏽
👨‍🦰 👨🏿‍🦰 👨‍🦱 👨🏿‍🦱 🦹🏿‍♂️
👾 🙇 💁 🙅 🙆 🙋 🙎 🙍
🐵 🙈 🙉 🙊
❤️ 💔 💌 💕 💞 💓 💗 💖 💘 💝 💟 💜 💛 💚 💙
✋🏿 💪🏿 👐🏿 🙌🏿 👏🏿 🙏🏿
👨‍👩‍👦 👨‍👩‍👧‍👦 👨‍👨‍👦 👩‍👩‍👧 👨‍👦 👨‍👧‍👦 👩‍👦 👩‍👧‍👦
🚾 🆒 🆓 🆕 🆖 🆗 🆙 🏧
0️⃣ 1️⃣ 2️⃣ 3️⃣ 4️⃣ 5️⃣ 6️⃣ 7️⃣ 8️⃣ 9️⃣ 🔟
# Regional Indicator Symbols
#
# Regional Indicator Symbols can be displayed differently across
# fonts, and have a number of special behaviors
🇺🇸🇷🇺🇸 🇦🇫🇦🇲🇸
🇺🇸🇷🇺🇸🇦🇫🇦🇲
🇺🇸🇷🇺🇸🇦
# Unicode Numbers
#
# Strings which contain unicode numbers; if the code is localized, it should see the input as numeric
123
١٢٣
# Right-To-Left Strings
#
# Strings which contain text that should be rendered RTL if possible (e.g. Arabic, Hebrew)
ثم نفس سقطت وبالتحديد،, جزيرتي باستخدام أن دنو. إذ هنا؟ الستار وتنصيب كان. أهّل ايطاليا، بريطانيا-فرنسا قد أخذ. سليمان، إتفاقية بين ما, يذكر الحدود أي بعد, معاملة بولندا، الإطلاق عل إيو.
בְּרֵאשִׁית, בָּרָא אֱלֹהִים, אֵת הַשָּׁמַיִם, וְאֵת הָאָרֶץ
הָיְתָהtestالصفحات التّحول
مُنَاقَشَةُ سُبُلِ اِسْتِخْدَامِ اللُّغَةِ فِي النُّظُمِ الْقَائِمَةِ وَفِيم يَخُصَّ التَّطْبِيقَاتُ الْحاسُوبِيَّةُ،
الكل في المجمو عة (5)
# Ogham Text
#
# The only unicode alphabet to use a space which isn't empty but should still act like a space.
᚛ᚄᚓᚐᚋᚒᚄ ᚑᚄᚂᚑᚏᚅ᚜
᚛                 ᚜
# Trick Unicode
#
# Strings which contain unicode with unusual properties (e.g. Right-to-left override) (c.f. http://www.unicode.org/charts/PDF/U2000.pdf)
‪‪test‪
‫test‫

test

test⁠test‫
⁦test⁧
# Zalgo Text
#
# Strings which contain "corrupted" text. The corruption will not appear in non-HTML text, however. (via http://www.eeemo.net)
Ṱ̺̺̕o͞ ̷i̲̬͇̪͙n̝̗͕v̟̜̘̦͟o̶̙̰̠kè͚̮̺̪̹̱̤ ̖t̝͕̳̣̻̪͞h̼͓̲̦̳̘̲e͇̣̰̦̬͎ ̢̼̻̱̘h͚͎͙̜̣̲ͅi̦̲̣̰̤v̻͍e̺̭̳̪̰-m̢iͅn̖̺̞̲̯̰d̵̼̟͙̩̼̘̳ ̞̥̱̳̭r̛̗̘e͙p͠r̼̞̻̭̗e̺̠̣͟s̘͇̳͍̝͉e͉̥̯̞̲͚̬͜ǹ̬͎͎̟̖͇̤t͍̬̤͓̼̭͘ͅi̪̱n͠g̴͉ ͏͉ͅc̬̟h͡a̫̻̯͘o̫̟̖͍̙̝͉s̗̦̲.̨̹͈̣
̡͓̞ͅI̗̘̦͝n͇͇͙v̮̫ok̲̫̙͈i̖͙̭̹̠̞n̡̻̮̣̺g̲͈͙̭͙̬͎ ̰t͔̦h̞̲e̢̤ ͍̬̲͖f̴̘͕̣è͖ẹ̥̩l͖͔͚i͓͚̦͠n͖͍̗͓̳̮g͍ ̨o͚̪͡f̘̣̬ ̖̘͖̟͙̮c҉͔̫͖͓͇͖ͅh̵̤̣͚͔á̗̼͕ͅo̼̣̥s̱͈̺̖̦̻͢.̛̖̞̠̫̰
̗̺͖̹̯͓Ṯ̤͍̥͇͈h̲́e͏͓̼̗̙̼̣͔ ͇̜̱̠͓͍ͅN͕͠e̗̱z̘̝̜̺͙p̤̺̹͍̯͚e̠̻̠͜r̨̤͍̺̖͔̖̖d̠̟̭̬̝͟i̦͖̩͓͔̤a̠̗̬͉̙n͚͜ ̻̞̰͚ͅh̵͉i̳̞v̢͇ḙ͎͟-҉̭̩̼͔m̤̭̫i͕͇̝̦n̗͙ḍ̟ ̯̲͕͞ǫ̟̯̰̲͙̻̝f ̪̰̰̗̖̭̘͘c̦͍̲̞͍̩̙ḥ͚a̮͎̟̙͜ơ̩̹͎s̤.̝̝ ҉Z̡̖̜͖̰̣͉̜a͖̰͙̬͡l̲̫̳͍̩g̡̟̼̱͚̞̬ͅo̗͜.̟
̦H̬̤̗̤͝e͜ ̜̥̝̻͍̟́w̕h̖̯͓o̝͙̖͎̱̮ ҉̺̙̞̟͈W̷̼̭a̺̪͍į͈͕̭͙̯̜t̶̼̮s̘͙͖̕ ̠̫̠B̻͍͙͉̳ͅe̵h̵̬͇̫͙i̹͓̳̳̮͎̫̕n͟d̴̪̜̖ ̰͉̩͇͙̲͞ͅT͖̼͓̪͢h͏͓̮̻e̬̝̟ͅ ̤̹̝W͙̞̝͔͇͝ͅa͏͓͔̹̼̣l̴͔̰̤̟͔ḽ̫.͕
Z̮̞̠͙͔ͅḀ̗̞͈̻̗Ḷ͙͎̯̹̞͓G̻O̭̗̮
# Unicode Upsidedown
#
# Strings which contain unicode with an "upsidedown" effect (via http://www.upsidedowntext.com)
˙ɐnbᴉlɐ ɐuƃɐɯ ǝɹolop ʇǝ ǝɹoqɐl ʇn ʇunpᴉpᴉɔuᴉ ɹodɯǝʇ poɯsnᴉǝ op pǝs 'ʇᴉlǝ ƃuᴉɔsᴉdᴉpɐ ɹnʇǝʇɔǝsuoɔ 'ʇǝɯɐ ʇᴉs ɹolop ɯnsdᴉ ɯǝɹo˥
00˙Ɩ$-
# Unicode font
#
# Strings which contain bold/italic/etc. versions of normal characters
The quick brown fox jumps over the lazy dog
𝐓𝐡𝐞 𝐪𝐮𝐢𝐜𝐤 𝐛𝐫𝐨𝐰𝐧 𝐟𝐨𝐱 𝐣𝐮𝐦𝐩𝐬 𝐨𝐯𝐞𝐫 𝐭𝐡𝐞 𝐥𝐚𝐳𝐲 𝐝𝐨𝐠
𝕿𝖍𝖊 𝖖𝖚𝖎𝖈𝖐 𝖇𝖗𝖔𝖜𝖓 𝖋𝖔𝖝 𝖏𝖚𝖒𝖕𝖘 𝖔𝖛𝖊𝖗 𝖙𝖍𝖊 𝖑𝖆𝖟𝖞 𝖉𝖔𝖌
𝑻𝒉𝒆 𝒒𝒖𝒊𝒄𝒌 𝒃𝒓𝒐𝒘𝒏 𝒇𝒐𝒙 𝒋𝒖𝒎𝒑𝒔 𝒐𝒗𝒆𝒓 𝒕𝒉𝒆 𝒍𝒂𝒛𝒚 𝒅𝒐𝒈
𝓣𝓱𝓮 𝓺𝓾𝓲𝓬𝓴 𝓫𝓻𝓸𝔀𝓷 𝓯𝓸𝔁 𝓳𝓾𝓶𝓹𝓼 𝓸𝓿𝓮𝓻 𝓽𝓱𝓮 𝓵𝓪𝔃𝔂 𝓭𝓸𝓰
𝕋𝕙𝕖 𝕢𝕦𝕚𝕔𝕜 𝕓𝕣𝕠𝕨𝕟 𝕗𝕠𝕩 𝕛𝕦𝕞𝕡𝕤 𝕠𝕧𝕖𝕣 𝕥𝕙𝕖 𝕝𝕒𝕫𝕪 𝕕𝕠𝕘
𝚃𝚑𝚎 𝚚𝚞𝚒𝚌𝚔 𝚋𝚛𝚘𝚠𝚗 𝚏𝚘𝚡 𝚓𝚞𝚖𝚙𝚜 𝚘𝚟𝚎𝚛 𝚝𝚑𝚎 𝚕𝚊𝚣𝚢 𝚍𝚘𝚐
⒯⒣⒠ ⒬⒰⒤⒞⒦ ⒝⒭⒪⒲⒩ ⒡⒪⒳ ⒥⒰⒨⒫⒮ ⒪⒱⒠⒭ ⒯⒣⒠ ⒧⒜⒵⒴ ⒟⒪⒢
# Script Injection
#
# Strings which attempt to invoke a benign script injection; shows vulnerability to XSS
<script>alert(0)</script>
&lt;script&gt;alert(&#39;1&#39;);&lt;/script&gt;
<img src=x onerror=alert(2) />
<svg><script>123<1>alert(3)</script>
"><script>alert(4)</script>
'><script>alert(5)</script>
><script>alert(6)</script>
</script><script>alert(7)</script>
< / script >< script >alert(8)< / script >
 onfocus=JaVaSCript:alert(9) autofocus
" onfocus=JaVaSCript:alert(10) autofocus
' onfocus=JaVaSCript:alert(11) autofocus
<script>alert(12)</script>
<sc<script>ript>alert(13)</sc</script>ript>
--><script>alert(14)</script>
";alert(15);t="
';alert(16);t='
JavaSCript:alert(17)
;alert(18);
src=JaVaSCript:prompt(19)
"><script>alert(20);</script x="
'><script>alert(21);</script x='
><script>alert(22);</script x=
" autofocus onkeyup="javascript:alert(23)
' autofocus onkeyup='javascript:alert(24)
<script\x20type="text/javascript">javascript:alert(25);</script>
<script\x3Etype="text/javascript">javascript:alert(26);</script>
<script\x0Dtype="text/javascript">javascript:alert(27);</script>
<script\x09type="text/javascript">javascript:alert(28);</script>
<script\x0Ctype="text/javascript">javascript:alert(29);</script>
<script\x2Ftype="text/javascript">javascript:alert(30);</script>
<script\x0Atype="text/javascript">javascript:alert(31);</script>
'`"><\x3Cscript>javascript:alert(32)</script>
'`"><\x00script>javascript:alert(33)</script>
ABC<div style="x\x3Aexpression(javascript:alert(34)">DEF
ABC<div style="x:expression\x5C(javascript:alert(35)">DEF
ABC<div style="x:expression\x00(javascript:alert(36)">DEF
ABC<div style="x:exp\x00ression(javascript:alert(37)">DEF
ABC<div style="x:exp\x5Cression(javascript:alert(38)">DEF
ABC<div style="x:\x0Aexpression(javascript:alert(39)">DEF
ABC<div style="x:\x09expression(javascript:alert(40)">DEF
ABC<div style="x:\xE3\x80\x80expression(javascript:alert(41)">DEF
ABC<div style="x:\xE2\x80\x84expression(javascript:alert(42)">DEF
ABC<div style="x:\xC2\xA0expression(javascript:alert(43)">DEF
ABC<div style="x:\xE2\x80\x80expression(javascript:alert(44)">DEF
ABC<div style="x:\xE2\x80\x8Aexpression(javascript:alert(45)">DEF
ABC<div style="x:\x0Dexpression(javascript:alert(46)">DEF
ABC<div style="x:\x0Cexpression(javascript:alert(47)">DEF
ABC<div style="x:\xE2\x80\x87expression(javascript:alert(48)">DEF
ABC<div style="x:\xEF\xBB\xBFexpression(javascript:alert(49)">DEF
ABC<div style="x:\x20expression(javascript:alert(50)">DEF
ABC<div style="x:\xE2\x80\x88expression(javascript:alert(51)">DEF
ABC<div style="x:\x00expression(javascript:alert(52)">DEF
ABC<div style="x:\xE2\x80\x8Bexpression(javascript:alert(53)">DEF
ABC<div style="x:\xE2\x80\x86expression(javascript:alert(54)">DEF
ABC<div style="x:\xE2\x80\x85expression(javascript:alert(55)">DEF
ABC<div style="x:\xE2\x80\x82expression(javascript:alert(56)">DEF
ABC<div style="x:\x0Bexpression(javascript:alert(57)">DEF
ABC<div style="x:\xE2\x80\x81expression(javascript:alert(58)">DEF
ABC<div style="x:\xE2\x80\x83expression(javascript:alert(59)">DEF
ABC<div style="x:\xE2\x80\x89expression(javascript:alert(60)">DEF
<a href="\x0Bjavascript:javascript:alert(61)" id="fuzzelement1">test</a>
<a href="\x0Fjavascript:javascript:alert(62)" id="fuzzelement1">test</a>
<a href="\xC2\xA0javascript:javascript:alert(63)" id="fuzzelement1">test</a>
<a href="\x05javascript:javascript:alert(64)" id="fuzzelement1">test</a>
<a href="\xE1\xA0\x8Ejavascript:javascript:alert(65)" id="fuzzelement1">test</a>
<a href="\x18javascript:javascript:alert(66)" id="fuzzelement1">test</a>
<a href="\x11javascript:javascript:alert(67)" id="fuzzelement1">test</a>
<a href="\xE2\x80\x88javascript:javascript:alert(68)" id="fuzzelement1">test</a>
<a href="\xE2\x80\x89javascript:javascript:alert(69)" id="fuzzelement1">test</a>
<a href="\xE2\x80\x80javascript:javascript:alert(70)" id="fuzzelement1">test</a>
<a href="\x17javascript:javascript:alert(71)" id="fuzzelement1">test</a>
<a href="\x03javascript:javascript:alert(72)" id="fuzzelement1">test</a>
<a href="\x0Ejavascript:javascript:alert(73)" id="fuzzelement1">test</a>
<a href="\x1Ajavascript:javascript:alert(74)" id="fuzzelement1">test</a>
<a href="\x00javascript:javascript:alert(75)" id="fuzzelement1">test</a>
<a href="\x10javascript:javascript:alert(76)" id="fuzzelement1">test</a>
<a href="\xE2\x80\x82javascript:javascript:alert(77)" id="fuzzelement1">test</a>
<a href="\x20javascript:javascript:alert(78)" id="fuzzelement1">test</a>
<a href="\x13javascript:javascript:alert(79)" id="fuzzelement1">test</a>
<a href="\x09javascript:javascript:alert(80)" id="fuzzelement1">test</a>
<a href="\xE2\x80\x8Ajavascript:javascript:alert(81)" id="fuzzelement1">test</a>
<a href="\x14javascript:javascript:alert(82)" id="fuzzelement1">test</a>
<a href="\x19javascript:javascript:alert(83)" id="fuzzelement1">test</a>
<a href="\xE2\x80\xAFjavascript:javascript:alert(84)" id="fuzzelement1">test</a>
<a href="\x1Fjavascript:javascript:alert(85)" id="fuzzelement1">test</a>
<a href="\xE2\x80\x81javascript:javascript:alert(86)" id="fuzzelement1">test</a>
<a href="\x1Djavascript:javascript:alert(87)" id="fuzzelement1">test</a>
<a href="\xE2\x80\x87javascript:javascript:alert(88)" id="fuzzelement1">test</a>
<a href="\x07javascript:javascript:alert(89)" id="fuzzelement1">test</a>
<a href="\xE1\x9A\x80javascript:javascript:alert(90)" id="fuzzelement1">test</a>
<a href="\xE2\x80\x83javascript:javascript:alert(91)" id="fuzzelement1">test</a>
<a href="\x04javascript:javascript:alert(92)" id="fuzzelement1">test</a>
<a href="\x01javascript:javascript:alert(93)" id="fuzzelement1">test</a>
<a href="\x08javascript:javascript:alert(94)" id="fuzzelement1">test</a>
<a href="\xE2\x80\x84javascript:javascript:alert(95)" id="fuzzelement1">test</a>
<a href="\xE2\x80\x86javascript:javascript:alert(96)" id="fuzzelement1">test</a>
<a href="\xE3\x80\x80javascript:javascript:alert(97)" id="fuzzelement1">test</a>
<a href="\x12javascript:javascript:alert(98)" id="fuzzelement1">test</a>
<a href="\x0Djavascript:javascript:alert(99)" id="fuzzelement1">test</a>
<a href="\x0Ajavascript:javascript:alert(100)" id="fuzzelement1">test</a>
<a href="\x0Cjavascript:javascript:alert(101)" id="fuzzelement1">test</a>
<a href="\x15javascript:javascript:alert(102)" id="fuzzelement1">test</a>
<a href="\xE2\x80\xA8javascript:javascript:alert(103)" id="fuzzelement1">test</a>
<a href="\x16javascript:javascript:alert(104)" id="fuzzelement1">test</a>
<a href="\x02javascript:javascript:alert(105)" id="fuzzelement1">test</a>
<a href="\x1Bjavascript:javascript:alert(106)" id="fuzzelement1">test</a>
<a href="\x06javascript:javascript:alert(107)" id="fuzzelement1">test</a>
<a href="\xE2\x80\xA9javascript:javascript:alert(108)" id="fuzzelement1">test</a>
<a href="\xE2\x80\x85javascript:javascript:alert(109)" id="fuzzelement1">test</a>
<a href="\x1Ejavascript:javascript:alert(110)" id="fuzzelement1">test</a>
<a href="\xE2\x81\x9Fjavascript:javascript:alert(111)" id="fuzzelement1">test</a>
<a href="\x1Cjavascript:javascript:alert(112)" id="fuzzelement1">test</a>
<a href="javascript\x00:javascript:alert(113)" id="fuzzelement1">test</a>
<a href="javascript\x3A:javascript:alert(114)" id="fuzzelement1">test</a>
<a href="javascript\x09:javascript:alert(115)" id="fuzzelement1">test</a>
<a href="javascript\x0D:javascript:alert(116)" id="fuzzelement1">test</a>
<a href="javascript\x0A:javascript:alert(117)" id="fuzzelement1">test</a>
`"'><img src=xxx:x \x0Aonerror=javascript:alert(118)>
`"'><img src=xxx:x \x22onerror=javascript:alert(119)>
`"'><img src=xxx:x \x0Bonerror=javascript:alert(120)>
`"'><img src=xxx:x \x0Donerror=javascript:alert(121)>
`"'><img src=xxx:x \x2Fonerror=javascript:alert(122)>
`"'><img src=xxx:x \x09onerror=javascript:alert(123)>
`"'><img src=xxx:x \x0Conerror=javascript:alert(124)>
`"'><img src=xxx:x \x00onerror=javascript:alert(125)>
`"'><img src=xxx:x \x27onerror=javascript:alert(126)>
`"'><img src=xxx:x \x20onerror=javascript:alert(127)>
"`'><script>\x3Bjavascript:alert(128)</script>
"`'><script>\x0Djavascript:alert(129)</script>
"`'><script>\xEF\xBB\xBFjavascript:alert(130)</script>
"`'><script>\xE2\x80\x81javascript:alert(131)</script>
"`'><script>\xE2\x80\x84javascript:alert(132)</script>
"`'><script>\xE3\x80\x80javascript:alert(133)</script>
"`'><script>\x09javascript:alert(134)</script>
"`'><script>\xE2\x80\x89javascript:alert(135)</script>
"`'><script>\xE2\x80\x85javascript:alert(136)</script>
"`'><script>\xE2\x80\x88javascript:alert(137)</script>
"`'><script>\x00javascript:alert(138)</script>
"`'><script>\xE2\x80\xA8javascript:alert(139)</script>
"`'><script>\xE2\x80\x8Ajavascript:alert(140)</script>
"`'><script>\xE1\x9A\x80javascript:alert(141)</script>
"`'><script>\x0Cjavascript:alert(142)</script>
"`'><script>\x2Bjavascript:alert(143)</script>
"`'><script>\xF0\x90\x96\x9Ajavascript:alert(144)</script>
"`'><script>-javascript:alert(145)</script>
"`'><script>\x0Ajavascript:alert(146)</script>
"`'><script>\xE2\x80\xAFjavascript:alert(147)</script>
"`'><script>\x7Ejavascript:alert(148)</script>
"`'><script>\xE2\x80\x87javascript:alert(149)</script>
"`'><script>\xE2\x81\x9Fjavascript:alert(150)</script>
"`'><script>\xE2\x80\xA9javascript:alert(151)</script>
"`'><script>\xC2\x85javascript:alert(152)</script>
"`'><script>\xEF\xBF\xAEjavascript:alert(153)</script>
"`'><script>\xE2\x80\x83javascript:alert(154)</script>
"`'><script>\xE2\x80\x8Bjavascript:alert(155)</script>
"`'><script>\xEF\xBF\xBEjavascript:alert(156)</script>
"`'><script>\xE2\x80\x80javascript:alert(157)</script>
"`'><script>\x21javascript:alert(158)</script>
"`'><script>\xE2\x80\x82javascript:alert(159)</script>
"`'><script>\xE2\x80\x86javascript:alert(160)</script>
"`'><script>\xE1\xA0\x8Ejavascript:alert(161)</script>
"`'><script>\x0Bjavascript:alert(162)</script>
"`'><script>\x20javascript:alert(163)</script>
"`'><script>\xC2\xA0javascript:alert(164)</script>
<img \x00src=x onerror="alert(165)">
<img \x47src=x onerror="javascript:alert(166)">
<img \x11src=x onerror="javascript:alert(167)">
<img \x12src=x onerror="javascript:alert(168)">
<img\x47src=x onerror="javascript:alert(169)">
<img\x10src=x onerror="javascript:alert(170)">
<img\x13src=x onerror="javascript:alert(171)">
<img\x32src=x onerror="javascript:alert(172)">
<img\x47src=x onerror="javascript:alert(173)">
<img\x11src=x onerror="javascript:alert(174)">
<img \x47src=x onerror="javascript:alert(175)">
<img \x34src=x onerror="javascript:alert(176)">
<img \x39src=x onerror="javascript:alert(177)">
<img \x00src=x onerror="javascript:alert(178)">
<img src\x09=x onerror="javascript:alert(179)">
<img src\x10=x onerror="javascript:alert(180)">
<img src\x13=x onerror="javascript:alert(181)">
<img src\x32=x onerror="javascript:alert(182)">
<img src\x12=x onerror="javascript:alert(183)">
<img src\x11=x onerror="javascript:alert(184)">
<img src\x00=x onerror="javascript:alert(185)">
<img src\x47=x onerror="javascript:alert(186)">
<img src=x\x09onerror="javascript:alert(187)">
<img src=x\x10onerror="javascript:alert(188)">
<img src=x\x11onerror="javascript:alert(189)">
<img src=x\x12onerror="javascript:alert(190)">
<img src=x\x13onerror="javascript:alert(191)">
<img[a][b][c]src[d]=x[e]onerror=[f]"alert(192)">
<img src=x onerror=\x09"javascript:alert(193)">
<img src=x onerror=\x10"javascript:alert(194)">
<img src=x onerror=\x11"javascript:alert(195)">
<img src=x onerror=\x12"javascript:alert(196)">
<img src=x onerror=\x32"javascript:alert(197)">
<img src=x onerror=\x00"javascript:alert(198)">
<a href=java&#1&#2&#3&#4&#5&#6&#7&#8&#11&#12script:javascript:alert(199)>XXX</a>
<img src="x` `<script>javascript:alert(200)</script>"` `>
<img src onerror /" '"= alt=javascript:alert(201)//">
<title onpropertychange=javascript:alert(202)></title><title title=>
<a href=http://foo.bar/#x=`y></a><img alt="`><img src=x:x onerror=javascript:alert(203)></a>">
<!--[if]><script>javascript:alert(204)</script -->
<!--[if<img src=x onerror=javascript:alert(205)//]> -->
<script src="/\%(jscript)s"></script>
<script src="\\%(jscript)s"></script>
<IMG """><SCRIPT>alert("206")</SCRIPT>">
<IMG SRC=javascript:alert(String.fromCharCode(50,48,55))>
<IMG SRC=# onmouseover="alert('208')">
<IMG SRC= onmouseover="alert('209')">
<IMG onmouseover="alert('210')">
<IMG SRC=&#106;&#97;&#118;&#97;&#115;&#99;&#114;&#105;&#112;&#116;&#58;&#97;&#108;&#101;&#114;&#116;&#40;&#39;&#50;&#49;&#49;&#39;&#41;>
<IMG SRC=&#0000106&#0000097&#0000118&#0000097&#0000115&#0000099&#0000114&#0000105&#0000112&#0000116&#0000058&#0000097&#0000108&#0000101&#0000114&#0000116&#0000040&#0000039&#0000050&#0000049&#0000050&#0000039&#0000041>
<IMG SRC=&#x6A&#x61&#x76&#x61&#x73&#x63&#x72&#x69&#x70&#x74&#x3A&#x61&#x6C&#x65&#x72&#x74&#x28&#x27&#x32&#x31&#x33&#x27&#x29>
<IMG SRC="jav   ascript:alert('214');">
<IMG SRC="jav&#x09;ascript:alert('215');">
<IMG SRC="jav&#x0A;ascript:alert('216');">
<IMG SRC="jav&#x0D;ascript:alert('217');">
perl -e 'print "<IMG SRC=java\0script:alert(\"218\")>";' > out
<IMG SRC=" &#14;  javascript:alert('219');">
<SCRIPT/XSS SRC="http://ha.ckers.org/xss.js"></SCRIPT>
<BODY onload!#$%&()*~+-_.,:;?@[/|\]^`=alert("220")>
<SCRIPT/SRC="http://ha.ckers.org/xss.js"></SCRIPT>
<<SCRIPT>alert("221");//<</SCRIPT>
<SCRIPT SRC=http://ha.ckers.org/xss.js?< B >
<SCRIPT SRC=//ha.ckers.org/.j>
<IMG SRC="javascript:alert('222')"
<iframe src=http://ha.ckers.org/scriptlet.html <
\";alert('223');//
<u oncopy=alert()> Copy me</u>
<i onwheel=alert(224)> Scroll over me </i>
<plaintext>
http://a/%%30%30
</textarea><script>alert(225)</script>
# SQL Injection
#
# Strings which can cause a SQL injection if inputs are not sanitized
1;DROP TABLE users
1'; DROP TABLE users-- 1
' OR 1=1 -- 1
' OR '1'='1
'; EXEC sp_MSForEachTable 'DROP TABLE ?'; --
%
_
# Server Code Injection
#
# Strings which can cause user to run code on server as a privileged user (c.f. https://news.ycombinator.com/item?id=7665153)
-
--
--version
--help
$USER
/dev/null; touch /tmp/blns.fail ; echo
`touch /tmp/blns.fail`
$(touch /tmp/blns.fail)
@{[system "touch /tmp/blns.fail"]}
# Command Injection (Ruby)
#
# Strings which can call system commands within Ruby/Rails applications
eval("puts 'hello world'")
System("ls -al /")
`ls -al /`
Kernel.exec("ls -al /")
Kernel.exit(1)
%x('ls -al /')
# XXE Injection (XML)
#
# String which can reveal system files when parsed by a badly configured XML parser
<?xml version="1.0" encoding="ISO-8859-1"?><!DOCTYPE foo [ <!ELEMENT foo ANY ><!ENTITY xxe SYSTEM "file:///etc/passwd" >]><foo>&xxe;</foo>
# Unwanted Interpolation
#
# Strings which can be accidentally expanded into different strings if evaluated in the wrong context, e.g. used as a printf format string or via Perl or shell eval. Might expose sensitive data from the program doing the interpolation, or might just represent the wrong string.
$HOME
$ENV{'HOME'}
%d
%s%s%s%s%s
{0}
%*.*s
%@
%n
File:///
# File Inclusion
#
# Strings which can cause user to pull in files that should not be a part of a web server
../../../../../../../../../../../etc/passwd%00
../../../../../../../../../../../etc/hosts
# Known CVEs and Vulnerabilities
#
# Strings that test for known vulnerabilities
() { 0; }; touch /tmp/blns.shellshock1.fail;
() { _; } >_[$($())] { touch /tmp/blns.shellshock2.fail; }
<<< %s(un='%s') = %u
+++ATH0
# MSDOS/Windows Special Filenames
#
# Strings which are reserved characters in MSDOS/Windows
CON
PRN
AUX
CLOCK$
NUL
A:
ZZ:
COM1
LPT1
LPT2
LPT3
COM2
COM3
COM4
# IRC specific strings
#
# Strings that may occur on IRC clients that make security products freak out
DCC SEND STARTKEYLOGGER 0 0 0
# Scunthorpe Problem
#
# Innocuous strings which may be blocked by profanity filters (https://en.wikipedia.org/wiki/Scunthorpe_problem)
Scunthorpe General Hospital
Penistone Community Church
Lightwater Country Park
Jimmy Clitheroe
Horniman Museum
shitake mushrooms
RomansInSussex.co.uk
http://www.cum.qc.ca/
Craig Cockburn, Software Specialist
Linda Callahan
Dr. Herman I. Libshitz
magna cum laude
Super Bowl XXX
medieval erection of parapets
evaluate
mocha
expression
Arsenal canal
classic
Tyson Gay
Dick Van Dyke
basement
# Human injection
#
# Strings which may cause human to reinterpret worldview
If you're reading this, you've been in a coma for almost 20 years now. We're trying a new technique. We don't know where this message will end up in your dream, but we hope it works. Please wake up, we miss you.
# Terminal escape codes
#
# Strings which punish the fools who use cat/type on this file
Roses are red, violets are blue. Hope you enjoy terminal hue
But now...for my greatest trick...
The quick brown fox... [Beeeep]
# iOS Vulnerabilities
#
# Strings which crashed iMessage in various versions of iOS
Powerلُلُصّبُلُلصّبُررً ॣ ॣh ॣ ॣ冗
🏳0🌈️
జ్ఞ‌ా
# Persian special characters
#
# This is a four characters string which includes Persian special characters (گچپژ)
گچپژ
# jinja2 injection
#
# first one is supposed to raise "MemoryError" exception
# second, obviously, prints contents of /etc/passwd
{% print 'x' * 64 * 1024**3 %}
{{ "".__class__.__mro__[2].__subclasses__()[40]("/etc/passwd").read() }}

View File

@@ -0,0 +1,3 @@
dist/
node_modules/
publish/

View File

@@ -0,0 +1,8 @@
*.md
!README.md
/*.jpl
/api
/src
/dist
tsconfig.json
webpack.config.js

View File

@@ -0,0 +1,72 @@
# generator-joplin
Scaffolds out a new Joplin plugin
## Installation
First, install [Yeoman](http://yeoman.io) and generator-joplin using [npm](https://www.npmjs.com/) (we assume you have pre-installed [node.js](https://nodejs.org/)).
```bash
npm install -g yo
npm install -g generator-joplin
```
Then generate your new project:
```bash
yo joplin
```
## Development
To test the generator for development purposes, follow the instructions there: https://yeoman.io/authoring/#running-the-generator
This is a template to create a new Joplin plugin.
## Structure
The main two files you will want to look at are:
- `/src/index.ts`, which contains the entry point for the plugin source code.
- `/src/manifest.json`, which is the plugin manifest. It contains information such as the plugin a name, version, etc.
The file `/plugin.config.json` could also be useful if you intend to use [external scripts](#external-script-files), such as content scripts or webview scripts.
## Building the plugin
The plugin is built using Webpack, which creates the compiled code in `/dist`. A JPL archive will also be created at the root, which can use to distribute the plugin.
To build the plugin, simply run `npm run dist`.
The project is setup to use TypeScript, although you can change the configuration to use plain JavaScript.
## Publishing the plugin
To publish the plugin, add it to npmjs.com by running `npm publish`. Later on, a script will pick up your plugin and add it automatically to the Joplin plugin repository as long as the package satisfies these conditions:
- In `package.json`, the name starts with "joplin-plugin-". For example, "joplin-plugin-toc".
- In `package.json`, the keywords include "joplin-plugin".
- In the `publish/` directory, there should be a .jpl and .json file (which are built by `npm run dist`)
In general all this is done automatically by the plugin generator, which will set the name and keywords of package.json, and will put the right files in the "publish" directory. But if something doesn't work and your plugin doesn't appear in the repository, double-check the above conditions.
## Updating the plugin framework
To update the plugin framework, run `npm run update`.
In general this command tries to do the right thing - in particular it's going to merge the changes in package.json and .gitignore instead of overwriting. It will also leave "/src" as well as README.md untouched.
The file that may cause problem is "webpack.config.js" because it's going to be overwritten. For that reason, if you want to change it, consider creating a separate JavaScript file and include it in webpack.config.js. That way, when you update, you only have to restore the line that include your file.
## External script files
By default, the compiler (webpack) is going to compile `src/index.ts` only (as well as any file it imports), and any other file will simply be copied to the plugin package. In some cases this is sufficient, however if you have [content scripts](https://joplinapp.org/api/references/plugin_api/classes/joplincontentscripts.html) or [webview scripts](https://joplinapp.org/api/references/plugin_api/classes/joplinviewspanels.html#addscript) you might want to compile them too, in particular in these two cases:
- The script is a TypeScript file - in which case it has to be compiled to JavaScript.
- The script requires modules you've added to package.json. In that case, the script, whether JS or TS, must be compiled so that the dependencies are bundled with the JPL file.
To get such an external script file to compile, you need to add it to the `extraScripts` array in `plugin.config.json`. The path you add should be relative to /src. For example, if you have a file in "/src/webviews/index.ts", the path should be set to "webviews/index.ts". Once compiled, the file will always be named with a .js extension. So you will get "webviews/index.js" in the plugin package, and that's the path you should use to reference the file.
## License
MIT © Laurent Cozic

View File

@@ -0,0 +1,24 @@
# Joplin Plugin
This is a template to create a new Joplin plugin.
The main two files you will want to look at are:
- `/src/index.ts`, which contains the entry point for the plugin source code.
- `/src/manifest.json`, which is the plugin manifest. It contains information such as the plugin a name, version, etc.
## Building the plugin
The plugin is built using Webpack, which creates the compiled code in `/dist`. A JPL archive will also be created at the root, which can use to distribute the plugin.
To build the plugin, simply run `npm run dist`.
The project is setup to use TypeScript, although you can change the configuration to use plain JavaScript.
## Updating the plugin framework
To update the plugin framework, run `npm run update`.
In general this command tries to do the right thing - in particular it's going to merge the changes in package.json and .gitignore instead of overwriting. It will also leave "/src" as well as README.md untouched.
The file that may cause problem is "webpack.config.js" because it's going to be overwritten. For that reason, if you want to change it, consider creating a separate JavaScript file and include it in webpack.config.js. That way, when you update, you only have to restore the line that include your file.

View File

@@ -0,0 +1,14 @@
import Plugin from '../Plugin';
import Joplin from './Joplin';
/**
* @ignore
*/
/**
* @ignore
*/
export default class Global {
private joplin_;
constructor(implementation: any, plugin: Plugin, store: any);
get joplin(): Joplin;
get process(): any;
}

View File

@@ -0,0 +1,65 @@
import Plugin from '../Plugin';
import JoplinData from './JoplinData';
import JoplinPlugins from './JoplinPlugins';
import JoplinWorkspace from './JoplinWorkspace';
import JoplinFilters from './JoplinFilters';
import JoplinCommands from './JoplinCommands';
import JoplinViews from './JoplinViews';
import JoplinInterop from './JoplinInterop';
import JoplinSettings from './JoplinSettings';
import JoplinContentScripts from './JoplinContentScripts';
/**
* This is the main entry point to the Joplin API. You can access various services using the provided accessors.
*
* **This is a beta API**
*
* Please note that the plugin API is relatively new and should be considered Beta state. Besides possible bugs, what it means is that there might be necessary breaking changes from one version to the next. Whenever such change is needed, best effort will be done to:
*
* - Maintain backward compatibility;
* - When possible, deprecate features instead of removing them;
* - Document breaking changes in the changelog;
*
* So if you are developing a plugin, please keep an eye on the changelog as everything will be in there with information about how to update your code. There won't be any major API rewrite or architecture changes, but possibly small tweaks like function signature change, type change, etc.
*
* Eventually, the plugin API will be versioned to make this process smoother.
*/
export default class Joplin {
private data_;
private plugins_;
private workspace_;
private filters_;
private commands_;
private views_;
private interop_;
private settings_;
private contentScripts_;
constructor(implementation: any, plugin: Plugin, store: any);
get data(): JoplinData;
get plugins(): JoplinPlugins;
get workspace(): JoplinWorkspace;
get contentScripts(): JoplinContentScripts;
/**
* @ignore
*
* Not sure if it's the best way to hook into the app
* so for now disable filters.
*/
get filters(): JoplinFilters;
get commands(): JoplinCommands;
get views(): JoplinViews;
get interop(): JoplinInterop;
get settings(): JoplinSettings;
/**
* It is not possible to bundle native packages with a plugin, because they
* need to work cross-platforms. Instead access to certain useful native
* packages is provided using this function.
*
* Currently these packages are available:
*
* - [sqlite3](https://www.npmjs.com/package/sqlite3)
* - [fs-extra](https://www.npmjs.com/package/fs-extra)
*
* [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/nativeModule)
*/
require(_path: string): any;
}

View File

@@ -0,0 +1,89 @@
import { Command } from './types';
/**
* This class allows executing or registering new Joplin commands. Commands
* can be executed or associated with
* {@link JoplinViewsToolbarButtons | toolbar buttons} or
* {@link JoplinViewsMenuItems | menu items}.
*
* [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/register_command)
*
* ## Executing Joplin's internal commands
*
* It is also possible to execute internal Joplin's commands which, as of
* now, are not well documented. You can find the list directly on GitHub
* though at the following locations:
*
* * [Main screen commands](https://github.com/laurent22/joplin/tree/dev/packages/app-desktop/gui/MainScreen/commands)
* * [Global commands](https://github.com/laurent22/joplin/tree/dev/packages/app-desktop/commands)
* * [Editor commands](https://github.com/laurent22/joplin/tree/dev/packages/app-desktop/gui/NoteEditor/commands/editorCommandDeclarations.ts)
*
* 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 {
/**
* <span class="platform-desktop">desktop</span> Executes the given
* command.
*
* The command can take any number of arguments, and the supported
* arguments will vary based on the command. For custom commands, this
* is the `args` passed to the `execute()` function. For built-in
* commands, you can find the supported arguments by checking the links
* above.
*
* ```typescript
* // Create a new note in the current notebook:
* await joplin.commands.execute('newNote');
*
* // Create a new sub-notebook under the provided notebook
* // Note: internally, notebooks are called "folders".
* await joplin.commands.execute('newFolder', "SOME_FOLDER_ID");
* ```
*/
execute(commandName: string, ...args: any[]): Promise<any | void>;
/**
* <span class="platform-desktop">desktop</span> Registers a new command.
*
* ```typescript
* // Register a new commmand called "testCommand1"
*
* await joplin.commands.register({
* name: 'testCommand1',
* label: 'My Test Command 1',
* iconName: 'fas fa-music',
* execute: () => {
* alert('Testing plugin command 1');
* },
* });
* ```
*/
register(command: Command): Promise<void>;
}

View File

@@ -0,0 +1,40 @@
import Plugin from '../Plugin';
import { ContentScriptType } from './types';
export default class JoplinContentScripts {
private plugin;
constructor(plugin: Plugin);
/**
* Registers a new content script. Unlike regular plugin code, which runs in
* a separate process, content scripts run within the main process code and
* thus allow improved performances and more customisations in specific
* cases. It can be used for example to load a Markdown or editor plugin.
*
* Note that registering a content script in itself will do nothing - it
* will only be loaded in specific cases by the relevant app modules (eg.
* the Markdown renderer or the code editor). So it is not a way to inject
* and run arbitrary code in the app, which for safety and performance
* reasons is not supported.
*
* The plugin generator provides a way to build any content script you might
* want to package as well as its dependencies. See the [Plugin Generator
* doc](https://github.com/laurent22/joplin/blob/dev/packages/generator-joplin/README.md)
* for more information.
*
* * [View the renderer demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/content_script)
* * [View the editor demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/codemirror_content_script)
*
* See also the [postMessage demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/post_messages)
*
* @param type Defines how the script will be used. See the type definition for more information about each supported type.
* @param id A unique ID for the content script.
* @param scriptPath Must be a path relative to the plugin main script. For example, if your file content_script.js is next to your index.ts file, you would set `scriptPath` to `"./content_script.js`.
*/
register(type: ContentScriptType, id: string, scriptPath: string): Promise<void>;
/**
* Listens to a messages sent from the content script using postMessage().
* See {@link ContentScriptType} for more information as well as the
* [postMessage
* demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/post_messages)
*/
onMessage(contentScriptId: string, callback: any): Promise<void>;
}

View File

@@ -0,0 +1,47 @@
import { Path } from './types';
/**
* This module provides access to the Joplin data API: https://joplinapp.org/api/references/rest_api/
* This is the main way to retrieve data, such as notes, notebooks, tags, etc.
* or to update them or delete them.
*
* This is also what you would use to search notes, via the `search` endpoint.
*
* [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/simple)
*
* In general you would use the methods in this class as if you were using a REST API. There are four methods that map to GET, POST, PUT and DELETE calls.
* And each method takes these parameters:
*
* * `path`: This is an array that represents the path to the resource in the form `["resouceName", "resourceId", "resourceLink"]` (eg. ["tags", ":id", "notes"]). The "resources" segment is the name of the resources you want to access (eg. "notes", "folders", etc.). If not followed by anything, it will refer to all the resources in that collection. The optional "resourceId" points to a particular resources within the collection. Finally, an optional "link" can be present, which links the resource to a collection of resources. This can be used in the API for example to retrieve all the notes associated with a tag.
* * `query`: (Optional) The query parameters. In a URL, this is the part after the question mark "?". In this case, it should be an object with key/value pairs.
* * `data`: (Optional) Applies to PUT and POST calls only. The request body contains the data you want to create or modify, for example the content of a note or folder.
* * `files`: (Optional) Used to create new resources and associate them with files.
*
* Please refer to the [Joplin API documentation](https://joplinapp.org/api/references/rest_api/) for complete details about each call. As the plugin runs within the Joplin application **you do not need an authorisation token** to use this API.
*
* For example:
*
* ```typescript
* // Get a note ID, title and body
* const noteId = 'some_note_id';
* const note = await joplin.data.get(['notes', noteId], { fields: ['id', 'title', 'body'] });
*
* // Get all folders
* const folders = await joplin.data.get(['folders']);
*
* // Set the note body
* await joplin.data.put(['notes', noteId], null, { body: "New note body" });
*
* // Create a new note under one of the folders
* await joplin.data.post(['notes'], null, { body: "my new note", title: "some title", parent_id: folders[0].id });
* ```
*/
export default class JoplinData {
private api_;
private pathSegmentRegex_;
private serializeApiBody;
private pathToString;
get(path: Path, query?: any): Promise<any>;
post(path: Path, query?: any, body?: any, files?: any[]): Promise<any>;
put(path: Path, query?: any, body?: any, files?: any[]): Promise<any>;
delete(path: Path, query?: any): Promise<any>;
}

View File

@@ -0,0 +1,10 @@
/**
* @ignore
*
* Not sure if it's the best way to hook into the app
* so for now disable filters.
*/
export default class JoplinFilters {
on(name: string, callback: Function): Promise<void>;
off(name: string, callback: Function): Promise<void>;
}

View File

@@ -0,0 +1,17 @@
import { ExportModule, ImportModule } from './types';
/**
* Provides a way to create modules to import external data into Joplin or to export notes into any arbitrary format.
*
* [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/json_export)
*
* To implement an import or export module, you would simply define an object with various event handlers that are called
* by the application during the import/export process.
*
* See the documentation of the [[ExportModule]] and [[ImportModule]] for more information.
*
* You may also want to refer to the Joplin API documentation to see the list of properties for each item (note, notebook, etc.) - https://joplinapp.org/api/references/rest_api/
*/
export default class JoplinInterop {
registerExportModule(module: ExportModule): Promise<void>;
registerImportModule(module: ImportModule): Promise<void>;
}

View File

@@ -0,0 +1,43 @@
import Plugin from '../Plugin';
import { ContentScriptType, Script } from './types';
/**
* This class provides access to plugin-related features.
*/
export default class JoplinPlugins {
private plugin;
constructor(plugin: Plugin);
/**
* Registers a new plugin. This is the entry point when creating a plugin. You should pass a simple object with an `onStart` method to it.
* That `onStart` method will be executed as soon as the plugin is loaded.
*
* ```typescript
* joplin.plugins.register({
* onStart: async function() {
* // Run your plugin code here
* }
* });
* ```
*/
register(script: Script): Promise<void>;
/**
* @deprecated Use joplin.contentScripts.register()
*/
registerContentScript(type: ContentScriptType, id: string, scriptPath: string): Promise<void>;
/**
* Gets the plugin own data directory path. Use this to store any
* plugin-related data. Unlike [[installationDir]], any data stored here
* will be persisted.
*/
dataDir(): Promise<string>;
/**
* Gets the plugin installation directory. This can be used to access any
* asset that was packaged with the plugin. This directory should be
* considered read-only because any data you store here might be deleted or
* re-created at any time. To store new persistent data, use [[dataDir]].
*/
installationDir(): Promise<string>;
/**
* @deprecated Use joplin.require()
*/
require(_path: string): any;
}

View File

@@ -0,0 +1,64 @@
import Plugin from '../Plugin';
import { SettingItem, SettingSection } from './types';
export interface ChangeEvent {
/**
* Setting keys that have been changed
*/
keys: string[];
}
export declare type ChangeHandler = (event: ChangeEvent)=> void;
/**
* This API allows registering new settings and setting sections, as well as getting and setting settings. Once a setting has been registered it will appear in the config screen and be editable by the user.
*
* Settings are essentially key/value pairs.
*
* Note: Currently this API does **not** provide access to Joplin's built-in settings. This is by design as plugins that modify user settings could give unexpected results
*
* [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/settings)
*/
export default class JoplinSettings {
private plugin_;
constructor(plugin: Plugin);
private get keyPrefix();
private namespacedKey;
/**
* Registers new settings.
* Note that registering a setting item is dynamic and will be gone next time Joplin starts.
* What it means is that you need to register the setting every time the plugin starts (for example in the onStart event).
* The setting value however will be preserved from one launch to the next so there is no risk that it will be lost even if for some
* reason the plugin fails to start at some point.
*/
registerSettings(settings: Record<string, SettingItem>): Promise<void>;
/**
* @deprecated Use joplin.settings.registerSettings()
*
* Registers a new setting.
*/
registerSetting(key: string, settingItem: SettingItem): Promise<void>;
/**
* Registers a new setting section. Like for registerSetting, it is dynamic and needs to be done every time the plugin starts.
*/
registerSection(name: string, section: SettingSection): Promise<void>;
/**
* Gets a setting value (only applies to setting you registered from your plugin)
*/
value(key: string): Promise<any>;
/**
* Sets a setting value (only applies to setting you registered from your plugin)
*/
setValue(key: string, value: any): Promise<void>;
/**
* Gets a global setting value, including app-specific settings and those set by other plugins.
*
* The list of available settings is not documented yet, but can be found by looking at the source code:
*
* https://github.com/laurent22/joplin/blob/dev/packages/lib/models/Setting.ts#L142
*/
globalValue(key: string): Promise<any>;
/**
* Called when one or multiple settings of your plugin have been changed.
* - For performance reasons, this event is triggered with a delay.
* - You will only get events for your own plugin settings.
*/
onChange(handler: ChangeHandler): Promise<void>;
}

View File

@@ -0,0 +1,28 @@
import Plugin from '../Plugin';
import JoplinViewsDialogs from './JoplinViewsDialogs';
import JoplinViewsMenuItems from './JoplinViewsMenuItems';
import JoplinViewsMenus from './JoplinViewsMenus';
import JoplinViewsToolbarButtons from './JoplinViewsToolbarButtons';
import JoplinViewsPanels from './JoplinViewsPanels';
/**
* This namespace provides access to view-related services.
*
* All view services provide a `create()` method which you would use to create the view object, whether it's a dialog, a toolbar button or a menu item.
* In some cases, the `create()` method will return a [[ViewHandle]], which you would use to act on the view, for example to set certain properties or call some methods.
*/
export default class JoplinViews {
private store;
private plugin;
private dialogs_;
private panels_;
private menuItems_;
private menus_;
private toolbarButtons_;
private implementation_;
constructor(implementation: any, plugin: Plugin, store: any);
get dialogs(): JoplinViewsDialogs;
get panels(): JoplinViewsPanels;
get menuItems(): JoplinViewsMenuItems;
get menus(): JoplinViewsMenus;
get toolbarButtons(): JoplinViewsToolbarButtons;
}

View File

@@ -0,0 +1,62 @@
import Plugin from '../Plugin';
import { ButtonSpec, ViewHandle, DialogResult } from './types';
/**
* Allows creating and managing dialogs. A dialog is modal window that
* contains a webview and a row of buttons. You can update the update the
* webview using the `setHtml` method. Dialogs are hidden by default and
* you need to call `open()` to open them. Once the user clicks on a
* button, the `open` call will return an object indicating what button was
* clicked on.
*
* ## Retrieving form values
*
* If your HTML content included one or more forms, a `formData` object
* will also be included with the key/value for each form.
*
* ## Special button IDs
*
* The following buttons IDs have a special meaning:
*
* - `ok`, `yes`, `submit`, `confirm`: They are considered "submit" buttons
* - `cancel`, `no`, `reject`: They are considered "dismiss" buttons
*
* This information is used by the application to determine what action
* should be done when the user presses "Enter" or "Escape" within the
* dialog. If they press "Enter", the first "submit" button will be
* automatically clicked. If they press "Escape" the first "dismiss" button
* will be automatically clicked.
*
* [View the demo
* plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/dialog)
*/
export default class JoplinViewsDialogs {
private store;
private plugin;
private implementation_;
constructor(implementation: any, plugin: Plugin, store: any);
private controller;
/**
* Creates a new dialog
*/
create(id: string): Promise<ViewHandle>;
/**
* Displays a message box with OK/Cancel buttons. Returns the button index that was clicked - "0" for OK and "1" for "Cancel"
*/
showMessageBox(message: string): Promise<number>;
/**
* Sets the dialog HTML content
*/
setHtml(handle: ViewHandle, html: string): Promise<string>;
/**
* Adds and loads a new JS or CSS files into the dialog.
*/
addScript(handle: ViewHandle, scriptPath: string): Promise<void>;
/**
* Sets the dialog buttons.
*/
setButtons(handle: ViewHandle, buttons: ButtonSpec[]): Promise<ButtonSpec[]>;
/**
* Opens the dialog
*/
open(handle: ViewHandle): Promise<DialogResult>;
}

View File

@@ -0,0 +1,16 @@
import { CreateMenuItemOptions, MenuItemLocation } from './types';
import Plugin from '../Plugin';
/**
* Allows creating and managing menu items.
*
* [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/register_command)
*/
export default class JoplinViewsMenuItems {
private store;
private plugin;
constructor(plugin: Plugin, store: any);
/**
* Creates a new menu item and associate it with the given command. You can specify under which menu the item should appear using the `location` parameter.
*/
create(id: string, commandName: string, location?: MenuItemLocation, options?: CreateMenuItemOptions): Promise<void>;
}

View File

@@ -0,0 +1,18 @@
import { MenuItem, MenuItemLocation } from './types';
import Plugin from '../Plugin';
/**
* Allows creating menus.
*
* [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/menu)
*/
export default class JoplinViewsMenus {
private store;
private plugin;
constructor(plugin: Plugin, store: any);
private registerCommandAccelerators;
/**
* Creates a new menu from the provided menu items and place it at the given location. As of now, it is only possible to place the
* menu as a sub-menu of the application build-in menus.
*/
create(id: string, label: string, menuItems: MenuItem[], location?: MenuItemLocation): Promise<void>;
}

View File

@@ -0,0 +1,61 @@
import Plugin from '../Plugin';
import { ViewHandle } from './types';
/**
* Allows creating and managing view panels. View panels currently are
* displayed at the right of the sidebar and allows displaying any HTML
* content (within a webview) and update it in real-time. For example it
* could be used to display a table of content for the active note, or
* display various metadata or graph.
*
* [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/toc)
*/
export default class JoplinViewsPanels {
private store;
private plugin;
constructor(plugin: Plugin, store: any);
private controller;
/**
* Creates a new panel
*/
create(id: string): Promise<ViewHandle>;
/**
* Sets the panel webview HTML
*/
setHtml(handle: ViewHandle, html: string): Promise<string>;
/**
* Adds and loads a new JS or CSS files into the panel.
*/
addScript(handle: ViewHandle, scriptPath: string): Promise<void>;
/**
* Called when a message is sent from the webview (using postMessage).
*
* To post a message from the webview to the plugin use:
*
* ```javascript
* const response = await webviewApi.postMessage(message);
* ```
*
* - `message` can be any JavaScript object, string or number
* - `response` is whatever was returned by the `onMessage` handler
*
* Using this mechanism, you can have two-way communication between the
* plugin and webview.
*
* See the [postMessage
* demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/post_messages) for more details.
*
*/
onMessage(handle: ViewHandle, callback: Function): Promise<void>;
/**
* Shows the panel
*/
show(handle: ViewHandle, show?: boolean): Promise<void>;
/**
* Hides the panel
*/
hide(handle: ViewHandle): Promise<void>;
/**
* Tells whether the panel is visible or not
*/
visible(handle: ViewHandle): Promise<boolean>;
}

View File

@@ -0,0 +1,16 @@
import { ToolbarButtonLocation } from './types';
import Plugin from '../Plugin';
/**
* Allows creating and managing toolbar buttons.
*
* [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/register_command)
*/
export default class JoplinViewsToolbarButtons {
private store;
private plugin;
constructor(plugin: Plugin, store: any);
/**
* Creates a new toolbar button and associate it with the given command.
*/
create(id: string, commandName: string, location: ToolbarButtonLocation): Promise<void>;
}

View File

@@ -0,0 +1,69 @@
import { FolderEntity } from '../../database/types';
import { Disposable } from './types';
declare enum ItemChangeEventType {
Create = 1,
Update = 2,
Delete = 3,
}
interface ItemChangeEvent {
id: string;
event: ItemChangeEventType;
}
interface SyncStartEvent {
withErrors: boolean;
}
declare type ItemChangeHandler = (event: ItemChangeEvent)=> void;
declare type SyncStartHandler = (event: SyncStartEvent)=> void;
/**
* The workspace service provides access to all the parts of Joplin that
* are being worked on - i.e. the currently selected notes or notebooks as
* well as various related events, such as when a new note is selected, or
* when the note content changes.
*
* [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins)
*/
export default class JoplinWorkspace {
private store;
constructor(store: any);
/**
* Called when a new note or notes are selected.
*/
onNoteSelectionChange(callback: Function): Promise<Disposable>;
/**
* Called when the content of a note changes.
* @deprecated Use `onNoteChange()` instead, which is reliably triggered whenever the note content, or any note property changes.
*/
onNoteContentChange(callback: Function): Promise<void>;
/**
* Called when the content of the current note changes.
*/
onNoteChange(handler: ItemChangeHandler): Promise<Disposable>;
/**
* Called when an alarm associated with a to-do is triggered.
*/
onNoteAlarmTrigger(handler: Function): Promise<Disposable>;
/**
* Called when the synchronisation process is starting.
*/
onSyncStart(handler: SyncStartHandler): Promise<Disposable>;
/**
* Called when the synchronisation process has finished.
*/
onSyncComplete(callback: Function): Promise<Disposable>;
/**
* Gets the currently selected note
*/
selectedNote(): Promise<any>;
/**
* Gets the currently selected folder. In some cases, for example during
* search or when viewing a tag, no folder is actually selected in the user
* interface. In that case, that function would return the last selected
* folder.
*/
selectedFolder(): Promise<FolderEntity>;
/**
* Gets the IDs of the selected notes (can be zero, one, or many). Use the data API to retrieve information about these notes.
*/
selectedNoteIds(): Promise<string[]>;
}
export {};

View File

@@ -0,0 +1,5 @@
import type Joplin from './Joplin';
declare const joplin: Joplin;
export default joplin;

View File

@@ -0,0 +1,616 @@
// =================================================================
// Command API types
// =================================================================
export interface Command {
/**
* Name of command - must be globally unique
*/
name: string;
/**
* Label to be displayed on menu items or keyboard shortcut editor for example.
* If it is missing, it's assumed it's a private command, to be called programmatically only.
* In that case the command will not appear in the shortcut editor or command panel, and logically
* should not be used as a menu item.
*/
label?: string;
/**
* Icon to be used on toolbar buttons for example
*/
iconName?: string;
/**
* Code to be ran when the command is executed. It may return a result.
*/
execute(...args: any[]): Promise<any | void>;
/**
* Defines whether the command should be enabled or disabled, which in turns
* affects the enabled state of any associated button or menu item.
*
* The condition should be expressed as a "when-clause" (as in Visual Studio
* Code). It's a simple boolean expression that evaluates to `true` or
* `false`. It supports the following operators:
*
* Operator | Symbol | Example
* -- | -- | --
* Equality | == | "editorType == markdown"
* Inequality | != | "currentScreen != config"
* Or | \|\| | "noteIsTodo \|\| noteTodoCompleted"
* And | && | "oneNoteSelected && !inConflictFolder"
*
* Joplin, unlike VSCode, also supports parenthesis, which allows creating
* more complex expressions such as `cond1 || (cond2 && cond3)`. Only one
* level of parenthesis is possible (nested ones aren't supported).
*
* Currently the supported context variables aren't documented, but you can
* find the list below:
*
* - [Global When Clauses](https://github.com/laurent22/joplin/blob/dev/packages/lib/services/commands/stateToWhenClauseContext.ts)
* - [Desktop app When Clauses](https://github.com/laurent22/joplin/blob/dev/packages/app-desktop/services/commands/stateToWhenClauseContext.ts)
*
* Note: Commands are enabled by default unless you use this property.
*/
enabledCondition?: string;
}
// =================================================================
// Interop API types
// =================================================================
export enum FileSystemItem {
File = 'file',
Directory = 'directory',
}
export enum ImportModuleOutputFormat {
Markdown = 'md',
Html = 'html',
}
/**
* Used to implement a module to export data from Joplin. [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/json_export) for an example.
*
* In general, all the event handlers you'll need to implement take a `context` object as a first argument. This object will contain the export or import path as well as various optional properties, such as which notes or notebooks need to be exported.
*
* To get a better sense of what it will contain it can be useful to print it using `console.info(context)`.
*/
export interface ExportModule {
/**
* The format to be exported, eg "enex", "jex", "json", etc.
*/
format: string;
/**
* The description that will appear in the UI, for example in the menu item.
*/
description: string;
/**
* Whether the module will export a single file or multiple files in a directory. It affects the open dialog that will be presented to the user when using your exporter.
*/
target: FileSystemItem;
/**
* Only applies to single file exporters or importers
* It tells whether the format can package multiple notes into one file.
* For example JEX or ENEX can, but HTML cannot.
*/
isNoteArchive: boolean;
/**
* The extensions of the files exported by your module. For example, it is `["htm", "html"]` for the HTML module, and just `["jex"]` for the JEX module.
*/
fileExtensions?: string[];
/**
* Called when the export process starts.
*/
onInit(context: ExportContext): Promise<void>;
/**
* Called when an item needs to be processed. An "item" can be any Joplin object, such as a note, a folder, a notebook, etc.
*/
onProcessItem(context: ExportContext, itemType: number, item: any): Promise<void>;
/**
* Called when a resource file needs to be exported.
*/
onProcessResource(context: ExportContext, resource: any, filePath: string): Promise<void>;
/**
* Called when the export process is done.
*/
onClose(context: ExportContext): Promise<void>;
}
export interface ImportModule {
/**
* The format to be exported, eg "enex", "jex", "json", etc.
*/
format: string;
/**
* The description that will appear in the UI, for example in the menu item.
*/
description: string;
/**
* Only applies to single file exporters or importers
* It tells whether the format can package multiple notes into one file.
* For example JEX or ENEX can, but HTML cannot.
*/
isNoteArchive: boolean;
/**
* The type of sources that are supported by the module. Tells whether the module can import files or directories or both.
*/
sources: FileSystemItem[];
/**
* Tells the file extensions of the exported files.
*/
fileExtensions?: string[];
/**
* Tells the type of notes that will be generated, either HTML or Markdown (default).
*/
outputFormat?: ImportModuleOutputFormat;
/**
* Called when the import process starts. There is only one event handler within which you should import the complete data.
*/
onExec(context: ImportContext): Promise<void>;
}
export interface ExportOptions {
format?: string;
path?: string;
sourceFolderIds?: string[];
sourceNoteIds?: string[];
// modulePath?: string;
target?: FileSystemItem;
}
export interface ExportContext {
destPath: string;
options: ExportOptions;
/**
* You can attach your own custom data using this propery - it will then be passed to each event handler, allowing you to keep state from one event to the next.
*/
userData?: any;
}
export interface ImportContext {
sourcePath: string;
options: any;
warnings: string[];
}
// =================================================================
// Misc types
// =================================================================
export interface Script {
onStart?(event: any): Promise<void>;
}
export interface Disposable {
// dispose():void;
}
// =================================================================
// Menu types
// =================================================================
export interface CreateMenuItemOptions {
accelerator: string;
}
export enum MenuItemLocation {
File = 'file',
Edit = 'edit',
View = 'view',
Note = 'note',
Tools = 'tools',
Help = 'help',
/**
* @deprecated Do not use - same as NoteListContextMenu
*/
Context = 'context',
// If adding an item here, don't forget to update isContextMenuItemLocation()
/**
* When a command is called from the note list context menu, the
* command will receive the following arguments:
*
* - `noteIds:string[]`: IDs of the notes that were right-clicked on.
*/
NoteListContextMenu = 'noteListContextMenu',
EditorContextMenu = 'editorContextMenu',
/**
* When a command is called from a folder context menu, the
* command will receive the following arguments:
*
* - `folderId:string`: ID of the folder that was right-clicked on
*/
FolderContextMenu = 'folderContextMenu',
/**
* When a command is called from a tag context menu, the
* command will receive the following arguments:
*
* - `tagId:string`: ID of the tag that was right-clicked on
*/
TagContextMenu = 'tagContextMenu',
}
export function isContextMenuItemLocation(location: MenuItemLocation): boolean {
return [
MenuItemLocation.Context,
MenuItemLocation.NoteListContextMenu,
MenuItemLocation.EditorContextMenu,
MenuItemLocation.FolderContextMenu,
MenuItemLocation.TagContextMenu,
].includes(location);
}
export interface MenuItem {
/**
* Command that should be associated with the menu item. All menu item should
* have a command associated with them unless they are a sub-menu.
*/
commandName?: string;
/**
* Accelerator associated with the menu item
*/
accelerator?: string;
/**
* Menu items that should appear below this menu item. Allows creating a menu tree.
*/
submenu?: MenuItem[];
/**
* Menu item label. If not specified, the command label will be used instead.
*/
label?: string;
}
// =================================================================
// View API types
// =================================================================
export interface ButtonSpec {
id: ButtonId;
title?: string;
onClick?(): void;
}
export type ButtonId = string;
export enum ToolbarButtonLocation {
/**
* This toolbar in the top right corner of the application. It applies to the note as a whole, including its metadata.
*/
NoteToolbar = 'noteToolbar',
/**
* This toolbar is right above the text editor. It applies to the note body only.
*/
EditorToolbar = 'editorToolbar',
}
export type ViewHandle = string;
export interface EditorCommand {
name: string;
value?: any;
}
export interface DialogResult {
id: ButtonId;
formData?: any;
}
// =================================================================
// Settings types
// =================================================================
export enum SettingItemType {
Int = 1,
String = 2,
Bool = 3,
Array = 4,
Object = 5,
Button = 6,
}
// Redefine a simplified interface to mask internal details
// and to remove function calls as they would have to be async.
export interface SettingItem {
value: any;
type: SettingItemType;
label: string;
description?: string;
/**
* A public setting will appear in the Configuration screen and will be
* modifiable by the user. A private setting however will not appear there,
* and can only be changed programmatically. You may use this to store some
* values that you do not want to directly expose.
*/
public: boolean;
/**
* You would usually set this to a section you would have created
* specifically for the plugin.
*/
section?: string;
/**
* To create a setting with multiple options, set this property to `true`.
* That setting will render as a dropdown list in the configuration screen.
*/
isEnum?: boolean;
/**
* This property is required when `isEnum` is `true`. In which case, it
* should contain a map of value => label.
*/
options?: Record<any, any>;
/**
* Reserved property. Not used at the moment.
*/
appTypes?: string[];
/**
* Set this to `true` to store secure data, such as passwords. Any such
* setting will be stored in the system keychain if one is available.
*/
secure?: boolean;
/**
* An advanced setting will be moved under the "Advanced" button in the
* config screen.
*/
advanced?: boolean;
/**
* Set the min, max and step values if you want to restrict an int setting
* to a particular range.
*/
minimum?: number;
maximum?: number;
step?: number;
}
export interface SettingSection {
label: string;
iconName?: string;
description?: string;
name?: string;
}
// =================================================================
// Data API types
// =================================================================
/**
* An array of at least one element and at most three elements.
*
* - **[0]**: Resource name (eg. "notes", "folders", "tags", etc.)
* - **[1]**: (Optional) Resource ID.
* - **[2]**: (Optional) Resource link.
*/
export type Path = string[];
// =================================================================
// Content Script types
// =================================================================
export type PostMessageHandler = (id: string, message: any)=> Promise<any>;
/**
* When a content script is initialised, it receives a `context` object.
*/
export interface ContentScriptContext {
/**
* The plugin ID that registered this content script
*/
pluginId: string;
/**
* The content script ID, which may be necessary to post messages
*/
contentScriptId: string;
/**
* Can be used by CodeMirror content scripts to post a message to the plugin
*/
postMessage: PostMessageHandler;
}
export enum ContentScriptType {
/**
* Registers a new Markdown-It plugin, which should follow the template
* below.
*
* ```javascript
* module.exports = {
* default: function(context) {
* return {
* plugin: function(markdownIt, options) {
* // ...
* },
* assets: {
* // ...
* },
* }
* }
* }
* ```
* See [the
* demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/content_script)
* for a simple Markdown-it plugin example.
*
* ## Exported members
*
* - The `context` argument is currently unused but could be used later on
* to provide access to your own plugin so that the content script and
* plugin can communicate.
*
* - The **required** `plugin` key is the actual Markdown-It plugin - check
* the [official doc](https://github.com/markdown-it/markdown-it) for more
* information. The `options` parameter is of type
* [RuleOptions](https://github.com/laurent22/joplin/blob/dev/packages/renderer/MdToHtml.ts),
* which contains a number of options, mostly useful for Joplin's internal
* code.
*
* - Using the **optional** `assets` key you may specify assets such as JS
* or CSS that should be loaded in the rendered HTML document. Check for
* example the Joplin [Mermaid
* plugin](https://github.com/laurent22/joplin/blob/dev/packages/renderer/MdToHtml/rules/mermaid.ts)
* to see how the data should be structured.
*
* ## Posting messages from the content script to your plugin
*
* The application provides the following function to allow executing
* commands from the rendered HTML code:
*
* ```javascript
* const response = await webviewApi.postMessage(contentScriptId, message);
* ```
*
* - `contentScriptId` is the ID you've defined when you registered the
* content script. You can retrieve it from the
* {@link ContentScriptContext | context}.
* - `message` can be any basic JavaScript type (number, string, plain
* object), but it cannot be a function or class instance.
*
* When you post a message, the plugin can send back a `response` thus
* allowing two-way communication:
*
* ```javascript
* await joplin.contentScripts.onMessage(contentScriptId, (message) => {
* // Process message
* return response; // Can be any object, string or number
* });
* ```
*
* See {@link JoplinContentScripts.onMessage} for more details, as well as
* the [postMessage
* demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/post_messages).
*
* ## Registering an existing Markdown-it plugin
*
* To include a regular Markdown-It plugin, that doesn't make use of any
* Joplin-specific features, you would simply create a file such as this:
*
* ```javascript
* module.exports = {
* default: function(context) {
* return {
* plugin: require('markdown-it-toc-done-right');
* }
* }
* }
* ```
*/
MarkdownItPlugin = 'markdownItPlugin',
/**
* Registers a new CodeMirror plugin, which should follow the template
* below.
*
* ```javascript
* module.exports = {
* default: function(context) {
* return {
* plugin: function(CodeMirror) {
* // ...
* },
* codeMirrorResources: [],
* codeMirrorOptions: {
* // ...
* },
* assets: {
* // ...
* },
* }
* }
* }
* ```
*
* - The `context` argument is currently unused but could be used later on
* to provide access to your own plugin so that the content script and
* plugin can communicate.
*
* - The `plugin` key is your CodeMirror plugin. This is where you can
* register new commands with CodeMirror or interact with the CodeMirror
* instance as needed.
*
* - The `codeMirrorResources` key is an array of CodeMirror resources that
* will be loaded and attached to the CodeMirror module. These are made up
* of addons, keymaps, and modes. For example, for a plugin that want's to
* enable clojure highlighting in code blocks. `codeMirrorResources` would
* be set to `['mode/clojure/clojure']`.
*
* - The `codeMirrorOptions` key contains all the
* [CodeMirror](https://codemirror.net/doc/manual.html#config) options
* that will be set or changed by this plugin. New options can alse be
* declared via
* [`CodeMirror.defineOption`](https://codemirror.net/doc/manual.html#defineOption),
* and then have their value set here. For example, a plugin that enables
* line numbers would set `codeMirrorOptions` to `{'lineNumbers': true}`.
*
* - Using the **optional** `assets` key you may specify **only** CSS assets
* that should be loaded in the rendered HTML document. Check for example
* the Joplin [Mermaid
* plugin](https://github.com/laurent22/joplin/blob/dev/packages/renderer/MdToHtml/rules/mermaid.ts)
* to see how the data should be structured.
*
* One of the `plugin`, `codeMirrorResources`, or `codeMirrorOptions` keys
* must be provided for the plugin to be valid. Having multiple or all
* provided is also okay.
*
* See also the [demo
* plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/codemirror_content_script)
* for an example of all these keys being used in one plugin.
*
* ## Posting messages from the content script to your plugin
*
* In order to post messages to the plugin, you can use the postMessage
* function passed to the {@link ContentScriptContext | context}.
*
* ```javascript
* const response = await context.postMessage('messageFromCodeMirrorContentScript');
* ```
*
* When you post a message, the plugin can send back a `response` thus
* allowing two-way communication:
*
* ```javascript
* await joplin.contentScripts.onMessage(contentScriptId, (message) => {
* // Process message
* return response; // Can be any object, string or number
* });
* ```
*
* See {@link JoplinContentScripts.onMessage} for more details, as well as
* the [postMessage
* demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/post_messages).
*
*/
CodeMirrorPlugin = 'codeMirrorPlugin',
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
{
"name": "joplin-plugin-clipboad-demo",
"version": "1.0.0",
"scripts": {
"dist": "webpack --joplin-plugin-config buildMain && webpack --joplin-plugin-config buildExtraScripts && webpack --joplin-plugin-config createArchive",
"prepare": "npm run dist",
"update": "npm install -g generator-joplin && yo joplin --update"
},
"license": "MIT",
"keywords": [
"joplin-plugin"
],
"devDependencies": {
"@types/node": "^14.0.14",
"chalk": "^4.1.0",
"copy-webpack-plugin": "^6.1.0",
"fs-extra": "^9.0.1",
"glob": "^7.1.6",
"on-build-webpack": "^0.1.0",
"tar": "^6.0.5",
"ts-loader": "^7.0.5",
"typescript": "^3.9.3",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11",
"yargs": "^16.2.0"
}
}

View File

@@ -0,0 +1,3 @@
{
"extraScripts": []
}

View File

@@ -0,0 +1,36 @@
import joplin from 'api';
import { ToolbarButtonLocation } from 'api/types';
const imageDataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAkCAYAAAD7PHgWAAAG0UlEQVRYhe2YXYhcZxnHf+97zszsxyTdpCa7zcYkXZstYpFUMdGC2AsRBIs3loCgdxW8Fo25UoTi10UpqBeFIrmI8UaLUD+QQmhBESoSzTaUZmNNstkmXXc3SWd2Zs6Z93m8eN/zNR+7C73QCw8MM3M+nvd//s//+T/POeajJ04q/8Ob/W8D2Gn7P8D3u8W7PfHBxSfYSgwiCgqgaPgGQKv/VQtpqxbXWGuYbsDdf/31/QOM4hqPP32G5bUazeOniDoO0QBGxH9U/UccKIgIqHiwmh0XVBR/sWAalsbsZfpXf4PbWN4WoBlXxcbGHDv9HL3mcZJUQRVUPCND4CQHI04CbVI6pvkxvy8ARXlAb7J+8YeIS0cCHKnBKK5x7PRztCcfIUkVg5bY0Ao4wkdEEYEkFbo9R7cndLqObtfR6Tg6nT7driPtC+CvcX1h083z4JPfHMvgyBR/7Okz3GoeR1LFGMIdZ8QMAJWClSiCTyxOVsBrplUVrIGbawkrt7eIjT/eV2VdDhHtW8Bt/nN3AK+u1YgbAZxzVWAD32F1nCj79lp+9pVDgBnLyLnX7vCDX75HNGFQFKOKiBAd+Txs/nTo/KEUH1h8gpnFU35hGdCcSEVzqODJKQHeBhyAteRFpOILyDnH1NyH2X/s8Z0ZbCUG2/E6Gac5zYEXFmON0uk6Xv77PQ7P1DhxdKoS9937CZdvtLn0dhtrTYgpeaxWKyVqD9frEEARRTIg5VSWmIRsP3nxoEK7JXznwi3qMfz27CL7p4vwz/9+lZdefZe4ZomicvX7IhRRCHLaFmDGSM6SVoOpCmBgAJyK+OSqkiaCy+wmbO2ueMWot5csHpkGxyhjJEAVDYY7mFavsbK3eVY1N2mjgjFgTHVFg2IQzMD1eZy82+wA0AveDQDwTGUM+WMu/NScRfDNwqoMhg0FEUCoIoAJGTEqqD+wM0BjgnNAUa0BmM+qQ1UQpySpw6US+rO3pcgqapzfVwaoijiHivU3q0HrIogqNsTfmcG8d0reIXKAQS9JKsQWHluY4tOPTnNopoY1sLSyxflX7uQ3OBA5Zz/TcsUldstgFijzqGy1DHiSCkdmG3zrCwf41IealStPLUxz4ZXbOBmR4uw+KQioWNiuiySIfbhdCb1enyNzDX7y1Xnm99VLiyvGGHqp4kL6BtnIYvnEaJ7mrIqxptD59gwC4vXiIxdTSmwNZ5+arYCDomIn6gYjgpPhjpJblRS/M7/Nb2A3DHqdZYZZGHTaFz7y8CQnF6Yr5//jeptfv/5vYmtYmG1gDEU+q4F9cam3GVOyFw1VvSsNlqffssW4VDi5MFkCbljZSPj6C8usr/cwBhp1Q5o6328HF1MBBCPeJqQMUMQTvhsfLOKVq0xxTpiZisr4uHKrzb17KY0J6xkRwZhsIK1uIlnhUQGXuYMdaTJjBta8ylzWJXy/3GiFqTfIa3FukkYd+qkLthFaXjbyV2IWuhscPFSEEYU/DqAGMy4FUMVa+NvbW5QRHjswwZc+c4B+4uh2UpwT0sSbe6NWDa0DmiusTIOzFUPJ9gCzOc1pMcWoEhnl8tX3eONmO98HcPaLR/jG6aPMHfT6nJywfPlz88xM1yphb9xphy5V9O/sYao8JQ1uI3ywatL+jj3yXs/x/B9WeeGZ4xhjcv/72mfneerjH+D6WodmI+Kxo3sqId9aaXFtpYWxhT5zcMEHrdUhWYxk0FhfTpr1XhVQByrUYvjTpU2efem6Pzdv3MpD+xp8cnGmBK5Y7Ue/WqbXTsKwQcFgPpj4Kcfa4XYyBLDZ8GkqqC+qD/xAcP6Pq5w9f417W6mfLnJTLlPg933vwptcfP0dbGwLreWPCFkTUJrTNWaawwmNZufmv1ve0b27ipv6IDpxMDT2EBCvTW8jwtLyfX53aR1FaTYseyZjIm+AbLYS/vLmBt/++RVefnUFY8AaMwCuJCPA3r+Gu3VxCOBIH3z0Ics1B52O5P6U3TGqRBZsw7J6e4tnz73FzEyNwwcnmJqIcH3H2maPG6st+okjim14dC3AAXkscd4hGpt/HgVl9JsFG9XY/+QZNmUel7el0gimYDT0UnGkqcP1HeKCWVuwUWCtXK1aSm34b1DmpjfoLb04EuBIoxaXsn7x+zzADeqxwYl4AJlpq+Aygw0eWa9Z6g1LfSKiVo+GwZV0l70CsQbmpjZIrpwbCW4sQJ9Sx93Xfswj8RLx5hLN6ZjIFKOXKT38ZNPxoLkXTPk+rOGavXvqTCbX2fvOL+i98SIq/bEAx748Gtz2Hj5Bq5ONUuVXIdnv0a/bsur3JextbM9UxNadK7tZlv8AuLmWJqiAwrcAAAAASUVORK5CYII=';
joplin.plugins.register({
onStart: async function() {
await joplin.commands.register({
name: 'readFromClipboard',
label: 'Read data from clipboard',
iconName: 'fas fa-drum',
execute: async () => {
const clipboard = (joplin as any).clipboard;
console.info('Formats', await clipboard.availableFormats());
console.info('Text:', await clipboard.readText());
console.info('HTML:', await clipboard.readHtml());
console.info('Image:', await clipboard.readImage());
},
});
await joplin.commands.register({
name: 'writeToClipboard',
label: 'Read data from clipboard',
iconName: 'fas fa-music',
execute: async () => {
const clipboard = (joplin as any).clipboard;
await clipboard.writeImage(imageDataUrl);
// await clipboard.writeText('text from plugin');
// await clipboard.writeHtml('<p>text <strong>from</strong> plugin</p>');
},
});
await joplin.views.toolbarButtons.create('readFromClipboard', 'readFromClipboard', ToolbarButtonLocation.NoteToolbar);
await joplin.views.toolbarButtons.create('writeToClipboard', 'writeToClipboard', ToolbarButtonLocation.NoteToolbar);
},
});

View File

@@ -0,0 +1,12 @@
{
"manifest_version": 1,
"id": "org.joplinapp.plugins.ClipboardDemo",
"app_min_version": "2.0",
"version": "1.0.0",
"name": "Clipboad Demo",
"description": "",
"author": "",
"homepage_url": "",
"repository_url": "",
"keywords": []
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"outDir": "./dist/",
"module": "commonjs",
"target": "es2015",
"jsx": "react",
"allowJs": true,
"baseUrl": "."
}
}

View File

@@ -0,0 +1,281 @@
// -----------------------------------------------------------------------------
// This file is used to build the plugin file (.jpl) and plugin info (.json). It
// is recommended not to edit this file as it would be overwritten when updating
// the plugin framework. If you do make some changes, consider using an external
// JS file and requiring it here to minimize the changes. That way when you
// update, you can easily restore the functionality you've added.
// -----------------------------------------------------------------------------
const path = require('path');
const crypto = require('crypto');
const fs = require('fs-extra');
const chalk = require('chalk');
const CopyPlugin = require('copy-webpack-plugin');
const WebpackOnBuildPlugin = require('on-build-webpack');
const tar = require('tar');
const glob = require('glob');
const execSync = require('child_process').execSync;
const rootDir = path.resolve(__dirname);
const userConfigFilename = './plugin.config.json';
const userConfigPath = path.resolve(rootDir, userConfigFilename);
const distDir = path.resolve(rootDir, 'dist');
const srcDir = path.resolve(rootDir, 'src');
const publishDir = path.resolve(rootDir, 'publish');
const userConfig = Object.assign({}, {
extraScripts: [],
}, fs.pathExistsSync(userConfigPath) ? require(userConfigFilename) : {});
const manifestPath = `${srcDir}/manifest.json`;
const packageJsonPath = `${rootDir}/package.json`;
const manifest = readManifest(manifestPath);
const pluginArchiveFilePath = path.resolve(publishDir, `${manifest.id}.jpl`);
const pluginInfoFilePath = path.resolve(publishDir, `${manifest.id}.json`);
function validatePackageJson() {
const content = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
if (!content.name || content.name.indexOf('joplin-plugin-') !== 0) {
console.warn(chalk.yellow(`WARNING: To publish the plugin, the package name should start with "joplin-plugin-" (found "${content.name}") in ${packageJsonPath}`));
}
if (!content.keywords || content.keywords.indexOf('joplin-plugin') < 0) {
console.warn(chalk.yellow(`WARNING: To publish the plugin, the package keywords should include "joplin-plugin" (found "${JSON.stringify(content.keywords)}") in ${packageJsonPath}`));
}
if (content.scripts && content.scripts.postinstall) {
console.warn(chalk.yellow(`WARNING: package.json contains a "postinstall" script. It is recommended to use a "prepare" script instead so that it is executed before publish. In ${packageJsonPath}`));
}
}
function fileSha256(filePath) {
const content = fs.readFileSync(filePath);
return crypto.createHash('sha256').update(content).digest('hex');
}
function currentGitInfo() {
try {
let branch = execSync('git rev-parse --abbrev-ref HEAD', { stdio: 'pipe' }).toString().trim();
const commit = execSync('git rev-parse HEAD', { stdio: 'pipe' }).toString().trim();
if (branch === 'HEAD') branch = 'master';
return `${branch}:${commit}`;
} catch (error) {
const messages = error.message ? error.message.split('\n') : [''];
console.info(chalk.cyan('Could not get git commit (not a git repo?):', messages[0].trim()));
console.info(chalk.cyan('Git information will not be stored in plugin info file'));
return '';
}
}
function readManifest(manifestPath) {
const content = fs.readFileSync(manifestPath, 'utf8');
const output = JSON.parse(content);
if (!output.id) throw new Error(`Manifest plugin ID is not set in ${manifestPath}`);
return output;
}
function createPluginArchive(sourceDir, destPath) {
const distFiles = glob.sync(`${sourceDir}/**/*`, { nodir: true })
.map(f => f.substr(sourceDir.length + 1));
if (!distFiles.length) throw new Error('Plugin archive was not created because the "dist" directory is empty');
fs.removeSync(destPath);
tar.create(
{
strict: true,
portable: true,
file: destPath,
cwd: sourceDir,
sync: true,
},
distFiles
);
console.info(chalk.cyan(`Plugin archive has been created in ${destPath}`));
}
function createPluginInfo(manifestPath, destPath, jplFilePath) {
const contentText = fs.readFileSync(manifestPath, 'utf8');
const content = JSON.parse(contentText);
content._publish_hash = `sha256:${fileSha256(jplFilePath)}`;
content._publish_commit = currentGitInfo();
fs.writeFileSync(destPath, JSON.stringify(content, null, '\t'), 'utf8');
}
function onBuildCompleted() {
try {
fs.removeSync(path.resolve(publishDir, 'index.js'));
createPluginArchive(distDir, pluginArchiveFilePath);
createPluginInfo(manifestPath, pluginInfoFilePath, pluginArchiveFilePath);
validatePackageJson();
} catch (error) {
console.error(chalk.red(error.message));
}
}
const baseConfig = {
mode: 'production',
target: 'node',
stats: 'errors-only',
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
],
},
};
const pluginConfig = Object.assign({}, baseConfig, {
entry: './src/index.ts',
resolve: {
alias: {
api: path.resolve(__dirname, 'api'),
},
// JSON files can also be required from scripts so we include this.
// https://github.com/joplin/plugin-bibtex/pull/2
extensions: ['.tsx', '.ts', '.js', '.json'],
},
output: {
filename: 'index.js',
path: distDir,
},
plugins: [
new CopyPlugin({
patterns: [
{
from: '**/*',
context: path.resolve(__dirname, 'src'),
to: path.resolve(__dirname, 'dist'),
globOptions: {
ignore: [
// All TypeScript files are compiled to JS and
// already copied into /dist so we don't copy them.
'**/*.ts',
'**/*.tsx',
],
},
},
],
}),
],
});
const extraScriptConfig = Object.assign({}, baseConfig, {
resolve: {
alias: {
api: path.resolve(__dirname, 'api'),
},
extensions: ['.tsx', '.ts', '.js', '.json'],
},
});
const createArchiveConfig = {
stats: 'errors-only',
entry: './dist/index.js',
output: {
filename: 'index.js',
path: publishDir,
},
plugins: [new WebpackOnBuildPlugin(onBuildCompleted)],
};
function resolveExtraScriptPath(name) {
const relativePath = `./src/${name}`;
const fullPath = path.resolve(`${rootDir}/${relativePath}`);
if (!fs.pathExistsSync(fullPath)) throw new Error(`Could not find extra script: "${name}" at "${fullPath}"`);
const s = name.split('.');
s.pop();
const nameNoExt = s.join('.');
return {
entry: relativePath,
output: {
filename: `${nameNoExt}.js`,
path: distDir,
library: 'default',
libraryTarget: 'commonjs',
libraryExport: 'default',
},
};
}
function buildExtraScriptConfigs(userConfig) {
if (!userConfig.extraScripts.length) return [];
const output = [];
for (const scriptName of userConfig.extraScripts) {
const scriptPaths = resolveExtraScriptPath(scriptName);
output.push(Object.assign({}, extraScriptConfig, {
entry: scriptPaths.entry,
output: scriptPaths.output,
}));
}
return output;
}
function main(processArgv) {
const yargs = require('yargs/yargs');
const argv = yargs(processArgv).argv;
const configName = argv['joplin-plugin-config'];
if (!configName) throw new Error('A config file must be specified via the --joplin-plugin-config flag');
// Webpack configurations run in parallel, while we need them to run in
// sequence, and to do that it seems the only way is to run webpack multiple
// times, with different config each time.
const configs = {
// Builds the main src/index.ts and copy the extra content from /src to
// /dist including scripts, CSS and any other asset.
buildMain: [pluginConfig],
// Builds the extra scripts as defined in plugin.config.json. When doing
// so, some JavaScript files that were copied in the previous might be
// overwritten here by the compiled version. This is by design. The
// result is that JS files that don't need compilation, are simply
// copied to /dist, while those that do need it are correctly compiled.
buildExtraScripts: buildExtraScriptConfigs(userConfig),
// Ths config is for creating the .jpl, which is done via the plugin, so
// it doesn't actually need an entry and output, however webpack won't
// run without this. So we give it an entry that we know is going to
// exist and output in the publish dir. Then the plugin will delete this
// temporary file before packaging the plugin.
createArchive: [createArchiveConfig],
};
// If we are running the first config step, we clean up and create the build
// directories.
if (configName === 'buildMain') {
fs.removeSync(distDir);
fs.removeSync(publishDir);
fs.mkdirpSync(publishDir);
}
return configs[configName];
}
let exportedConfigs = [];
try {
exportedConfigs = main(process.argv);
} catch (error) {
console.error(chalk.red(error.message));
process.exit(1);
}
if (!exportedConfigs.length) {
// Nothing to do - for example where there are no external scripts to
// compile.
process.exit(0);
}
module.exports = exportedConfigs;

View File

@@ -8,25 +8,46 @@ joplin.plugins.register({
iconName: 'fas fa-music',
});
await joplin.settings.registerSetting('myCustomSetting', {
value: 123,
type: SettingItemType.Int,
section: 'myCustomSection',
public: true,
label: 'My Custom Setting',
});
await joplin.settings.registerSettings({
'myCustomSetting': {
value: 123,
type: SettingItemType.Int,
section: 'myCustomSection',
public: true,
label: 'My Custom Setting',
},
await joplin.settings.registerSetting('multiOptionTest', {
value: 'en',
type: SettingItemType.String,
section: 'myCustomSection',
isEnum: true,
public: true,
label: 'Multi-options test',
options: {
'en': 'English',
'fr': 'French',
'es': 'Spanish',
'multiOptionTest': {
value: 'en',
type: SettingItemType.String,
section: 'myCustomSection',
isEnum: true,
public: true,
label: 'Multi-options test',
options: {
'en': 'English',
'fr': 'French',
'es': 'Spanish',
},
},
'mySecureSetting': {
value: 'hunter2',
type: SettingItemType.String,
section: 'myCustomSection',
public: true,
secure: true,
label: 'My Secure Setting',
},
'myFileSetting': {
value: 'abcd',
type: SettingItemType.String,
section: 'myCustomSection',
public: true,
label: 'My file setting',
description: 'This setting will be saved to settings.json',
['storage' as any]: 2, // Should be `storage: SettingStorage.File`
},
});
@@ -47,7 +68,11 @@ joplin.plugins.register({
iconName: 'fas fa-drum',
execute: async () => {
const value = await joplin.settings.value('myCustomSetting');
alert('Current value is: ' + value);
console.info('Current value is: ' + value);
const secureValue = await joplin.settings.value('mySecureSetting');
console.info('Secure value is: ' + secureValue);
const fileValue = await joplin.settings.value('myFileSetting');
console.info('Setting in file is: ' + fileValue);
},
});

View File

@@ -5,6 +5,9 @@ set -e
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
ROOT_DIR="$SCRIPT_DIR/../../../../.."
API_BASE_URL="https://test.joplincloud.com"
# API_BASE_URL="http://api.joplincloud.local:22300"
COMMANDS=($(echo $1 | tr "," "\n"))
PROFILE_DIR=~/.config/joplindev-testperf
@@ -16,7 +19,7 @@ for CMD in "${COMMANDS[@]}"
do
if [[ $CMD == "createUsers" ]]; then
curl --data '{"action": "createTestUsers"}' -H 'Content-Type: application/json' http://api.joplincloud.local:22300/api/debug
curl --data '{"action": "createTestUsers"}' -H 'Content-Type: application/json' $API_BASE_URL/api/debug
# elif [[ $CMD == "createData" ]]; then
@@ -32,7 +35,7 @@ do
rm -rf "$PROFILE_DIR"
echo "config keychain.supported 0" >> "$CMD_FILE"
echo "config sync.target 9" >> "$CMD_FILE"
echo "config sync.9.path http://api.joplincloud.local:22300" >> "$CMD_FILE"
echo "config sync.9.path $API_BASE_URL" >> "$CMD_FILE"
echo "config sync.9.username $USER_EMAIL" >> "$CMD_FILE"
echo "config sync.9.password 123456" >> "$CMD_FILE"
@@ -51,5 +54,5 @@ done
cd "$ROOT_DIR/packages/app-cli"
npm start -- --profile "$PROFILE_DIR" batch "$CMD_FILE"
npm start -- --profile "$PROFILE_DIR" import ~/Desktop/Joplin_17_06_2021.jex
npm start -- --profile "$PROFILE_DIR" sync
# npm start -- --profile "$PROFILE_DIR" import ~/Desktop/Tout_18_06_2021.jex
npm start -- --profile "$PROFILE_DIR" sync --use-lock 1

View File

@@ -4,7 +4,7 @@ import ButtonBar from './ButtonBar';
import Button, { ButtonLevel } from '../Button/Button';
import { _ } from '@joplin/lib/locale';
import bridge from '../../services/bridge';
import Setting, { SyncStartupOperation } from '@joplin/lib/models/Setting';
import Setting, { AppType, SyncStartupOperation } from '@joplin/lib/models/Setting';
import control_PluginsStates from './controls/plugins/PluginsStates';
const { connect } = require('react-redux');
@@ -360,7 +360,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
const md = Setting.settingMetadata(key);
const descriptionText = Setting.keyDescription(key, 'desktop');
const descriptionText = Setting.keyDescription(key, AppType.Desktop);
const descriptionComp = this.renderDescription(this.props.themeId, descriptionText);
if (settingKeyToControl[key]) {

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/app-desktop",
"version": "2.0.11",
"version": "2.1.3",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/app-desktop",
"version": "2.1.0",
"version": "2.1.3",
"description": "Joplin for Desktop",
"main": "main.js",
"private": true,

View File

@@ -1,4 +1,5 @@
import bridge from '../bridge';
const { clipboard, nativeImage } = require('electron');
interface JoplinViewsDialogs {
showMessageBox(message: string): Promise<number>;
@@ -16,6 +17,9 @@ interface Components {
[key: string]: any;
}
// PlatformImplementation provides access to platform specific dependencies,
// such as the clipboard, message dialog, etc. It allows having the same plugin
// API for all platforms, but with different implementations.
export default class PlatformImplementation {
private static instance_: PlatformImplementation;
@@ -27,6 +31,14 @@ export default class PlatformImplementation {
return this.instance_;
}
public get clipboard() {
return clipboard;
}
public get nativeImage() {
return nativeImage;
}
constructor() {
this.components_ = {};

View File

@@ -4,5 +4,5 @@
# It could be used to develop plugins too.
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
PLUGIN_PATH="$SCRIPT_DIR/../app-cli/tests/support/plugins/external_assets"
PLUGIN_PATH="$SCRIPT_DIR/../app-cli/tests/support/plugins/settings"
npm i --prefix="$PLUGIN_PATH" && npm start -- --dev-plugins "$PLUGIN_PATH"

View File

@@ -141,8 +141,8 @@ android {
applicationId "net.cozic.joplin"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2097635
versionName "2.1.0"
versionCode 2097637
versionName "2.1.2"
ndk {
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
}

View File

@@ -1,5 +1,4 @@
const React = require('react');
const { StyleSheet } = require('react-native');
const { themeStyle } = require('./global-style.js');

View File

@@ -1,28 +1,29 @@
import Slider from '@react-native-community/slider';
const React = require('react');
const { Platform, TouchableOpacity, Linking, View, Switch, StyleSheet, Text, Button, ScrollView, TextInput, Alert, PermissionsAndroid } = require('react-native');
const { Platform, Linking, View, Switch, StyleSheet, ScrollView, Text, Button, TouchableOpacity, TextInput, Alert, PermissionsAndroid } = require('react-native');
import Setting, { AppType } from '@joplin/lib/models/Setting';
import NavService from '@joplin/lib/services/NavService';
import ReportService from '@joplin/lib/services/ReportService';
import SearchEngine from '@joplin/lib/services/searchengine/SearchEngine';
import checkPermissions from '../../utils/checkPermissions';
import time from '@joplin/lib/time';
import shim from '@joplin/lib/shim';
import setIgnoreTlsErrors from '../../utils/TlsUtils';
import { reg } from '@joplin/lib/registry';
import { State } from '@joplin/lib/reducer';
const VersionInfo = require('react-native-version-info');
const { connect } = require('react-redux');
const { ScreenHeader } = require('../screen-header.js');
const { _ } = require('@joplin/lib/locale');
const { BaseScreenComponent } = require('../base-screen.js');
const { Dropdown } = require('../Dropdown.js');
const { themeStyle } = require('../global-style.js');
const Setting = require('@joplin/lib/models/Setting').default;
const shared = require('@joplin/lib/components/shared/config-shared.js');
const SyncTargetRegistry = require('@joplin/lib/SyncTargetRegistry');
const { reg } = require('@joplin/lib/registry.js');
const NavService = require('@joplin/lib/services/NavService').default;
const VersionInfo = require('react-native-version-info').default;
const ReportService = require('@joplin/lib/services/ReportService').default;
const time = require('@joplin/lib/time').default;
const shim = require('@joplin/lib/shim').default;
const SearchEngine = require('@joplin/lib/services/searchengine/SearchEngine').default;
const RNFS = require('react-native-fs');
const checkPermissions = require('../../utils/checkPermissions.js').default;
import setIgnoreTlsErrors from '../../utils/TlsUtils';
class ConfigScreenComponent extends BaseScreenComponent {
static navigationOptions() {
static navigationOptions(): any {
return { header: null };
}
@@ -49,7 +50,7 @@ class ConfigScreenComponent extends BaseScreenComponent {
};
this.e2eeConfig_ = () => {
NavService.go('EncryptionConfig');
void NavService.go('EncryptionConfig');
};
this.saveButton_press = async () => {
@@ -69,7 +70,7 @@ class ConfigScreenComponent extends BaseScreenComponent {
};
this.syncStatusButtonPress_ = () => {
NavService.go('Status');
void NavService.go('Status');
};
this.exportDebugButtonPress_ = async () => {
@@ -120,7 +121,7 @@ class ConfigScreenComponent extends BaseScreenComponent {
throw new Error('Permission denied');
}
const copyFiles = async (source, dest) => {
const copyFiles = async (source: string, dest: string) => {
await shim.fsDriver().mkdir(dest);
const files = await shim.fsDriver().readDirStats(source);
@@ -148,7 +149,7 @@ class ConfigScreenComponent extends BaseScreenComponent {
};
this.logButtonPress_ = () => {
NavService.go('Log');
void NavService.go('Log');
};
}
@@ -175,7 +176,7 @@ class ConfigScreenComponent extends BaseScreenComponent {
if (this.styles_[themeId]) return this.styles_[themeId];
this.styles_ = {};
const styles = {
const styles: any = {
body: {
flex: 1,
justifyContent: 'flex-start',
@@ -263,7 +264,7 @@ class ConfigScreenComponent extends BaseScreenComponent {
return this.styles_[themeId];
}
renderHeader(key, title) {
renderHeader(key: string, title: string) {
const theme = themeStyle(this.props.themeId);
return (
<View key={key} style={this.styles().headerWrapperStyle}>
@@ -272,7 +273,7 @@ class ConfigScreenComponent extends BaseScreenComponent {
);
}
renderButton(key, title, clickHandler, options = null) {
renderButton(key: string, title: string, clickHandler: Function, options: any = null) {
if (!options) options = {};
let descriptionComp = null;
@@ -297,7 +298,7 @@ class ConfigScreenComponent extends BaseScreenComponent {
);
}
sectionToComponent(key, section, settings) {
sectionToComponent(key: string, section: any, settings: any) {
const settingComps = [];
for (let i = 0; i < section.metadatas.length; i++) {
@@ -341,12 +342,32 @@ class ConfigScreenComponent extends BaseScreenComponent {
);
}
settingToComponent(key, value) {
private renderToggle(key: string, label: string, value: any, updateSettingValue: Function, descriptionComp: any = null) {
const theme = themeStyle(this.props.themeId);
return (
<View key={key}>
<View style={this.containerStyle(false)}>
<Text key="label" style={this.styles().switchSettingText}>
{label}
</Text>
<Switch key="control" style={this.styles().switchSettingControl} trackColor={{ false: theme.dividerColor }} value={value} onValueChange={(value: any) => updateSettingValue(key, value)} />
</View>
{descriptionComp}
</View>
);
}
private containerStyle(hasDescription: boolean): any {
return !hasDescription ? this.styles().settingContainer : this.styles().settingContainerNoBottomBorder;
}
settingToComponent(key: string, value: any) {
const themeId = this.props.themeId;
const theme = themeStyle(themeId);
const output = null;
const output: any = null;
const updateSettingValue = (key, value) => {
const updateSettingValue = (key: string, value: any) => {
return shared.updateSettingValue(this, key, value);
};
@@ -354,7 +375,7 @@ class ConfigScreenComponent extends BaseScreenComponent {
const settingDescription = md.description ? md.description() : '';
const descriptionComp = !settingDescription ? null : <Text style={this.styles().settingDescriptionText}>{settingDescription}</Text>;
const containerStyle = !settingDescription ? this.styles().settingContainer : this.styles().settingContainerNoBottomBorder;
const containerStyle = this.containerStyle(!!settingDescription);
if (md.isEnum) {
value = value.toString();
@@ -388,7 +409,7 @@ class ConfigScreenComponent extends BaseScreenComponent {
color: theme.color,
fontSize: theme.fontSize,
}}
onValueChange={(itemValue) => {
onValueChange={(itemValue: any) => {
updateSettingValue(key, itemValue);
}}
/>
@@ -397,17 +418,18 @@ class ConfigScreenComponent extends BaseScreenComponent {
</View>
);
} else if (md.type == Setting.TYPE_BOOL) {
return (
<View key={key}>
<View style={containerStyle}>
<Text key="label" style={this.styles().switchSettingText}>
{md.label()}
</Text>
<Switch key="control" style={this.styles().switchSettingControl} trackColor={{ false: theme.dividerColor }} value={value} onValueChange={value => updateSettingValue(key, value)} />
</View>
{descriptionComp}
</View>
);
return this.renderToggle(key, md.label(), value, updateSettingValue, descriptionComp);
// return (
// <View key={key}>
// <View style={containerStyle}>
// <Text key="label" style={this.styles().switchSettingText}>
// {md.label()}
// </Text>
// <Switch key="control" style={this.styles().switchSettingControl} trackColor={{ false: theme.dividerColor }} value={value} onValueChange={(value:any) => updateSettingValue(key, value)} />
// </View>
// {descriptionComp}
// </View>
// );
} else if (md.type == Setting.TYPE_INT) {
const unitLabel = md.unitLabel ? md.unitLabel(value) : value;
// Note: Do NOT add the minimumTrackTintColor and maximumTrackTintColor props
@@ -431,7 +453,7 @@ class ConfigScreenComponent extends BaseScreenComponent {
<Text key="label" style={this.styles().settingText}>
{md.label()}
</Text>
<TextInput autoCorrect={false} autoCompleteType="off" selectionColor={theme.textSelectionColor} keyboardAppearance={theme.keyboardAppearance} autoCapitalize="none" key="control" style={this.styles().settingControl} value={value} onChangeText={value => updateSettingValue(key, value)} secureTextEntry={!!md.secure} />
<TextInput autoCorrect={false} autoCompleteType="off" selectionColor={theme.textSelectionColor} keyboardAppearance={theme.keyboardAppearance} autoCapitalize="none" key="control" style={this.styles().settingControl} value={value} onChangeText={(value: any) => updateSettingValue(key, value)} secureTextEntry={!!md.secure} />
</View>
);
} else {
@@ -441,6 +463,19 @@ class ConfigScreenComponent extends BaseScreenComponent {
return output;
}
private renderFeatureFlags(settings: any): any[] {
const updateSettingValue = (key: string, value: any) => {
console.info('UPDATE', key, value);
return shared.updateSettingValue(this, key, value);
};
const output: any[] = [];
for (const key of Setting.featureFlagKeys(AppType.Mobile)) {
output.push(this.renderToggle(key, key, settings[key], updateSettingValue));
}
return output;
}
render() {
const settings = this.state.settings;
@@ -464,7 +499,7 @@ class ConfigScreenComponent extends BaseScreenComponent {
const profileExportPrompt = (
<View style={this.styles().settingContainer} key="profileExport">
<Text style={this.styles().settingText}>Path:</Text>
<TextInput style={{ ...this.styles().textInput, paddingRight: 20 }} onChange={(event) => this.setState({ profileExportPath: event.nativeEvent.text })} value={this.state.profileExportPath} placeholder="/path/to/sdcard" keyboardAppearance={theme.keyboardAppearance}></TextInput>
<TextInput style={{ ...this.styles().textInput, paddingRight: 20 }} onChange={(event: any) => this.setState({ profileExportPath: event.nativeEvent.text })} value={this.state.profileExportPath} placeholder="/path/to/sdcard" keyboardAppearance={theme.keyboardAppearance}></TextInput>
<Button title="OK" onPress={this.exportProfileButtonPress2_}></Button>
</View>
);
@@ -473,6 +508,9 @@ class ConfigScreenComponent extends BaseScreenComponent {
}
}
settingComps.push(this.renderHeader('featureFlags', _('Feature flags')));
settingComps.push(<View key="featureFlagsContainer">{this.renderFeatureFlags(settings)}</View>);
settingComps.push(this.renderHeader('moreInfo', _('More information')));
if (Platform.OS === 'android' && Platform.Version >= 23) {
@@ -568,11 +606,11 @@ class ConfigScreenComponent extends BaseScreenComponent {
}
}
const ConfigScreen = connect(state => {
const ConfigScreen = connect((state: State) => {
return {
settings: state.settings,
themeId: state.settings.theme,
};
})(ConfigScreenComponent);
module.exports = { ConfigScreen };
export default ConfigScreen;

View File

@@ -1,5 +1,5 @@
module.exports = {
hash:"45572502e8b0a8e9b85192de7291684c", files: {
hash:"6608023b8053b48e0eec248644475e33", files: {
'highlight.js/atom-one-dark-reasonable.css': { data: require('./highlight.js/atom-one-dark-reasonable.css.base64.js'), mime: 'text/css', encoding: 'base64' },
'highlight.js/atom-one-light.css': { data: require('./highlight.js/atom-one-light.css.base64.js'), mime: 'text/css', encoding: 'base64' },
'katex/fonts/KaTeX_AMS-Regular.woff2': { data: require('./katex/fonts/KaTeX_AMS-Regular.woff2.base64.js'), mime: 'application/octet-stream', encoding: 'base64' },

File diff suppressed because one or more lines are too long

View File

@@ -57,7 +57,7 @@ import JoplinDatabase from '@joplin/lib/JoplinDatabase';
import Database from '@joplin/lib/database';
const { NotesScreen } = require('./components/screens/notes.js');
const { TagsScreen } = require('./components/screens/tags.js');
const { ConfigScreen } = require('./components/screens/config.js');
import ConfigScreen from './components/screens/ConfigScreen';
const { FolderScreen } = require('./components/screens/folder.js');
const { LogScreen } = require('./components/screens/log.js');
const { StatusScreen } = require('./components/screens/status.js');

View File

@@ -91,7 +91,7 @@ export default class JoplinServerApi {
return _('Could not connect to Joplin Server. Please check the Synchronisation options in the config screen. Full error was:\n\n%s', msg);
}
private hidePassword(o: any): any {
private hidePasswords(o: any): any {
if (typeof o === 'string') {
try {
const output = JSON.parse(o);
@@ -104,6 +104,7 @@ export default class JoplinServerApi {
} else {
const output = { ...o };
if (output.password) output.password = '******';
if (output['X-API-AUTH']) output['X-API-AUTH'] = '******';
return output;
}
}
@@ -114,14 +115,14 @@ export default class JoplinServerApi {
output.push('-v');
if (options.method) output.push(`-X ${options.method}`);
if (options.headers) {
const headers = this.hidePasswords(options.headers);
for (const n in options.headers) {
if (!options.headers.hasOwnProperty(n)) continue;
const headerValue = n === 'X-API-AUTH' ? '******' : options.headers[n];
output.push(`${'-H ' + '"'}${n}: ${headerValue}"`);
output.push(`${'-H ' + '"'}${n}: ${headers[n]}"`);
}
}
if (options.body) {
const serialized = typeof options.body !== 'string' ? JSON.stringify(this.hidePassword(options.body)) : this.hidePassword(options.body);
const serialized = typeof options.body !== 'string' ? JSON.stringify(this.hidePasswords(options.body)) : this.hidePasswords(options.body);
output.push(`${'--data ' + '\''}${serialized}'`);
}
output.push(`'${url}'`);

View File

@@ -1,7 +1,7 @@
const BaseSyncTarget = require('./BaseSyncTarget').default;
const Setting = require('./models/Setting').default;
const { FileApi } = require('./file-api.js');
const { FileApiDriverMemory } = require('./file-api-driver-memory.js');
const FileApiDriverMemory = require('./file-api-driver-memory').default;
const Synchronizer = require('./Synchronizer').default;
class SyncTargetMemory extends BaseSyncTarget {

View File

@@ -19,6 +19,8 @@ import EncryptionService from './services/EncryptionService';
import JoplinError from './JoplinError';
import ShareService from './services/share/ShareService';
import TaskQueue from './TaskQueue';
import ItemUploader from './services/synchronizer/ItemUploader';
import { FileApi } from './file-api';
const { sprintf } = require('sprintf-js');
const { Dirnames } = require('./services/synchronizer/utils/types');
@@ -26,6 +28,18 @@ interface RemoteItem {
id: string;
path?: string;
type_?: number;
isDeleted?: boolean;
// This the time when the file was created on the server. It is used for
// example for the locking mechanim or any file that's not an actual Joplin
// item.
updated_time?: number;
// This is the time that corresponds to the actual Joplin item updated_time
// value. A note is always uploaded with a delay so the server updated_time
// value will always be ahead. However for synchronising we need to know the
// exact Joplin item updated_time value.
jop_updated_time?: number;
}
function isCannotSyncError(error: any): boolean {
@@ -49,7 +63,7 @@ export default class Synchronizer {
public static verboseMode: boolean = true;
private db_: any;
private api_: any;
private api_: FileApi;
private appType_: string;
private logger_: Logger = new Logger();
private state_: string = 'idle';
@@ -73,7 +87,7 @@ export default class Synchronizer {
public dispatch: Function;
constructor(db: any, api: any, appType: string) {
public constructor(db: any, api: FileApi, appType: string) {
this.db_ = db;
this.api_ = api;
this.appType_ = appType;
@@ -83,6 +97,8 @@ export default class Synchronizer {
this.progressReport_ = {};
this.dispatch = function() {};
this.apiCall = this.apiCall.bind(this);
}
state() {
@@ -300,11 +316,11 @@ export default class Synchronizer {
return '';
}
async apiCall(fnName: string, ...args: any[]) {
private async apiCall(fnName: string, ...args: any[]) {
if (this.syncTargetIsLocked_) throw new JoplinError('Sync target is locked - aborting API call', 'lockError');
try {
const output = await this.api()[fnName](...args);
const output = await (this.api() as any)[fnName](...args);
return output;
} catch (error) {
const lockStatus = await this.lockErrorStatus_();
@@ -362,7 +378,7 @@ export default class Synchronizer {
this.dispatch({ type: 'SYNC_STARTED' });
eventManager.emit('syncStart');
this.logSyncOperation('starting', null, null, `Starting synchronisation to target ${syncTargetId}... [${synchronizationId}]`);
this.logSyncOperation('starting', null, null, `Starting synchronisation to target ${syncTargetId}... supportsAccurateTimestamp = ${this.api().supportsAccurateTimestamp}; supportsMultiPut = ${this.api().supportsMultiPut} [${synchronizationId}]`);
const handleCannotSyncItem = async (ItemClass: any, syncTargetId: any, item: any, cannotSyncReason: string, itemLocation: any = null) => {
await ItemClass.saveSyncDisabled(syncTargetId, item, cannotSyncReason, itemLocation);
@@ -389,6 +405,8 @@ export default class Synchronizer {
// correctly so as to share/unshare the right items.
await Folder.updateAllShareIds();
const itemUploader = new ItemUploader(this.api(), this.apiCall);
let errorToThrow = null;
let syncLock = null;
@@ -440,6 +458,8 @@ export default class Synchronizer {
const result = await BaseItem.itemsThatNeedSync(syncTargetId);
const locals = result.items;
await itemUploader.preUploadItems(result.items.filter((it: any) => result.neverSyncedItemIds.includes(it.id)));
for (let i = 0; i < locals.length; i++) {
if (this.cancelling()) break;
@@ -588,8 +608,7 @@ export default class Synchronizer {
let canSync = true;
try {
if (this.testingHooks_.indexOf('notesRejectedByTarget') >= 0 && local.type_ === BaseModel.TYPE_NOTE) throw new JoplinError('Testing rejectedByTarget', 'rejectedByTarget');
const content = await ItemClass.serializeForSync(local);
await this.apiCall('put', path, content);
await itemUploader.serializeAndUploadItem(ItemClass, path, local);
} catch (error) {
if (error && error.code === 'rejectedByTarget') {
await handleCannotSyncItem(ItemClass, syncTargetId, local, error.message);
@@ -619,7 +638,6 @@ export default class Synchronizer {
// above also doesn't use it because it fetches the whole remote object and read the
// more reliable 'updated_time' property. Basically remote.updated_time is deprecated.
// await this.api().setTimestamp(path, local.updated_time);
await ItemClass.saveSyncTime(syncTargetId, local, local.updated_time);
}
} else if (action == 'itemConflict') {
@@ -764,16 +782,27 @@ export default class Synchronizer {
logger: this.logger(),
});
const remotes = listResult.items;
const remotes: RemoteItem[] = listResult.items;
this.logSyncOperation('fetchingTotal', null, null, 'Fetching delta items from sync target', remotes.length);
const remoteIds = remotes.map(r => BaseItem.pathToId(r.path));
const locals = await BaseItem.loadItemsByIds(remoteIds);
for (const remote of remotes) {
if (this.cancelling()) break;
this.downloadQueue_.push(remote.path, async () => {
return this.apiCall('get', remote.path);
});
let needsToDownload = true;
if (this.api().supportsAccurateTimestamp) {
const local = locals.find(l => l.id === BaseItem.pathToId(remote.path));
if (local && local.updated_time === remote.jop_updated_time) needsToDownload = false;
}
if (needsToDownload) {
this.downloadQueue_.push(remote.path, async () => {
return this.apiCall('get', remote.path);
});
}
}
for (let i = 0; i < remotes.length; i++) {
@@ -795,9 +824,10 @@ export default class Synchronizer {
};
const path = remote.path;
const remoteId = BaseItem.pathToId(path);
let action = null;
let reason = '';
let local = await BaseItem.loadItemByPath(path);
let local = locals.find(l => l.id === remoteId);
let ItemClass = null;
let content = null;
@@ -816,10 +846,14 @@ export default class Synchronizer {
action = 'deleteLocal';
reason = 'remote has been deleted';
} else {
content = await loadContent();
if (content && content.updated_time > local.updated_time) {
action = 'updateLocal';
reason = 'remote is more recent than local';
if (this.api().supportsAccurateTimestamp && remote.jop_updated_time === local.updated_time) {
// Nothing to do, and no need to fetch the content
} else {
content = await loadContent();
if (content && content.updated_time > local.updated_time) {
action = 'updateLocal';
reason = 'remote is more recent than local';
}
}
}
}

View File

@@ -1,5 +1,7 @@
import { MultiPutItem } from './file-api';
import JoplinError from './JoplinError';
import JoplinServerApi from './JoplinServerApi';
import Setting from './models/Setting';
import { trimSlashes } from './path-utils';
// All input paths should be in the format: "path/to/file". This is converted to
@@ -31,6 +33,14 @@ export default class FileApiDriverJoplinServer {
return this.api_;
}
public get supportsMultiPut() {
return Setting.value('featureFlag.syncMultiPut');
}
public get supportsAccurateTimestamp() {
return Setting.value('featureFlag.syncAccurateTimestamps');
}
public requestRepeatCount() {
return 3;
}
@@ -39,7 +49,8 @@ export default class FileApiDriverJoplinServer {
const output = {
path: rootPath ? path.substr(rootPath.length + 1) : path,
updated_time: md.updated_time,
isDir: false, // !!md.is_directory,
jop_updated_time: md.jop_updated_time,
isDir: false,
isDeleted: isDeleted,
};
@@ -174,6 +185,10 @@ export default class FileApiDriverJoplinServer {
}
}
public async multiPut(items: MultiPutItem[], options: any = null) {
return this.api().exec('PUT', 'api/batch_items', null, { items: items }, null, options);
}
public async delete(path: string) {
return this.api().exec('DELETE', this.apiFilePath_(path));
}

View File

@@ -1,14 +1,18 @@
const time = require('./time').default;
import time from './time';
const fs = require('fs-extra');
const { basicDelta } = require('./file-api');
import { basicDelta, MultiPutItem } from './file-api';
export default class FileApiDriverMemory {
private items_: any[];
private deletedItems_: any[];
class FileApiDriverMemory {
constructor() {
this.items_ = [];
this.deletedItems_ = [];
}
encodeContent_(content) {
encodeContent_(content: any) {
if (content instanceof Buffer) {
return content.toString('base64');
} else {
@@ -16,23 +20,31 @@ class FileApiDriverMemory {
}
}
decodeContent_(content) {
public get supportsMultiPut() {
return true;
}
public get supportsAccurateTimestamp() {
return true;
}
decodeContent_(content: any) {
return Buffer.from(content, 'base64').toString('utf-8');
}
itemIndexByPath(path) {
itemIndexByPath(path: string) {
for (let i = 0; i < this.items_.length; i++) {
if (this.items_[i].path == path) return i;
}
return -1;
}
itemByPath(path) {
itemByPath(path: string) {
const index = this.itemIndexByPath(path);
return index < 0 ? null : this.items_[index];
}
newItem(path, isDir = false) {
newItem(path: string, isDir = false) {
const now = time.unixMs();
return {
path: path,
@@ -43,18 +55,18 @@ class FileApiDriverMemory {
};
}
stat(path) {
stat(path: string) {
const item = this.itemByPath(path);
return Promise.resolve(item ? Object.assign({}, item) : null);
}
async setTimestamp(path, timestampMs) {
async setTimestamp(path: string, timestampMs: number): Promise<any> {
const item = this.itemByPath(path);
if (!item) return Promise.reject(new Error(`File not found: ${path}`));
item.updated_time = timestampMs;
}
async list(path) {
async list(path: string) {
const output = [];
for (let i = 0; i < this.items_.length; i++) {
@@ -77,7 +89,7 @@ class FileApiDriverMemory {
});
}
async get(path, options) {
async get(path: string, options: any) {
const item = this.itemByPath(path);
if (!item) return Promise.resolve(null);
if (item.isDir) return Promise.reject(new Error(`${path} is a directory, not a file`));
@@ -93,13 +105,13 @@ class FileApiDriverMemory {
return output;
}
async mkdir(path) {
async mkdir(path: string) {
const index = this.itemIndexByPath(path);
if (index >= 0) return;
this.items_.push(this.newItem(path, true));
}
async put(path, content, options = null) {
async put(path: string, content: any, options: any = null) {
if (!options) options = {};
if (options.source === 'file') content = await fs.readFile(options.path);
@@ -109,13 +121,38 @@ class FileApiDriverMemory {
const item = this.newItem(path, false);
item.content = this.encodeContent_(content);
this.items_.push(item);
return item;
} else {
this.items_[index].content = this.encodeContent_(content);
this.items_[index].updated_time = time.unixMs();
return this.items_[index];
}
}
async delete(path) {
public async multiPut(items: MultiPutItem[], options: any = null) {
const output: any = {
items: {},
};
for (const item of items) {
try {
const processedItem = await this.put(`/root/${item.name}`, item.body, options);
output.items[item.name] = {
item: processedItem,
error: null,
};
} catch (error) {
output.items[item.name] = {
item: null,
error: error,
};
}
}
return output;
}
async delete(path: string) {
const index = this.itemIndexByPath(path);
if (index >= 0) {
const item = Object.assign({}, this.items_[index]);
@@ -126,10 +163,10 @@ class FileApiDriverMemory {
}
}
async move(oldPath, newPath) {
async move(oldPath: string, newPath: string): Promise<any> {
const sourceItem = this.itemByPath(oldPath);
if (!sourceItem) return Promise.reject(new Error(`Path not found: ${oldPath}`));
this.delete(newPath); // Overwrite if newPath already exists
await this.delete(newPath); // Overwrite if newPath already exists
sourceItem.path = newPath;
}
@@ -137,8 +174,8 @@ class FileApiDriverMemory {
this.items_ = [];
}
async delta(path, options = null) {
const getStatFn = async path => {
async delta(path: string, options: any = null) {
const getStatFn = async (path: string) => {
const output = this.items_.slice();
for (let i = 0; i < output.length; i++) {
const item = Object.assign({}, output[i]);
@@ -156,5 +193,3 @@ class FileApiDriverMemory {
this.items_ = [];
}
}
module.exports = { FileApiDriverMemory };

View File

@@ -11,6 +11,11 @@ const Mutex = require('async-mutex').Mutex;
const logger = Logger.create('FileApi');
export interface MultiPutItem {
name: string;
body: string;
}
function requestCanBeRepeated(error: any) {
const errorCode = typeof error === 'object' && error.code ? error.code : null;
@@ -81,6 +86,26 @@ class FileApi {
if (this.driver_.initialize) return this.driver_.initialize(this.fullPath(''));
}
// This can be true if the driver implements uploading items in batch. Will
// probably only be supported by Joplin Server.
public get supportsMultiPut(): boolean {
return !!this.driver().supportsMultiPut;
}
// This can be true when the sync target timestamps (updated_time) provided
// in the delta call are guaranteed to be accurate. That requires
// explicitely setting the timestamp, which is not done anymore on any sync
// target as it wasn't accurate (for example, the file system can't be
// relied on, and even OneDrive for some reason doesn't guarantee that the
// timestamp you set is what you get back).
//
// The only reliable one at the moment is Joplin Server since it reads the
// updated_time property directly from the item (it unserializes it
// server-side).
public get supportsAccurateTimestamp(): boolean {
return !!this.driver().supportsAccurateTimestamp;
}
async fetchRemoteDateOffset_() {
const tempFile = `${this.tempDirName()}/timeCheck${Math.round(Math.random() * 1000000)}.txt`;
const startTime = Date.now();
@@ -251,12 +276,6 @@ class FileApi {
if (!output) return output;
output.path = path;
return output;
// return this.driver_.stat(this.fullPath(path)).then((output) => {
// if (!output) return output;
// output.path = path;
// return output;
// });
}
// Returns UTF-8 encoded string by default, or a Response if `options.target = 'file'`
@@ -277,6 +296,11 @@ class FileApi {
return tryAndRepeat(() => this.driver_.put(this.fullPath(path), content, options), this.requestRepeatCount());
}
public async multiPut(items: MultiPutItem[], options: any = null) {
if (!this.driver().supportsMultiPut) throw new Error('Multi PUT not supported');
return tryAndRepeat(() => this.driver_.multiPut(items, options), this.requestRepeatCount());
}
delete(path: string) {
logger.debug(`delete ${this.fullPath(path)}`);
return tryAndRepeat(() => this.driver_.delete(this.fullPath(path)), this.requestRepeatCount());

View File

@@ -403,7 +403,7 @@ export default class BaseItem extends BaseModel {
return this.shareService_;
}
public static async serializeForSync(item: BaseItemEntity) {
public static async serializeForSync(item: BaseItemEntity): Promise<string> {
const ItemClass = this.itemClass(item);
const shownKeys = ItemClass.fieldNames();
shownKeys.push('type_');

View File

@@ -326,37 +326,65 @@ describe('models_Folder.sharing', function() {
await Folder.updateAllShareIds();
// await NoteResource.updateResourceShareIds();
{
const resource: ResourceEntity = await Resource.load(resourceId);
expect(resource.share_id).toBe('');
}
});
// it('should not recursively delete when non-owner deletes a shared folder', async () => {
// const folder = await createFolderTree('', [
// {
// title: 'folder 1',
// children: [
// {
// title: 'note 1',
// },
// ],
// },
// ]);
it('should unshare items that are no longer part of an existing share', async () => {
await createFolderTree('', [
{
title: 'folder 1',
share_id: '1',
children: [
{
title: 'note 1',
},
],
},
{
title: 'folder 2',
share_id: '2',
children: [
{
title: 'note 2',
},
],
},
]);
// BaseItem.shareService_ = {
// isSharedFolderOwner: (_folderId: string) => false,
// } as any;
const resourceService = new ResourceService();
// await Folder.save({ id: folder.id, share_id: 'abcd1234' });
// await Folder.updateAllShareIds();
let note1: NoteEntity = await Note.loadByTitle('note 1');
let note2: NoteEntity = await Note.loadByTitle('note 2');
note1 = await shim.attachFileToNote(note1, testImagePath);
note2 = await shim.attachFileToNote(note2, testImagePath);
const resourceId1 = (await Note.linkedResourceIds(note1.body))[0];
const resourceId2 = (await Note.linkedResourceIds(note2.body))[0];
// await Folder.delete(folder.id);
await resourceService.indexNoteResources();
// expect((await Folder.all()).length).toBe(0);
// expect((await Note.all()).length).toBe(1);
// });
await Folder.updateAllShareIds();
await Folder.updateNoLongerSharedItems(['1']);
// At this point, all items associated with share 2 should have their
// share_id cleared, because the share no longer exists. We also
// double-check that share 1 hasn't been cleared.
expect((await Note.loadByTitle('note 1')).share_id).toBe('1');
expect((await Note.loadByTitle('note 2')).share_id).toBe('');
expect((await Folder.loadByTitle('folder 1')).share_id).toBe('1');
expect((await Folder.loadByTitle('folder 2')).share_id).toBe('');
expect((await Resource.load(resourceId1)).share_id).toBe('1');
expect((await Resource.load(resourceId2)).share_id).toBe('');
// If we pass an empty array, it means there are no active share
// anymore, so all share_id should be cleared.
await Folder.updateNoLongerSharedItems([]);
expect((await Note.loadByTitle('note 1')).share_id).toBe('');
expect((await Folder.loadByTitle('folder 1')).share_id).toBe('');
expect((await Resource.load(resourceId1)).share_id).toBe('');
});
});

View File

@@ -374,6 +374,38 @@ export default class Folder extends BaseItem {
await this.updateResourceShareIds();
}
// Clear the "share_id" property for the items that are associated with a
// share that no longer exists.
public static async updateNoLongerSharedItems(activeShareIds: string[]) {
const tableNameToClasses: Record<string, any> = {
'folders': Folder,
'notes': Note,
'resources': Resource,
};
for (const tableName of ['folders', 'notes', 'resources']) {
const ItemClass = tableNameToClasses[tableName];
const query = activeShareIds.length ? `
SELECT id FROM ${tableName}
WHERE share_id NOT IN ("${activeShareIds.join('","')}")
` : `
SELECT id FROM ${tableName}
WHERE share_id != ''
`;
const rows = await this.db().selectAll(query);
for (const row of rows) {
await ItemClass.save({
id: row.id,
share_id: '',
updated_time: Date.now(),
}, { autoTimestamp: false });
}
}
}
static async allAsTree(folders: FolderEntity[] = null, options: any = null) {
const all = folders ? folders : await this.all(options);

View File

@@ -458,7 +458,7 @@ export default class Note extends BaseItem {
return this.modelSelectAll('SELECT * FROM notes WHERE is_conflict = 0');
}
static async updateGeolocation(noteId: string) {
public static async updateGeolocation(noteId: string): Promise<void> {
if (!Setting.value('trackLocation')) return;
if (!Note.updateGeolocationEnabled_) return;
@@ -503,7 +503,7 @@ export default class Note extends BaseItem {
note.longitude = geoData.coords.longitude;
note.latitude = geoData.coords.latitude;
note.altitude = geoData.coords.altitude;
return Note.save(note, { ignoreProvisionalFlag: true });
await Note.save(note, { ignoreProvisionalFlag: true });
}
static filter(note: NoteEntity) {
@@ -633,7 +633,7 @@ export default class Note extends BaseItem {
return n.updated_time < date;
}
static async save(o: NoteEntity, options: any = null) {
public static async save(o: NoteEntity, options: any = null): Promise<NoteEntity> {
const isNew = this.isNew(o, options);
// If true, this is a provisional note - it will be saved permanently

View File

@@ -1,92 +0,0 @@
const { setupDatabaseAndSynchronizer, switchClient } = require('../testing/test-utils.js');
const Note = require('../models/Note').default;
const Revision = require('../models/Revision').default;
describe('models_Revision', function() {
beforeEach(async (done) => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
done();
});
it('should create patches of text and apply it', (async () => {
const note1 = await Note.save({ body: 'my note\nsecond line' });
const patch = Revision.createTextPatch(note1.body, 'my new note\nsecond line');
const merged = Revision.applyTextPatch(note1.body, patch);
expect(merged).toBe('my new note\nsecond line');
}));
it('should create patches of objects and apply it', (async () => {
const oldObject = {
one: '123',
two: '456',
three: '789',
};
const newObject = {
one: '123',
three: '999',
};
const patch = Revision.createObjectPatch(oldObject, newObject);
const merged = Revision.applyObjectPatch(oldObject, patch);
expect(JSON.stringify(merged)).toBe(JSON.stringify(newObject));
}));
it('should move target revision to the top', (async () => {
const revs = [
{ id: '123' },
{ id: '456' },
{ id: '789' },
];
let newRevs;
newRevs = Revision.moveRevisionToTop({ id: '456' }, revs);
expect(newRevs[0].id).toBe('123');
expect(newRevs[1].id).toBe('789');
expect(newRevs[2].id).toBe('456');
newRevs = Revision.moveRevisionToTop({ id: '789' }, revs);
expect(newRevs[0].id).toBe('123');
expect(newRevs[1].id).toBe('456');
expect(newRevs[2].id).toBe('789');
}));
it('should create patch stats', (async () => {
const tests = [
{
patch: `@@ -625,16 +625,48 @@
rrupted download
+%0A- %5B %5D Fix mobile screen options`,
expected: [-0, +32],
},
{
patch: `@@ -564,17 +564,17 @@
ages%0A- %5B
-
+x
%5D Check `,
expected: [-1, +1],
},
{
patch: `@@ -1022,56 +1022,415 @@
.%0A%0A#
- How to view a note history%0A%0AWhile all the apps
+%C2%A0How does it work?%0A%0AAll the apps save a version of the modified notes every 10 minutes.
%0A%0A# `,
expected: [-(19 + 27 + 2), 17 + 67 + 4],
},
];
for (const test of tests) {
const stats = Revision.patchStats(test.patch);
expect(stats.removed).toBe(-test.expected[0]);
expect(stats.added).toBe(test.expected[1]);
}
}));
});

View File

@@ -0,0 +1,194 @@
import { expectNotThrow, naughtyStrings, setupDatabaseAndSynchronizer, switchClient } from '../testing/test-utils';
import Note from '../models/Note';
import Revision from '../models/Revision';
describe('models/Revision', function() {
beforeEach(async (done) => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
done();
});
it('should create patches of text and apply it', (async () => {
const note1 = await Note.save({ body: 'my note\nsecond line' });
const patch = Revision.createTextPatch(note1.body, 'my new note\nsecond line');
const merged = Revision.applyTextPatch(note1.body, patch);
expect(merged).toBe('my new note\nsecond line');
}));
it('should check if it is an empty revision', async () => {
const testCases = [
[false, {
title_diff: '',
body_diff: '',
metadata_diff: '{"new":{"id":"aaa"},"deleted":[]}',
}],
[true, {
title_diff: '',
body_diff: '',
metadata_diff: '',
}],
[true, {
title_diff: '[]',
body_diff: '',
metadata_diff: '{"new":{},"deleted":[]}',
}],
[true, {
title_diff: '',
body_diff: '[]',
metadata_diff: '{"new":{},"deleted":[]}',
}],
[false, {
title_diff: '[{"diffs":[[1,"hello"]],"start1":0,"start2":0,"length1":0,"length2":5}]',
body_diff: '[]',
metadata_diff: '{"new":{},"deleted":[]}',
}],
];
for (const t of testCases) {
const [expected, input] = t;
expect(Revision.isEmptyRevision(input as any)).toBe(expected);
}
});
it('should not fail to create revisions on naughty strings', (async () => {
// Previously this pattern would fail:
// - Create a patch between an empty string and smileys
// - Use that patch on the empty string to get back the smileys
// - Create a patch between those smileys and new smileys
// https://github.com/JackuB/diff-match-patch/issues/22
const nss = await naughtyStrings();
// First confirm that it indeed fails with the legacy approach.
let errorCount = 0;
for (let i = 0; i < nss.length - 1; i++) {
const ns1 = nss[i];
const ns2 = nss[i + 1];
try {
const patchText = Revision.createTextPatchLegacy('', ns1);
const patchedText = Revision.applyTextPatchLegacy('', patchText);
Revision.createTextPatchLegacy(patchedText, ns2);
} catch (error) {
errorCount++;
}
}
expect(errorCount).toBe(10);
// Now feed the naughty list again but using the new approach. In that
// case it should work fine.
await expectNotThrow(async () => {
for (let i = 0; i < nss.length - 1; i++) {
const ns1 = nss[i];
const ns2 = nss[i + 1];
const patchText = Revision.createTextPatch('', ns1);
const patchedText = Revision.applyTextPatch('', patchText);
Revision.createTextPatch(patchedText, ns2);
}
});
}));
it('should successfully handle legacy patches', async () => {
// The code should handle applying a series of new style patches and
// legacy patches, and the correct text should be recovered at the end.
const changes = [
'',
'one',
'one three',
'one two three',
];
const patches = [
Revision.createTextPatch(changes[0], changes[1]),
Revision.createTextPatchLegacy(changes[1], changes[2]),
Revision.createTextPatch(changes[2], changes[3]),
];
// Sanity check - verify that the patches are as expected
expect(patches[0].substr(0, 2)).toBe('[{'); // New
expect(patches[1].substr(0, 2)).toBe('@@'); // Legacy
expect(patches[2].substr(0, 2)).toBe('[{'); // New
let finalString = Revision.applyTextPatch(changes[0], patches[0]);
finalString = Revision.applyTextPatch(finalString, patches[1]);
finalString = Revision.applyTextPatch(finalString, patches[2]);
expect(finalString).toBe('one two three');
});
it('should create patches of objects and apply it', (async () => {
const oldObject = {
one: '123',
two: '456',
three: '789',
};
const newObject = {
one: '123',
three: '999',
};
const patch = Revision.createObjectPatch(oldObject, newObject);
const merged = Revision.applyObjectPatch(oldObject, patch);
expect(JSON.stringify(merged)).toBe(JSON.stringify(newObject));
}));
it('should move target revision to the top', (async () => {
const revs = [
{ id: '123' },
{ id: '456' },
{ id: '789' },
];
let newRevs;
newRevs = Revision.moveRevisionToTop({ id: '456' }, revs);
expect(newRevs[0].id).toBe('123');
expect(newRevs[1].id).toBe('789');
expect(newRevs[2].id).toBe('456');
newRevs = Revision.moveRevisionToTop({ id: '789' }, revs);
expect(newRevs[0].id).toBe('123');
expect(newRevs[1].id).toBe('456');
expect(newRevs[2].id).toBe('789');
}));
it('should create patch stats', (async () => {
const tests = [
{
patch: `@@ -625,16 +625,48 @@
rrupted download
+%0A- %5B %5D Fix mobile screen options`,
expected: [-0, +32],
},
{
patch: `@@ -564,17 +564,17 @@
ages%0A- %5B
-
+x
%5D Check `,
expected: [-1, +1],
},
{
patch: `@@ -1022,56 +1022,415 @@
.%0A%0A#
- How to view a note history%0A%0AWhile all the apps
+%C2%A0How does it work?%0A%0AAll the apps save a version of the modified notes every 10 minutes.
%0A%0A# `,
expected: [-(19 + 27 + 2), 17 + 67 + 4],
},
];
for (const test of tests) {
const stats = Revision.patchStats(test.patch);
expect(stats.removed).toBe(-test.expected[0]);
expect(stats.added).toBe(test.expected[1]);
}
}));
});

View File

@@ -17,17 +17,57 @@ export default class Revision extends BaseItem {
return BaseModel.TYPE_REVISION;
}
static createTextPatch(oldText: string, newText: string) {
public static createTextPatchLegacy(oldText: string, newText: string): string {
return dmp.patch_toText(dmp.patch_make(oldText, newText));
}
static applyTextPatch(text: string, patch: string) {
public static createTextPatch(oldText: string, newText: string): string {
return JSON.stringify(dmp.patch_make(oldText, newText));
}
public static applyTextPatchLegacy(text: string, patch: string): string {
patch = dmp.patch_fromText(patch);
const result = dmp.patch_apply(patch, text);
if (!result || !result.length) throw new Error('Could not apply patch');
return result[0];
}
private static isLegacyPatch(patch: string): boolean {
return patch && patch.indexOf('@@') === 0;
}
private static isNewPatch(patch: string): boolean {
if (!patch) return true;
return patch.indexOf('[{') === 0;
}
public static applyTextPatch(text: string, patch: string): string {
if (this.isLegacyPatch(patch)) {
return this.applyTextPatchLegacy(text, patch);
} else {
// An empty patch should be '[]', but legacy data may be just "".
// However an empty string would make JSON.parse fail so we set it
// to '[]'.
const result = dmp.patch_apply(JSON.parse(patch ? patch : '[]'), text);
if (!result || !result.length) throw new Error('Could not apply patch');
return result[0];
}
}
public static isEmptyRevision(rev: RevisionEntity): boolean {
if (this.isLegacyPatch(rev.title_diff) && rev.title_diff) return false;
if (this.isLegacyPatch(rev.body_diff) && rev.body_diff) return false;
if (this.isNewPatch(rev.title_diff) && rev.title_diff && rev.title_diff !== '[]') return false;
if (this.isNewPatch(rev.body_diff) && rev.body_diff && rev.body_diff !== '[]') return false;
const md = rev.metadata_diff ? JSON.parse(rev.metadata_diff) : {};
if (md.new && Object.keys(md.new).length) return false;
if (md.deleted && Object.keys(md.deleted).length) return false;
return true;
}
static createObjectPatch(oldObject: any, newObject: any) {
if (!oldObject) oldObject = {};

View File

@@ -42,7 +42,7 @@ export interface SettingItem {
label?(): string;
description?: Function;
options?(): any;
appTypes?: string[];
appTypes?: AppType[];
show?(settings: any): boolean;
filter?(value: any): any;
secure?: boolean;
@@ -93,12 +93,18 @@ export enum Env {
Prod = 'prod',
}
export enum AppType {
Desktop = 'desktop',
Mobile = 'mobile',
Cli = 'cli',
}
export interface Constants {
env: Env;
isDemo: boolean;
appName: string;
appId: string;
appType: string;
appType: AppType;
resourceDirName: string;
resourceDir: string;
profileDir: string;
@@ -180,7 +186,7 @@ class Setting extends BaseModel {
isDemo: false,
appName: 'joplin',
appId: 'SET_ME', // Each app should set this identifier
appType: 'SET_ME', // 'cli' or 'mobile'
appType: 'SET_ME' as any, // 'cli' or 'mobile'
resourceDirName: '',
resourceDir: '',
profileDir: '',
@@ -290,7 +296,7 @@ class Setting extends BaseModel {
value: true,
type: SettingItemType.Bool,
public: false,
appTypes: ['desktop'],
appTypes: [AppType.Desktop],
storage: SettingStorage.File,
},
'sync.target': {
@@ -300,7 +306,7 @@ class Setting extends BaseModel {
public: true,
section: 'sync',
label: () => _('Synchronisation target'),
description: (appType: string) => {
description: (appType: AppType) => {
return appType !== 'cli' ? null : _('The target to synchronise to. Each sync target may have additional parameters which are named as `sync.NUM.NAME` (all documented below).');
},
options: () => {
@@ -553,7 +559,7 @@ class Setting extends BaseModel {
public: true,
advanced: true,
isEnum: true,
appTypes: ['mobile', 'desktop'],
appTypes: [AppType.Mobile, AppType.Desktop],
label: () => _('Attachment download behaviour'),
description: () => _('In "Manual" mode, attachments are downloaded only when you click on them. In "Auto", they are downloaded when you open the note. In "Always", all the attachments are downloaded whether you open the note or not.'),
options: () => {
@@ -645,7 +651,7 @@ class Setting extends BaseModel {
value: Setting.THEME_LIGHT,
type: SettingItemType.Int,
public: true,
appTypes: ['mobile', 'desktop'],
appTypes: [AppType.Mobile, AppType.Desktop],
show: (settings) => {
return !settings['themeAutoDetect'];
},
@@ -660,7 +666,7 @@ class Setting extends BaseModel {
value: false,
type: SettingItemType.Bool,
section: 'appearance',
appTypes: ['desktop'],
appTypes: [AppType.Desktop],
public: true,
label: () => _('Automatically switch theme to match system theme'),
storage: SettingStorage.File,
@@ -673,7 +679,7 @@ class Setting extends BaseModel {
show: (settings) => {
return settings['themeAutoDetect'];
},
appTypes: ['desktop'],
appTypes: [AppType.Desktop],
isEnum: true,
label: () => _('Preferred light theme'),
section: 'appearance',
@@ -688,7 +694,7 @@ class Setting extends BaseModel {
show: (settings) => {
return settings['themeAutoDetect'];
},
appTypes: ['desktop'],
appTypes: [AppType.Desktop],
isEnum: true,
label: () => _('Preferred dark theme'),
section: 'appearance',
@@ -702,13 +708,13 @@ class Setting extends BaseModel {
public: false,
},
showNoteCounts: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, public: false, advanced: true, appTypes: ['desktop'], label: () => _('Show note counts') },
showNoteCounts: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, public: false, advanced: true, appTypes: [AppType.Desktop], label: () => _('Show note counts') },
layoutButtonSequence: {
value: Setting.LAYOUT_ALL,
type: SettingItemType.Int,
public: false,
appTypes: ['desktop'],
appTypes: [AppType.Desktop],
isEnum: true,
options: () => ({
[Setting.LAYOUT_ALL]: _('%s / %s / %s', _('Editor'), _('Viewer'), _('Split View')),
@@ -718,15 +724,15 @@ class Setting extends BaseModel {
}),
storage: SettingStorage.File,
},
uncompletedTodosOnTop: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'note', public: true, appTypes: ['cli'], label: () => _('Uncompleted to-dos on top') },
showCompletedTodos: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'note', public: true, appTypes: ['cli'], label: () => _('Show completed to-dos') },
uncompletedTodosOnTop: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'note', public: true, appTypes: [AppType.Cli], label: () => _('Uncompleted to-dos on top') },
showCompletedTodos: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'note', public: true, appTypes: [AppType.Cli], label: () => _('Show completed to-dos') },
'notes.sortOrder.field': {
value: 'user_updated_time',
type: SettingItemType.String,
section: 'note',
isEnum: true,
public: true,
appTypes: ['cli'],
appTypes: [AppType.Cli],
label: () => _('Sort notes by'),
options: () => {
const Note = require('./Note').default;
@@ -744,17 +750,17 @@ class Setting extends BaseModel {
type: SettingItemType.Bool,
public: true,
section: 'note',
appTypes: ['desktop'],
appTypes: [AppType.Desktop],
label: () => _('Auto-pair braces, parenthesis, quotations, etc.'),
storage: SettingStorage.File,
},
'notes.sortOrder.reverse': { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'note', public: true, label: () => _('Reverse sort order'), appTypes: ['cli'] },
'notes.sortOrder.reverse': { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'note', public: true, label: () => _('Reverse sort order'), appTypes: [AppType.Cli] },
'folders.sortOrder.field': {
value: 'title',
type: SettingItemType.String,
isEnum: true,
public: true,
appTypes: ['cli'],
appTypes: [AppType.Cli],
label: () => _('Sort notebooks by'),
options: () => {
const Folder = require('./Folder').default;
@@ -767,7 +773,7 @@ class Setting extends BaseModel {
},
storage: SettingStorage.File,
},
'folders.sortOrder.reverse': { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, public: true, label: () => _('Reverse sort order'), appTypes: ['cli'] },
'folders.sortOrder.reverse': { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, public: true, label: () => _('Reverse sort order'), appTypes: [AppType.Cli] },
trackLocation: { value: true, type: SettingItemType.Bool, section: 'note', storage: SettingStorage.File, public: true, label: () => _('Save geo-location with notes') },
// 2020-10-29: For now disable the beta editor due to
@@ -780,7 +786,7 @@ class Setting extends BaseModel {
type: SettingItemType.Bool,
section: 'note',
public: false, // mobilePlatform === 'ios',
appTypes: ['mobile'],
appTypes: [AppType.Mobile],
label: () => 'Opt-in to the editor beta',
description: () => 'This beta adds list continuation, Markdown preview, and Markdown shortcuts. If you find bugs, please report them in the Discourse forum.',
},
@@ -791,7 +797,7 @@ class Setting extends BaseModel {
section: 'note',
isEnum: true,
public: true,
appTypes: ['desktop'],
appTypes: [AppType.Desktop],
label: () => _('When creating a new to-do:'),
options: () => {
return {
@@ -807,7 +813,7 @@ class Setting extends BaseModel {
section: 'note',
isEnum: true,
public: true,
appTypes: ['desktop'],
appTypes: [AppType.Desktop],
label: () => _('When creating a new note:'),
options: () => {
return {
@@ -823,7 +829,7 @@ class Setting extends BaseModel {
type: SettingItemType.Object,
section: 'plugins',
public: true,
appTypes: ['desktop'],
appTypes: [AppType.Desktop],
needRestart: true,
autoSave: true,
},
@@ -834,38 +840,38 @@ class Setting extends BaseModel {
section: 'plugins',
public: true,
advanced: true,
appTypes: ['desktop'],
appTypes: [AppType.Desktop],
label: () => 'Development plugins',
description: () => 'You may add multiple plugin paths, each separated by a comma. You will need to restart the application for the changes to take effect.',
storage: SettingStorage.File,
},
// Deprecated - use markdown.plugin.*
'markdown.softbreaks': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, public: false, appTypes: ['mobile', 'desktop'] },
'markdown.typographer': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, public: false, appTypes: ['mobile', 'desktop'] },
'markdown.softbreaks': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, public: false, appTypes: [AppType.Mobile, AppType.Desktop] },
'markdown.typographer': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, public: false, appTypes: [AppType.Mobile, AppType.Desktop] },
// Deprecated
'markdown.plugin.softbreaks': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable soft breaks')}${wysiwygYes}` },
'markdown.plugin.typographer': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable typographer support')}${wysiwygYes}` },
'markdown.plugin.linkify': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable Linkify')}${wysiwygYes}` },
'markdown.plugin.softbreaks': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable soft breaks')}${wysiwygYes}` },
'markdown.plugin.typographer': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable typographer support')}${wysiwygYes}` },
'markdown.plugin.linkify': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable Linkify')}${wysiwygYes}` },
'markdown.plugin.katex': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable math expressions')}${wysiwygYes}` },
'markdown.plugin.fountain': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable Fountain syntax support')}${wysiwygYes}` },
'markdown.plugin.mermaid': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable Mermaid diagrams support')}${wysiwygYes}` },
'markdown.plugin.katex': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable math expressions')}${wysiwygYes}` },
'markdown.plugin.fountain': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable Fountain syntax support')}${wysiwygYes}` },
'markdown.plugin.mermaid': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable Mermaid diagrams support')}${wysiwygYes}` },
'markdown.plugin.audioPlayer': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable audio player')}${wysiwygNo}` },
'markdown.plugin.videoPlayer': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable video player')}${wysiwygNo}` },
'markdown.plugin.pdfViewer': { storage: SettingStorage.File, value: !mobilePlatform, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['desktop'], label: () => `${_('Enable PDF viewer')}${wysiwygNo}` },
'markdown.plugin.mark': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable ==mark== syntax')}${wysiwygYes}` },
'markdown.plugin.footnote': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable footnotes')}${wysiwygNo}` },
'markdown.plugin.toc': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable table of contents extension')}${wysiwygNo}` },
'markdown.plugin.sub': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable ~sub~ syntax')}${wysiwygYes}` },
'markdown.plugin.sup': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable ^sup^ syntax')}${wysiwygYes}` },
'markdown.plugin.deflist': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable deflist syntax')}${wysiwygNo}` },
'markdown.plugin.abbr': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable abbreviation syntax')}${wysiwygNo}` },
'markdown.plugin.emoji': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable markdown emoji')}${wysiwygNo}` },
'markdown.plugin.insert': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable ++insert++ syntax')}${wysiwygYes}` },
'markdown.plugin.multitable': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: ['mobile', 'desktop'], label: () => `${_('Enable multimarkdown table extension')}${wysiwygNo}` },
'markdown.plugin.audioPlayer': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable audio player')}${wysiwygNo}` },
'markdown.plugin.videoPlayer': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable video player')}${wysiwygNo}` },
'markdown.plugin.pdfViewer': { storage: SettingStorage.File, value: !mobilePlatform, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Desktop], label: () => `${_('Enable PDF viewer')}${wysiwygNo}` },
'markdown.plugin.mark': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable ==mark== syntax')}${wysiwygYes}` },
'markdown.plugin.footnote': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable footnotes')}${wysiwygNo}` },
'markdown.plugin.toc': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable table of contents extension')}${wysiwygNo}` },
'markdown.plugin.sub': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable ~sub~ syntax')}${wysiwygYes}` },
'markdown.plugin.sup': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable ^sup^ syntax')}${wysiwygYes}` },
'markdown.plugin.deflist': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable deflist syntax')}${wysiwygNo}` },
'markdown.plugin.abbr': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable abbreviation syntax')}${wysiwygNo}` },
'markdown.plugin.emoji': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable markdown emoji')}${wysiwygNo}` },
'markdown.plugin.insert': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable ++insert++ syntax')}${wysiwygYes}` },
'markdown.plugin.multitable': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable multimarkdown table extension')}${wysiwygNo}` },
// Tray icon (called AppIndicator) doesn't work in Ubuntu
// http://www.webupd8.org/2017/04/fix-appindicator-not-working-for.html
@@ -876,7 +882,7 @@ class Setting extends BaseModel {
type: SettingItemType.Bool,
section: 'application',
public: true,
appTypes: ['desktop'],
appTypes: [AppType.Desktop],
label: () => _('Show tray icon'),
description: () => {
return platform === 'linux' ? _('Note: Does not work in all desktop environments.') : _('This will allow Joplin to run in the background. It is recommended to enable this setting so that your notes are constantly being synchronised, thus reducing the number of conflicts.');
@@ -884,7 +890,7 @@ class Setting extends BaseModel {
storage: SettingStorage.File,
},
startMinimized: { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'application', public: true, appTypes: ['desktop'], label: () => _('Start application minimised in the tray icon') },
startMinimized: { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'application', public: true, appTypes: [AppType.Desktop], label: () => _('Start application minimised in the tray icon') },
collapsedFolderIds: { value: [], type: SettingItemType.Array, public: false },
@@ -907,9 +913,9 @@ class Setting extends BaseModel {
},
// Deprecated in favour of windowContentZoomFactor
'style.zoom': { value: 100, type: SettingItemType.Int, public: false, storage: SettingStorage.File, appTypes: ['desktop'], section: 'appearance', label: () => '', minimum: 50, maximum: 500, step: 10 },
'style.zoom': { value: 100, type: SettingItemType.Int, public: false, storage: SettingStorage.File, appTypes: [AppType.Desktop], section: 'appearance', label: () => '', minimum: 50, maximum: 500, step: 10 },
'style.editor.fontSize': { value: 13, type: SettingItemType.Int, public: true, storage: SettingStorage.File, appTypes: ['desktop'], section: 'appearance', label: () => _('Editor font size'), minimum: 4, maximum: 50, step: 1 },
'style.editor.fontSize': { value: 13, type: SettingItemType.Int, public: true, storage: SettingStorage.File, appTypes: [AppType.Desktop], section: 'appearance', label: () => _('Editor font size'), minimum: 4, maximum: 50, step: 1 },
'style.editor.fontFamily':
(mobilePlatform) ?
({
@@ -918,7 +924,7 @@ class Setting extends BaseModel {
isEnum: true,
public: true,
label: () => _('Editor font'),
appTypes: ['mobile'],
appTypes: [AppType.Mobile],
section: 'appearance',
options: () => {
// IMPORTANT: The font mapping must match the one in global-styles.js::editorFont()
@@ -940,7 +946,7 @@ class Setting extends BaseModel {
value: '',
type: SettingItemType.String,
public: true,
appTypes: ['desktop'],
appTypes: [AppType.Desktop],
section: 'appearance',
label: () => _('Editor font family'),
description: () =>
@@ -951,7 +957,7 @@ class Setting extends BaseModel {
value: '',
type: SettingItemType.String,
public: true,
appTypes: ['desktop'],
appTypes: [AppType.Desktop],
section: 'appearance',
label: () => _('Editor monospace font family'),
description: () =>
@@ -959,7 +965,7 @@ class Setting extends BaseModel {
storage: SettingStorage.File,
},
'ui.layout': { value: {}, type: SettingItemType.Object, storage: SettingStorage.File, public: false, appTypes: ['desktop'] },
'ui.layout': { value: {}, type: SettingItemType.Object, storage: SettingStorage.File, public: false, appTypes: [AppType.Desktop] },
// TODO: Is there a better way to do this? The goal here is to simply have
// a way to display a link to the customizable stylesheets, not for it to
@@ -978,7 +984,7 @@ class Setting extends BaseModel {
},
type: SettingItemType.Button,
public: true,
appTypes: ['desktop'],
appTypes: [AppType.Desktop],
label: () => _('Custom stylesheet for rendered Markdown'),
section: 'appearance',
advanced: true,
@@ -995,7 +1001,7 @@ class Setting extends BaseModel {
},
type: SettingItemType.Button,
public: true,
appTypes: ['desktop'],
appTypes: [AppType.Desktop],
label: () => _('Custom stylesheet for Joplin-wide app styles'),
section: 'appearance',
advanced: true,
@@ -1006,7 +1012,7 @@ class Setting extends BaseModel {
value: null,
type: SettingItemType.Button,
public: true,
appTypes: ['desktop'],
appTypes: [AppType.Desktop],
label: () => _('Re-upload local data to sync target'),
section: 'sync',
advanced: true,
@@ -1017,7 +1023,7 @@ class Setting extends BaseModel {
value: null,
type: SettingItemType.Button,
public: true,
appTypes: ['desktop'],
appTypes: [AppType.Desktop],
label: () => _('Delete local data and re-download from sync target'),
section: 'sync',
advanced: true,
@@ -1025,8 +1031,8 @@ class Setting extends BaseModel {
},
autoUpdateEnabled: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'application', public: platform !== 'linux', appTypes: ['desktop'], label: () => _('Automatically update the application') },
'autoUpdate.includePreReleases': { value: false, type: SettingItemType.Bool, section: 'application', storage: SettingStorage.File, public: true, appTypes: ['desktop'], label: () => _('Get pre-releases when checking for updates'), description: () => _('See the pre-release page for more details: %s', 'https://joplinapp.org/prereleases') },
autoUpdateEnabled: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'application', public: platform !== 'linux', appTypes: [AppType.Desktop], label: () => _('Automatically update the application') },
'autoUpdate.includePreReleases': { value: false, type: SettingItemType.Bool, section: 'application', storage: SettingStorage.File, public: true, appTypes: [AppType.Desktop], label: () => _('Get pre-releases when checking for updates'), description: () => _('See the pre-release page for more details: %s', 'https://joplinapp.org/prereleases') },
'clipperServer.autoStart': { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, public: false },
'sync.interval': {
value: 300,
@@ -1055,13 +1061,13 @@ class Setting extends BaseModel {
public: true,
label: () => _('Synchronise only over WiFi connection'),
storage: SettingStorage.File,
appTypes: ['mobile'],
appTypes: [AppType.Mobile],
},
noteVisiblePanes: { value: ['editor', 'viewer'], type: SettingItemType.Array, storage: SettingStorage.File, public: false, appTypes: ['desktop'] },
tagHeaderIsExpanded: { value: true, type: SettingItemType.Bool, public: false, appTypes: ['desktop'] },
folderHeaderIsExpanded: { value: true, type: SettingItemType.Bool, public: false, appTypes: ['desktop'] },
editor: { value: '', type: SettingItemType.String, subType: 'file_path_and_args', storage: SettingStorage.File, public: true, appTypes: ['cli', 'desktop'], label: () => _('Text editor command'), description: () => _('The editor command (may include arguments) that will be used to open a note. If none is provided it will try to auto-detect the default editor.') },
'export.pdfPageSize': { value: 'A4', type: SettingItemType.String, advanced: true, storage: SettingStorage.File, isEnum: true, public: true, appTypes: ['desktop'], label: () => _('Page size for PDF export'), options: () => {
noteVisiblePanes: { value: ['editor', 'viewer'], type: SettingItemType.Array, storage: SettingStorage.File, public: false, appTypes: [AppType.Desktop] },
tagHeaderIsExpanded: { value: true, type: SettingItemType.Bool, public: false, appTypes: [AppType.Desktop] },
folderHeaderIsExpanded: { value: true, type: SettingItemType.Bool, public: false, appTypes: [AppType.Desktop] },
editor: { value: '', type: SettingItemType.String, subType: 'file_path_and_args', storage: SettingStorage.File, public: true, appTypes: [AppType.Cli, AppType.Desktop], label: () => _('Text editor command'), description: () => _('The editor command (may include arguments) that will be used to open a note. If none is provided it will try to auto-detect the default editor.') },
'export.pdfPageSize': { value: 'A4', type: SettingItemType.String, advanced: true, storage: SettingStorage.File, isEnum: true, public: true, appTypes: [AppType.Desktop], label: () => _('Page size for PDF export'), options: () => {
return {
'A4': _('A4'),
'Letter': _('Letter'),
@@ -1071,7 +1077,7 @@ class Setting extends BaseModel {
'Legal': _('Legal'),
};
} },
'export.pdfPageOrientation': { value: 'portrait', type: SettingItemType.String, storage: SettingStorage.File, advanced: true, isEnum: true, public: true, appTypes: ['desktop'], label: () => _('Page orientation for PDF export'), options: () => {
'export.pdfPageOrientation': { value: 'portrait', type: SettingItemType.String, storage: SettingStorage.File, advanced: true, isEnum: true, public: true, appTypes: [AppType.Desktop], label: () => _('Page orientation for PDF export'), options: () => {
return {
'portrait': _('Portrait'),
'landscape': _('Landscape'),
@@ -1082,7 +1088,7 @@ class Setting extends BaseModel {
value: '',
type: SettingItemType.String,
public: true,
appTypes: ['desktop'],
appTypes: [AppType.Desktop],
isEnum: true,
advanced: true,
label: () => _('Keyboard Mode'),
@@ -1100,7 +1106,7 @@ class Setting extends BaseModel {
value: false,
type: SettingItemType.Bool,
public: true,
appTypes: ['desktop'],
appTypes: [AppType.Desktop],
label: () => 'Enable spell checking in Markdown editor? (WARNING BETA feature)',
description: () => 'Spell checker in the Markdown editor was previously unstable (cursor location was not stable, sometimes edits would not be saved or reflected in the viewer, etc.) however it appears to be more reliable now. If you notice any issue, please report it on GitHub or the Joplin Forum (Help -> Joplin Forum)',
},
@@ -1114,7 +1120,7 @@ class Setting extends BaseModel {
return [SyncTargetRegistry.nameToId('nextcloud'), SyncTargetRegistry.nameToId('webdav'), SyncTargetRegistry.nameToId('joplinServer')].indexOf(settings['sync.target']) >= 0;
},
public: true,
appTypes: ['desktop', 'cli'],
appTypes: [AppType.Desktop, AppType.Cli],
label: () => _('Custom TLS certificates'),
description: () => _('Comma-separated list of paths to directories to load the certificates from, or path to individual cert files. For example: /my/cert_dir, /other/custom.pem. Note that if you make changes to the TLS settings, you must save your changes before clicking on "Check synchronisation configuration".'),
storage: SettingStorage.File,
@@ -1129,7 +1135,6 @@ class Setting extends BaseModel {
[SyncTargetRegistry.nameToId('nextcloud'), SyncTargetRegistry.nameToId('webdav'), SyncTargetRegistry.nameToId('joplinServer')].indexOf(settings['sync.target']) >= 0;
},
public: true,
appTypes: ['desktop', 'cli', 'mobile'],
label: () => _('Ignore TLS certificate errors'),
storage: SettingStorage.File,
},
@@ -1146,7 +1151,7 @@ class Setting extends BaseModel {
},
'api.token': { value: null, type: SettingItemType.String, public: false, storage: SettingStorage.File },
'api.port': { value: null, type: SettingItemType.Int, storage: SettingStorage.File, public: true, appTypes: ['cli'], description: () => _('Specify the port that should be used by the API server. If not set, a default will be used.') },
'api.port': { value: null, type: SettingItemType.Int, storage: SettingStorage.File, public: true, appTypes: [AppType.Cli], description: () => _('Specify the port that should be used by the API server. If not set, a default will be used.') },
'resourceService.lastProcessedChangeId': { value: 0, type: SettingItemType.Int, public: false },
'searchEngine.lastProcessedChangeId': { value: 0, type: SettingItemType.Int, public: false },
@@ -1175,8 +1180,8 @@ class Setting extends BaseModel {
'welcome.wasBuilt': { value: false, type: SettingItemType.Bool, public: false },
'welcome.enabled': { value: true, type: SettingItemType.Bool, public: false },
'camera.type': { value: 0, type: SettingItemType.Int, public: false, appTypes: ['mobile'] },
'camera.ratio': { value: '4:3', type: SettingItemType.String, public: false, appTypes: ['mobile'] },
'camera.type': { value: 0, type: SettingItemType.Int, public: false, appTypes: [AppType.Mobile] },
'camera.ratio': { value: '4:3', type: SettingItemType.String, public: false, appTypes: [AppType.Mobile] },
'spellChecker.enabled': { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, public: false },
'spellChecker.language': { value: '', type: SettingItemType.String, storage: SettingStorage.File, public: false },
@@ -1185,7 +1190,7 @@ class Setting extends BaseModel {
value: 100,
type: SettingItemType.Int,
public: false,
appTypes: ['desktop'],
appTypes: [AppType.Desktop],
minimum: 30,
maximum: 300,
step: 10,
@@ -1197,7 +1202,7 @@ class Setting extends BaseModel {
type: SettingItemType.Int,
section: 'appearance',
public: true,
appTypes: ['cli'],
appTypes: [AppType.Cli],
label: () => _('Notebook list growth factor'),
description: () =>
_('The factor property sets how the item will grow or shrink ' +
@@ -1211,7 +1216,7 @@ class Setting extends BaseModel {
type: SettingItemType.Int,
section: 'appearance',
public: true,
appTypes: ['cli'],
appTypes: [AppType.Cli],
label: () => _('Note list growth factor'),
description: () =>
_('The factor property sets how the item will grow or shrink ' +
@@ -1225,7 +1230,7 @@ class Setting extends BaseModel {
type: SettingItemType.Int,
section: 'appearance',
public: true,
appTypes: ['cli'],
appTypes: [AppType.Cli],
label: () => _('Note area growth factor'),
description: () =>
_('The factor property sets how the item will grow or shrink ' +
@@ -1239,9 +1244,24 @@ class Setting extends BaseModel {
value: false,
type: SettingItemType.Bool,
public: false,
appTypes: ['desktop'],
appTypes: [AppType.Desktop],
storage: SettingStorage.Database,
},
'featureFlag.syncAccurateTimestamps': {
value: false,
type: SettingItemType.Bool,
public: false,
storage: SettingStorage.File,
},
'featureFlag.syncMultiPut': {
value: false,
type: SettingItemType.Bool,
public: false,
storage: SettingStorage.File,
},
};
this.metadata_ = Object.assign(this.metadata_, this.customMetadata_);
@@ -1249,6 +1269,11 @@ class Setting extends BaseModel {
return this.metadata_;
}
public static featureFlagKeys(appType: AppType): string[] {
const keys = this.keys(false, appType);
return keys.filter(k => k.indexOf('featureFlag.') === 0);
}
private static validateKey(key: string) {
if (!key) throw new Error('Cannot register empty key');
if (key.length > 128) throw new Error(`Key length cannot be longer than 128 characters: ${key}`);
@@ -1304,7 +1329,7 @@ class Setting extends BaseModel {
return key in this.metadata();
}
static keyDescription(key: string, appType: string = null) {
static keyDescription(key: string, appType: AppType = null) {
const md = this.settingMetadata(key);
if (!md.description) return null;
return md.description(appType);
@@ -1314,7 +1339,7 @@ class Setting extends BaseModel {
return this.metadata()[key] && this.metadata()[key].secure === true;
}
static keys(publicOnly: boolean = false, appType: string = null, options: KeysOptions = null) {
static keys(publicOnly: boolean = false, appType: AppType = null, options: KeysOptions = null) {
options = Object.assign({}, {
secureOnly: false,
}, options);
@@ -1348,10 +1373,18 @@ class Setting extends BaseModel {
}
// Low-level method to load a setting directly from the database. Should not be used in most cases.
public static async loadOne(key: string) {
public static async loadOne(key: string): Promise<CacheItem> {
if (this.keyStorage(key) === SettingStorage.File) {
const fromFile = await this.fileHandler.load();
return fromFile[key];
return {
key,
value: fromFile[key],
};
} else if (this.settingMetadata(key).secure) {
return {
key,
value: await this.keychainService().password(`setting.${key}`),
};
} else {
return this.modelSelectOne('SELECT * FROM settings WHERE key = ?', [key]);
}
@@ -1804,7 +1837,7 @@ class Setting extends BaseModel {
this.saveTimeoutId_ = null;
}
static publicSettings(appType: string) {
static publicSettings(appType: AppType) {
if (!appType) throw new Error('appType is required');
const metadata = this.metadata();

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/lib",
"version": "2.0.3",
"version": "2.1.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

@@ -12,6 +12,12 @@ function hexPad(s: string, length: number) {
return padLeft(s, length, '0');
}
export function isValidHeaderIdentifier(id: string, ignoreTooLongLength = false) {
if (!id) return false;
if (!ignoreTooLongLength && id.length !== 5) return false;
return /JED\d\d/.test(id);
}
export default class EncryptionService {
public static instance_: EncryptionService = null;
@@ -657,7 +663,7 @@ export default class EncryptionService {
async decodeHeaderSource_(source: any) {
const identifier = await source.read(5);
if (!this.isValidHeaderIdentifier(identifier)) throw new JoplinError(`Invalid encryption identifier. Data is not actually encrypted? ID was: ${identifier}`, 'invalidIdentifier');
if (!isValidHeaderIdentifier(identifier)) throw new JoplinError(`Invalid encryption identifier. Data is not actually encrypted? ID was: ${identifier}`, 'invalidIdentifier');
const mdSizeHex = await source.read(6);
const mdSize = parseInt(mdSizeHex, 16);
if (isNaN(mdSize) || !mdSize) throw new Error(`Invalid header metadata size: ${mdSizeHex}`);
@@ -697,12 +703,6 @@ export default class EncryptionService {
return output;
}
isValidHeaderIdentifier(id: string, ignoreTooLongLength = false) {
if (!id) return false;
if (!ignoreTooLongLength && id.length !== 5) return false;
return /JED\d\d/.test(id);
}
isValidEncryptionMethod(method: number) {
return [EncryptionService.METHOD_SJCL, EncryptionService.METHOD_SJCL_1A, EncryptionService.METHOD_SJCL_2, EncryptionService.METHOD_SJCL_3, EncryptionService.METHOD_SJCL_4].indexOf(method) >= 0;
}
@@ -711,13 +711,13 @@ export default class EncryptionService {
if (!item) throw new Error('No item');
const ItemClass = BaseItem.itemClass(item);
if (!ItemClass.encryptionSupported()) return false;
return item.encryption_applied && this.isValidHeaderIdentifier(item.encryption_cipher_text, true);
return item.encryption_applied && isValidHeaderIdentifier(item.encryption_cipher_text, true);
}
async fileIsEncrypted(path: string) {
const handle = await this.fsDriver().open(path, 'r');
const headerIdentifier = await this.fsDriver().readFileChunk(handle, 5, 'ascii');
await this.fsDriver().close(handle);
return this.isValidHeaderIdentifier(headerIdentifier);
return isValidHeaderIdentifier(headerIdentifier);
}
}

View File

@@ -3,7 +3,7 @@ export default class NavService {
public static dispatch: Function = () => {};
private static handlers_: Function[] = [];
static async go(routeName: string) {
public static async go(routeName: string) {
if (this.handlers_.length) {
const r = await this.handlers_[this.handlers_.length - 1]();
if (r) return r;
@@ -15,7 +15,7 @@ export default class NavService {
});
}
static addHandler(handler: Function) {
public static addHandler(handler: Function) {
for (let i = this.handlers_.length - 1; i >= 0; i--) {
const h = this.handlers_[i];
if (h === handler) return;
@@ -24,7 +24,7 @@ export default class NavService {
this.handlers_.push(handler);
}
static removeHandler(hanlder: Function) {
public static removeHandler(hanlder: Function) {
for (let i = this.handlers_.length - 1; i >= 0; i--) {
const h = this.handlers_[i];
if (h === hanlder) this.handlers_.splice(i, 1);

View File

@@ -1,13 +1,11 @@
/* eslint-disable no-unused-vars, @typescript-eslint/no-unused-vars, prefer-const */
const time = require('../time').default;
const { revisionService, setupDatabaseAndSynchronizer, switchClient } = require('../testing/test-utils.js');
const Setting = require('../models/Setting').default;
const Note = require('../models/Note').default;
const ItemChange = require('../models/ItemChange').default;
const Revision = require('../models/Revision').default;
const BaseModel = require('../BaseModel').default;
const RevisionService = require('../services/RevisionService').default;
import time from '../time';
import { revisionService, setupDatabaseAndSynchronizer, switchClient } from '../testing/test-utils';
import Setting from '../models/Setting';
import Note from '../models/Note';
import ItemChange from '../models/ItemChange';
import Revision from '../models/Revision';
import BaseModel from '../BaseModel';
import RevisionService from '../services/RevisionService';
describe('services_Revision', function() {
@@ -25,7 +23,7 @@ describe('services_Revision', function() {
await service.collectRevisions();
await Note.save({ id: n1_v1.id, title: 'hello', author: 'testing' });
await service.collectRevisions();
const n1_v2 = await Note.save({ id: n1_v1.id, title: 'hello welcome', author: '' });
await Note.save({ id: n1_v1.id, title: 'hello welcome', author: '' });
await service.collectRevisions();
const revisions = await Revision.allByType(BaseModel.TYPE_NOTE, n1_v1.id);
@@ -49,6 +47,46 @@ describe('services_Revision', function() {
expect(revisions2.length).toBe(0);
}));
// ----------------------------------------------------------------------
// This is to verify that the revision service continues processing
// revisions even when it fails on one note. However, now that the
// diff-match-patch bug is fixed, it's not possible to create notes that
// would make the process fail. Keeping the test anyway in case such case
// comes up again.
// ----------------------------------------------------------------------
// it('should handle corrupted strings', (async () => {
// const service = new RevisionService();
// // Silence the logger because the revision service is going to print
// // errors.
// // Logger.globalLogger.enabled = false;
// const n1 = await Note.save({ body: '' });
// await service.collectRevisions();
// await Note.save({ id: n1.id, body: naughtyStrings[152] }); // REV 1
// await service.collectRevisions();
// await Note.save({ id: n1.id, body: naughtyStrings[153] }); // FAIL (Should have been REV 2)
// await service.collectRevisions();
// // Because it fails, only one revision was generated. The second was skipped.
// expect((await Revision.all()).length).toBe(1);
// // From this point, note 1 will always fail because of a
// // diff-match-patch bug:
// // https://github.com/JackuB/diff-match-patch/issues/22
// // It will throw "URI malformed". But it shouldn't prevent other notes
// // from getting revisions.
// const n2 = await Note.save({ body: '' });
// await service.collectRevisions();
// await Note.save({ id: n2.id, body: 'valid' }); // REV 2
// await service.collectRevisions();
// expect((await Revision.all()).length).toBe(2);
// Logger.globalLogger.enabled = true;
// }));
it('should delete old revisions (1 note, 2 rev)', (async () => {
const service = new RevisionService();
@@ -59,7 +97,7 @@ describe('services_Revision', function() {
const time_v1 = Date.now();
await time.msleep(100);
const n1_v2 = await Note.save({ id: n1_v1.id, title: 'hello welcome' });
await Note.save({ id: n1_v1.id, title: 'hello welcome' });
await service.collectRevisions();
expect((await Revision.allByType(BaseModel.TYPE_NOTE, n1_v1.id)).length).toBe(2);
@@ -81,12 +119,12 @@ describe('services_Revision', function() {
const time_v1 = Date.now();
await time.msleep(100);
const n1_v2 = await Note.save({ id: n1_v1.id, title: 'one two' });
await Note.save({ id: n1_v1.id, title: 'one two' });
await service.collectRevisions();
const time_v2 = Date.now();
await time.msleep(100);
const n1_v3 = await Note.save({ id: n1_v1.id, title: 'one two three' });
await Note.save({ id: n1_v1.id, title: 'one two three' });
await service.collectRevisions();
{
@@ -124,8 +162,8 @@ describe('services_Revision', function() {
const time_n2_v1 = Date.now();
await time.msleep(100);
const n1_v2 = await Note.save({ id: n1_v1.id, title: 'note 1 (v2)' });
const n2_v2 = await Note.save({ id: n2_v1.id, title: 'note 2 (v2)' });
await Note.save({ id: n1_v1.id, title: 'note 1 (v2)' });
await Note.save({ id: n2_v1.id, title: 'note 2 (v2)' });
await service.collectRevisions();
expect((await Revision.all()).length).toBe(4);
@@ -167,9 +205,9 @@ describe('services_Revision', function() {
const noteId = n1_v1.id;
const rev1 = await service.createNoteRevision_(n1_v1);
const n1_v2 = await Note.save({ id: noteId, title: 'hello Paul' });
const rev2 = await service.createNoteRevision_(n1_v2, rev1.id);
await service.createNoteRevision_(n1_v2, rev1.id);
const n1_v3 = await Note.save({ id: noteId, title: 'hello John' });
const rev3 = await service.createNoteRevision_(n1_v3, rev1.id);
await service.createNoteRevision_(n1_v3, rev1.id);
const revisions = await Revision.allByType(BaseModel.TYPE_NOTE, noteId);
expect(revisions.length).toBe(3);
@@ -311,7 +349,7 @@ describe('services_Revision', function() {
const n1_v1 = await Note.save({ id: n1_v0.id, title: 'hello' });
await revisionService().collectRevisions(); // REV 1
await time.sleep(0.1);
const n1_v2 = await Note.save({ id: n1_v1.id, title: 'hello welcome' });
await Note.save({ id: n1_v1.id, title: 'hello welcome' });
await revisionService().collectRevisions(); // REV 2
await time.sleep(0.1);
@@ -340,7 +378,7 @@ describe('services_Revision', function() {
const timeRev1 = Date.now();
await time.msleep(100);
const n1_v2 = await Note.save({ id: n1_v1.id, title: 'hello welcome' });
await Note.save({ id: n1_v1.id, title: 'hello welcome' });
await revisionService().collectRevisions(); // REV 2
expect((await Revision.all()).length).toBe(2);
@@ -364,7 +402,7 @@ describe('services_Revision', function() {
const timeRev1 = Date.now();
await time.msleep(100);
const n1_v2 = await Note.save({ id: n1_v1.id, title: 'hello welcome' });
await Note.save({ id: n1_v1.id, title: 'hello welcome' });
await revisionService().collectRevisions(); // REV 2
expect((await Revision.all()).length).toBe(2);
@@ -385,11 +423,11 @@ describe('services_Revision', function() {
it('should not create a revision if the note has not changed', (async () => {
const n1_v0 = await Note.save({ title: '' });
const n1_v1 = await Note.save({ id: n1_v0.id, title: 'hello' });
await Note.save({ id: n1_v0.id, title: 'hello' });
await revisionService().collectRevisions(); // REV 1
expect((await Revision.all()).length).toBe(1);
const n1_v2 = await Note.save({ id: n1_v0.id, title: 'hello' });
await Note.save({ id: n1_v0.id, title: 'hello' });
await revisionService().collectRevisions(); // Note has not changed (except its timestamp) so don't create a revision
expect((await Revision.all()).length).toBe(1);
}));
@@ -399,12 +437,12 @@ describe('services_Revision', function() {
// places so make sure it is saved correctly with the revision
const n1_v0 = await Note.save({ title: '' });
const n1_v1 = await Note.save({ id: n1_v0.id, title: 'hello' });
await Note.save({ id: n1_v0.id, title: 'hello' });
await revisionService().collectRevisions(); // REV 1
expect((await Revision.all()).length).toBe(1);
const userUpdatedTime = Date.now() - 1000 * 60 * 60;
const n1_v2 = await Note.save({ id: n1_v0.id, title: 'hello', updated_time: Date.now(), user_updated_time: userUpdatedTime }, { autoTimestamp: false });
await Note.save({ id: n1_v0.id, title: 'hello', updated_time: Date.now(), user_updated_time: userUpdatedTime }, { autoTimestamp: false });
await revisionService().collectRevisions(); // Only the user timestamp has changed, but that needs to be saved
const revisions = await Revision.all();
@@ -416,20 +454,20 @@ describe('services_Revision', function() {
it('should not create a revision if there is already a recent one', (async () => {
const n1_v0 = await Note.save({ title: '' });
const n1_v1 = await Note.save({ id: n1_v0.id, title: 'hello' });
await Note.save({ id: n1_v0.id, title: 'hello' });
await revisionService().collectRevisions(); // REV 1
const timeRev1 = Date.now();
await time.sleep(2);
const timeRev2 = Date.now();
const n1_v2 = await Note.save({ id: n1_v0.id, title: 'hello 2' });
await Note.save({ id: n1_v0.id, title: 'hello 2' });
await revisionService().collectRevisions(); // REV 2
expect((await Revision.all()).length).toBe(2);
const interval = Date.now() - timeRev1 + 1;
Setting.setValue('revisionService.intervalBetweenRevisions', interval);
const n1_v3 = await Note.save({ id: n1_v0.id, title: 'hello 3' });
await Note.save({ id: n1_v0.id, title: 'hello 3' });
await revisionService().collectRevisions(); // No rev because time since last rev is less than the required 'interval between revisions'
expect(Date.now() - interval < timeRev2).toBe(true); // check the computer is not too slow for this test
expect((await Revision.all()).length).toBe(2);

View File

@@ -9,10 +9,13 @@ import shim from '../shim';
import BaseService from './BaseService';
import { _ } from '../locale';
import { ItemChangeEntity, NoteEntity, RevisionEntity } from './database/types';
import Logger from '../Logger';
const { substrWithEllipsis } = require('../string-utils');
const { sprintf } = require('sprintf-js');
const { wrapError } = require('../errorUtils');
const logger = Logger.create('RevisionService');
export default class RevisionService extends BaseService {
public static instance_: RevisionService;
@@ -60,18 +63,7 @@ export default class RevisionService extends BaseService {
return md;
}
isEmptyRevision_(rev: RevisionEntity) {
if (rev.title_diff) return false;
if (rev.body_diff) return false;
const md = JSON.parse(rev.metadata_diff);
if (md.new && Object.keys(md.new).length) return false;
if (md.deleted && Object.keys(md.deleted).length) return false;
return true;
}
async createNoteRevision_(note: NoteEntity, parentRevId: string = null) {
public async createNoteRevision_(note: NoteEntity, parentRevId: string = null): Promise<RevisionEntity> {
try {
const parentRev = parentRevId ? await Revision.load(parentRevId) : await Revision.latestRevision(BaseModel.TYPE_NOTE, note.id);
@@ -100,7 +92,7 @@ export default class RevisionService extends BaseService {
output.metadata_diff = Revision.createObjectPatch(merged.metadata, noteMd);
}
if (this.isEmptyRevision_(output)) return null;
if (Revision.isEmptyRevision(output)) return null;
return Revision.save(output);
} catch (error) {
@@ -109,7 +101,7 @@ export default class RevisionService extends BaseService {
}
}
async collectRevisions() {
public async collectRevisions() {
if (this.isCollecting_) return;
this.isCollecting_ = true;
@@ -153,11 +145,11 @@ export default class RevisionService extends BaseService {
if (oldNote && oldNote.updated_time < this.oldNoteCutOffDate_()) {
// This is where we save the original version of this old note
const rev = await this.createNoteRevision_(oldNote);
if (rev) this.logger().debug(sprintf('RevisionService::collectRevisions: Saved revision %s (old note)', rev.id));
if (rev) logger.debug(sprintf('RevisionService::collectRevisions: Saved revision %s (old note)', rev.id));
}
const rev = await this.createNoteRevision_(note);
if (rev) this.logger().debug(sprintf('RevisionService::collectRevisions: Saved revision %s (Last rev was more than %d ms ago)', rev.id, Setting.value('revisionService.intervalBetweenRevisions')));
if (rev) logger.debug(sprintf('RevisionService::collectRevisions: Saved revision %s (Last rev was more than %d ms ago)', rev.id, Setting.value('revisionService.intervalBetweenRevisions')));
doneNoteIds.push(noteId);
this.isOldNotesCache_[noteId] = false;
}
@@ -168,7 +160,7 @@ export default class RevisionService extends BaseService {
const revExists = await Revision.revisionExists(BaseModel.TYPE_NOTE, note.id, note.updated_time);
if (!revExists) {
const rev = await this.createNoteRevision_(note);
if (rev) this.logger().debug(sprintf('RevisionService::collectRevisions: Saved revision %s (for deleted note)', rev.id));
if (rev) logger.debug(sprintf('RevisionService::collectRevisions: Saved revision %s (for deleted note)', rev.id));
}
doneNoteIds.push(noteId);
}
@@ -181,9 +173,15 @@ export default class RevisionService extends BaseService {
// One or more revisions are encrypted - stop processing for now
// and these revisions will be processed next time the revision
// collector runs.
this.logger().info('RevisionService::collectRevisions: One or more revision was encrypted. Processing was stopped but will resume later when the revision is decrypted.', error);
logger.info('RevisionService::collectRevisions: One or more revision was encrypted. Processing was stopped but will resume later when the revision is decrypted.', error);
} else {
this.logger().error('RevisionService::collectRevisions:', error);
// Note that, for now, if any revision creation fails, the whole
// process fails. This is on purpose because if we keep on
// processing, whatever caused the error will be in the past
// changes (before revisionService.lastProcessedChangeId) and
// will never be processed again. Now that the diff-match-patch
// issue is fixed, there should be no such error anyway.
logger.error('RevisionService::collectRevisions:', error);
}
}
@@ -192,7 +190,7 @@ export default class RevisionService extends BaseService {
this.isCollecting_ = false;
this.logger().info(`RevisionService::collectRevisions: Created revisions for ${doneNoteIds.length} notes`);
logger.info(`RevisionService::collectRevisions: Created revisions for ${doneNoteIds.length} notes`);
}
async deleteOldRevisions(ttl: number) {
@@ -266,23 +264,23 @@ export default class RevisionService extends BaseService {
this.maintenanceCalls_.push(true);
try {
const startTime = Date.now();
this.logger().info('RevisionService::maintenance: Starting...');
logger.info('RevisionService::maintenance: Starting...');
if (!Setting.value('revisionService.enabled')) {
this.logger().info('RevisionService::maintenance: Service is disabled');
logger.info('RevisionService::maintenance: Service is disabled');
// We do as if we had processed all the latest changes so that they can be cleaned up
// later on by ItemChangeUtils.deleteProcessedChanges().
Setting.setValue('revisionService.lastProcessedChangeId', await ItemChange.lastChangeId());
await this.deleteOldRevisions(Setting.value('revisionService.ttlDays') * 24 * 60 * 60 * 1000);
} else {
this.logger().info('RevisionService::maintenance: Service is enabled');
logger.info('RevisionService::maintenance: Service is enabled');
await this.collectRevisions();
await this.deleteOldRevisions(Setting.value('revisionService.ttlDays') * 24 * 60 * 60 * 1000);
this.logger().info(`RevisionService::maintenance: Done in ${Date.now() - startTime}ms`);
logger.info(`RevisionService::maintenance: Done in ${Date.now() - startTime}ms`);
}
} catch (error) {
this.logger().error('RevisionService::maintenance:', error);
logger.error('RevisionService::maintenance:', error);
} finally {
this.maintenanceCalls_.pop();
}
@@ -294,7 +292,7 @@ export default class RevisionService extends BaseService {
if (collectRevisionInterval === null) collectRevisionInterval = 1000 * 60 * 10;
this.logger().info(`RevisionService::runInBackground: Starting background service with revision collection interval ${collectRevisionInterval}`);
logger.info(`RevisionService::runInBackground: Starting background service with revision collection interval ${collectRevisionInterval}`);
this.maintenanceTimer1_ = shim.setTimeout(() => {
void this.maintenance();

View File

@@ -2,10 +2,12 @@ import { ModelType } from "../../BaseModel";
export interface BaseItemEntity {
id?: string;
encryption_applied?: boolean;
encryption_applied?: number;
is_shared?: number;
share_id?: string;
type_?: ModelType;
updated_time?: number;
created_time?: number;
}

View File

@@ -12,51 +12,15 @@ import Joplin from './Joplin';
export default class Global {
private joplin_: Joplin;
// private requireWhiteList_: string[] = null;
// private consoleWrapper_:any = null;
constructor(implementation: any, plugin: Plugin, store: any) {
this.joplin_ = new Joplin(implementation.joplin, plugin, store);
// this.consoleWrapper_ = this.createConsoleWrapper(plugin.id);
this.joplin_ = new Joplin(implementation, plugin, store);
}
// Wraps console calls to allow prefixing them with "Plugin PLUGIN_ID:"
// private createConsoleWrapper(pluginId:string) {
// const wrapper:any = {};
// for (const n in console) {
// if (!console.hasOwnProperty(n)) continue;
// wrapper[n] = (...args:any[]) => {
// const newArgs = args.slice();
// newArgs.splice(0, 0, `Plugin "${pluginId}":`);
// return (console as any)[n](...newArgs);
// };
// }
// return wrapper;
// }
get joplin(): Joplin {
return this.joplin_;
}
// private requireWhiteList(): string[] {
// if (!this.requireWhiteList_) {
// this.requireWhiteList_ = builtinModules.slice();
// this.requireWhiteList_.push('fs-extra');
// }
// return this.requireWhiteList_;
// }
// get console(): any {
// return this.consoleWrapper_;
// }
// require(filePath: string): any {
// if (!this.requireWhiteList().includes(filePath)) throw new Error(`Path not allowed: ${filePath}`);
// return require(filePath);
// }
// To get webpack to work with Node module we need to set the parameter `target: "node"`, however
// when setting this, the code generated by webpack will try to access the `process` global variable,
// which won't be defined in the sandbox. So here we simply forward the variable, which makes it all work.
@@ -64,24 +28,4 @@ export default class Global {
return process;
}
// setTimeout(fn: Function, interval: number) {
// return shim.setTimeout(() => {
// fn();
// }, interval);
// }
// setInterval(fn: Function, interval: number) {
// return shim.setInterval(() => {
// fn();
// }, interval);
// }
// alert(message:string) {
// return alert(message);
// }
// confirm(message:string) {
// return confirm(message);
// }
}

View File

@@ -8,6 +8,7 @@ import JoplinViews from './JoplinViews';
import JoplinInterop from './JoplinInterop';
import JoplinSettings from './JoplinSettings';
import JoplinContentScripts from './JoplinContentScripts';
import JoplinClipboard from './JoplinClipboard';
/**
* This is the main entry point to the Joplin API. You can access various services using the provided accessors.
@@ -35,6 +36,7 @@ export default class Joplin {
private interop_: JoplinInterop = null;
private settings_: JoplinSettings = null;
private contentScripts_: JoplinContentScripts = null;
private clipboard_: JoplinClipboard = null;
constructor(implementation: any, plugin: Plugin, store: any) {
this.data_ = new JoplinData();
@@ -42,16 +44,21 @@ export default class Joplin {
this.workspace_ = new JoplinWorkspace(store);
this.filters_ = new JoplinFilters();
this.commands_ = new JoplinCommands();
this.views_ = new JoplinViews(implementation.views, plugin, store);
this.views_ = new JoplinViews(implementation.joplin.views, plugin, store);
this.interop_ = new JoplinInterop();
this.settings_ = new JoplinSettings(plugin);
this.contentScripts_ = new JoplinContentScripts(plugin);
this.clipboard_ = new JoplinClipboard(implementation.clipboard, implementation.nativeImage);
}
get data(): JoplinData {
return this.data_;
}
get clipboard(): JoplinClipboard {
return this.clipboard_;
}
get plugins(): JoplinPlugins {
return this.plugins_;
}

View File

@@ -0,0 +1,51 @@
export default class JoplinClipboard {
private electronClipboard_: any = null;
private electronNativeImage_: any = null;
public constructor(electronClipboard: any, electronNativeImage: any) {
this.electronClipboard_ = electronClipboard;
this.electronNativeImage_ = electronNativeImage;
}
public async readText(): Promise<string> {
return this.electronClipboard_.readText();
}
public async writeText(text: string): Promise<void> {
this.electronClipboard_.writeText(text);
}
public async readHtml(): Promise<string> {
return this.electronClipboard_.readHTML();
}
public async writeHtml(html: string): Promise<void> {
this.electronClipboard_.writeHTML(html);
}
/**
* Returns the image in [data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) format.
*/
public async readImage(): Promise<string> {
const image = this.electronClipboard_.readImage();
return image ? image.toDataURL() : '';
}
/**
* Takes an image in [data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) format.
*/
public async writeImage(dataUrl: string): Promise<void> {
this.electronClipboard_.writeImage(this.electronNativeImage_.createFromDataURL(dataUrl));
}
/**
* Returns the list available formats (mime types).
*
* For example [ 'text/plain', 'text/html' ]
*/
public async availableFormats(): Promise<string[]> {
return this.electronClipboard_.availableFormats();
}
}

View File

@@ -65,6 +65,7 @@ export default class JoplinSettings {
if ('minimum' in setting) internalSettingItem.minimum = setting.minimum;
if ('maximum' in setting) internalSettingItem.maximum = setting.maximum;
if ('step' in setting) internalSettingItem.step = setting.step;
if ('storage' in setting) internalSettingItem.storage = setting.storage;
await Setting.registerSetting(this.namespacedKey(key), internalSettingItem);
}

View File

@@ -334,6 +334,17 @@ export enum SettingItemType {
Button = 6,
}
export enum AppType {
Desktop = 'desktop',
Mobile = 'mobile',
Cli = 'cli',
}
export enum SettingStorage {
Database = 1,
File = 2,
}
// Redefine a simplified interface to mask internal details
// and to remove function calls as they would have to be async.
export interface SettingItem {
@@ -372,7 +383,7 @@ export interface SettingItem {
/**
* Reserved property. Not used at the moment.
*/
appTypes?: string[];
appTypes?: AppType[];
/**
* Set this to `true` to store secure data, such as passwords. Any such
@@ -393,6 +404,11 @@ export interface SettingItem {
minimum?: number;
maximum?: number;
step?: number;
/**
* Either store the setting in the database or in settings.json. Defaults to database.
*/
storage?: SettingStorage;
}
export interface SettingSection {

View File

@@ -1,10 +1,13 @@
import { Store } from 'redux';
import JoplinServerApi from '../../JoplinServerApi';
import Logger from '../../Logger';
import Folder from '../../models/Folder';
import Note from '../../models/Note';
import Setting from '../../models/Setting';
import { State, stateRootKey, StateShare } from './reducer';
const logger = Logger.create('ShareService');
export default class ShareService {
private static instance_: ShareService;
@@ -152,6 +155,10 @@ export default class ShareService {
return this.shares.filter(s => !!s.note_id).map(s => s.note_id);
}
public get shareInvitations() {
return this.state.shareInvitations;
}
public async addShareRecipient(shareId: string, recipientEmail: string) {
return this.api().exec('POST', `api/shares/${shareId}/users`, {}, {
email: recipientEmail,
@@ -216,11 +223,26 @@ export default class ShareService {
});
}
private async updateNoLongerSharedItems() {
const shareIds = this.shares.map(share => share.id).concat(this.shareInvitations.map(si => si.share.id));
await Folder.updateNoLongerSharedItems(shareIds);
}
public async maintenance() {
if (this.enabled) {
await this.refreshShareInvitations();
await this.refreshShares();
Setting.setValue('sync.userId', this.api().userId);
let hasError = false;
try {
await this.refreshShareInvitations();
await this.refreshShares();
Setting.setValue('sync.userId', this.api().userId);
} catch (error) {
hasError = true;
logger.error('Failed to run maintenance:', error);
}
// If there was no errors, it means we have all the share objects,
// so we can run the clean up function.
if (!hasError) await this.updateNoLongerSharedItems();
}
}

View File

@@ -0,0 +1,167 @@
import { FileApi } from '../../file-api';
import BaseItem from '../../models/BaseItem';
import Note from '../../models/Note';
import { expectNotThrow, expectThrow, setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils';
import time from '../../time';
import ItemUploader, { ApiCallFunction } from './ItemUploader';
interface ApiCall {
name: string;
args: any[];
}
function clearArray(a: any[]) {
a.splice(0, a.length);
}
function newFakeApi(): FileApi {
return { supportsMultiPut: true } as any;
}
function newFakeApiCall(callRecorder: ApiCall[], itemBodyCallback: Function = null): ApiCallFunction {
const apiCall = async (callName: string, ...args: any[]): Promise<any> => {
callRecorder.push({ name: callName, args });
if (callName === 'multiPut') {
const [batch] = args;
const output: any = { items: {} };
for (const item of batch) {
if (itemBodyCallback) {
output.items[item.name] = itemBodyCallback(item);
} else {
output.items[item.name] = {
item: item.body,
error: null,
};
}
}
return output;
}
};
return apiCall;
}
describe('synchronizer_ItemUplader', function() {
beforeEach(async (done) => {
await setupDatabaseAndSynchronizer(1);
await setupDatabaseAndSynchronizer(2);
await switchClient(1);
done();
});
it('should batch uploads and use the cache afterwards', (async () => {
const callRecorder: ApiCall[] = [];
const itemUploader = new ItemUploader(newFakeApi(), newFakeApiCall(callRecorder));
const notes = [
await Note.save({ title: '1' }),
await Note.save({ title: '2' }),
];
await itemUploader.preUploadItems(notes);
// There should be only one call to "multiPut" because the items have
// been batched.
expect(callRecorder.length).toBe(1);
expect(callRecorder[0].name).toBe('multiPut');
clearArray(callRecorder);
// Now if we try to upload the item it shouldn't call the API because it
// will use the cached item.
await itemUploader.serializeAndUploadItem(Note, BaseItem.systemPath(notes[0]), notes[0]);
expect(callRecorder.length).toBe(0);
// Now try to process a note that hasn't been cached. In that case, it
// should call "PUT" directly.
const note3 = await Note.save({ title: '3' });
await itemUploader.serializeAndUploadItem(Note, BaseItem.systemPath(note3), note3);
expect(callRecorder.length).toBe(1);
expect(callRecorder[0].name).toBe('put');
}));
it('should not batch upload if the items are over the batch size limit', (async () => {
const callRecorder: ApiCall[] = [];
const itemUploader = new ItemUploader(newFakeApi(), newFakeApiCall(callRecorder));
itemUploader.maxBatchSize = 1;
const notes = [
await Note.save({ title: '1' }),
await Note.save({ title: '2' }),
];
await itemUploader.preUploadItems(notes);
expect(callRecorder.length).toBe(0);
}));
it('should not use the cache if the note has changed since the pre-upload', (async () => {
const callRecorder: ApiCall[] = [];
const itemUploader = new ItemUploader(newFakeApi(), newFakeApiCall(callRecorder));
const notes = [
await Note.save({ title: '1' }),
await Note.save({ title: '2' }),
];
await itemUploader.preUploadItems(notes);
clearArray(callRecorder);
await itemUploader.serializeAndUploadItem(Note, BaseItem.systemPath(notes[0]), notes[0]);
expect(callRecorder.length).toBe(0);
await time.msleep(1);
notes[1] = await Note.save({ title: '22' }),
await itemUploader.serializeAndUploadItem(Note, BaseItem.systemPath(notes[1]), notes[1]);
expect(callRecorder.length).toBe(1);
}));
it('should respect the max batch size', (async () => {
const callRecorder: ApiCall[] = [];
const itemUploader = new ItemUploader(newFakeApi(), newFakeApiCall(callRecorder));
const notes = [
await Note.save({ title: '1' }),
await Note.save({ title: '2' }),
await Note.save({ title: '3' }),
];
const noteSize = BaseItem.systemPath(notes[0]).length + (await Note.serializeForSync(notes[0])).length;
itemUploader.maxBatchSize = noteSize * 2;
// It should send two batches - one with two notes, and the second with
// only one note.
await itemUploader.preUploadItems(notes);
expect(callRecorder.length).toBe(2);
expect(callRecorder[0].args[0].length).toBe(2);
expect(callRecorder[1].args[0].length).toBe(1);
}));
it('should rethrow error for items within the batch', (async () => {
const callRecorder: ApiCall[] = [];
const notes = [
await Note.save({ title: '1' }),
await Note.save({ title: '2' }),
await Note.save({ title: '3' }),
];
// Simulates throwing an error on note 2
const itemBodyCallback = (item: any): any => {
if (item.name === BaseItem.systemPath(notes[1])) {
return { error: new Error('Could not save item'), item: null };
} else {
return { error: null, item: item.body };
}
};
const itemUploader = new ItemUploader(newFakeApi(), newFakeApiCall(callRecorder, itemBodyCallback));
await itemUploader.preUploadItems(notes);
await expectNotThrow(async () => itemUploader.serializeAndUploadItem(Note, BaseItem.systemPath(notes[0]), notes[0]));
await expectThrow(async () => itemUploader.serializeAndUploadItem(Note, BaseItem.systemPath(notes[1]), notes[1]));
await expectNotThrow(async () => itemUploader.serializeAndUploadItem(Note, BaseItem.systemPath(notes[2]), notes[2]));
}));
});

View File

@@ -0,0 +1,111 @@
import { ModelType } from '../../BaseModel';
import { FileApi, MultiPutItem } from '../../file-api';
import Logger from '../../Logger';
import BaseItem from '../../models/BaseItem';
import { BaseItemEntity } from '../database/types';
const logger = Logger.create('ItemUploader');
export type ApiCallFunction = (fnName: string, ...args: any[])=> Promise<any>;
interface BatchItem extends MultiPutItem {
localItemUpdatedTime: number;
}
export default class ItemUploader {
private api_: FileApi;
private apiCall_: ApiCallFunction;
private preUploadedItems_: Record<string, any> = {};
private preUploadedItemUpdatedTimes_: Record<string, number> = {};
private maxBatchSize_ = 1 * 1024 * 1024; // 1MB;
public constructor(api: FileApi, apiCall: ApiCallFunction) {
this.api_ = api;
this.apiCall_ = apiCall;
}
public get maxBatchSize() {
return this.maxBatchSize_;
}
public set maxBatchSize(v: number) {
this.maxBatchSize_ = v;
}
public async serializeAndUploadItem(ItemClass: any, path: string, local: BaseItemEntity) {
const preUploadItem = this.preUploadedItems_[path];
if (preUploadItem) {
if (this.preUploadedItemUpdatedTimes_[path] !== local.updated_time) {
// Normally this should be rare as it can only happen if the
// item has been changed between the moment it was pre-uploaded
// and the moment where it's being processed by the
// synchronizer. It could happen for example for a note being
// edited just at the same time. In that case, we proceed with
// the regular upload.
logger.warn(`Pre-uploaded item updated_time has changed. It is going to be re-uploaded again: ${path} (From ${this.preUploadedItemUpdatedTimes_[path]} to ${local.updated_time})`);
} else {
if (preUploadItem.error) throw new Error(preUploadItem.error.message ? preUploadItem.error.message : 'Unknown pre-upload error');
return;
}
}
const content = await ItemClass.serializeForSync(local);
await this.apiCall_('put', path, content);
}
public async preUploadItems(items: BaseItemEntity[]) {
if (!this.api_.supportsMultiPut) return;
const itemsToUpload: BatchItem[] = [];
for (const local of items) {
// For resources, additional logic is necessary - in particular the blob
// should be uploaded before the metadata, so we can't batch process.
if (local.type_ === ModelType.Resource) continue;
const ItemClass = BaseItem.itemClass(local);
itemsToUpload.push({
name: BaseItem.systemPath(local),
body: await ItemClass.serializeForSync(local),
localItemUpdatedTime: local.updated_time,
});
}
let batchSize = 0;
let currentBatch: BatchItem[] = [];
const uploadBatch = async (batch: BatchItem[]) => {
for (const batchItem of batch) {
this.preUploadedItemUpdatedTimes_[batchItem.name] = batchItem.localItemUpdatedTime;
}
const response = await this.apiCall_('multiPut', batch);
this.preUploadedItems_ = {
...this.preUploadedItems_,
...response.items,
};
};
while (itemsToUpload.length) {
const itemToUpload = itemsToUpload.pop();
const itemSize = itemToUpload.name.length + itemToUpload.body.length;
// Although it should be rare, if the item itself is above the
// batch max size, we skip it. In that case it will be uploaded the
// regular way when the synchronizer calls `serializeAndUploadItem()`
if (itemSize > this.maxBatchSize) continue;
if (batchSize + itemSize > this.maxBatchSize) {
await uploadBatch(currentBatch);
batchSize = itemSize;
currentBatch = [itemToUpload];
} else {
batchSize += itemSize;
currentBatch.push(itemToUpload);
}
}
if (currentBatch.length) await uploadBatch(currentBatch);
}
}

View File

@@ -1,4 +1,4 @@
import { ResourceEntity } from './services/database/types';
import { NoteEntity, ResourceEntity } from './services/database/types';
let isTestingEnv_ = false;
@@ -211,7 +211,7 @@ const shim = {
detectAndSetLocale: null as Function,
attachFileToNote: async (_note: any, _filePath: string) => {
attachFileToNote: async (_note: any, _filePath: string): Promise<NoteEntity> => {
throw new Error('Not implemented');
},

View File

@@ -17,7 +17,7 @@ import FileApiDriverJoplinServer from '../file-api-driver-joplinServer';
import OneDriveApi from '../onedrive-api';
import SyncTargetOneDrive from '../SyncTargetOneDrive';
import JoplinDatabase from '../JoplinDatabase';
const fs = require('fs-extra');
import * as fs from 'fs-extra';
const { DatabaseDriverNode } = require('../database-driver-node.js');
import Folder from '../models/Folder';
import Note from '../models/Note';
@@ -29,7 +29,7 @@ import Revision from '../models/Revision';
import MasterKey from '../models/MasterKey';
import BaseItem from '../models/BaseItem';
const { FileApi } = require('../file-api.js');
const { FileApiDriverMemory } = require('../file-api-driver-memory.js');
const FileApiDriverMemory = require('../file-api-driver-memory').default;
const { FileApiDriverLocal } = require('../file-api-driver-local.js');
const { FileApiDriverWebDav } = require('../file-api-driver-webdav.js');
const { FileApiDriverDropbox } = require('../file-api-driver-dropbox.js');
@@ -101,8 +101,8 @@ const supportDir = `${oldTestDir}/support`;
const dataDir = `${oldTestDir}/test data/${suiteName_}`;
const profileDir = `${dataDir}/profile`;
fs.mkdirpSync(logDir, 0o755);
fs.mkdirpSync(baseTempDir, 0o755);
fs.mkdirpSync(logDir);
fs.mkdirpSync(baseTempDir);
fs.mkdirpSync(dataDir);
fs.mkdirpSync(profileDir);
@@ -392,10 +392,10 @@ async function setupDatabaseAndSynchronizer(id: number, options: any = null) {
DecryptionWorker.instance_ = null;
await fs.remove(resourceDir(id));
await fs.mkdirp(resourceDir(id), 0o755);
await fs.mkdirp(resourceDir(id));
await fs.remove(pluginDir(id));
await fs.mkdirp(pluginDir(id), 0o755);
await fs.mkdirp(pluginDir(id));
if (!synchronizers_[id]) {
const SyncTargetClass = SyncTargetRegistry.classById(syncTargetId_);
@@ -512,7 +512,7 @@ async function initFileApi() {
let fileApi = null;
if (syncTargetId_ == SyncTargetRegistry.nameToId('filesystem')) {
fs.removeSync(syncDir);
fs.mkdirpSync(syncDir, 0o755);
fs.mkdirpSync(syncDir);
fileApi = new FileApi(syncDir, new FileApiDriverLocal());
} else if (syncTargetId_ == SyncTargetRegistry.nameToId('memory')) {
fileApi = new FileApi('/root', new FileApiDriverMemory());
@@ -788,6 +788,21 @@ async function waitForFolderCount(count: number) {
}
}
let naughtyStrings_: string[] = null;
export async function naughtyStrings() {
if (naughtyStrings_) return naughtyStrings_;
const t = await fs.readFile(`${supportDir}/big-list-of-naughty-strings.txt`, 'utf8');
const lines = t.split('\n');
naughtyStrings_ = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.indexOf('#') === 0) continue;
naughtyStrings_.push(line);
}
return naughtyStrings_;
}
// TODO: Update for Jest
// function mockDate(year, month, day, tick) {

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/renderer",
"version": "2.0.3",
"version": "2.1.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -4757,9 +4757,9 @@
}
},
"khroma": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/khroma/-/khroma-1.1.0.tgz",
"integrity": "sha512-aTO+YX22tYOLEQJYFiatAj1lc5QZ+H5sHWFRBWNCiKwc5NWNUJZyeSeiHEPeURJ2a1GEVYcmyMUwGjjLe5ec5A=="
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/khroma/-/khroma-1.4.1.tgz",
"integrity": "sha512-+GmxKvmiRuCcUYDgR7g5Ngo0JEDeOsGdNONdU2zsiBQaK4z19Y2NvXqfEDE0ZiIrg45GTZyAnPLVsLZZACYm3Q=="
},
"kind-of": {
"version": "6.0.3",
@@ -4998,9 +4998,9 @@
"dev": true
},
"mermaid": {
"version": "8.8.4",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-8.8.4.tgz",
"integrity": "sha512-YPn35uEAIrOcsDPjCiKNXXBdO1Aoazsv2zTZjG4+oXa7+tTVUb5sI81NqaTYa47RnoH9Vl4waLlEEJfB8KM9VA==",
"version": "8.10.2",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-8.10.2.tgz",
"integrity": "sha512-Za5MrbAOMbEsyY4ONgGjfYz06sbwF1iNGRzp1sQqpOtvXxjxGu/J1jRJ8QyE9kD/D9zj1/KlRrYegWEvA7eZ5Q==",
"requires": {
"@braintree/sanitize-url": "^3.1.0",
"d3": "^5.7.0",
@@ -6539,9 +6539,9 @@
"integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA=="
},
"uglify-js": {
"version": "3.12.1",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.12.1.tgz",
"integrity": "sha512-o8lHP20KjIiQe5b/67Rh68xEGRrc2SRsCuuoYclXXoC74AfSRGblU1HKzJWH3HxPZ+Ort85fWHpSX7KwBUC9CQ=="
"version": "3.13.9",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.13.9.tgz",
"integrity": "sha512-wZbyTQ1w6Y7fHdt8sJnHfSIuWeDgk6B5rCb4E/AM6QNNPbOMIZph21PW5dRB3h7Df0GszN+t7RuUH6sWK5bF0g=="
},
"union-value": {
"version": "1.0.1",

View File

@@ -45,7 +45,7 @@
"markdown-it-sup": "^1.0.0",
"markdown-it-toc-done-right": "^4.1.0",
"md5": "^2.2.1",
"mermaid": "^8.8.4",
"mermaid": "^8.10.2",
"uslug": "git+https://github.com/laurent22/uslug.git#emoji-support"
},
"gitHead": "80c0089d2c52aff608b2bea74389de5a7f12f2e2"

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/server",
"version": "2.1.1",
"version": "2.1.3",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/server",
"version": "2.1.1",
"version": "2.1.3",
"private": true,
"scripts": {
"start-dev": "nodemon --config nodemon.json --ext ts,js,mustache,css,tsx dist/app.js --env dev",

Binary file not shown.

View File

@@ -356,6 +356,7 @@ export interface Item extends WithDates, WithUuid {
jop_share_id?: Uuid;
jop_type?: number;
jop_encryption_applied?: number;
jop_updated_time?: number;
}
export interface UserItem extends WithDates {
@@ -503,6 +504,7 @@ export const databaseSchema: DatabaseTables = {
jop_share_id: { type: 'string' },
jop_type: { type: 'number' },
jop_encryption_applied: { type: 'number' },
jop_updated_time: { type: 'number' },
},
user_items: {
id: { type: 'number' },

View File

@@ -0,0 +1,29 @@
import { Knex } from 'knex';
import { DbConnection } from '../db';
export async function up(db: DbConnection): Promise<any> {
await db.schema.alterTable('items', function(table: Knex.CreateTableBuilder) {
table.bigInteger('jop_updated_time').defaultTo(0).notNullable();
});
while (true) {
const items = await db('items')
.select('id', 'content')
.where('jop_type', '>', 0)
.andWhere('jop_updated_time', '=', 0)
.limit(1000);
if (!items.length) break;
await db.transaction(async trx => {
for (const item of items) {
const unserialized = JSON.parse(item.content);
await trx('items').update({ jop_updated_time: unserialized.updated_time }).where('id', '=', item.id);
}
});
}
}
export async function down(_db: DbConnection): Promise<any> {
}

View File

@@ -5,14 +5,12 @@ import { ErrorResyncRequired } from '../utils/errors';
import BaseModel, { SaveOptions } from './BaseModel';
import { PaginatedResults, Pagination, PaginationOrderDir } from './utils/pagination';
export interface ChangeWithItem {
item: Item;
updated_time: number;
type: ChangeType;
export interface DeltaChange extends Change {
jop_updated_time?: number;
}
export interface PaginatedChanges extends PaginatedResults {
items: Change[];
items: DeltaChange[];
}
export interface ChangePagination {
@@ -158,9 +156,20 @@ export default class ChangeModel extends BaseModel<Change> {
.orderBy('counter', 'asc')
.limit(pagination.limit) as any[];
const changes = await query;
const changes: Change[] = await query;
const finalChanges = await this.removeDeletedItems(this.compressChanges(changes));
const items: Item[] = await this.db('items').select('id', 'jop_updated_time').whereIn('items.id', changes.map(c => c.item_id));
let finalChanges: DeltaChange[] = this.compressChanges(changes);
finalChanges = await this.removeDeletedItems(finalChanges, items);
finalChanges = finalChanges.map(c => {
const item = items.find(item => item.id === c.item_id);
if (!item) return c;
return {
...c,
jop_updated_time: item.jop_updated_time,
};
});
return {
items: finalChanges,
@@ -171,14 +180,14 @@ export default class ChangeModel extends BaseModel<Change> {
};
}
private async removeDeletedItems(changes: Change[]): Promise<Change[]> {
private async removeDeletedItems(changes: Change[], items: Item[] = null): Promise<Change[]> {
const itemIds = changes.map(c => c.item_id);
// We skip permission check here because, when an item is shared, we need
// to fetch files that don't belong to the current user. This check
// would not be needed anyway because the change items are generated in
// a context where permissions have already been checked.
const items: Item[] = await this.db('items').select('id').whereIn('items.id', itemIds);
items = items === null ? await this.db('items').select('id').whereIn('items.id', itemIds) : items;
const output: Change[] = [];

View File

@@ -227,54 +227,6 @@ export default class ItemModel extends BaseModel<Item> {
return output;
}
// private async folderChildrenItems(userId: Uuid, folderId: string): Promise<Item[]> {
// let output: Item[] = [];
// const rows: Item[] = await this
// .db('user_items')
// .leftJoin('items', 'items.id', 'user_items.item_id')
// .select('items.id', 'items.jop_id', 'items.jop_type')
// .where('items.jop_parent_id', '=', folderId)
// .where('user_items.user_id', '=', userId);
// for (const row of rows) {
// output.push(row);
// if (row.jop_type === ModelType.Folder) {
// const children = await this.folderChildrenItems(userId, row.jop_id);
// output = output.concat(children);
// }
// }
// return output;
// }
// public async shareJoplinFolderAndContent(shareId: Uuid, fromUserId: Uuid, toUserId: Uuid, folderId: string) {
// const folderItem = await this.loadByJopId(fromUserId, folderId, { fields: ['id'] });
// if (!folderItem) throw new ErrorNotFound(`Could not find folder "${folderId}" for share "${shareId}"`);
// const items = [folderItem].concat(await this.folderChildrenItems(fromUserId, folderId));
// const alreadySharedItemIds: string[] = await this
// .db('user_items')
// .pluck('item_id')
// .whereIn('item_id', items.map(i => i.id))
// .where('user_id', '=', toUserId)
// // .where('share_id', '!=', '');
// await this.withTransaction(async () => {
// for (const item of items) {
// if (alreadySharedItemIds.includes(item.id)) continue;
// await this.models().userItem().add(toUserId, item.id, shareId);
// if (item.jop_type === ModelType.Note) {
// const resourceIds = await this.models().itemResource().byItemId(item.id);
// await this.models().share().updateResourceShareStatus(true, shareId, fromUserId, toUserId, resourceIds);
// }
// }
// });
// }
public itemToJoplinItem(itemRow: Item): any {
if (itemRow.jop_type <= 0) throw new Error(`Not a Joplin item: ${itemRow.id}`);
if (!itemRow.content) throw new Error('Item content is missing');
@@ -285,6 +237,7 @@ export default class ItemModel extends BaseModel<Item> {
item.share_id = itemRow.jop_share_id;
item.type_ = itemRow.jop_type;
item.encryption_applied = itemRow.jop_encryption_applied;
item.updated_time = itemRow.jop_updated_time;
return item;
}
@@ -336,6 +289,7 @@ export default class ItemModel extends BaseModel<Item> {
item.jop_type = joplinItem.type_;
item.jop_encryption_applied = joplinItem.encryption_applied || 0;
item.jop_share_id = joplinItem.share_id || '';
item.jop_updated_time = joplinItem.updated_time;
const joplinItemToSave = { ...joplinItem };
@@ -344,6 +298,7 @@ export default class ItemModel extends BaseModel<Item> {
delete joplinItemToSave.share_id;
delete joplinItemToSave.type_;
delete joplinItemToSave.encryption_applied;
delete joplinItemToSave.updated_time;
item.content = Buffer.from(JSON.stringify(joplinItemToSave));
} else {
@@ -407,7 +362,7 @@ export default class ItemModel extends BaseModel<Item> {
};
}
}
});
}, 'ItemModel::saveFromRawContent');
return output;
}
@@ -633,7 +588,7 @@ export default class ItemModel extends BaseModel<Item> {
}
return item;
});
}, 'ItemModel::saveForUser');
}
public async save(_item: Item, _options: SaveOptions = {}): Promise<Item> {

View File

@@ -5,6 +5,7 @@ import { ErrorUnprocessableEntity, ErrorForbidden, ErrorPayloadTooLarge, ErrorNo
import { ModelType } from '@joplin/lib/BaseModel';
import { _ } from '@joplin/lib/locale';
import { formatBytes, MB } from '../utils/bytes';
import { itemIsEncrypted } from '../utils/joplinUtils';
export enum AccountType {
Default = 0,
@@ -147,7 +148,8 @@ export default class UserModel extends BaseModel<User> {
// If the item is encrypted, we apply a multipler because encrypted
// items can be much larger (seems to be up to twice the size but for
// safety let's go with 2.2).
const maxSize = user.max_item_size * (item.jop_encryption_applied ? 2.2 : 1);
const maxSize = user.max_item_size * (itemIsEncrypted(item) ? 2.2 : 1);
if (maxSize && buffer.byteLength > maxSize) {
const itemTitle = joplinItem ? joplinItem.title || '' : '';
const isNote = joplinItem && joplinItem.type_ === ModelType.Note;

View File

@@ -5,14 +5,17 @@ import Router from '../../utils/Router';
import { RouteType } from '../../utils/types';
import { AppContext } from '../../utils/types';
import * as fs from 'fs-extra';
import { ErrorForbidden, ErrorMethodNotAllowed, ErrorNotFound } from '../../utils/errors';
import { ErrorForbidden, ErrorMethodNotAllowed, ErrorNotFound, ErrorPayloadTooLarge } from '../../utils/errors';
import ItemModel, { ItemSaveOption, SaveFromRawContentItem } from '../../models/ItemModel';
import { requestDeltaPagination, requestPagination } from '../../models/utils/pagination';
import { AclAction } from '../../models/BaseModel';
import { safeRemove } from '../../utils/fileUtils';
import { formatBytes, MB } from '../../utils/bytes';
const router = new Router(RouteType.Api);
const batchMaxSize = 1 * MB;
export async function putItemContents(path: SubPath, ctx: AppContext, isBatch: boolean) {
if (!ctx.owner.can_upload) throw new ErrorForbidden('Uploading content is disabled');
@@ -23,12 +26,16 @@ export async function putItemContents(path: SubPath, ctx: AppContext, isBatch: b
let items: SaveFromRawContentItem[] = [];
if (isBatch) {
let totalSize = 0;
items = bodyFields.items.map((item: any) => {
totalSize += item.name.length + (item.body ? item.body.length : 0);
return {
name: item.name,
body: item.body ? Buffer.from(item.body, 'utf8') : Buffer.alloc(0),
};
});
if (totalSize > batchMaxSize) throw new ErrorPayloadTooLarge(`Size of items (${formatBytes(totalSize)}) is over the limit (${formatBytes(batchMaxSize)})`);
} else {
const filePath = parsedBody?.files?.file ? parsedBody.files.file.path : null;

View File

@@ -105,14 +105,14 @@ router.get('api/shares/:id', async (path: SubPath, ctx: AppContext) => {
router.get('api/shares', async (_path: SubPath, ctx: AppContext) => {
ownerRequired(ctx);
const items = ctx.models.share().toApiOutput(await ctx.models.share().sharesByUser(ctx.owner.id)) as Share[];
const shares = ctx.models.share().toApiOutput(await ctx.models.share().sharesByUser(ctx.owner.id)) as Share[];
// Fake paginated results so that it can be added later on, if needed.
return {
items: items.map(i => {
items: shares.map(share => {
return {
...i,
...share,
user: {
id: i.owner_id,
id: share.owner_id,
},
};
}),

View File

@@ -68,10 +68,21 @@ export default class TransactionHandler {
}
}
// Only the function that started the transaction can rollback it. In
// practice it works as expected even for nested transactions: If any of the
// sub-function throws an error, it will propagate to the parent function,
// which will rollback the connection.
//
// If a sub-function throws an error, but it's catched by the parent, we
// also don't want the transaction to be rollbacked, because the errors are
// essentially managed by the parent function. This is for example how
// ItemModel::saveFromRawContent works because it catches any error and
// saves them to an array, to be returned to the caller. So we don't want
// any error to rollback everything.
public async rollback(txIndex: number): Promise<void> {
this.log(`Rollback transaction: ${txIndex}`);
this.finishTransaction(txIndex);
if (this.activeTransaction_) {
const isLastTransaction = this.finishTransaction(txIndex);
if (isLastTransaction) {
this.log(`Transaction is active - doing rollback: ${txIndex}`);
await this.activeTransaction_.rollback();
this.activeTransaction_ = null;

View File

@@ -0,0 +1,24 @@
import { Item } from '../db';
import { itemIsEncrypted } from './joplinUtils';
import { expectThrow } from './testing/testUtils';
describe('joplinUtils', function() {
it('should check if an item is encrypted', async function() {
type TestCase = [boolean, Item];
const testCases: TestCase[] = [
[true, { jop_encryption_applied: 1 }],
[false, { jop_encryption_applied: 0 }],
[true, { content: Buffer.from('JED01blablablabla', 'utf8') }],
[false, { content: Buffer.from('plain text', 'utf8') }],
];
for (const [expected, input] of testCases) {
expect(itemIsEncrypted(input)).toBe(expected);
}
await expectThrow(async () => itemIsEncrypted({ name: 'missing props' }));
});
});

View File

@@ -18,6 +18,7 @@ import { formatDateTime } from './time';
import { ErrorNotFound } from './errors';
import { MarkupToHtml } from '@joplin/renderer';
import { OptionsResourceModel } from '@joplin/renderer/MarkupToHtml';
import { isValidHeaderIdentifier } from '@joplin/lib/services/EncryptionService';
const { DatabaseDriverNode } = require('@joplin/lib/database-driver-node.js');
import { themeStyle } from '@joplin/lib/theme';
import Setting from '@joplin/lib/models/Setting';
@@ -221,6 +222,13 @@ async function renderNote(share: Share, note: NoteEntity, resourceInfos: Resourc
};
}
export function itemIsEncrypted(item: Item): boolean {
if ('jop_encryption_applied' in item) return !!item.jop_encryption_applied;
if (!('content' in item)) throw new Error('Cannot check encryption - item is missing both "content" and "jop_encryption_applied" property');
const header = item.content.toString('utf8', 0, 5);
return isValidHeaderIdentifier(header);
}
export async function renderItem(userId: Uuid, item: Item, share: Share, query: Record<string, any>): Promise<FileViewerResponse> {
const rootNote: NoteEntity = models_.item().itemToJoplinItem(item); // await this.unserializeItem(content);
const linkedItemInfos: LinkedItemInfos = await noteLinkedItemInfos(userId, models_.item(), rootNote);

View File

@@ -1,9 +1,10 @@
import { execCommand2, rootDir } from './tool-utils';
function getVersionFromTag(tagName: string): string {
function getVersionFromTag(tagName: string, isPreRelease: boolean): string {
if (tagName.indexOf('server-') !== 0) throw new Error(`Invalid tag: ${tagName}`);
const s = tagName.split('-');
return s[1].substr(1);
const suffix = isPreRelease ? '-beta' : '';
return s[1].substr(1) + suffix;
}
function getIsPreRelease(tagName: string): boolean {
@@ -15,8 +16,8 @@ async function main() {
if (!argv.tagName) throw new Error('--tag-name not provided');
const tagName = argv.tagName;
const imageVersion = getVersionFromTag(tagName);
const isPreRelease = getIsPreRelease(tagName);
const imageVersion = getVersionFromTag(tagName, isPreRelease);
process.chdir(rootDir);
console.info(`Running from: ${process.cwd()}`);
@@ -26,10 +27,12 @@ async function main() {
console.info('isPreRelease:', isPreRelease);
await execCommand2(`docker build -t "joplin/server:${imageVersion}" -f Dockerfile.server .`);
await execCommand2(`docker tag "joplin/server:${imageVersion}" "joplin/server:latest"`);
await execCommand2(`docker push joplin/server:${imageVersion}`);
if (!isPreRelease) await execCommand2('docker push joplin/server:latest');
if (!isPreRelease) {
await execCommand2(`docker tag "joplin/server:${imageVersion}" "joplin/server:latest"`);
await execCommand2('docker push joplin/server:latest');
}
}
main().catch((error) => {

View File

@@ -14,15 +14,17 @@ msgid ""
msgstr ""
"Project-Id-Version: Joplin-CLI 1.0.0\n"
"Report-Msgid-Bugs-To: \n"
"Last-Translator: Mario Campo <mario.campo@gmail.com>\n"
"Last-Translator: Francisco Mora <francisco.m.collao@gmail.com>\n"
"Language-Team: Spanish <lucas.vieites@gmail.com>\n"
"Language: es_ES\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 2.4.2\n"
"X-Generator: Poedit 3.0\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Poedit-SourceCharset: UTF-8\n"
"POT-Creation-Date: \n"
"PO-Revision-Date: \n"
#: packages/app-desktop/bridge.js:106 packages/app-desktop/bridge.js:110
#: packages/app-desktop/bridge.js:126 packages/app-desktop/bridge.js:134
@@ -131,11 +133,11 @@ msgstr "Descargar"
#: packages/app-desktop/checkForUpdates.js:189
msgid "Skip this version"
msgstr ""
msgstr "Omitir esta versión"
#: packages/app-desktop/checkForUpdates.js:189
msgid "Full changelog"
msgstr ""
msgstr "Registro de cambios completo"
#: packages/app-desktop/gui/NoteRevisionViewer.min.js:75
#: packages/lib/services/RevisionService.js:242
@@ -280,14 +282,12 @@ msgid "Retry"
msgstr "Reintentar"
#: packages/app-desktop/gui/StatusScreen/StatusScreen.js:137
#, fuzzy
msgid "Advanced tools"
msgstr "Opciones avanzadas"
msgstr "Herramientas avanzadas"
#: packages/app-desktop/gui/StatusScreen/StatusScreen.js:139
#, fuzzy
msgid "Export debug report"
msgstr "Exportar Informe de Depuración"
msgstr "Exportar Informe de depuración"
#: packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.js:183
msgid "strong text"
@@ -345,23 +345,23 @@ msgstr "Casillas"
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js:17
msgid "Highlight"
msgstr ""
msgstr "Destacar"
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js:22
msgid "Strikethrough"
msgstr ""
msgstr "Tachado"
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js:27
msgid "Insert"
msgstr ""
msgstr "Insertar"
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js:33
msgid "Superscript"
msgstr ""
msgstr "Superindice"
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js:39
msgid "Subscript"
msgstr ""
msgstr "Subíndice"
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js:564
#: packages/app-mobile/components/screens/Note.js:1016
@@ -527,9 +527,8 @@ msgid "Delete line"
msgstr "Borrar línea"
#: packages/app-desktop/gui/NoteEditor/commands/editorCommandDeclarations.js:92
#, fuzzy
msgid "Duplicate line"
msgstr "Duplicar"
msgstr "Duplicar linea"
#: packages/app-desktop/gui/NoteEditor/commands/editorCommandDeclarations.js:96
msgid "Undo"
@@ -722,10 +721,12 @@ msgid ""
"Safe mode is currently active. Note rendering and all plugins are "
"temporarily disabled."
msgstr ""
"El modo seguro está activo actualmente. La representación de notas y todos "
"los complementos están temporalmente deshabilitados."
#: packages/app-desktop/gui/MainScreen/MainScreen.js:447
msgid "Disable safe mode and restart"
msgstr ""
msgstr "Desactivar el modo seguro y reiniciar"
#: packages/app-desktop/gui/MainScreen/MainScreen.js:451
msgid ""
@@ -771,15 +772,15 @@ msgstr "Más información"
#: packages/app-desktop/gui/MainScreen/MainScreen.js:477
#, javascript-format
msgid "%s (%s) would like to share a notebook with you."
msgstr ""
msgstr "%s (%s) le gustaría compartir una libreta contigo."
#: packages/app-desktop/gui/MainScreen/MainScreen.js:479
msgid "Accept"
msgstr ""
msgstr "Aceptar"
#: packages/app-desktop/gui/MainScreen/MainScreen.js:481
msgid "Reject"
msgstr ""
msgstr "Rechazar"
#: packages/app-desktop/gui/MainScreen/MainScreen.js:485
msgid "Some items cannot be synchronised."
@@ -865,9 +866,8 @@ msgid "Toggle editors"
msgstr "Alternar editores"
#: packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.js:16
#, fuzzy
msgid "Share notebook..."
msgstr "Compartir nota..."
msgstr "Compartir libreta..."
#: packages/app-desktop/gui/MainScreen/commands/toggleLayoutMoveMode.js:16
msgid "Change application layout"
@@ -947,7 +947,7 @@ msgstr "¡El token ha sido copiado al portapapeles!"
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:44
msgid "Are you sure you want to renew the authorisation token?"
msgstr ""
msgstr "¿Está seguro de que desea renovar el token de autorización?"
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:84
msgid "The web clipper service is enabled and set to auto-start."
@@ -1031,7 +1031,7 @@ msgstr ""
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:222
msgid "Renew token"
msgstr ""
msgstr "Renovar token"
#: packages/app-desktop/gui/MenuBar.js:167
#, javascript-format
@@ -1124,9 +1124,8 @@ msgid "&Go"
msgstr "&Ir"
#: packages/app-desktop/gui/MenuBar.js:631
#, fuzzy
msgid "Note&book"
msgstr "Libretas"
msgstr "Libreta"
#: packages/app-desktop/gui/MenuBar.js:637
msgid "&Note"
@@ -1524,13 +1523,12 @@ msgid "You do not have any installed plugin."
msgstr "No tiene ningún plugin instalado."
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js:232
#, fuzzy
msgid "Could not connect to plugin repository"
msgstr "No se pudo instalar el plugin: %s"
msgstr "No se pudo conectar con el repositorio del plugin: %s"
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js:234
msgid "Try again"
msgstr ""
msgstr "Intenta nuevamente"
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js:242
msgid "Plugin tools"
@@ -1718,9 +1716,8 @@ msgstr ""
"(límite: %s)."
#: packages/app-desktop/gui/ShareNoteDialog.js:141
#, fuzzy
msgid "Unshare note"
msgstr "Compartir"
msgstr "Dejar de compartir nota"
#: packages/app-desktop/gui/ShareNoteDialog.js:168
msgid "Synchronising..."
@@ -1755,19 +1752,20 @@ msgstr[0] "Copiar Enlace Compartible"
msgstr[1] "Copiar Enlaces Compartible"
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:138
#, fuzzy
msgid "Unshare"
msgstr "Compartir"
msgstr "Dejar de compartir"
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:180
msgid ""
"Delete this invitation? The recipient will no longer have access to this "
"shared notebook."
msgstr ""
"¿Eliminar esta invitación? El destinatario ya no tendrá acceso a esta "
"libreta compartida."
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:194
msgid "Add recipient:"
msgstr ""
msgstr "Agregar destinatario:"
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:197
#: packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.js:28
@@ -1777,45 +1775,43 @@ msgstr "Compartir"
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:206
msgid "Recipient has not yet accepted the invitation"
msgstr ""
msgstr "El destinatario aún no ha aceptado la invitación"
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:207
msgid "Recipient has rejected the invitation"
msgstr ""
msgstr "El destinatario ha rechazado la invitación"
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:208
msgid "Recipient has accepted the invitation"
msgstr ""
msgstr "El destinatario ha aceptado la invitación"
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:218
msgid "Recipients:"
msgstr ""
msgstr "Destinatarios:"
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:230
#, fuzzy
msgid "Synchronizing..."
msgstr "Sincronizando..."
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:231
#, fuzzy
msgid "Sharing notebook..."
msgstr "Compartir nota..."
msgstr "Compartir libreta..."
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:241
msgid ""
"Unshare this notebook? The recipients will no longer have access to its "
"content."
msgstr ""
"¿Dejar de compartir esta libreta? Los destinatarios ya no tendrán acceso a "
"su contenido."
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:251
#, fuzzy
msgid "Share Notebook"
msgstr "Compartir Notas"
msgstr "Compartir Libreta"
#: packages/app-desktop/commands/toggleSafeMode.js:18
#, fuzzy
msgid "Toggle safe mode"
msgstr "Alternar la barra lateral"
msgstr "Alternar modo seguro"
#: packages/app-desktop/commands/toggleExternalEditing.js:18
msgid "Toggle external editing"
@@ -1907,7 +1903,7 @@ msgstr "Configuración"
#: packages/app-mobile/components/side-menu-content.js:351
msgid "Mobile data - auto-sync disabled"
msgstr ""
msgstr "Datos móviles: sincronización automática deshabilitada"
#: packages/app-mobile/components/note-list.js:97
msgid "You currently have no notebooks."
@@ -2257,15 +2253,15 @@ msgstr ""
"cambiada! [Cambiarla ahora](%s)"
#: packages/server/dist/models/UserModel.js:134
#, fuzzy
msgid "attachment"
msgstr "Adjuntos"
msgstr "adjunto"
#: packages/server/dist/models/UserModel.js:134
#, javascript-format
msgid ""
"Cannot save %s \"%s\" because it is larger than than the allowed limit (%s)"
msgstr ""
"No se puede guardar %s \"%s\" porque es mayor que el límite permitido (%s)"
#: packages/lib/onedrive-api-node-utils.js:46
#, javascript-format
@@ -2456,23 +2452,20 @@ msgid "Joplin Server URL"
msgstr "URL del Servidor de Joplin"
#: packages/lib/models/Setting.js:327
#, fuzzy
msgid "Joplin Server email"
msgstr "Servidor de Joplin"
msgstr "Email del servidor Joplin"
#: packages/lib/models/Setting.js:338
msgid "Joplin Server password"
msgstr "Contraseña del Servidor de Joplin"
#: packages/lib/models/Setting.js:365
#, fuzzy
msgid "Joplin Cloud email"
msgstr "Servidor de Joplin"
msgstr "Email del servidor Joplin"
#: packages/lib/models/Setting.js:376
#, fuzzy
msgid "Joplin Cloud password"
msgstr "Contraseña del Servidor de Joplin"
msgstr "Contraseña del servidor Joplin"
#: packages/lib/models/Setting.js:388
msgid "Attachment download behaviour"
@@ -2714,11 +2707,12 @@ msgid ""
"Used for most text in the markdown editor. If not found, a generic "
"proportional (variable width) font is used."
msgstr ""
"Se utiliza para la mayor parte del texto en el editor markdown. Si no se "
"encuentra, se utiliza una fuente genérica proporcional (ancho variable)."
#: packages/lib/models/Setting.js:758
#, fuzzy
msgid "Editor monospace font family"
msgstr "Familia de fuente del editor"
msgstr "Familia de fuente monoespaciada del editor"
#: packages/lib/models/Setting.js:759
msgid ""
@@ -2726,6 +2720,10 @@ msgid ""
"tables, checkboxes, code). If not found, a generic monospace (fixed width) "
"font is used."
msgstr ""
"Se utiliza cuando se necesita una fuente de ancho fijo para presentar el "
"texto de manera legible (por ejemplo, tablas, casillas de verificación, "
"código). Si no se encuentra, se utiliza una fuente genérica monoespaciada "
"(ancho fijo)."
#: packages/lib/models/Setting.js:780
msgid "Custom stylesheet for rendered Markdown"
@@ -2737,11 +2735,13 @@ msgstr "Hoja de estilos para personalizar todo Joplin"
#: packages/lib/models/Setting.js:806
msgid "Re-upload local data to sync target"
msgstr ""
msgstr "Vuelva a cargar datos locales para sincronizar el destino"
#: packages/lib/models/Setting.js:816
msgid "Delete local data and re-download from sync target"
msgstr ""
"Elimine los datos locales y vuelva a descargarlos desde el destino de "
"sincronización"
#: packages/lib/models/Setting.js:821
msgid "Automatically update the application"
@@ -2778,7 +2778,7 @@ msgstr "%d horas"
#: packages/lib/models/Setting.js:849
msgid "Synchronise only over WiFi connection"
msgstr ""
msgstr "Sincronizar solo a través de una conexión WiFi"
#: packages/lib/models/Setting.js:856
msgid "Text editor command"
@@ -3064,9 +3064,8 @@ msgid "Encrypted items cannot be modified"
msgstr "Los elementos cifrados no pueden ser modificados"
#: packages/lib/SyncTargetJoplinCloud.js:28
#, fuzzy
msgid "Joplin Cloud"
msgstr "Foro de Joplin"
msgstr "Nube de Joplin"
#: packages/lib/BaseApplication.js:152 packages/lib/BaseApplication.js:164
#: packages/lib/BaseApplication.js:196
@@ -3224,16 +3223,15 @@ msgstr ""
"Joplin a la última versión"
#: packages/lib/JoplinServerApi.js:80
#, fuzzy, javascript-format
#, javascript-format
msgid ""
"Could not connect to Joplin Server. Please check the Synchronisation options "
"in the config screen. Full error was:\n"
"\n"
"%s"
msgstr ""
"No se pudo conectar con la aplicación de Joplin de Nextcloud. Verifique la "
"configuración en la pantalla de configuración de Sincronización. El error "
"completo fue:\n"
"No se pudo conectar al servidor Joplin. Compruebe las opciones de "
"sincronización en la pantalla de configuración. El error completo fue:\n"
"\n"
"%s"
@@ -3908,6 +3906,8 @@ msgid ""
"Runs the commands contained in the text file. There should be one command "
"per line."
msgstr ""
"Ejecuta los comandos contenidos en el archivo de texto. Debe haber un "
"comando por línea."
#: packages/app-cli/app/command-version.js:11
msgid "Displays version information"

View File

@@ -6,13 +6,15 @@ msgid ""
msgstr ""
"Project-Id-Version: Joplin-CLI 1.0.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: \n"
"PO-Revision-Date: \n"
"Last-Translator: 南宫小骏 <jackytsu@vip.qq.com>\n"
"Language-Team: zh_CN <jackytsu.vip.qq.com>\n"
"Language: zh_CN\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 2.4.3\n"
"X-Generator: Poedit 3.0\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#: packages/app-desktop/bridge.js:106 packages/app-desktop/bridge.js:110
@@ -1038,7 +1040,7 @@ msgstr "关于 Joplin"
#: packages/app-desktop/gui/MenuBar.js:401
msgid "Preferences..."
msgstr "首选项"
msgstr "首选项..."
#: packages/app-desktop/gui/MenuBar.js:411
#: packages/app-desktop/gui/MenuBar.js:664
@@ -1087,9 +1089,8 @@ msgid "&Go"
msgstr "跳转 (&G)"
#: packages/app-desktop/gui/MenuBar.js:631
#, fuzzy
msgid "Note&book"
msgstr "笔记本"
msgstr "笔记本 (&B)"
#: packages/app-desktop/gui/MenuBar.js:637
msgid "&Note"
@@ -1658,9 +1659,8 @@ msgid "Warning: not all resources shown for performance reasons (limit: %s)."
msgstr "警告: 由于性能原因无法显示所有资源 (最多:%s)。"
#: packages/app-desktop/gui/ShareNoteDialog.js:141
#, fuzzy
msgid "Unshare note"
msgstr "取消分享"
msgstr "取消分享笔记"
#: packages/app-desktop/gui/ShareNoteDialog.js:168
msgid "Synchronising..."
@@ -2172,7 +2172,6 @@ msgid ""
msgstr "默认管理员密码不安全且尚未更改![现在更改](%s)"
#: packages/server/dist/models/UserModel.js:134
#, fuzzy
msgid "attachment"
msgstr "附件"
@@ -2180,7 +2179,7 @@ msgstr "附件"
#, javascript-format
msgid ""
"Cannot save %s \"%s\" because it is larger than than the allowed limit (%s)"
msgstr ""
msgstr "无法保存 %s “%s”,因为超过了允许的限制大小(%s)。"
#: packages/lib/onedrive-api-node-utils.js:46
#, javascript-format
@@ -2370,14 +2369,12 @@ msgid "Joplin Server password"
msgstr "Joplin 服务器密码"
#: packages/lib/models/Setting.js:365
#, fuzzy
msgid "Joplin Cloud email"
msgstr "Joplin Server 邮箱"
msgstr "Joplin 论坛邮箱"
#: packages/lib/models/Setting.js:376
#, fuzzy
msgid "Joplin Cloud password"
msgstr "Joplin 服务器密码"
msgstr "Joplin 论坛密码"
#: packages/lib/models/Setting.js:388
msgid "Attachment download behaviour"
@@ -2554,7 +2551,7 @@ msgstr "启用 ^sup^ 语法"
#: packages/lib/models/Setting.js:675
msgid "Enable deflist syntax"
msgstr "启用术语表语法"
msgstr "启用清单语法"
#: packages/lib/models/Setting.js:676
msgid "Enable abbreviation syntax"
@@ -2956,7 +2953,6 @@ msgid "Encrypted items cannot be modified"
msgstr "无法修改已加密的条目"
#: packages/lib/SyncTargetJoplinCloud.js:28
#, fuzzy
msgid "Joplin Cloud"
msgstr "Joplin 论坛"

View File

@@ -1,5 +1,16 @@
# Joplin Android app changelog
## [android-v2.1.1](https://github.com/laurent22/joplin/releases/tag/android-v2.1.1) (Pre-release) - 2021-06-19T16:42:57Z
- New: Add version number to log (525ab01)
- New: Added feature flags to disable Joplin Server sync optimisations by default, so that it still work with server 2.0 (326fef4)
- Improved: Allow enabling and disabling feature flags (5b368e3)
- Improved: Allow uploading items in batch when synchronising with Joplin Server (0222c0f)
- Improved: Improved first sync speed when synchronising with Joplin Server (4dc1210)
- Improved: Mask auth token and password in log (0d33955)
- Improved: Optimise first synchronisation, when items have never been synced before (15ce5cd)
- Improved: Update Mermaid: 8.8.4 -&gt; 8.10.2 (#5092 by Helmut K. C. Tessarek)
## [android-v2.0.4](https://github.com/laurent22/joplin/releases/tag/android-v2.0.4) - 2021-06-16T12:15:56Z
- Improved: Prevent sync process from being stuck when the download state of a resource is invalid (5c6fd93)

View File

@@ -1,5 +1,9 @@
# Joplin Server Changelog
## [server-v2.1.3-beta](https://github.com/laurent22/joplin/releases/tag/server-v2.1.3-beta) (Pre-release) - 2021-06-19T14:15:06Z
- New: Add support for uploading multiple items in one request (3b9c02e)
## [server-v2.1.1](https://github.com/laurent22/joplin/releases/tag/server-v2.1.1) - 2021-06-17T17:27:29Z
- New: Added account info to dashboard and title to pages (7f0b3fd)