You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-08-30 20:39:46 +02:00
Compare commits
36 Commits
server_mul
...
android-v2
Author | SHA1 | Date | |
---|---|---|---|
|
5ef5888bfb | ||
|
12aae48ce6 | ||
|
5bb68ba65d | ||
|
112157e33f | ||
|
d129744138 | ||
|
975951ccbc | ||
|
efb6aaf8cc | ||
|
9323caf2f1 | ||
|
d13b1f96ba | ||
|
8e55fe31ee | ||
|
50ecdc2ff1 | ||
|
8a2ca0535d | ||
|
097e49d797 | ||
|
11ffbe7d66 | ||
|
e79f965e5d | ||
|
8cc103a062 | ||
|
7f05efb789 | ||
|
5b368e39ca | ||
|
b88bbc74a2 | ||
|
fc132216cb | ||
|
1dc49c7673 | ||
|
ab9bbcbff2 | ||
|
b7b12f9369 | ||
|
d8752ab889 | ||
|
372495afd3 | ||
|
2320934853 | ||
|
326fef486c | ||
|
7aff6d271d | ||
|
a86a80b21c | ||
|
4dc1210eb5 | ||
|
0222c0f0a6 | ||
|
63f7349c17 | ||
|
3b9c02e92d | ||
|
cef5834d73 | ||
|
b341caf287 | ||
|
fc063fe68e |
@@ -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
21
.gitignore
vendored
@@ -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
|
||||
|
@@ -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
41
LICENSE
@@ -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.
|
||||
|
@@ -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 = () => {
|
||||
|
742
packages/app-cli/tests/support/big-list-of-naughty-strings.txt
Normal file
742
packages/app-cli/tests/support/big-list-of-naughty-strings.txt
Normal 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
|
||||
testtest
|
||||
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>
|
||||
<script>alert('1');</script>
|
||||
<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script: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=javascript:alert('211')>
|
||||
<IMG SRC=javascript:alert('212')>
|
||||
<IMG SRC=javascript:alert('213')>
|
||||
<IMG SRC="jav ascript:alert('214');">
|
||||
<IMG SRC="jav	ascript:alert('215');">
|
||||
<IMG SRC="jav
ascript:alert('216');">
|
||||
<IMG SRC="jav
ascript:alert('217');">
|
||||
perl -e 'print "<IMG SRC=java\0script:alert(\"218\")>";' > out
|
||||
<IMG SRC="  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 [0;31mred[0m, violets are [0;34mblue. Hope you enjoy terminal hue
|
||||
But now...[20Cfor my greatest trick...[8m
|
||||
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() }}
|
3
packages/app-cli/tests/support/plugins/clipboard/.gitignore
vendored
Normal file
3
packages/app-cli/tests/support/plugins/clipboard/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
dist/
|
||||
node_modules/
|
||||
publish/
|
@@ -0,0 +1,8 @@
|
||||
*.md
|
||||
!README.md
|
||||
/*.jpl
|
||||
/api
|
||||
/src
|
||||
/dist
|
||||
tsconfig.json
|
||||
webpack.config.js
|
@@ -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
|
24
packages/app-cli/tests/support/plugins/clipboard/README.md
Normal file
24
packages/app-cli/tests/support/plugins/clipboard/README.md
Normal 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.
|
14
packages/app-cli/tests/support/plugins/clipboard/api/Global.d.ts
vendored
Normal file
14
packages/app-cli/tests/support/plugins/clipboard/api/Global.d.ts
vendored
Normal 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;
|
||||
}
|
65
packages/app-cli/tests/support/plugins/clipboard/api/Joplin.d.ts
vendored
Normal file
65
packages/app-cli/tests/support/plugins/clipboard/api/Joplin.d.ts
vendored
Normal 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;
|
||||
}
|
89
packages/app-cli/tests/support/plugins/clipboard/api/JoplinCommands.d.ts
vendored
Normal file
89
packages/app-cli/tests/support/plugins/clipboard/api/JoplinCommands.d.ts
vendored
Normal 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>;
|
||||
}
|
40
packages/app-cli/tests/support/plugins/clipboard/api/JoplinContentScripts.d.ts
vendored
Normal file
40
packages/app-cli/tests/support/plugins/clipboard/api/JoplinContentScripts.d.ts
vendored
Normal 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>;
|
||||
}
|
47
packages/app-cli/tests/support/plugins/clipboard/api/JoplinData.d.ts
vendored
Normal file
47
packages/app-cli/tests/support/plugins/clipboard/api/JoplinData.d.ts
vendored
Normal 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>;
|
||||
}
|
10
packages/app-cli/tests/support/plugins/clipboard/api/JoplinFilters.d.ts
vendored
Normal file
10
packages/app-cli/tests/support/plugins/clipboard/api/JoplinFilters.d.ts
vendored
Normal 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>;
|
||||
}
|
17
packages/app-cli/tests/support/plugins/clipboard/api/JoplinInterop.d.ts
vendored
Normal file
17
packages/app-cli/tests/support/plugins/clipboard/api/JoplinInterop.d.ts
vendored
Normal 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>;
|
||||
}
|
43
packages/app-cli/tests/support/plugins/clipboard/api/JoplinPlugins.d.ts
vendored
Normal file
43
packages/app-cli/tests/support/plugins/clipboard/api/JoplinPlugins.d.ts
vendored
Normal 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;
|
||||
}
|
64
packages/app-cli/tests/support/plugins/clipboard/api/JoplinSettings.d.ts
vendored
Normal file
64
packages/app-cli/tests/support/plugins/clipboard/api/JoplinSettings.d.ts
vendored
Normal 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>;
|
||||
}
|
28
packages/app-cli/tests/support/plugins/clipboard/api/JoplinViews.d.ts
vendored
Normal file
28
packages/app-cli/tests/support/plugins/clipboard/api/JoplinViews.d.ts
vendored
Normal 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;
|
||||
}
|
62
packages/app-cli/tests/support/plugins/clipboard/api/JoplinViewsDialogs.d.ts
vendored
Normal file
62
packages/app-cli/tests/support/plugins/clipboard/api/JoplinViewsDialogs.d.ts
vendored
Normal 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>;
|
||||
}
|
16
packages/app-cli/tests/support/plugins/clipboard/api/JoplinViewsMenuItems.d.ts
vendored
Normal file
16
packages/app-cli/tests/support/plugins/clipboard/api/JoplinViewsMenuItems.d.ts
vendored
Normal 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>;
|
||||
}
|
18
packages/app-cli/tests/support/plugins/clipboard/api/JoplinViewsMenus.d.ts
vendored
Normal file
18
packages/app-cli/tests/support/plugins/clipboard/api/JoplinViewsMenus.d.ts
vendored
Normal 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>;
|
||||
}
|
61
packages/app-cli/tests/support/plugins/clipboard/api/JoplinViewsPanels.d.ts
vendored
Normal file
61
packages/app-cli/tests/support/plugins/clipboard/api/JoplinViewsPanels.d.ts
vendored
Normal 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>;
|
||||
}
|
16
packages/app-cli/tests/support/plugins/clipboard/api/JoplinViewsToolbarButtons.d.ts
vendored
Normal file
16
packages/app-cli/tests/support/plugins/clipboard/api/JoplinViewsToolbarButtons.d.ts
vendored
Normal 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>;
|
||||
}
|
69
packages/app-cli/tests/support/plugins/clipboard/api/JoplinWorkspace.d.ts
vendored
Normal file
69
packages/app-cli/tests/support/plugins/clipboard/api/JoplinWorkspace.d.ts
vendored
Normal 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 {};
|
@@ -0,0 +1,5 @@
|
||||
import type Joplin from './Joplin';
|
||||
|
||||
declare const joplin: Joplin;
|
||||
|
||||
export default joplin;
|
616
packages/app-cli/tests/support/plugins/clipboard/api/types.ts
Normal file
616
packages/app-cli/tests/support/plugins/clipboard/api/types.ts
Normal 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',
|
||||
}
|
4811
packages/app-cli/tests/support/plugins/clipboard/package-lock.json
generated
Normal file
4811
packages/app-cli/tests/support/plugins/clipboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extraScripts": []
|
||||
}
|
@@ -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);
|
||||
},
|
||||
});
|
@@ -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": []
|
||||
}
|
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/",
|
||||
"module": "commonjs",
|
||||
"target": "es2015",
|
||||
"jsx": "react",
|
||||
"allowJs": true,
|
||||
"baseUrl": "."
|
||||
}
|
||||
}
|
@@ -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;
|
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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]) {
|
||||
|
2
packages/app-desktop/package-lock.json
generated
2
packages/app-desktop/package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "2.0.11",
|
||||
"version": "2.1.3",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
@@ -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,
|
||||
|
@@ -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_ = {};
|
||||
|
||||
|
@@ -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"
|
@@ -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"
|
||||
}
|
||||
|
@@ -1,5 +1,4 @@
|
||||
const React = require('react');
|
||||
|
||||
const { StyleSheet } = require('react-native');
|
||||
const { themeStyle } = require('./global-style.js');
|
||||
|
||||
|
@@ -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;
|
@@ -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
@@ -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');
|
||||
|
@@ -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}'`);
|
||||
|
@@ -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 {
|
||||
|
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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));
|
||||
}
|
||||
|
@@ -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 };
|
@@ -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());
|
||||
|
@@ -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_');
|
||||
|
@@ -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('');
|
||||
});
|
||||
|
||||
});
|
||||
|
@@ -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);
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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]);
|
||||
}
|
||||
}));
|
||||
|
||||
});
|
194
packages/lib/models/Revision.test.ts
Normal file
194
packages/lib/models/Revision.test.ts
Normal 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]);
|
||||
}
|
||||
}));
|
||||
|
||||
});
|
@@ -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 = {};
|
||||
|
||||
|
@@ -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();
|
||||
|
2
packages/lib/package-lock.json
generated
2
packages/lib/package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/lib",
|
||||
"version": "2.0.3",
|
||||
"version": "2.1.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
@@ -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);
|
||||
|
@@ -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);
|
@@ -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();
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
@@ -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);
|
||||
// }
|
||||
|
||||
}
|
||||
|
@@ -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_;
|
||||
}
|
||||
|
51
packages/lib/services/plugins/api/JoplinClipboard.ts
Normal file
51
packages/lib/services/plugins/api/JoplinClipboard.ts
Normal 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();
|
||||
}
|
||||
|
||||
}
|
@@ -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);
|
||||
}
|
||||
|
@@ -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 {
|
||||
|
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
167
packages/lib/services/synchronizer/ItemUploader.test.ts
Normal file
167
packages/lib/services/synchronizer/ItemUploader.test.ts
Normal 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]));
|
||||
}));
|
||||
|
||||
});
|
111
packages/lib/services/synchronizer/ItemUploader.ts
Normal file
111
packages/lib/services/synchronizer/ItemUploader.ts
Normal 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);
|
||||
}
|
||||
|
||||
}
|
@@ -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');
|
||||
},
|
||||
|
||||
|
@@ -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) {
|
||||
|
10
packages/renderer/assets/mermaid/mermaid.min.js
vendored
10
packages/renderer/assets/mermaid/mermaid.min.js
vendored
File diff suppressed because one or more lines are too long
20
packages/renderer/package-lock.json
generated
20
packages/renderer/package-lock.json
generated
@@ -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",
|
||||
|
@@ -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"
|
||||
|
2
packages/server/package-lock.json
generated
2
packages/server/package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/server",
|
||||
"version": "2.1.1",
|
||||
"version": "2.1.3",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
@@ -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.
@@ -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' },
|
||||
|
@@ -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> {
|
||||
|
||||
}
|
@@ -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[] = [];
|
||||
|
||||
|
@@ -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> {
|
||||
|
@@ -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;
|
||||
|
@@ -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;
|
||||
|
||||
|
@@ -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,
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
@@ -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;
|
||||
|
24
packages/server/src/utils/joplinUtils.test.ts
Normal file
24
packages/server/src/utils/joplinUtils.test.ts
Normal 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' }));
|
||||
});
|
||||
|
||||
});
|
@@ -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);
|
||||
|
@@ -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) => {
|
||||
|
@@ -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"
|
||||
|
@@ -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 "默认管理员密码不安全且尚未更改"
|
||||
|
||||
#: 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 论坛"
|
||||
|
||||
|
@@ -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 -> 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)
|
||||
|
@@ -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)
|
||||
|
Reference in New Issue
Block a user