Compare commits
130 Commits
cloud-v2.7
...
multi_prof
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6cd988939 | ||
|
|
a99eec7cdf | ||
|
|
7ef09dfa77 | ||
|
|
b11573a2a7 | ||
|
|
f4034b1ff0 | ||
|
|
58bf93a112 | ||
|
|
5962b0813e | ||
|
|
cffea3ea1e | ||
|
|
dfadacd7f4 | ||
|
|
ecc7b17708 | ||
|
|
ee6ab55649 | ||
|
|
b0d64e2f51 | ||
|
|
f6e21e0180 | ||
|
|
e02422070e | ||
|
|
727d64b646 | ||
|
|
23e54a60d9 | ||
|
|
0d4978223e | ||
|
|
0b32a29cce | ||
|
|
f322d40910 | ||
|
|
557cb9a6c3 | ||
|
|
d5a55c7908 | ||
|
|
7308bbd3ca | ||
|
|
c1e8f9befd | ||
|
|
a0d77d10ba | ||
|
|
bdd9c6cf35 | ||
|
|
f2bfa30e04 | ||
|
|
8077117e65 | ||
|
|
7e8927398a | ||
|
|
09dcee876c | ||
|
|
23b56f4f70 | ||
|
|
b3d09ce776 | ||
|
|
84d40b805e | ||
|
|
c097a82b7b | ||
|
|
dfa22b560e | ||
|
|
7d31a3fe90 | ||
|
|
79aabc2d06 | ||
|
|
70e82ca64f | ||
|
|
a0662412b2 | ||
|
|
220b48ef02 | ||
|
|
cb637e817b | ||
|
|
27198a16a4 | ||
|
|
1a5bff3bf4 | ||
|
|
571147acbb | ||
|
|
9d9420a35c | ||
|
|
a79bc69604 | ||
|
|
3153d3a1b6 | ||
|
|
df8c265ee4 | ||
|
|
3725b14e04 | ||
|
|
a73d822998 | ||
|
|
a62e1fba96 | ||
|
|
37d51c3b58 | ||
|
|
d5dfecc19f | ||
|
|
8f8cc12d79 | ||
|
|
8e1802409f | ||
|
|
42b2f2146c | ||
|
|
2ba1563d92 | ||
|
|
a679b21119 | ||
|
|
dd10b6ac65 | ||
|
|
32600df7ce | ||
|
|
8a1cfabfc6 | ||
|
|
1b2046f2fa | ||
|
|
a1d5168918 | ||
|
|
047c1fb1a5 | ||
|
|
e83d662555 | ||
|
|
5c3b2671bf | ||
|
|
02a2dce605 | ||
|
|
c4e158d0fb | ||
|
|
fa8a1c2122 | ||
|
|
3f732939d0 | ||
|
|
fb8886db4b | ||
|
|
eb86e9c896 | ||
|
|
85e3a44276 | ||
|
|
addcfb0129 | ||
|
|
f4ec73ab0e | ||
|
|
19ba939f0f | ||
|
|
780b58ada4 | ||
|
|
b3aed81bea | ||
|
|
74dd2d1194 | ||
|
|
3c11445db0 | ||
|
|
50890a7b2b | ||
|
|
dc60da219a | ||
|
|
bef93e1375 | ||
|
|
45e02a6b3f | ||
|
|
0c5f64207f | ||
|
|
8e88686bb1 | ||
|
|
9594508c73 | ||
|
|
de7d3b6b8a | ||
|
|
f5b6398d07 | ||
|
|
62a5cf520a | ||
|
|
90bfffed0f | ||
|
|
a5b0d594c3 | ||
|
|
1547256af4 | ||
|
|
c3e7fcb471 | ||
|
|
261302d5c4 | ||
|
|
38e65253ad | ||
|
|
eb8d0daa3d | ||
|
|
5a6c03fbac | ||
|
|
21f9dab908 | ||
|
|
6dba4730b0 | ||
|
|
9599d4ef7e | ||
|
|
252f08e828 | ||
|
|
94dc216add | ||
|
|
32de63fad3 | ||
|
|
299a14755a | ||
|
|
053dbabf74 | ||
|
|
07f128ae95 | ||
|
|
98f9ed641c | ||
|
|
d814fd6965 | ||
|
|
d44aade075 | ||
|
|
4884c9ef87 | ||
|
|
3e518ff2c7 | ||
|
|
195c84ff59 | ||
|
|
6881259501 | ||
|
|
6d7f6e29bd | ||
|
|
dc991d4c0f | ||
|
|
88ac664e37 | ||
|
|
20784b0e99 | ||
|
|
a325bf6dc6 | ||
|
|
ff79745d28 | ||
|
|
82109a3132 | ||
|
|
4e901436cc | ||
|
|
f0113c0673 | ||
|
|
0a1947a712 | ||
|
|
39aee65eee | ||
|
|
d37b820bd3 | ||
|
|
06b95bb718 | ||
|
|
8e87e64dea | ||
|
|
422a5bfa91 | ||
|
|
bfe5ee8ba3 | ||
|
|
00e0f6b97c |
@@ -148,6 +148,9 @@ packages/app-desktop/checkForUpdates.js.map
|
||||
packages/app-desktop/commands/copyDevCommand.d.ts
|
||||
packages/app-desktop/commands/copyDevCommand.js
|
||||
packages/app-desktop/commands/copyDevCommand.js.map
|
||||
packages/app-desktop/commands/editProfileConfig.d.ts
|
||||
packages/app-desktop/commands/editProfileConfig.js
|
||||
packages/app-desktop/commands/editProfileConfig.js.map
|
||||
packages/app-desktop/commands/exportFolders.d.ts
|
||||
packages/app-desktop/commands/exportFolders.js
|
||||
packages/app-desktop/commands/exportFolders.js.map
|
||||
@@ -175,6 +178,18 @@ packages/app-desktop/commands/startExternalEditing.js.map
|
||||
packages/app-desktop/commands/stopExternalEditing.d.ts
|
||||
packages/app-desktop/commands/stopExternalEditing.js
|
||||
packages/app-desktop/commands/stopExternalEditing.js.map
|
||||
packages/app-desktop/commands/switchProfile.d.ts
|
||||
packages/app-desktop/commands/switchProfile.js
|
||||
packages/app-desktop/commands/switchProfile.js.map
|
||||
packages/app-desktop/commands/switchProfile1.d.ts
|
||||
packages/app-desktop/commands/switchProfile1.js
|
||||
packages/app-desktop/commands/switchProfile1.js.map
|
||||
packages/app-desktop/commands/switchProfile2.d.ts
|
||||
packages/app-desktop/commands/switchProfile2.js
|
||||
packages/app-desktop/commands/switchProfile2.js.map
|
||||
packages/app-desktop/commands/switchProfile3.d.ts
|
||||
packages/app-desktop/commands/switchProfile3.js
|
||||
packages/app-desktop/commands/switchProfile3.js.map
|
||||
packages/app-desktop/commands/toggleExternalEditing.d.ts
|
||||
packages/app-desktop/commands/toggleExternalEditing.js
|
||||
packages/app-desktop/commands/toggleExternalEditing.js.map
|
||||
@@ -259,6 +274,9 @@ packages/app-desktop/gui/KeymapConfig/utils/useKeymap.js.map
|
||||
packages/app-desktop/gui/MainScreen/MainScreen.d.ts
|
||||
packages/app-desktop/gui/MainScreen/MainScreen.js
|
||||
packages/app-desktop/gui/MainScreen/MainScreen.js.map
|
||||
packages/app-desktop/gui/MainScreen/commands/addProfile.d.ts
|
||||
packages/app-desktop/gui/MainScreen/commands/addProfile.js
|
||||
packages/app-desktop/gui/MainScreen/commands/addProfile.js.map
|
||||
packages/app-desktop/gui/MainScreen/commands/commandPalette.d.ts
|
||||
packages/app-desktop/gui/MainScreen/commands/commandPalette.js
|
||||
packages/app-desktop/gui/MainScreen/commands/commandPalette.js.map
|
||||
@@ -490,6 +508,12 @@ packages/app-desktop/gui/NoteEditor/utils/clipboardUtils.test.js.map
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenu.d.ts
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenu.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenu.js.map
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenu.test.d.ts
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenu.test.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenu.test.js.map
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.d.ts
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.js.map
|
||||
packages/app-desktop/gui/NoteEditor/utils/index.d.ts
|
||||
packages/app-desktop/gui/NoteEditor/utils/index.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/index.js.map
|
||||
@@ -724,6 +748,9 @@ packages/app-desktop/gui/utils/convertToScreenCoordinates.js.map
|
||||
packages/app-desktop/gui/utils/loadScript.d.ts
|
||||
packages/app-desktop/gui/utils/loadScript.js
|
||||
packages/app-desktop/gui/utils/loadScript.js.map
|
||||
packages/app-desktop/loadResources.testEnv.d.ts
|
||||
packages/app-desktop/loadResources.testEnv.js
|
||||
packages/app-desktop/loadResources.testEnv.js.map
|
||||
packages/app-desktop/plugins/GotoAnything.d.ts
|
||||
packages/app-desktop/plugins/GotoAnything.js
|
||||
packages/app-desktop/plugins/GotoAnything.js.map
|
||||
@@ -1237,6 +1264,9 @@ packages/lib/services/DecryptionWorker.js.map
|
||||
packages/lib/services/ExternalEditWatcher.d.ts
|
||||
packages/lib/services/ExternalEditWatcher.js
|
||||
packages/lib/services/ExternalEditWatcher.js.map
|
||||
packages/lib/services/ExternalEditWatcher/utils.d.ts
|
||||
packages/lib/services/ExternalEditWatcher/utils.js
|
||||
packages/lib/services/ExternalEditWatcher/utils.js.map
|
||||
packages/lib/services/ItemChangeUtils.d.ts
|
||||
packages/lib/services/ItemChangeUtils.js
|
||||
packages/lib/services/ItemChangeUtils.js.map
|
||||
@@ -1561,6 +1591,24 @@ packages/lib/services/plugins/utils/validatePluginVersion.js.map
|
||||
packages/lib/services/plugins/utils/validatePluginVersion.test.d.ts
|
||||
packages/lib/services/plugins/utils/validatePluginVersion.test.js
|
||||
packages/lib/services/plugins/utils/validatePluginVersion.test.js.map
|
||||
packages/lib/services/profileConfig/index.d.ts
|
||||
packages/lib/services/profileConfig/index.js
|
||||
packages/lib/services/profileConfig/index.js.map
|
||||
packages/lib/services/profileConfig/index.test.d.ts
|
||||
packages/lib/services/profileConfig/index.test.js
|
||||
packages/lib/services/profileConfig/index.test.js.map
|
||||
packages/lib/services/profileConfig/initProfile.d.ts
|
||||
packages/lib/services/profileConfig/initProfile.js
|
||||
packages/lib/services/profileConfig/initProfile.js.map
|
||||
packages/lib/services/profileConfig/mergeGlobalAndLocalSettings.d.ts
|
||||
packages/lib/services/profileConfig/mergeGlobalAndLocalSettings.js
|
||||
packages/lib/services/profileConfig/mergeGlobalAndLocalSettings.js.map
|
||||
packages/lib/services/profileConfig/splitGlobalAndLocalSettings.d.ts
|
||||
packages/lib/services/profileConfig/splitGlobalAndLocalSettings.js
|
||||
packages/lib/services/profileConfig/splitGlobalAndLocalSettings.js.map
|
||||
packages/lib/services/profileConfig/types.d.ts
|
||||
packages/lib/services/profileConfig/types.js
|
||||
packages/lib/services/profileConfig/types.js.map
|
||||
packages/lib/services/rest/Api.d.ts
|
||||
packages/lib/services/rest/Api.js
|
||||
packages/lib/services/rest/Api.js.map
|
||||
@@ -1639,6 +1687,9 @@ packages/lib/services/searchengine/SearchEngineUtils.js.map
|
||||
packages/lib/services/searchengine/SearchEngineUtils.test.d.ts
|
||||
packages/lib/services/searchengine/SearchEngineUtils.test.js
|
||||
packages/lib/services/searchengine/SearchEngineUtils.test.js.map
|
||||
packages/lib/services/searchengine/SearchFilter.test.d.ts
|
||||
packages/lib/services/searchengine/SearchFilter.test.js
|
||||
packages/lib/services/searchengine/SearchFilter.test.js.map
|
||||
packages/lib/services/searchengine/filterParser.d.ts
|
||||
packages/lib/services/searchengine/filterParser.js
|
||||
packages/lib/services/searchengine/filterParser.js.map
|
||||
@@ -1966,6 +2017,9 @@ packages/tools/generate-images.js.map
|
||||
packages/tools/git-changelog.d.ts
|
||||
packages/tools/git-changelog.js
|
||||
packages/tools/git-changelog.js.map
|
||||
packages/tools/licenseChecker.d.ts
|
||||
packages/tools/licenseChecker.js
|
||||
packages/tools/licenseChecker.js.map
|
||||
packages/tools/release-android.d.ts
|
||||
packages/tools/release-android.js
|
||||
packages/tools/release-android.js.map
|
||||
@@ -2008,9 +2062,21 @@ packages/tools/website/build.js.map
|
||||
packages/tools/website/updateDownloadPage.d.ts
|
||||
packages/tools/website/updateDownloadPage.js
|
||||
packages/tools/website/updateDownloadPage.js.map
|
||||
packages/tools/website/updateNews.d.ts
|
||||
packages/tools/website/updateNews.js
|
||||
packages/tools/website/updateNews.js.map
|
||||
packages/tools/website/utils/frontMatter.d.ts
|
||||
packages/tools/website/utils/frontMatter.js
|
||||
packages/tools/website/utils/frontMatter.js.map
|
||||
packages/tools/website/utils/openGraph.d.ts
|
||||
packages/tools/website/utils/openGraph.js
|
||||
packages/tools/website/utils/openGraph.js.map
|
||||
packages/tools/website/utils/openGraph.test.d.ts
|
||||
packages/tools/website/utils/openGraph.test.js
|
||||
packages/tools/website/utils/openGraph.test.js.map
|
||||
packages/tools/website/utils/parser.d.ts
|
||||
packages/tools/website/utils/parser.js
|
||||
packages/tools/website/utils/parser.js.map
|
||||
packages/tools/website/utils/pressCarousel.d.ts
|
||||
packages/tools/website/utils/pressCarousel.js
|
||||
packages/tools/website/utils/pressCarousel.js.map
|
||||
|
||||
3
.github/workflows/github-actions-main.yml
vendored
@@ -5,7 +5,8 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-latest, ubuntu-latest, windows-2016]
|
||||
# Removed windows-2016 for now - discontinued by GitHub
|
||||
os: [macos-latest, ubuntu-latest]
|
||||
steps:
|
||||
|
||||
# Silence apt-get update errors (for example when a module doesn't
|
||||
|
||||
66
.gitignore
vendored
@@ -138,6 +138,9 @@ packages/app-desktop/checkForUpdates.js.map
|
||||
packages/app-desktop/commands/copyDevCommand.d.ts
|
||||
packages/app-desktop/commands/copyDevCommand.js
|
||||
packages/app-desktop/commands/copyDevCommand.js.map
|
||||
packages/app-desktop/commands/editProfileConfig.d.ts
|
||||
packages/app-desktop/commands/editProfileConfig.js
|
||||
packages/app-desktop/commands/editProfileConfig.js.map
|
||||
packages/app-desktop/commands/exportFolders.d.ts
|
||||
packages/app-desktop/commands/exportFolders.js
|
||||
packages/app-desktop/commands/exportFolders.js.map
|
||||
@@ -165,6 +168,18 @@ packages/app-desktop/commands/startExternalEditing.js.map
|
||||
packages/app-desktop/commands/stopExternalEditing.d.ts
|
||||
packages/app-desktop/commands/stopExternalEditing.js
|
||||
packages/app-desktop/commands/stopExternalEditing.js.map
|
||||
packages/app-desktop/commands/switchProfile.d.ts
|
||||
packages/app-desktop/commands/switchProfile.js
|
||||
packages/app-desktop/commands/switchProfile.js.map
|
||||
packages/app-desktop/commands/switchProfile1.d.ts
|
||||
packages/app-desktop/commands/switchProfile1.js
|
||||
packages/app-desktop/commands/switchProfile1.js.map
|
||||
packages/app-desktop/commands/switchProfile2.d.ts
|
||||
packages/app-desktop/commands/switchProfile2.js
|
||||
packages/app-desktop/commands/switchProfile2.js.map
|
||||
packages/app-desktop/commands/switchProfile3.d.ts
|
||||
packages/app-desktop/commands/switchProfile3.js
|
||||
packages/app-desktop/commands/switchProfile3.js.map
|
||||
packages/app-desktop/commands/toggleExternalEditing.d.ts
|
||||
packages/app-desktop/commands/toggleExternalEditing.js
|
||||
packages/app-desktop/commands/toggleExternalEditing.js.map
|
||||
@@ -249,6 +264,9 @@ packages/app-desktop/gui/KeymapConfig/utils/useKeymap.js.map
|
||||
packages/app-desktop/gui/MainScreen/MainScreen.d.ts
|
||||
packages/app-desktop/gui/MainScreen/MainScreen.js
|
||||
packages/app-desktop/gui/MainScreen/MainScreen.js.map
|
||||
packages/app-desktop/gui/MainScreen/commands/addProfile.d.ts
|
||||
packages/app-desktop/gui/MainScreen/commands/addProfile.js
|
||||
packages/app-desktop/gui/MainScreen/commands/addProfile.js.map
|
||||
packages/app-desktop/gui/MainScreen/commands/commandPalette.d.ts
|
||||
packages/app-desktop/gui/MainScreen/commands/commandPalette.js
|
||||
packages/app-desktop/gui/MainScreen/commands/commandPalette.js.map
|
||||
@@ -480,6 +498,12 @@ packages/app-desktop/gui/NoteEditor/utils/clipboardUtils.test.js.map
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenu.d.ts
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenu.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenu.js.map
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenu.test.d.ts
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenu.test.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenu.test.js.map
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.d.ts
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.js.map
|
||||
packages/app-desktop/gui/NoteEditor/utils/index.d.ts
|
||||
packages/app-desktop/gui/NoteEditor/utils/index.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/index.js.map
|
||||
@@ -714,6 +738,9 @@ packages/app-desktop/gui/utils/convertToScreenCoordinates.js.map
|
||||
packages/app-desktop/gui/utils/loadScript.d.ts
|
||||
packages/app-desktop/gui/utils/loadScript.js
|
||||
packages/app-desktop/gui/utils/loadScript.js.map
|
||||
packages/app-desktop/loadResources.testEnv.d.ts
|
||||
packages/app-desktop/loadResources.testEnv.js
|
||||
packages/app-desktop/loadResources.testEnv.js.map
|
||||
packages/app-desktop/plugins/GotoAnything.d.ts
|
||||
packages/app-desktop/plugins/GotoAnything.js
|
||||
packages/app-desktop/plugins/GotoAnything.js.map
|
||||
@@ -1227,6 +1254,9 @@ packages/lib/services/DecryptionWorker.js.map
|
||||
packages/lib/services/ExternalEditWatcher.d.ts
|
||||
packages/lib/services/ExternalEditWatcher.js
|
||||
packages/lib/services/ExternalEditWatcher.js.map
|
||||
packages/lib/services/ExternalEditWatcher/utils.d.ts
|
||||
packages/lib/services/ExternalEditWatcher/utils.js
|
||||
packages/lib/services/ExternalEditWatcher/utils.js.map
|
||||
packages/lib/services/ItemChangeUtils.d.ts
|
||||
packages/lib/services/ItemChangeUtils.js
|
||||
packages/lib/services/ItemChangeUtils.js.map
|
||||
@@ -1551,6 +1581,24 @@ packages/lib/services/plugins/utils/validatePluginVersion.js.map
|
||||
packages/lib/services/plugins/utils/validatePluginVersion.test.d.ts
|
||||
packages/lib/services/plugins/utils/validatePluginVersion.test.js
|
||||
packages/lib/services/plugins/utils/validatePluginVersion.test.js.map
|
||||
packages/lib/services/profileConfig/index.d.ts
|
||||
packages/lib/services/profileConfig/index.js
|
||||
packages/lib/services/profileConfig/index.js.map
|
||||
packages/lib/services/profileConfig/index.test.d.ts
|
||||
packages/lib/services/profileConfig/index.test.js
|
||||
packages/lib/services/profileConfig/index.test.js.map
|
||||
packages/lib/services/profileConfig/initProfile.d.ts
|
||||
packages/lib/services/profileConfig/initProfile.js
|
||||
packages/lib/services/profileConfig/initProfile.js.map
|
||||
packages/lib/services/profileConfig/mergeGlobalAndLocalSettings.d.ts
|
||||
packages/lib/services/profileConfig/mergeGlobalAndLocalSettings.js
|
||||
packages/lib/services/profileConfig/mergeGlobalAndLocalSettings.js.map
|
||||
packages/lib/services/profileConfig/splitGlobalAndLocalSettings.d.ts
|
||||
packages/lib/services/profileConfig/splitGlobalAndLocalSettings.js
|
||||
packages/lib/services/profileConfig/splitGlobalAndLocalSettings.js.map
|
||||
packages/lib/services/profileConfig/types.d.ts
|
||||
packages/lib/services/profileConfig/types.js
|
||||
packages/lib/services/profileConfig/types.js.map
|
||||
packages/lib/services/rest/Api.d.ts
|
||||
packages/lib/services/rest/Api.js
|
||||
packages/lib/services/rest/Api.js.map
|
||||
@@ -1629,6 +1677,9 @@ packages/lib/services/searchengine/SearchEngineUtils.js.map
|
||||
packages/lib/services/searchengine/SearchEngineUtils.test.d.ts
|
||||
packages/lib/services/searchengine/SearchEngineUtils.test.js
|
||||
packages/lib/services/searchengine/SearchEngineUtils.test.js.map
|
||||
packages/lib/services/searchengine/SearchFilter.test.d.ts
|
||||
packages/lib/services/searchengine/SearchFilter.test.js
|
||||
packages/lib/services/searchengine/SearchFilter.test.js.map
|
||||
packages/lib/services/searchengine/filterParser.d.ts
|
||||
packages/lib/services/searchengine/filterParser.js
|
||||
packages/lib/services/searchengine/filterParser.js.map
|
||||
@@ -1956,6 +2007,9 @@ packages/tools/generate-images.js.map
|
||||
packages/tools/git-changelog.d.ts
|
||||
packages/tools/git-changelog.js
|
||||
packages/tools/git-changelog.js.map
|
||||
packages/tools/licenseChecker.d.ts
|
||||
packages/tools/licenseChecker.js
|
||||
packages/tools/licenseChecker.js.map
|
||||
packages/tools/release-android.d.ts
|
||||
packages/tools/release-android.js
|
||||
packages/tools/release-android.js.map
|
||||
@@ -1998,9 +2052,21 @@ packages/tools/website/build.js.map
|
||||
packages/tools/website/updateDownloadPage.d.ts
|
||||
packages/tools/website/updateDownloadPage.js
|
||||
packages/tools/website/updateDownloadPage.js.map
|
||||
packages/tools/website/updateNews.d.ts
|
||||
packages/tools/website/updateNews.js
|
||||
packages/tools/website/updateNews.js.map
|
||||
packages/tools/website/utils/frontMatter.d.ts
|
||||
packages/tools/website/utils/frontMatter.js
|
||||
packages/tools/website/utils/frontMatter.js.map
|
||||
packages/tools/website/utils/openGraph.d.ts
|
||||
packages/tools/website/utils/openGraph.js
|
||||
packages/tools/website/utils/openGraph.js.map
|
||||
packages/tools/website/utils/openGraph.test.d.ts
|
||||
packages/tools/website/utils/openGraph.test.js
|
||||
packages/tools/website/utils/openGraph.test.js.map
|
||||
packages/tools/website/utils/parser.d.ts
|
||||
packages/tools/website/utils/parser.js
|
||||
packages/tools/website/utils/parser.js.map
|
||||
packages/tools/website/utils/pressCarousel.d.ts
|
||||
packages/tools/website/utils/pressCarousel.js
|
||||
packages/tools/website/utils/pressCarousel.js.map
|
||||
|
||||
@@ -1073,6 +1073,10 @@ footer .bottom-links-row p {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.joplin-cloud-feature-list table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.price-row .plan-type {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
BIN
Assets/WebsiteAssets/images/news/20220224-edit-dialog.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
Assets/WebsiteAssets/images/news/20220224-edit-notebook.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
Assets/WebsiteAssets/images/news/20220224-notebook-icon.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
Assets/WebsiteAssets/images/news/20220308-gsoc-banner.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
@@ -10,8 +10,8 @@
|
||||
<link rel="icon" href="{{imageBaseUrl}}/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="Joplin, the open source note-taking application" />
|
||||
<link rel="stylesheet" href="{{{assetUrls.css.fontawesome}}}">
|
||||
{{> openGraphTags}}
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{{cssBaseUrl}}/bootstrap5.0.2.min.css"
|
||||
|
||||
@@ -23,7 +23,7 @@ https://github.com/laurent22/joplin/blob/dev/{{{sourceMarkdownFile}}}
|
||||
<link rel="icon" href="{{imageBaseUrl}}/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="Joplin, the open source note-taking application" />
|
||||
{{> openGraphTags}}
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{{cssBaseUrl}}/bootstrap5.0.2.min.css"
|
||||
@@ -65,11 +65,6 @@ https://github.com/laurent22/joplin/blob/dev/{{{sourceMarkdownFile}}}
|
||||
{{{contentHtml}}}
|
||||
{{#showBottomLinks}}
|
||||
<div class="bottom-links">
|
||||
{{#discussOnForumLink}}
|
||||
<a class="bottom-link" href="{{{discussOnForumLink}}}">
|
||||
<i class="fab fa-discourse"></i></i> Discuss on the forum
|
||||
</a>
|
||||
{{/discussOnForumLink}}
|
||||
{{#showImproveThisDoc}}
|
||||
<a class="bottom-link" href="https://github.com/laurent22/joplin/blob/dev/{{{sourceMarkdownFile}}}">
|
||||
<i class="fab fa-github"></i> Improve this doc
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{{#openGraph}}
|
||||
<meta name="description" content="{{openGraph.description}}" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:site" content="@joplinapp" />
|
||||
<meta property="og:url" content="{{{openGraph.url}}}" />
|
||||
<meta property="og:title" content="{{openGraph.title}}" />
|
||||
<meta property="twitter:title" content="{{openGraph.title}}" />
|
||||
<meta property="og:description" content="{{openGraph.description}}" />
|
||||
<meta property="twitter:description" content="{{openGraph.description}}" />
|
||||
{{#openGraph.image}}
|
||||
<meta property="og:image" content="{{{openGraph.image}}}" />
|
||||
<meta property="twitter:image" content="{{{openGraph.image}}}" />
|
||||
{{/openGraph.image}}
|
||||
{{/openGraph}}
|
||||
@@ -6,11 +6,11 @@
|
||||
</div>
|
||||
|
||||
<div class="plan-price plan-price-monthly">
|
||||
{{priceMonthly.formattedMonthlyAmount}}<sub class="per-month"> /month</sub>
|
||||
{{priceMonthly.formattedMonthlyAmount}}<sub class="per-month"> /month{{#footnote}} (*){{/footnote}}</sub>
|
||||
</div>
|
||||
|
||||
<div class="plan-price plan-price-yearly">
|
||||
{{priceYearly.formattedMonthlyAmount}}<sub class="per-month"> /month</sub>
|
||||
{{priceYearly.formattedMonthlyAmount}}<sub class="per-month"> /month{{#footnote}} (*){{/footnote}}</sub>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,17 +20,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#featuresOn}}
|
||||
{{#featureLabelsOn}}
|
||||
<p><i class="fas fa-check feature feature-on"></i>{{.}}</p>
|
||||
{{/featuresOn}}
|
||||
{{/featureLabelsOn}}
|
||||
|
||||
{{#featuresOff}}
|
||||
{{#featureLabelsOff}}
|
||||
<p class="unchecked-text"><i class="fas fa-times feature feature-off"></i>{{.}}</p>
|
||||
{{/featuresOff}}
|
||||
{{/featureLabelsOff}}
|
||||
|
||||
<p class="text-center subscribe-wrapper">
|
||||
<a id="subscribeButton-{{name}}" href="{{cfaUrl}}" class="button-link btn-white subscribeButton">{{cfaLabel}}</a>
|
||||
</p>
|
||||
|
||||
{{#footnote}}<sub>(*) {{.}}</sub>{{/footnote}}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -42,13 +42,23 @@
|
||||
{{> plan}}
|
||||
{{/plans.pro}}
|
||||
|
||||
{{#plans.business}}
|
||||
{{#plans.teams}}
|
||||
{{> plan}}
|
||||
{{/plans.business}}
|
||||
{{/plans.teams}}
|
||||
|
||||
<p class="joplin-cloud-login-info">Already have a Joplin Cloud account? <a href="https://joplincloud.com">Login now</a></p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div>
|
||||
<h1>Feature comparison</h1>
|
||||
<div class="joplin-cloud-feature-list">
|
||||
{{{featureListHtml}}}
|
||||
</div>
|
||||
<p> </p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
{{{faqHtml}}}
|
||||
</div>
|
||||
|
||||
2
BUILD.md
@@ -25,6 +25,8 @@ There are also a few forks of existing packages under the "fork-*" name.
|
||||
|
||||
## Building
|
||||
|
||||
Make sure the path to the project directory does not contain spaces or the build may fail.
|
||||
|
||||
Before doing anything else, from the root of the project, run:
|
||||
|
||||
yarn install
|
||||
|
||||
@@ -38,7 +38,7 @@ If you want to start contributing to the project's code, please follow these gui
|
||||
- **Changes that will consist of more than 50 lines of code should be discussed on the [Joplin Forum](https://discourse.joplinapp.org/)**, so that you don't spend too much time implementing something that might not be accepted.
|
||||
- All the applications share the same backend (database, synchronisation, settings, models, business logic, etc.) so if you change something in the backend in one app, make sure it still works in the other apps. Usually it does, but keep this in mind.
|
||||
- Pull requests that make many changes using an automated tool, like for spell fixing, styling, etc. will not be accepted. An exception would be if the changes have been discussed in the forum and someone has agreed to review **and test** the pull request.
|
||||
- Pull requests that make address multiple issues will most likely stall and eventually be closed. This is because we might be fine with one of the changes but not with others and untangling that kind of pull request is too much hassle both for maintainers and the person who submitted it. So most of the time someone gives up and the PR gets closed. So please keep the pull request focused on one issue.
|
||||
- Pull requests that address multiple issues will most likely stall and eventually be closed. This is because we might be fine with one of the changes but not with others and untangling that kind of pull request is too much hassle both for maintainers and the person who submitted it. So most of the time someone gives up and the PR gets closed. So please keep the pull request focused on one issue.
|
||||
- **Do not mark your reviewer's comments as "resolved"**. If you do that, the comments will be hidden and the reviewer will not know what are the pending issues in the pull request. Only the reviewer should resolve the comments.
|
||||
|
||||
Building the apps is relatively easy - please [see the build instructions](https://github.com/laurent22/joplin/blob/dev/BUILD.md) for more details.
|
||||
@@ -53,37 +53,57 @@ For changes made to the Desktop client that affect the user interface, refer to
|
||||
|
||||
## Automated tests
|
||||
|
||||
When submitting a pull request for a new feature or a bug fix, please add automated tests for your code whenever possible. Tests in Joplin are divided into **unit tests** and **feature tests**.
|
||||
When submitting a pull request for a new feature or a bug fix, please add automated tests. We use [Jest](https://jestjs.io/) as a testing framework so you will need to be familiar with it or go through their documentation.
|
||||
|
||||
* **Unit tests** are used to test models, services or utility classes - they are relatively low level. Unit tests should be prefixed with the type of class that is being tested - for example "models_Folder" or "services_SearchEngine".
|
||||
### Running the tests
|
||||
|
||||
* **Feature tests** on the other hand are to test higher level functionalities such as interactions with the GUI and how they affect the underlying model. Often these tests would dispatch Redux actions, and inspect how the application state has been changed. The feature tests should be prefixed with "feature_", for example "feature_TagList". There's a good explanation on what qualifies as a feature test in [this post](https://github.com/laurent22/joplin/pull/2819#issuecomment-603502230).
|
||||
|
||||
The tests are under packages/app-cli/tests. To get them running, you first need to build the CLI app:
|
||||
|
||||
```sh
|
||||
yarn install
|
||||
cd packages/app-cli
|
||||
```
|
||||
|
||||
To run all the test units:
|
||||
To run all the test units, run from the root:
|
||||
|
||||
```sh
|
||||
yarn test
|
||||
```
|
||||
|
||||
Or you can go inside a package folder, and run the tests from there. For example to run all the library tests, go in `packages/lib` and run `yarn test`
|
||||
|
||||
To run just one particular file:
|
||||
|
||||
```sh
|
||||
yarn test --filter=markdownUtils # Don't add the .js extension
|
||||
# Run all the tests in markdownUtils.test.ts
|
||||
yarn test markdownUtils
|
||||
```
|
||||
|
||||
To filter tests. For example, to run all the test units that contain "should handle conflict" in their description:
|
||||
To run only a particular test in a file:
|
||||
|
||||
```sh
|
||||
yarn test --filter="should handle conflict"
|
||||
# Run only the test described as "should handle conflict"
|
||||
# inside markdownUtils.test.ts:
|
||||
yarn test markdownUtils --filter="should handle conflict"
|
||||
```
|
||||
|
||||
### Adding a new test file
|
||||
|
||||
To add a test, simply create a new file with an extension `.test.ts` in the same directory. For example if you are working on the file `example.ts`, create a file `example.test.ts` for the unit tests. If this file already exist, simply add your tests directly to it.
|
||||
|
||||
### Setting the testing environment
|
||||
|
||||
Many utility functions are available under the package `@joplin/lib/testing/test-utils`. Have a look for example at [Note.test.ts](https://github.com/laurent22/joplin/blob/dev/packages/lib/models/Note.test.ts) to see how to setup test units with database support and synchroniser support. Note that this is not needed for all tests - if you just have a simple functions to test you won't need that extra setup.
|
||||
|
||||
### Testing React Hooks
|
||||
|
||||
To test React Hooks please use the package `@testing-library/react-hooks`. See [useLayoutItemSizes.test.ts](https://github.com/laurent22/joplin/blob/dev/packages/app-desktop/gui/ResizableLayout/utils/useLayoutItemSizes.test.ts) for an example.
|
||||
|
||||
### If it is not possible to add tests
|
||||
|
||||
More often than not, it is actually possible to add tests - just go back to your code and see if it can be refactored and certain functionalities moved to simple functions (with no dependencies). Once you have a simple function, you can easily add unit tests for it.
|
||||
|
||||
Additionally, if the unit tests are not sufficient, please provide a **manual testing plan**, which should include detailed steps on:
|
||||
|
||||
- How to test that your feature is working. Include at least 5 tests. Try to think of the possible inputs - if it's a list, how does it work with 0 elements, or 1, or 10, or 100,000. If it's a text input, how does it work with an empty string, or a very large string, etc. Basically don't just put one test that check the best case scenario.
|
||||
|
||||
- How to verify that related parts of the applications are not broken. For example if you changed the note loading logic, check that the toolbar is still working as expected (and not modifying the previously loaded note for example), check that switching from one note to another still works. Look at the note list and verify that the note title is updated there too, etc.
|
||||
|
||||
A reviewer should be able to run the app with your changes, then do the above steps to verify that everything's working as expected.
|
||||
|
||||
## About abandoned pull requests
|
||||
|
||||
It happens that a pull request is started but not finished and despite our attempts to contact the contributor, we don't hear from them again.
|
||||
|
||||
27
README.md
@@ -4,6 +4,10 @@
|
||||
|
||||
* * *
|
||||
|
||||
🌞 Joplin participates in **Google Summer of Code 2022**! More info on [the announcement post](https://github.com/laurent22/joplin/blob/dev/readme/news/20220308-gsoc2022-start.md). 🌞
|
||||
|
||||
* * *
|
||||
|
||||
<img width="64" src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/LinuxIcons/256x256.png" align="left" /> **Joplin** is a free, open source note taking and to-do application, which can handle a large number of notes organised into notebooks. The notes are searchable, can be copied, tagged and modified either from the applications directly or from your own text editor. The notes are in [Markdown format](#markdown).
|
||||
|
||||
Notes exported from Evernote [can be imported](#importing) into Joplin, including the formatted content (which is converted to Markdown), resources (images, attachments, etc.) and complete metadata (geolocation, updated time, created time, etc.). Plain Markdown files can also be imported.
|
||||
@@ -24,11 +28,11 @@ Three types of applications are available: for **desktop** (Windows, macOS and L
|
||||
|
||||
Operating System | Download
|
||||
---|---
|
||||
Windows (32 and 64-bit) | <a href='https://github.com/laurent22/joplin/releases/download/v2.6.10/Joplin-Setup-2.6.10.exe'><img alt='Get it on Windows' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeWindows.png'/></a>
|
||||
macOS | <a href='https://github.com/laurent22/joplin/releases/download/v2.6.10/Joplin-2.6.10.dmg'><img alt='Get it on macOS' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeMacOS.png'/></a>
|
||||
Linux | <a href='https://github.com/laurent22/joplin/releases/download/v2.6.10/Joplin-2.6.10.AppImage'><img alt='Get it on Linux' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeLinux.png'/></a>
|
||||
Windows (32 and 64-bit) | <a href='https://github.com/laurent22/joplin/releases/download/v2.7.15/Joplin-Setup-2.7.15.exe'><img alt='Get it on Windows' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeWindows.png'/></a>
|
||||
macOS | <a href='https://github.com/laurent22/joplin/releases/download/v2.7.15/Joplin-2.7.15.dmg'><img alt='Get it on macOS' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeMacOS.png'/></a>
|
||||
Linux | <a href='https://github.com/laurent22/joplin/releases/download/v2.7.15/Joplin-2.7.15.AppImage'><img alt='Get it on Linux' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeLinux.png'/></a>
|
||||
|
||||
**On Windows**, you may also use the <a href='https://github.com/laurent22/joplin/releases/download/v2.6.10/JoplinPortable.exe'>Portable version</a>. The [portable application](https://en.wikipedia.org/wiki/Portable_application) allows installing the software on a portable device such as a USB key. Simply copy the file JoplinPortable.exe in any directory on that USB key ; the application will then create a directory called "JoplinProfile" next to the executable file.
|
||||
**On Windows**, you may also use the <a href='https://github.com/laurent22/joplin/releases/download/v2.7.15/JoplinPortable.exe'>Portable version</a>. The [portable application](https://en.wikipedia.org/wiki/Portable_application) allows installing the software on a portable device such as a USB key. Simply copy the file JoplinPortable.exe in any directory on that USB key ; the application will then create a directory called "JoplinProfile" next to the executable file.
|
||||
|
||||
**On Linux**, the recommended way is to use the following installation script as it will handle the desktop icon too:
|
||||
|
||||
@@ -38,7 +42,7 @@ Linux | <a href='https://github.com/laurent22/joplin/releases/download/v2.6.10/J
|
||||
|
||||
Operating System | Download | Alt. Download
|
||||
---|---|---
|
||||
Android | <a href='https://play.google.com/store/apps/details?id=net.cozic.joplin&utm_source=GitHub&utm_campaign=README&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeAndroid.png'/></a> | or download the APK file: [64-bit](https://github.com/laurent22/joplin-android/releases/download/android-v2.6.9/joplin-v2.6.9.apk) [32-bit](https://github.com/laurent22/joplin-android/releases/download/android-v2.6.9/joplin-v2.6.9-32bit.apk)
|
||||
Android | <a href='https://play.google.com/store/apps/details?id=net.cozic.joplin&utm_source=GitHub&utm_campaign=README&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeAndroid.png'/></a> | or download the APK file: [64-bit](https://github.com/laurent22/joplin-android/releases/download/android-v2.7.2/joplin-v2.7.2.apk) [32-bit](https://github.com/laurent22/joplin-android/releases/download/android-v2.7.2/joplin-v2.7.2-32bit.apk)
|
||||
iOS | <a href='https://itunes.apple.com/us/app/joplin/id1315599797'><img alt='Get it on the App Store' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeIOS.png'/></a> | -
|
||||
|
||||
## Terminal application
|
||||
@@ -76,11 +80,11 @@ A community maintained list of these distributions can be found here: [Unofficia
|
||||
| :---: | :---: | :---: | :---: |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/215668?s=96&v=4"/></br>[avanderberg](https://github.com/avanderberg) | <img width="50" src="https://avatars2.githubusercontent.com/u/3061769?s=96&v=4"/></br>[c-nagy](https://github.com/c-nagy) | <img width="50" src="https://avatars2.githubusercontent.com/u/70780798?s=96&v=4"/></br>[cabottech](https://github.com/cabottech) | <img width="50" src="https://avatars2.githubusercontent.com/u/67130?s=96&v=4"/></br>[chr15m](https://github.com/chr15m) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/4862947?s=96&v=4"/></br>[chrootlogin](https://github.com/chrootlogin) | <img width="50" src="https://avatars2.githubusercontent.com/u/82579431?s=96&v=4"/></br>[clmntsl](https://github.com/clmntsl) | <img width="50" src="https://avatars2.githubusercontent.com/u/808091?s=96&v=4"/></br>[cuongtransc](https://github.com/cuongtransc) | <img width="50" src="https://avatars2.githubusercontent.com/u/1307332?s=96&v=4"/></br>[dbrandonjohnson](https://github.com/dbrandonjohnson) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/1439535?s=96&v=4"/></br>[fbloise](https://github.com/fbloise) | <img width="50" src="https://avatars2.githubusercontent.com/u/38898566?s=96&v=4"/></br>[h4sh5](https://github.com/h4sh5) | <img width="50" src="https://avatars2.githubusercontent.com/u/3266447?s=96&v=4"/></br>[iamwillbar](https://github.com/iamwillbar) | <img width="50" src="https://avatars2.githubusercontent.com/u/37297218?s=96&v=4"/></br>[Jesssullivan](https://github.com/Jesssullivan) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/1248504?s=96&v=4"/></br>[joesfer](https://github.com/joesfer) | <img width="50" src="https://avatars2.githubusercontent.com/u/5588131?s=96&v=4"/></br>[kianenigma](https://github.com/kianenigma) | <img width="50" src="https://avatars2.githubusercontent.com/u/24908652?s=96&v=4"/></br>[konishi-t](https://github.com/konishi-t) | <img width="50" src="https://avatars2.githubusercontent.com/u/42319182?s=96&v=4"/></br>[marcdw1289](https://github.com/marcdw1289) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/1788010?s=96&v=4"/></br>[maxtruxa](https://github.com/maxtruxa) | <img width="50" src="https://avatars2.githubusercontent.com/u/29300939?s=96&v=4"/></br>[mcejp](https://github.com/mcejp) | <img width="50" src="https://avatars2.githubusercontent.com/u/1168659?s=96&v=4"/></br>[nicholashead](https://github.com/nicholashead) | <img width="50" src="https://avatars2.githubusercontent.com/u/5782817?s=96&v=4"/></br>[piccobit](https://github.com/piccobit) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/47742?s=96&v=4"/></br>[ravenscroftj](https://github.com/ravenscroftj) | <img width="50" src="https://avatars2.githubusercontent.com/u/765564?s=96&v=4"/></br>[taskcruncher](https://github.com/taskcruncher) | <img width="50" src="https://avatars2.githubusercontent.com/u/73081837?s=96&v=4"/></br>[thismarty](https://github.com/thismarty) | <img width="50" src="https://avatars2.githubusercontent.com/u/15859362?s=96&v=4"/></br>[thomasbroussard](https://github.com/thomasbroussard) |
|
||||
| | | | |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/1439535?s=96&v=4"/></br>[fbloise](https://github.com/fbloise) | <img width="50" src="https://avatars2.githubusercontent.com/u/49439044?s=96&v=4"/></br>[fourstepper](https://github.com/fourstepper) | <img width="50" src="https://avatars2.githubusercontent.com/u/38898566?s=96&v=4"/></br>[h4sh5](https://github.com/h4sh5) | <img width="50" src="https://avatars2.githubusercontent.com/u/3266447?s=96&v=4"/></br>[iamwillbar](https://github.com/iamwillbar) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/37297218?s=96&v=4"/></br>[Jesssullivan](https://github.com/Jesssullivan) | <img width="50" src="https://avatars2.githubusercontent.com/u/1248504?s=96&v=4"/></br>[joesfer](https://github.com/joesfer) | <img width="50" src="https://avatars2.githubusercontent.com/u/5588131?s=96&v=4"/></br>[kianenigma](https://github.com/kianenigma) | <img width="50" src="https://avatars2.githubusercontent.com/u/24908652?s=96&v=4"/></br>[konishi-t](https://github.com/konishi-t) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/42319182?s=96&v=4"/></br>[marcdw1289](https://github.com/marcdw1289) | <img width="50" src="https://avatars2.githubusercontent.com/u/1788010?s=96&v=4"/></br>[maxtruxa](https://github.com/maxtruxa) | <img width="50" src="https://avatars2.githubusercontent.com/u/29300939?s=96&v=4"/></br>[mcejp](https://github.com/mcejp) | <img width="50" src="https://avatars2.githubusercontent.com/u/1168659?s=96&v=4"/></br>[nicholashead](https://github.com/nicholashead) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/5782817?s=96&v=4"/></br>[piccobit](https://github.com/piccobit) | <img width="50" src="https://avatars2.githubusercontent.com/u/47742?s=96&v=4"/></br>[ravenscroftj](https://github.com/ravenscroftj) | <img width="50" src="https://avatars2.githubusercontent.com/u/765564?s=96&v=4"/></br>[taskcruncher](https://github.com/taskcruncher) | <img width="50" src="https://avatars2.githubusercontent.com/u/73081837?s=96&v=4"/></br>[thismarty](https://github.com/thismarty) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/15859362?s=96&v=4"/></br>[thomasbroussard](https://github.com/thomasbroussard) | | | |
|
||||
<!-- SPONSORS-GITHUB -->
|
||||
|
||||
<!-- TOC -->
|
||||
@@ -129,6 +133,7 @@ A community maintained list of these distributions can be found here: [Unofficia
|
||||
- [How to build the apps](https://github.com/laurent22/joplin/blob/dev/BUILD.md)
|
||||
- [Writing a technical spec](https://github.com/laurent22/joplin/blob/dev/readme/technical_spec.md)
|
||||
- [End-to-end encryption spec](https://github.com/laurent22/joplin/blob/dev/readme/spec/e2ee.md)
|
||||
- [Desktop application styling](https://github.com/laurent22/joplin/blob/dev/readme/spec/desktop_styling.md)
|
||||
- [Note History spec](https://github.com/laurent22/joplin/blob/dev/readme/spec/history.md)
|
||||
- [Sync Lock spec](https://github.com/laurent22/joplin/blob/dev/readme/spec/sync_lock.md)
|
||||
- [Synchronous Scroll spec](https://github.com/laurent22/joplin/blob/dev/readme/spec/sync_scroll.md)
|
||||
@@ -529,7 +534,7 @@ Current translations:
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/us.png" width="16px"/> | English (United States of America) | [en_US](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/en_US.po) | | 100%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/es.png" width="16px"/> | Español (España) | [es_ES](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/es_ES.po) | [Francisco Mora](mailto:francisco.m.collao@gmail.com) | 96%
|
||||
<img src="https://joplinapp.org/images/flags/esperanto.png" width="16px"/> | Esperanto | [eo](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/eo.po) | Marton Paulo | 29%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/fi.png" width="16px"/> | Finnish (Suomi) | [fi_FI](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fi_FI.po) | mrkaato | 91%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/fi.png" width="16px"/> | Finnish (Suomi) | [fi_FI](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fi_FI.po) | miucci | 92%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/fr.png" width="16px"/> | Français (France) | [fr_FR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fr_FR.po) | Laurent Cozic | 100%
|
||||
<img src="https://joplinapp.org/images/flags/es/galicia.png" width="16px"/> | Galician (España) | [gl_ES](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/gl_ES.po) | [Marcos Lans](mailto:marcoslansgarza@gmail.com) | 33%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/id.png" width="16px"/> | Indonesian (Indonesia) | [id_ID](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/id_ID.po) | [eresytter](mailto:42007357+eresytter@users.noreply.github.com) | 90%
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
# This is a sample docker-compose file that can be used to run Joplin Server
|
||||
# along with a PostgreSQL server.
|
||||
#
|
||||
# All environment variables are optional. If you don't set them, you will get a
|
||||
# warning from docker-compose, however the app should use working defaults.
|
||||
# Update the following fields in the stanza below:
|
||||
#
|
||||
# POSTGRES_USER
|
||||
# POSTGRES_PASSWORD
|
||||
# APP_BASE_URL
|
||||
#
|
||||
# APP_BASE_URL: This is the base public URL where the service will be running.
|
||||
# - If Joplin Server needs to be accessible over the internet, configure APP_BASE_URL as follows: https://example.com/joplin.
|
||||
# - If Joplin Server does not need to be accessible over the internet, set the the APP_BASE_URL to your server's hostname.
|
||||
# For Example: http://[hostname]:22300. The base URL can include the port.
|
||||
# APP_PORT: The local port on which the Docker container will listen.
|
||||
# - This would typically be mapped to port to 443 (TLS) with a reverse proxy.
|
||||
# - If Joplin Server does not need to be accessible over the internet, the port can be mapped to 22300.
|
||||
|
||||
version: '3'
|
||||
|
||||
|
||||
1
fastlane/metadata/android/de/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
eine Notiz- und Aufgaben-App mit Sync zwischen Linux, macOS, Windows
|
||||
9
fastlane/metadata/android/en-US/full_description.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
<strong>Joplin</strong> is a note taking and to-do application, which can handle a large number of notes organised into notebooks. The notes are searchable, can be copied, tagged and modified either from the applications directly or from your own text editor. The notes are in <a href="#markdown">Markdown format</a>.
|
||||
|
||||
Notes exported from Evernote and other applications <a href="https://joplinapp.org/help/#importing">can be imported</a> into Joplin, including the formatted content (which is converted to Markdown), resources (images, attachments, etc.) and complete metadata (geolocation, updated time, created time, etc.). Plain Markdown files can also be imported.
|
||||
|
||||
The notes can be securely <a href="https://joplinapp.org/help/#synchronisation">synchronised</a> using <a href="https://joplinapp.org/help/#encryption">end-to-end encryption</a> with various cloud services including Nextcloud, Dropbox, OneDrive and <a href="https://joplinapp.org/plans/">Joplin Cloud</a>.
|
||||
|
||||
Full text search is available on all platforms to quickly find the information you need. The app can be customised using plugins and themes, and you can also easily create your own.
|
||||
|
||||
The application is available for Windows, Linux, macOS, Android and iOS. A <a href="https://joplinapp.org/clipper/">Web Clipper</a>, to save web pages and screenshots from your browser, is also available for <a href="https://addons.mozilla.org/firefox/addon/joplin-web-clipper/">Firefox</a> and <a href="https://chrome.google.com/webstore/detail/joplin-web-clipper/alofnhikmmkdbbbgpnglcpdollgjjfek?hl=en-GB">Chrome</a>.
|
||||
BIN
fastlane/metadata/android/en-US/images/icon.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/01.png
Normal file
|
After Width: | Height: | Size: 281 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/02.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
1
fastlane/metadata/android/en-US/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
a note taking and to-do app with sync between Linux, macOS, Windows, and mobile
|
||||
@@ -18,6 +18,7 @@
|
||||
"buildCommandIndex": "gulp buildCommandIndex",
|
||||
"buildPluginDoc": "typedoc --name 'Joplin Plugin API Documentation' --mode file -theme './Assets/PluginDocTheme/' --readme './Assets/PluginDocTheme/index.md' --excludeNotExported --excludeExternals --excludePrivate --excludeProtected --out ../joplin-website/docs/api/references/plugin_api packages/lib/services/plugins/api/",
|
||||
"updateMarkdownDoc": "node ./packages/tools/updateMarkdownDoc",
|
||||
"updateNews": "node ./packages/tools/website/updateNews",
|
||||
"buildSettingJsonSchema": "yarn workspace joplin start settingschema ../../../joplin-website/docs/schema/settings.json",
|
||||
"buildTranslations": "node packages/tools/build-translation.js",
|
||||
"buildWebsite": "node ./packages/tools/website/build.js && yarn run buildPluginDoc && yarn run buildSettingJsonSchema",
|
||||
|
||||
@@ -313,6 +313,14 @@ async function fetchAllNotes() {
|
||||
lines.push('');
|
||||
lines.push('\tcurl -F \'data=@/path/to/file.jpg\' -F \'props={"title":"my resource title"}\' http://localhost:41184/resources');
|
||||
lines.push('');
|
||||
lines.push('To **update** the resource content, you can make a PUT request with the same arguments:');
|
||||
lines.push('');
|
||||
lines.push('\tcurl -X PUT -F \'data=@/path/to/file.jpg\' -F \'props={"title":"my modified title"}\' http://localhost:41184/resources/8fe1417d7b184324bf6b0122b76c4696');
|
||||
lines.push('');
|
||||
lines.push('Or if you only need to update the resource properties (title, etc.), without changing the content, you can make a regular PUT request:');
|
||||
lines.push('');
|
||||
lines.push('\tcurl -X PUT --data \'{"title": "My new title"}\' http://localhost:41184/resources/8fe1417d7b184324bf6b0122b76c4696');
|
||||
lines.push('');
|
||||
lines.push('The "data" field is required, while the "props" one is not. If not specified, default values will be used.');
|
||||
lines.push('');
|
||||
lines.push('**From a plugin** the syntax to create a resource is also a bit special:');
|
||||
@@ -368,6 +376,11 @@ async function fetchAllNotes() {
|
||||
lines.push(`Sets the properties of the ${singular} with ID :id`);
|
||||
lines.push('');
|
||||
|
||||
if (model.type === BaseModel.TYPE_RESOURCE) {
|
||||
lines.push('You may also update the file data by specifying a file (See `POST /resources` example).');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push(`## DELETE /${tableName}/:id`);
|
||||
lines.push('');
|
||||
lines.push(`Deletes the ${singular} with ID :id`);
|
||||
|
||||
@@ -242,9 +242,9 @@ describe('MdToHtml', function() {
|
||||
{
|
||||
const input = '# Head\nFruits\n- Apple\n';
|
||||
const result = await mdToHtml.render(input, null, { bodyOnly: true, mapsToLine: true });
|
||||
expect(result.html.trim()).toBe('<h1 id="head" class="maps-to-line" source-line="0">Head</h1>\n' +
|
||||
'<p class="maps-to-line" source-line="1">Fruits</p>\n' +
|
||||
'<ul>\n<li class="maps-to-line" source-line="2">Apple</li>\n</ul>'
|
||||
expect(result.html.trim()).toBe('<h1 id="head" class="maps-to-line" source-line="0" source-line-end="1">Head</h1>\n' +
|
||||
'<p class="maps-to-line" source-line="1" source-line-end="2">Fruits</p>\n' +
|
||||
'<ul>\n<li class="maps-to-line" source-line="2" source-line-end="3">Apple</li>\n</ul>'
|
||||
);
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -63,13 +63,15 @@ class AppComponent extends Component {
|
||||
contentScriptLoaded: false,
|
||||
selectedTags: [],
|
||||
contentScriptError: '',
|
||||
newNoteId: null,
|
||||
});
|
||||
|
||||
this.confirm_click = () => {
|
||||
this.confirm_click = async () => {
|
||||
const content = Object.assign({}, this.props.clippedContent);
|
||||
content.tags = this.state.selectedTags.join(',');
|
||||
content.parent_id = this.props.selectedFolderId;
|
||||
bridge().sendContentToJoplin(content);
|
||||
const response = await bridge().sendContentToJoplin(content);
|
||||
this.setState({ newNoteId: response.id });
|
||||
};
|
||||
|
||||
this.contentTitle_change = (event) => {
|
||||
@@ -402,6 +404,24 @@ class AppComponent extends Component {
|
||||
);
|
||||
};
|
||||
|
||||
const openNewNoteButton = () => {
|
||||
|
||||
if (!this.state.newNoteId) { return null; } else {
|
||||
return (
|
||||
// The jopin:// link must be opened in a new tab. When it's opened for the first time, a system dialog will ask for the user's permission.
|
||||
// The system dialog is too big to fit into the popup so the user will not be able to see the dialog buttons and get stuck.
|
||||
<a
|
||||
className="Button"
|
||||
href={`joplin://x-callback-url/openNote?id=${encodeURIComponent(this.state.newNoteId)}`}
|
||||
target="_blank"
|
||||
onClick={() => this.setState({ newNoteId: null })}
|
||||
>
|
||||
Open newly created note
|
||||
</a>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const tagDataListOptions = [];
|
||||
for (let i = 0; i < this.props.tags.length; i++) {
|
||||
const tag = this.props.tags[i];
|
||||
@@ -437,6 +457,7 @@ class AppComponent extends Component {
|
||||
</div>
|
||||
{ warningComponent }
|
||||
{ previewComponent }
|
||||
{ openNewNoteButton() }
|
||||
{ clipperStatusComp() }
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -463,9 +463,11 @@ class Bridge {
|
||||
// This is the perfect Heisenbug - it happens always when opening the popup the first time EXCEPT
|
||||
// when the debugger is open. Then everything is working fine and the bug NEVER EVER happens,
|
||||
// so it's impossible to understand what's going on.
|
||||
await this.clipperApiExec('POST', 'notes', { nounce: this.nounce_++ }, content);
|
||||
const response = await this.clipperApiExec('POST', 'notes', { nounce: this.nounce_++ }, content);
|
||||
|
||||
this.dispatch({ type: 'CONTENT_UPLOAD', operation: { uploading: false, success: true } });
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error.message === '{"error":"Duplicate Nounce"}') {
|
||||
this.dispatch({ type: 'CONTENT_UPLOAD', operation: { uploading: false, success: true } });
|
||||
|
||||
19
packages/app-desktop/commands/editProfileConfig.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import Setting from '../../lib/models/Setting';
|
||||
import { openFileWithExternalEditor } from '../../lib/services/ExternalEditWatcher/utils';
|
||||
import bridge from '../services/bridge';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'editProfileConfig',
|
||||
label: () => _('Edit profile configuration...'),
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (_context: CommandContext) => {
|
||||
await openFileWithExternalEditor(`${Setting.value('rootProfileDir')}/profiles.json`, bridge());
|
||||
},
|
||||
enabledCondition: 'hasMultiProfiles',
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
// AUTO-GENERATED using `gulp buildCommandIndex`
|
||||
import * as copyDevCommand from './copyDevCommand';
|
||||
import * as editProfileConfig from './editProfileConfig';
|
||||
import * as exportFolders from './exportFolders';
|
||||
import * as exportNotes from './exportNotes';
|
||||
import * as focusElement from './focusElement';
|
||||
@@ -8,11 +9,16 @@ import * as replaceMisspelling from './replaceMisspelling';
|
||||
import * as restoreNoteRevision from './restoreNoteRevision';
|
||||
import * as startExternalEditing from './startExternalEditing';
|
||||
import * as stopExternalEditing from './stopExternalEditing';
|
||||
import * as switchProfile from './switchProfile';
|
||||
import * as switchProfile1 from './switchProfile1';
|
||||
import * as switchProfile2 from './switchProfile2';
|
||||
import * as switchProfile3 from './switchProfile3';
|
||||
import * as toggleExternalEditing from './toggleExternalEditing';
|
||||
import * as toggleSafeMode from './toggleSafeMode';
|
||||
|
||||
const index:any[] = [
|
||||
copyDevCommand,
|
||||
editProfileConfig,
|
||||
exportFolders,
|
||||
exportNotes,
|
||||
focusElement,
|
||||
@@ -21,6 +27,10 @@ const index:any[] = [
|
||||
restoreNoteRevision,
|
||||
startExternalEditing,
|
||||
stopExternalEditing,
|
||||
switchProfile,
|
||||
switchProfile1,
|
||||
switchProfile2,
|
||||
switchProfile3,
|
||||
toggleExternalEditing,
|
||||
toggleSafeMode,
|
||||
];
|
||||
|
||||
26
packages/app-desktop/commands/switchProfile.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { saveProfileConfig } from '@joplin/lib/services/profileConfig';
|
||||
import { ProfileConfig } from '@joplin/lib/services/profileConfig/types';
|
||||
import bridge from '../services/bridge';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'switchProfile',
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (context: CommandContext, profileIndex: number) => {
|
||||
const currentConfig = context.state.profileConfig;
|
||||
if (currentConfig.currentProfile === profileIndex) return;
|
||||
|
||||
const newConfig: ProfileConfig = {
|
||||
...currentConfig,
|
||||
currentProfile: profileIndex,
|
||||
};
|
||||
|
||||
await saveProfileConfig(`${Setting.value('rootProfileDir')}/profiles.json`, newConfig);
|
||||
bridge().restart();
|
||||
},
|
||||
};
|
||||
};
|
||||
15
packages/app-desktop/commands/switchProfile1.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import CommandService, { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'switchProfile1',
|
||||
label: () => _('Switch to profile %d', 1),
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (_context: CommandContext) => {
|
||||
await CommandService.instance().execute('switchProfile', 0);
|
||||
},
|
||||
};
|
||||
};
|
||||
15
packages/app-desktop/commands/switchProfile2.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import CommandService, { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'switchProfile2',
|
||||
label: () => _('Switch to profile %d', 2),
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (_context: CommandContext) => {
|
||||
await CommandService.instance().execute('switchProfile', 1);
|
||||
},
|
||||
};
|
||||
};
|
||||
15
packages/app-desktop/commands/switchProfile3.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import CommandService, { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'switchProfile3',
|
||||
label: () => _('Switch to profile %d', 3),
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (_context: CommandContext) => {
|
||||
await CommandService.instance().execute('switchProfile', 2);
|
||||
},
|
||||
};
|
||||
};
|
||||
34
packages/app-desktop/gui/MainScreen/commands/addProfile.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { createNewProfile, saveProfileConfig } from '@joplin/lib/services/profileConfig';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import bridge from '../../../services/bridge';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'addProfile',
|
||||
label: () => _('Create new profile...'),
|
||||
};
|
||||
|
||||
export const runtime = (comp: any): CommandRuntime => {
|
||||
return {
|
||||
execute: async (context: CommandContext) => {
|
||||
comp.setState({
|
||||
promptOptions: {
|
||||
label: _('Profile name:'),
|
||||
buttons: ['create', 'cancel'],
|
||||
value: '',
|
||||
onClose: async (answer: string) => {
|
||||
if (answer) {
|
||||
const newConfig = await createNewProfile(context.state.profileConfig, answer);
|
||||
newConfig.currentProfile = newConfig.profiles.length - 1;
|
||||
await saveProfileConfig(`${Setting.value('rootProfileDir')}/profiles.json`, newConfig);
|
||||
bridge().restart();
|
||||
}
|
||||
|
||||
comp.setState({ promptOptions: null });
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
// AUTO-GENERATED using `gulp buildCommandIndex`
|
||||
import * as addProfile from './addProfile';
|
||||
import * as commandPalette from './commandPalette';
|
||||
import * as editAlarm from './editAlarm';
|
||||
import * as exportPdf from './exportPdf';
|
||||
@@ -38,6 +39,7 @@ import * as toggleSideBar from './toggleSideBar';
|
||||
import * as toggleVisiblePanes from './toggleVisiblePanes';
|
||||
|
||||
const index:any[] = [
|
||||
addProfile,
|
||||
commandPalette,
|
||||
editAlarm,
|
||||
exportPdf,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { AppState } from '../app.reducer';
|
||||
import InteropService from '@joplin/lib/services/interop/InteropService';
|
||||
import { stateUtils } from '@joplin/lib/reducer';
|
||||
@@ -18,9 +18,9 @@ import menuCommandNames from './menuCommandNames';
|
||||
import stateToWhenClauseContext from '../services/commands/stateToWhenClauseContext';
|
||||
import bridge from '../services/bridge';
|
||||
import checkForUpdates from '../checkForUpdates';
|
||||
|
||||
const { connect } = require('react-redux');
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
import { ProfileConfig } from '../../lib/services/profileConfig/types';
|
||||
const packageInfo = require('../packageInfo.js');
|
||||
const { clipboard } = require('electron');
|
||||
const Menu = bridge().Menu;
|
||||
@@ -39,7 +39,7 @@ function pluginMenuItemsCommandNames(menuItems: MenuItem[]): string[] {
|
||||
return output;
|
||||
}
|
||||
|
||||
function pluginCommandNames(plugins: PluginStates): string[] {
|
||||
function getPluginCommandNames(plugins: PluginStates): string[] {
|
||||
let output: string[] = [];
|
||||
|
||||
for (const view of pluginUtils.viewsByType(plugins, 'menu')) {
|
||||
@@ -70,6 +70,42 @@ function createPluginMenuTree(label: string, menuItems: MenuItem[], onMenuItemCl
|
||||
return output;
|
||||
}
|
||||
|
||||
const useSwitchProfileMenuItems = (profileConfig: ProfileConfig, menuItemDic: any) => {
|
||||
return useMemo(() => {
|
||||
const switchProfileMenuItems: any[] = [];
|
||||
|
||||
for (let i = 0; i < profileConfig.profiles.length; i++) {
|
||||
const profile = profileConfig.profiles[i];
|
||||
|
||||
let menuItem: any = {};
|
||||
const profileNum = i + 1;
|
||||
|
||||
if (menuItemDic[`switchProfile${profileNum}`]) {
|
||||
menuItem = { ...menuItemDic[`switchProfile${profileNum}`] };
|
||||
} else {
|
||||
menuItem = {
|
||||
label: profile.name,
|
||||
click: () => {
|
||||
void CommandService.instance().execute('switchProfile', i);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
menuItem.label = profile.name;
|
||||
menuItem.type = 'checkbox';
|
||||
menuItem.checked = profileConfig.currentProfile === i;
|
||||
|
||||
switchProfileMenuItems.push(menuItem);
|
||||
}
|
||||
|
||||
switchProfileMenuItems.push({ type: 'separator' });
|
||||
switchProfileMenuItems.push(menuItemDic.addProfile);
|
||||
switchProfileMenuItems.push(menuItemDic.editProfileConfig);
|
||||
|
||||
return switchProfileMenuItems;
|
||||
}, [profileConfig, menuItemDic]);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
dispatch: Function;
|
||||
menuItemProps: any;
|
||||
@@ -90,6 +126,7 @@ interface Props {
|
||||
plugins: PluginStates;
|
||||
customCss: string;
|
||||
locale: string;
|
||||
profileConfig: ProfileConfig;
|
||||
}
|
||||
|
||||
const commandNames: string[] = menuCommandNames();
|
||||
@@ -241,6 +278,18 @@ function useMenu(props: Props) {
|
||||
const onImportModuleClickRef = useRef(null);
|
||||
onImportModuleClickRef.current = onImportModuleClick;
|
||||
|
||||
const pluginCommandNames = useMemo(() => props.pluginMenuItems.map((view: any) => view.commandName), [props.pluginMenuItems]);
|
||||
|
||||
const menuItemDic = useMemo(() => {
|
||||
return menuUtils.commandsToMenuItems(
|
||||
commandNames.concat(pluginCommandNames),
|
||||
(commandName: string) => onMenuItemClickRef.current(commandName),
|
||||
props.locale
|
||||
);
|
||||
}, [commandNames, pluginCommandNames, props.locale]);
|
||||
|
||||
const switchProfileMenuItems: any[] = useSwitchProfileMenuItems(props.profileConfig, menuItemDic);
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: any = null;
|
||||
|
||||
@@ -249,13 +298,6 @@ function useMenu(props: Props) {
|
||||
|
||||
const keymapService = KeymapService.instance();
|
||||
|
||||
const pluginCommandNames = props.pluginMenuItems.map((view: any) => view.commandName);
|
||||
const menuItemDic = menuUtils.commandsToMenuItems(
|
||||
commandNames.concat(pluginCommandNames),
|
||||
(commandName: string) => onMenuItemClickRef.current(commandName),
|
||||
props.locale
|
||||
);
|
||||
|
||||
const quitMenuItem = {
|
||||
label: _('Quit'),
|
||||
accelerator: keymapService.getAccelerator('quit'),
|
||||
@@ -385,6 +427,10 @@ function useMenu(props: Props) {
|
||||
const newFolderItem = menuItemDic.newFolder;
|
||||
const newSubFolderItem = menuItemDic.newSubFolder;
|
||||
const printItem = menuItemDic.print;
|
||||
const switchProfileItem = {
|
||||
label: _('Switch profile'),
|
||||
submenu: switchProfileMenuItems,
|
||||
};
|
||||
|
||||
let toolsItems: any[] = [];
|
||||
|
||||
@@ -499,6 +545,8 @@ function useMenu(props: Props) {
|
||||
platforms: ['darwin'],
|
||||
},
|
||||
|
||||
shim.isMac() ? noItem : switchProfileItem,
|
||||
|
||||
shim.isMac() ? {
|
||||
label: _('Hide %s', 'Joplin'),
|
||||
platforms: ['darwin'],
|
||||
@@ -545,6 +593,7 @@ function useMenu(props: Props) {
|
||||
type: 'separator',
|
||||
},
|
||||
printItem,
|
||||
switchProfileItem,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -572,8 +621,15 @@ function useMenu(props: Props) {
|
||||
menuItemDic.textPaste,
|
||||
menuItemDic.textSelectAll,
|
||||
separator(),
|
||||
menuItemDic['editor.undo'],
|
||||
menuItemDic['editor.redo'],
|
||||
// Using the generic "undo"/"redo" roles mean the menu
|
||||
// item will work in every text fields, whether it's the
|
||||
// editor or a regular text field.
|
||||
{
|
||||
role: 'undo',
|
||||
},
|
||||
{
|
||||
role: 'redo',
|
||||
},
|
||||
separator(),
|
||||
menuItemDic.textBold,
|
||||
menuItemDic.textItalic,
|
||||
@@ -841,7 +897,21 @@ function useMenu(props: Props) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
};
|
||||
}, [props.routeName, props.pluginMenuItems, props.pluginMenus, keymapLastChangeTime, modulesLastChangeTime, props['spellChecker.language'], props['spellChecker.enabled'], props.plugins, props.customCss, props.locale]);
|
||||
}, [
|
||||
props.routeName,
|
||||
props.pluginMenuItems,
|
||||
props.pluginMenus,
|
||||
keymapLastChangeTime,
|
||||
modulesLastChangeTime,
|
||||
props['spellChecker.language'],
|
||||
props['spellChecker.enabled'],
|
||||
props.plugins,
|
||||
props.customCss,
|
||||
props.locale,
|
||||
props.profileConfig,
|
||||
switchProfileMenuItems,
|
||||
menuItemDic,
|
||||
]);
|
||||
|
||||
useMenuStates(menu, props);
|
||||
|
||||
@@ -882,7 +952,7 @@ const mapStateToProps = (state: AppState) => {
|
||||
const whenClauseContext = stateToWhenClauseContext(state);
|
||||
|
||||
return {
|
||||
menuItemProps: menuUtils.commandsToMenuItemProps(commandNames.concat(pluginCommandNames(state.pluginService.plugins)), whenClauseContext),
|
||||
menuItemProps: menuUtils.commandsToMenuItemProps(commandNames.concat(getPluginCommandNames(state.pluginService.plugins)), whenClauseContext),
|
||||
locale: state.settings.locale,
|
||||
routeName: state.route.routeName,
|
||||
selectedFolderId: state.selectedFolderId,
|
||||
@@ -900,6 +970,7 @@ const mapStateToProps = (state: AppState) => {
|
||||
['spellChecker.enabled']: state.settings['spellChecker.enabled'],
|
||||
plugins: state.pluginService.plugins,
|
||||
customCss: state.customCss,
|
||||
profileConfig: state.profileConfig,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -649,6 +649,11 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
||||
// undefined. Maybe due to the error boundary that unmount components.
|
||||
// Since we can't do much about it we just print an error.
|
||||
if (webviewRef.current && webviewRef.current.wrappedInstance) {
|
||||
// To keep consistency among CodeMirror's editing and scroll percents
|
||||
// of Editor and Viewer.
|
||||
const percent = getLineScrollPercent();
|
||||
setEditorPercentScroll(percent);
|
||||
options.percent = percent;
|
||||
webviewRef.current.wrappedInstance.send('setHtml', renderedBody.html, options);
|
||||
} else {
|
||||
console.error('Trying to set HTML on an undefined webview ref');
|
||||
@@ -732,15 +737,19 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
||||
// It might be buggy, refer to the below issue
|
||||
// https://github.com/laurent22/joplin/pull/3974#issuecomment-718936703
|
||||
useEffect(() => {
|
||||
function pointerInsideEditor(x: number, y: number) {
|
||||
function pointerInsideEditor(params: any) {
|
||||
const x = params.x, y = params.y, isEditable = params.isEditable, inputFieldType = params.inputFieldType;
|
||||
const elements = document.getElementsByClassName('codeMirrorEditor');
|
||||
if (!elements.length) return null;
|
||||
|
||||
// inputFieldType: The input field type of CodeMirror is "textarea" so the inputFieldType = "none",
|
||||
// and any single-line input above codeMirror has inputFieldType value according to the type of input e.g.(text = plainText, password = password, ...).
|
||||
if (!elements.length || !isEditable || inputFieldType !== 'none') return null;
|
||||
const rect = convertToScreenCoordinates(Setting.value('windowContentZoomFactor'), elements[0].getBoundingClientRect());
|
||||
return rect.x < x && rect.y < y && rect.right > x && rect.bottom > y;
|
||||
}
|
||||
|
||||
async function onContextMenu(_event: any, params: any) {
|
||||
if (!pointerInsideEditor(params.x, params.y)) return;
|
||||
if (!pointerInsideEditor(params)) return;
|
||||
|
||||
const menu = new Menu();
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ export interface EditorProps {
|
||||
function Editor(props: EditorProps, ref: any) {
|
||||
const [editor, setEditor] = useState(null);
|
||||
const editorParent = useRef(null);
|
||||
const lastEditTime = useRef(NaN);
|
||||
|
||||
// Codemirror plugins add new commands to codemirror (or change it's behavior)
|
||||
// This command adds the smartListIndent function which will be bound to tab
|
||||
@@ -120,6 +121,7 @@ function Editor(props: EditorProps, ref: any) {
|
||||
const editor_change = useCallback((cm: any, change: any) => {
|
||||
if (props.onChange && change.origin !== 'setValue') {
|
||||
props.onChange(cm.getValue());
|
||||
lastEditTime.current = Date.now();
|
||||
}
|
||||
}, [props.onChange]);
|
||||
|
||||
@@ -154,7 +156,8 @@ function Editor(props: EditorProps, ref: any) {
|
||||
}, [props.onResize]);
|
||||
|
||||
const editor_update = useCallback((cm: any) => {
|
||||
props.onUpdate(cm);
|
||||
const edited = Date.now() - lastEditTime.current <= 100;
|
||||
props.onUpdate(cm, edited);
|
||||
}, [props.onUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function useScrollHandler(editorRef: any, webviewRef: any, onScro
|
||||
const now = Date.now();
|
||||
if (now >= ignoreNextEditorScrollTime_.current) ignoreNextEditorScrollEventCount_.current = 0;
|
||||
if (ignoreNextEditorScrollEventCount_.current < 10) { // for safety
|
||||
ignoreNextEditorScrollTime_.current = now + 200;
|
||||
ignoreNextEditorScrollTime_.current = now + 1000;
|
||||
ignoreNextEditorScrollEventCount_.current += 1;
|
||||
}
|
||||
};
|
||||
@@ -157,8 +157,9 @@ export default function useScrollHandler(editorRef: any, webviewRef: any, onScro
|
||||
// When heights of lines are updated in CodeMirror, 'update' events are raised.
|
||||
// If such an update event is raised, scroll position should be restored.
|
||||
// See https://github.com/laurent22/joplin/issues/5981
|
||||
const editor_update = useCallback((cm) => {
|
||||
const editor_update = useCallback((cm: any, edited: boolean) => {
|
||||
if (isCodeMirrorReady(cm)) {
|
||||
if (edited) return;
|
||||
const linesHeight = cm.heightAtLine(cm.lineCount()) - cm.heightAtLine(0);
|
||||
if (lastLinesHeight_.current !== linesHeight) {
|
||||
// To avoid cancelling intentional scroll position changes,
|
||||
|
||||
@@ -3,7 +3,8 @@ import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import SpellCheckerService from '@joplin/lib/services/spellChecker/SpellCheckerService';
|
||||
import { useEffect } from 'react';
|
||||
import bridge from '../../../../../services/bridge';
|
||||
import { menuItems, ContextMenuOptions, ContextMenuItemType } from '../../../utils/contextMenu';
|
||||
import { ContextMenuOptions, ContextMenuItemType } from '../../../utils/contextMenuUtils';
|
||||
import { menuItems } from '../../../utils/contextMenu';
|
||||
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import convertToScreenCoordinates from '../../../../utils/convertToScreenCoordinates';
|
||||
@@ -67,6 +68,8 @@ export default function(editor: any, plugins: PluginStates, dispatch: Function)
|
||||
contextMenuActionOptions.current = {
|
||||
itemType,
|
||||
resourceId,
|
||||
filename: null,
|
||||
mime: null,
|
||||
linkToCopy,
|
||||
textToCopy: null,
|
||||
htmlToCopy: editor.selection ? editor.selection.getContent() : '',
|
||||
|
||||
@@ -93,11 +93,11 @@ const declarations: CommandDeclaration[] = [
|
||||
},
|
||||
{
|
||||
name: 'editor.undo',
|
||||
label: () => _('Undo'),
|
||||
label: () => _('Editor: %s', _('Undo')),
|
||||
},
|
||||
{
|
||||
name: 'editor.redo',
|
||||
label: () => _('Redo'),
|
||||
label: () => _('Editor: %s', _('Redo')),
|
||||
},
|
||||
{
|
||||
name: 'editor.indentLess',
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/** @jest-environment ./loadResources.testEnv */
|
||||
// eslint-disable-next-line strict, lines-around-directive
|
||||
'use strict';
|
||||
// use strict is necessary here so that typescript doesn't place "use strict" above the jest docblock
|
||||
// https://github.com/microsoft/TypeScript/issues/15819#issuecomment-782235619
|
||||
|
||||
import { textToDataUri, svgUriToPng } from './contextMenuUtils';
|
||||
|
||||
jest.mock('@joplin/lib/models/Resource');
|
||||
|
||||
describe('contextMenu', () => {
|
||||
it('should provide proper copy path', async () => {
|
||||
const testCase = [
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve">test</svg>',
|
||||
'image/svg+xml',
|
||||
];
|
||||
const expectedText = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4bWw6c3BhY2U9InByZXNlcnZlIj50ZXN0PC9zdmc+';
|
||||
expect(textToDataUri(testCase[0], testCase[1])).toBe(expectedText);
|
||||
});
|
||||
|
||||
it('should convert to png binary', async () => {
|
||||
const testCase = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2ZXJzaW9uPSIxLjEiIGlkPSJMYXllcl8xIiB4PSIwcHgiIHk9IjBweCIgdmlld0JveD0iMCAwIDEwMCAxMDAiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDEwMCAxMDAiIHhtbDpzcGFjZT0icHJlc2VydmUiIGhlaWdodD0iMTAwcHgiIHdpZHRoPSIxMDBweCI+CjxnPgoJPHBhdGggZD0iTTI4LjEsMzYuNmM0LjYsMS45LDEyLjIsMS42LDIwLjksMS4xYzguOS0wLjQsMTktMC45LDI4LjksMC45YzYuMywxLjIsMTEuOSwzLjEsMTYuOCw2Yy0xLjUtMTIuMi03LjktMjMuNy0xOC42LTMxLjMgICBjLTQuOS0wLjItOS45LDAuMy0xNC44LDEuNEM0Ny44LDE3LjksMzYuMiwyNS42LDI4LjEsMzYuNnoiLz4KCTxwYXRoIGQ9Ik03MC4zLDkuOEM1Ny41LDMuNCw0Mi44LDMuNiwzMC41LDkuNWMtMyw2LTguNCwxOS42LTUuMywyNC45YzguNi0xMS43LDIwLjktMTkuOCwzNS4yLTIzLjFDNjMuNywxMC41LDY3LDEwLDcwLjMsOS44eiIvPgoJPHBhdGggZD0iTTE2LjUsNTEuM2MwLjYtMS43LDEuMi0zLjQsMi01LjFjLTMuOC0zLjQtNy41LTctMTEtMTAuOGMtMi4xLDYuMS0yLjgsMTIuNS0yLjMsMTguN0M5LjYsNTEuMSwxMy40LDUwLjIsMTYuNSw1MS4zeiIvPgoJPHBhdGggZD0iTTksMzEuNmMzLjUsMy45LDcuMiw3LjYsMTEuMSwxMS4xYzAuOC0xLjYsMS43LTMuMSwyLjYtNC42YzAuMS0wLjIsMC4zLTAuNCwwLjQtMC42Yy0yLjktMy4zLTMuMS05LjItMC42LTE3LjYgICBjMC44LTIuNywxLjgtNS4zLDIuNy03LjRjLTUuMiwzLjQtOS44LDgtMTMuMywxMy43QzEwLjgsMjcuOSw5LjgsMjkuNyw5LDMxLjZ6Ii8+Cgk8cGF0aCBkPSJNMTUuNCw1NC43Yy0yLjYtMS02LjEsMC43LTkuNywzLjRjMS4yLDYuNiwzLjksMTMsOCwxOC41QzEzLDY5LjMsMTMuNSw2MS44LDE1LjQsNTQuN3oiLz4KCTxwYXRoIGQ9Ik0zOS44LDU3LjZDNTQuMyw2Ni43LDcwLDczLDg2LjUsNzYuNGMwLjYtMC44LDEuMS0xLjYsMS43LTIuNWM0LjgtNy43LDctMTYuMyw2LjgtMjQuOGMtMTMuOC05LjMtMzEuMy04LjQtNDUuOC03LjcgICBjLTkuNSwwLjUtMTcuOCwwLjktMjMuMi0xLjdjLTAuMSwwLjEtMC4yLDAuMy0wLjMsMC40Yy0xLDEuNy0yLDMuNC0yLjksNS4xQzI4LjIsNDkuNywzMy44LDUzLjksMzkuOCw1Ny42eiIvPgoJPHBhdGggZD0iTTI2LjIsODguMmMzLjMsMiw2LjcsMy42LDEwLjIsNC43Yy0zLjUtNi4yLTYuMy0xMi42LTguOC0xOC41Yy0zLjEtNy4yLTUuOC0xMy41LTktMTcuMmMtMS45LDgtMiwxNi40LTAuMywyNC43ICAgQzIwLjYsODQuMiwyMy4yLDg2LjMsMjYuMiw4OC4yeiIvPgoJPHBhdGggZD0iTTMwLjksNzNjMi45LDYuOCw2LjEsMTQuNCwxMC41LDIxLjJjMTUuNiwzLDMyLTIuMyw0Mi42LTE0LjZDNjcuNyw3Niw1Mi4yLDY5LjYsMzcuOSw2MC43QzMyLDU3LDI2LjUsNTMsMjEuMyw0OC42ICAgYy0wLjYsMS41LTEuMiwzLTEuNyw0LjZDMjQuMSw1Ny4xLDI3LjMsNjQuNSwzMC45LDczeiIvPgo8L2c+Cjwvc3ZnPg==';
|
||||
const png = await svgUriToPng(document, testCase);
|
||||
expect(png).toBeInstanceOf(Uint8Array);
|
||||
});
|
||||
|
||||
it('should throw error on invalid svg uri', async () => {
|
||||
// We are mocking console.error since jsdom throws errors to console when we try to load an invalid img
|
||||
// https://github.com/facebook/jest/pull/5267#issuecomment-356605468
|
||||
const consoleError = console.error;
|
||||
console.error = jest.fn();
|
||||
const testCases: Array<string> = [
|
||||
'data:image/svg+xml;base64,error',
|
||||
'invalid',
|
||||
];
|
||||
for (const testCase of testCases) {
|
||||
await expect(svgUriToPng(document, testCase)).rejects.toBeInstanceOf(Error);
|
||||
}
|
||||
console.error = consoleError;
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import ResourceEditWatcher from '@joplin/lib/services/ResourceEditWatcher/index'
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { copyHtmlToClipboard } from './clipboardUtils';
|
||||
import bridge from '../../../services/bridge';
|
||||
import { ContextMenuItemType, ContextMenuOptions, ContextMenuItems, resourceInfo, textToDataUri, svgUriToPng } from './contextMenuUtils';
|
||||
const Menu = bridge().Menu;
|
||||
const MenuItem = bridge().MenuItem;
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
@@ -10,43 +11,10 @@ import BaseModel from '@joplin/lib/BaseModel';
|
||||
import { processPastedHtml } from './resourceHandling';
|
||||
import { NoteEntity, ResourceEntity } from '@joplin/lib/services/database/types';
|
||||
const fs = require('fs-extra');
|
||||
const { writeFile } = require('fs-extra');
|
||||
const { clipboard } = require('electron');
|
||||
const { toSystemSlashes } = require('@joplin/lib/path-utils');
|
||||
|
||||
export enum ContextMenuItemType {
|
||||
None = '',
|
||||
Image = 'image',
|
||||
Resource = 'resource',
|
||||
Text = 'text',
|
||||
Link = 'link',
|
||||
}
|
||||
|
||||
export interface ContextMenuOptions {
|
||||
itemType: ContextMenuItemType;
|
||||
resourceId: string;
|
||||
linkToCopy: string;
|
||||
textToCopy: string;
|
||||
htmlToCopy: string;
|
||||
insertContent: Function;
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
|
||||
interface ContextMenuItem {
|
||||
label: string;
|
||||
onAction: Function;
|
||||
isActive: Function;
|
||||
}
|
||||
|
||||
interface ContextMenuItems {
|
||||
[key: string]: ContextMenuItem;
|
||||
}
|
||||
|
||||
async function resourceInfo(options: ContextMenuOptions): Promise<any> {
|
||||
const resource = options.resourceId ? await Resource.load(options.resourceId) : null;
|
||||
const resourcePath = resource ? Resource.fullPath(resource) : '';
|
||||
return { resource, resourcePath };
|
||||
}
|
||||
|
||||
function handleCopyToClipboard(options: ContextMenuOptions) {
|
||||
if (options.textToCopy) {
|
||||
clipboard.writeText(options.textToCopy);
|
||||
@@ -55,6 +23,12 @@ function handleCopyToClipboard(options: ContextMenuOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFileData(data: any, filename: string) {
|
||||
const newFilePath = await bridge().showSaveDialog({ defaultPath: filename });
|
||||
if (!newFilePath) return;
|
||||
await writeFile(newFilePath, data);
|
||||
}
|
||||
|
||||
export async function openItemById(itemId: string, dispatch: Function, hash: string = '') {
|
||||
|
||||
const item = await BaseItem.loadItemById(itemId);
|
||||
@@ -100,7 +74,7 @@ export function menuItems(dispatch: Function): ContextMenuItems {
|
||||
onAction: async (options: ContextMenuOptions) => {
|
||||
await openItemById(options.resourceId, dispatch);
|
||||
},
|
||||
isActive: (itemType: ContextMenuItemType) => itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource,
|
||||
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => !options.textToCopy && (itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource),
|
||||
},
|
||||
saveAs: {
|
||||
label: _('Save as...'),
|
||||
@@ -112,7 +86,32 @@ export function menuItems(dispatch: Function): ContextMenuItems {
|
||||
if (!filePath) return;
|
||||
await fs.copy(resourcePath, filePath);
|
||||
},
|
||||
isActive: (itemType: ContextMenuItemType) => itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource,
|
||||
// We handle images received as text seperately as it can be saved in multiple formats
|
||||
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => !options.textToCopy && (itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource),
|
||||
},
|
||||
saveAsSvg: {
|
||||
label: _('Save as SVG'),
|
||||
onAction: async (options: ContextMenuOptions) => {
|
||||
await saveFileData(options.textToCopy, options.filename);
|
||||
},
|
||||
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => !!options.textToCopy && itemType === ContextMenuItemType.Image && options.mime?.startsWith('image/svg'),
|
||||
},
|
||||
saveAsPng: {
|
||||
label: _('Save as PNG'),
|
||||
onAction: async (options: ContextMenuOptions) => {
|
||||
// First convert it to png then save
|
||||
if (options.mime != 'image/svg+xml') {
|
||||
throw new Error(`Unsupported image type: ${options.mime}`);
|
||||
}
|
||||
if (!options.filename) {
|
||||
throw new Error('Filename is needed to save as png');
|
||||
}
|
||||
const dataUri = textToDataUri(options.textToCopy, options.mime);
|
||||
const png = await svgUriToPng(document, dataUri);
|
||||
const filename = options.filename.replace('.svg', '.png');
|
||||
await saveFileData(png, filename);
|
||||
},
|
||||
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => !!options.textToCopy && itemType === ContextMenuItemType.Image && options.mime?.startsWith('image/svg'),
|
||||
},
|
||||
revealInFolder: {
|
||||
label: _('Reveal file in folder'),
|
||||
@@ -120,13 +119,20 @@ export function menuItems(dispatch: Function): ContextMenuItems {
|
||||
const { resourcePath } = await resourceInfo(options);
|
||||
bridge().showItemInFolder(resourcePath);
|
||||
},
|
||||
isActive: (itemType: ContextMenuItemType) => itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource,
|
||||
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => !options.textToCopy && itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource,
|
||||
},
|
||||
copyPathToClipboard: {
|
||||
label: _('Copy path to clipboard'),
|
||||
onAction: async (options: ContextMenuOptions) => {
|
||||
const { resourcePath } = await resourceInfo(options);
|
||||
clipboard.writeText(toSystemSlashes(resourcePath));
|
||||
let path = '';
|
||||
if (options.textToCopy && options.mime) {
|
||||
path = textToDataUri(options.textToCopy, options.mime);
|
||||
} else {
|
||||
const { resourcePath } = await resourceInfo(options);
|
||||
if (resourcePath) path = toSystemSlashes(resourcePath);
|
||||
}
|
||||
if (!path) return;
|
||||
clipboard.writeText(path);
|
||||
},
|
||||
isActive: (itemType: ContextMenuItemType) => itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource,
|
||||
},
|
||||
@@ -137,7 +143,7 @@ export function menuItems(dispatch: Function): ContextMenuItems {
|
||||
const image = bridge().createImageFromPath(resourcePath);
|
||||
clipboard.writeImage(image);
|
||||
},
|
||||
isActive: (itemType: ContextMenuItemType) => itemType === ContextMenuItemType.Image,
|
||||
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => !options.textToCopy && itemType === ContextMenuItemType.Image,
|
||||
},
|
||||
cut: {
|
||||
label: _('Cut'),
|
||||
@@ -145,14 +151,14 @@ export function menuItems(dispatch: Function): ContextMenuItems {
|
||||
handleCopyToClipboard(options);
|
||||
options.insertContent('');
|
||||
},
|
||||
isActive: (_itemType: ContextMenuItemType, options: ContextMenuOptions) => !options.isReadOnly && (!!options.textToCopy || !!options.htmlToCopy),
|
||||
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => itemType != ContextMenuItemType.Image && (!options.isReadOnly && (!!options.textToCopy || !!options.htmlToCopy)),
|
||||
},
|
||||
copy: {
|
||||
label: _('Copy'),
|
||||
onAction: async (options: ContextMenuOptions) => {
|
||||
handleCopyToClipboard(options);
|
||||
},
|
||||
isActive: (_itemType: ContextMenuItemType, options: ContextMenuOptions) => !!options.textToCopy || !!options.htmlToCopy,
|
||||
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => itemType != ContextMenuItemType.Image && (!!options.textToCopy || !!options.htmlToCopy),
|
||||
},
|
||||
paste: {
|
||||
label: _('Paste'),
|
||||
@@ -184,7 +190,6 @@ export default async function contextMenu(options: ContextMenuOptions, dispatch:
|
||||
const items = menuItems(dispatch);
|
||||
|
||||
if (!('readyOnly' in options)) options.isReadOnly = true;
|
||||
|
||||
for (const itemKey in items) {
|
||||
const item = items[itemKey];
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
|
||||
export enum ContextMenuItemType {
|
||||
None = '',
|
||||
Image = 'image',
|
||||
Resource = 'resource',
|
||||
Text = 'text',
|
||||
Link = 'link',
|
||||
}
|
||||
|
||||
export interface ContextMenuOptions {
|
||||
itemType: ContextMenuItemType;
|
||||
resourceId: string;
|
||||
mime: string;
|
||||
filename: string;
|
||||
linkToCopy: string;
|
||||
textToCopy: string;
|
||||
htmlToCopy: string;
|
||||
insertContent: Function;
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface ContextMenuItem {
|
||||
label: string;
|
||||
onAction: Function;
|
||||
isActive: Function;
|
||||
}
|
||||
|
||||
export interface ContextMenuItems {
|
||||
[key: string]: ContextMenuItem;
|
||||
}
|
||||
|
||||
export async function resourceInfo(options: ContextMenuOptions): Promise<any> {
|
||||
const resource = options.resourceId ? await Resource.load(options.resourceId) : null;
|
||||
const filePath = resource ? Resource.fullPath(resource) : null;
|
||||
const filename = resource ? (resource.filename ? resource.filename : resource.title) : options.filename ? options.filename : '';
|
||||
return { resource, filePath, filename };
|
||||
}
|
||||
|
||||
export function textToDataUri(text: string, mime: string): string {
|
||||
return `data:${mime};base64,${Buffer.from(text).toString('base64')}`;
|
||||
}
|
||||
|
||||
export const svgUriToPng = (document: Document, svg: string) => {
|
||||
return new Promise<Uint8Array>((resolve, reject) => {
|
||||
let canvas: HTMLCanvasElement;
|
||||
let img: HTMLImageElement;
|
||||
|
||||
const cleanUpAndReject = (e: Error) => {
|
||||
if (canvas) canvas.remove();
|
||||
if (img) img.remove();
|
||||
return reject(e);
|
||||
};
|
||||
|
||||
try {
|
||||
img = document.createElement('img');
|
||||
if (!img) throw new Error('Failed to create img element');
|
||||
} catch (e) {
|
||||
return cleanUpAndReject(e);
|
||||
}
|
||||
|
||||
img.onload = function() {
|
||||
try {
|
||||
canvas = document.createElement('canvas');
|
||||
if (!canvas) throw new Error('Failed to create canvas element');
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('Failed to get context');
|
||||
ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, img.width, img.height);
|
||||
const pngUri = canvas.toDataURL('image/png');
|
||||
if (!pngUri) throw new Error('Failed to generate png uri');
|
||||
const pngBase64 = pngUri.split(',')[1];
|
||||
const byteString = atob(pngBase64);
|
||||
// write the bytes of the string to a typed array
|
||||
const buff = new Uint8Array(byteString.length);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
buff[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
canvas.remove();
|
||||
img.remove();
|
||||
resolve(buff);
|
||||
} catch (err) {
|
||||
cleanUpAndReject(err);
|
||||
}
|
||||
};
|
||||
img.onerror = function(e) {
|
||||
cleanUpAndReject(new Error(e.toString()));
|
||||
};
|
||||
img.src = svg;
|
||||
});
|
||||
};
|
||||
@@ -35,6 +35,8 @@ export default function useMessageHandler(scrollWhenReady: any, setScrollWhenRea
|
||||
const menu = await contextMenu({
|
||||
itemType: arg0 && arg0.type,
|
||||
resourceId: arg0.resourceId,
|
||||
filename: arg0.filename,
|
||||
mime: arg0.mime,
|
||||
textToCopy: arg0.textToCopy,
|
||||
linkToCopy: arg0.linkToCopy || null,
|
||||
htmlToCopy: '',
|
||||
|
||||
@@ -231,6 +231,13 @@ class PromptDialog extends React.Component {
|
||||
}
|
||||
|
||||
const buttonComps = [];
|
||||
if (buttonTypes.indexOf('create') >= 0) {
|
||||
buttonComps.push(
|
||||
<button key="create" disabled={!this.state.answer} style={styles.button} onClick={() => onClose(true, 'create')}>
|
||||
{_('Create')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (buttonTypes.indexOf('ok') >= 0) {
|
||||
buttonComps.push(
|
||||
<button key="ok" disabled={!this.state.answer} style={styles.button} onClick={() => onClose(true, 'ok')}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import JoplinServerApi from '@joplin/lib/JoplinServerApi';
|
||||
import { _, _n } from '@joplin/lib/locale';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
@@ -15,6 +15,7 @@ import Button from './Button/Button';
|
||||
import { connect } from 'react-redux';
|
||||
import { AppState } from '../app.reducer';
|
||||
import { getEncryptionEnabled } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
||||
import SyncTargetRegistry from '../../lib/SyncTargetRegistry';
|
||||
const { clipboard } = require('electron');
|
||||
|
||||
interface Props {
|
||||
@@ -22,6 +23,7 @@ interface Props {
|
||||
noteIds: Array<string>;
|
||||
onClose: Function;
|
||||
shares: StateShare[];
|
||||
syncTargetId: number;
|
||||
}
|
||||
|
||||
function styles_(props: Props) {
|
||||
@@ -69,9 +71,10 @@ export function ShareNoteDialog(props: Props) {
|
||||
console.info('Render ShareNoteDialog');
|
||||
|
||||
const [notes, setNotes] = useState<NoteEntity[]>([]);
|
||||
const [recursiveShare, setRecursiveShare] = useState<boolean>(false);
|
||||
const [sharesState, setSharesState] = useState<string>('unknown');
|
||||
// const [shares, setShares] = useState<SharesMap>({});
|
||||
|
||||
const syncTargetInfo = useMemo(() => SyncTargetRegistry.infoById(props.syncTargetId), [props.syncTargetId]);
|
||||
const noteCount = notes.length;
|
||||
const theme = themeStyle(props.themeId);
|
||||
const styles = styles_(props);
|
||||
@@ -102,7 +105,7 @@ export function ShareNoteDialog(props: Props) {
|
||||
clipboard.writeText(links.join('\n'));
|
||||
};
|
||||
|
||||
const shareLinkButton_click = async () => {
|
||||
const shareLinkButton_click = useCallback(async () => {
|
||||
const service = ShareService.instance();
|
||||
|
||||
let hasSynced = false;
|
||||
@@ -121,7 +124,7 @@ export function ShareNoteDialog(props: Props) {
|
||||
const newShares: StateShare[] = [];
|
||||
|
||||
for (const note of notes) {
|
||||
const share = await service.shareNote(note.id);
|
||||
const share = await service.shareNote(note.id, recursiveShare);
|
||||
newShares.push(share);
|
||||
}
|
||||
|
||||
@@ -149,17 +152,7 @@ export function ShareNoteDialog(props: Props) {
|
||||
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// const removeNoteButton_click = (event: any) => {
|
||||
// const newNotes = [];
|
||||
// for (let i = 0; i < notes.length; i++) {
|
||||
// const n = notes[i];
|
||||
// if (n.id === event.noteId) continue;
|
||||
// newNotes.push(n);
|
||||
// }
|
||||
// setNotes(newNotes);
|
||||
// };
|
||||
}, [recursiveShare, notes]);
|
||||
|
||||
const unshareNoteButton_click = async (event: any) => {
|
||||
await ShareService.instance().unshareNote(event.noteId);
|
||||
@@ -171,22 +164,6 @@ export function ShareNoteDialog(props: Props) {
|
||||
<Button tooltip={_('Unpublish note')} iconName="fas fa-share-alt" onClick={() => unshareNoteButton_click({ noteId: note.id })}/>
|
||||
);
|
||||
|
||||
// const removeButton = notes.length <= 1 ? null : (
|
||||
// <Button iconName="fa fa-times" onClick={() => removeNoteButton_click({ noteId: note.id })}/>
|
||||
// );
|
||||
|
||||
// const unshareButton = !shares[note.id] ? null : (
|
||||
// <button onClick={() => unshareNoteButton_click({ noteId: note.id })} style={styles.noteRemoveButton}>
|
||||
// <i style={styles.noteRemoveButtonIcon} className={'fas fa-share-alt'}></i>
|
||||
// </button>
|
||||
// );
|
||||
|
||||
// const removeButton = notes.length <= 1 ? null : (
|
||||
// <button onClick={() => removeNoteButton_click({ noteId: note.id })} style={styles.noteRemoveButton}>
|
||||
// <i style={styles.noteRemoveButtonIcon} className={'fa fa-times'}></i>
|
||||
// </button>
|
||||
// );
|
||||
|
||||
return (
|
||||
<div key={note.id} style={styles.note}>
|
||||
<span style={styles.noteTitle}>{note.title}</span>{unshareButton}
|
||||
@@ -214,11 +191,26 @@ export function ShareNoteDialog(props: Props) {
|
||||
return <div style={theme.textStyle}>{_('Note: When a note is shared, it will no longer be encrypted on the server.')}<hr/></div>;
|
||||
}
|
||||
|
||||
function renderContent() {
|
||||
const onRecursiveShareChange = useCallback(() => {
|
||||
setRecursiveShare(v => !v);
|
||||
}, []);
|
||||
|
||||
const renderRecursiveShareCheckbox = () => {
|
||||
if (!syncTargetInfo.supportsRecursiveLinkedNotes) return null;
|
||||
|
||||
return (
|
||||
<div style={styles.root}>
|
||||
<div className="form-input-group form-input-group-checkbox">
|
||||
<input id="recursiveShare" name="recursiveShare" type="checkbox" checked={!!recursiveShare} onChange={onRecursiveShareChange} /> <label htmlFor="recursiveShare">{_('Also publish linked notes')}</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
return (
|
||||
<div style={styles.root} className="form">
|
||||
<DialogTitle title={_('Publish Notes')}/>
|
||||
{renderNoteList(notes)}
|
||||
{renderRecursiveShareCheckbox()}
|
||||
<button disabled={['creating', 'synchronizing'].indexOf(sharesState) >= 0} style={styles.copyShareLinkButton} onClick={shareLinkButton_click}>{_n('Copy Shareable Link', 'Copy Shareable Links', noteCount)}</button>
|
||||
<div style={theme.textStyle}>{statusMessage(sharesState)}</div>
|
||||
{renderEncryptionWarningMessage()}
|
||||
@@ -230,7 +222,7 @@ export function ShareNoteDialog(props: Props) {
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog renderContent={renderContent}/>
|
||||
@@ -240,6 +232,7 @@ export function ShareNoteDialog(props: Props) {
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
shares: state.shareService.shares.filter(s => !!s.note_id),
|
||||
syncTargetId: state.settings['sync.target'],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -40,8 +40,13 @@ export default function() {
|
||||
'toggleVisiblePanes',
|
||||
'editor.deleteLine',
|
||||
'editor.duplicateLine',
|
||||
'editor.undo',
|
||||
'editor.redo',
|
||||
// We cannot put the undo/redo commands in the menu because they are
|
||||
// editor-specific commands. If we put them there it will break the
|
||||
// undo/redo in regular text fields.
|
||||
// https://github.com/laurent22/joplin/issues/6214
|
||||
|
||||
// 'editor.undo',
|
||||
// 'editor.redo',
|
||||
'editor.indentLess',
|
||||
'editor.indentMore',
|
||||
'editor.toggleComment',
|
||||
@@ -55,5 +60,10 @@ export default function() {
|
||||
'gotoAnything',
|
||||
'commandPalette',
|
||||
'openMasterPasswordDialog',
|
||||
'addProfile',
|
||||
'editProfileConfig',
|
||||
'switchProfile1',
|
||||
'switchProfile2',
|
||||
'switchProfile3',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
const now = Date.now();
|
||||
if (now >= ignoreNextScrollTime_) ignoreNextScrollEventCount_ = 0;
|
||||
if (ignoreNextScrollEventCount_ < 10) { // for safety
|
||||
ignoreNextScrollTime_ = now + 200;
|
||||
ignoreNextScrollTime_ = now + 1000;
|
||||
ignoreNextScrollEventCount_ += 1;
|
||||
}
|
||||
};
|
||||
@@ -293,7 +293,7 @@
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!heightChanged) return;
|
||||
if (!heightChanged && cause !== 'dom-changed') return;
|
||||
const restoreAndRefresh = () => {
|
||||
scrollmap.refresh();
|
||||
restorePercentScroll();
|
||||
@@ -337,7 +337,11 @@
|
||||
contentElement.innerHTML = html;
|
||||
|
||||
scrollmap.create(event.options.markupLineCount);
|
||||
restorePercentScroll(); // First, a quick treatment is applied.
|
||||
if (typeof event.options.percent !== 'number') {
|
||||
restorePercentScroll(); // First, a quick treatment is applied.
|
||||
} else {
|
||||
setPercentScroll(event.options.percent);
|
||||
}
|
||||
|
||||
addPluginAssets(event.options.pluginAssets);
|
||||
|
||||
@@ -586,9 +590,24 @@
|
||||
}));
|
||||
|
||||
document.addEventListener('contextmenu', webviewLib.logEnabledEventHandler(event => {
|
||||
let element = event.target;
|
||||
|
||||
// To handle right clicks on resource icons
|
||||
let element = event.target;
|
||||
|
||||
// Mermaid svgs are wrapped inside a <pre> with class "mermaid"
|
||||
let mermaidElement = element.closest(".mermaid")?.children[0];
|
||||
if (mermaidElement) {
|
||||
const svgString = new XMLSerializer().serializeToString(mermaidElement);
|
||||
if (!!svgString) {
|
||||
ipcProxySendToHost('contextMenu', {
|
||||
type: 'image',
|
||||
textToCopy: svgString,
|
||||
mime: 'image/svg+xml',
|
||||
filename: mermaidElement.id + '.svg',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (element && !element.getAttribute('data-resource-id')) element = element.parentElement;
|
||||
|
||||
if (element && element.getAttribute('data-resource-id')) {
|
||||
|
||||
@@ -45,7 +45,8 @@ scrollmap.get_ = () => {
|
||||
// Each map entry is total-ordered.
|
||||
let last = 0;
|
||||
for (let i = 0; i < elems.length; i++) {
|
||||
const top = elems[i].getBoundingClientRect().top - offset;
|
||||
const rect = elems[i].getBoundingClientRect();
|
||||
const top = rect.top - offset;
|
||||
const line = Number(elems[i].getAttribute('source-line'));
|
||||
const percent = Math.max(0, Math.min(1, top / height));
|
||||
if (map.line[last] < line && map.percent[last] < percent) {
|
||||
@@ -53,12 +54,20 @@ scrollmap.get_ = () => {
|
||||
map.percent.push(percent);
|
||||
last += 1;
|
||||
}
|
||||
const bottom = rect.bottom - offset;
|
||||
const lineEnd = Number(elems[i].getAttribute('source-line-end'));
|
||||
const percentEnd = Math.max(0, Math.min(1, bottom / height));
|
||||
if (map.line[last] < lineEnd && map.percent[last] < percentEnd) {
|
||||
map.line.push(lineEnd);
|
||||
map.percent.push(percentEnd);
|
||||
last += 1;
|
||||
}
|
||||
}
|
||||
const lineCount = scrollmap.lineCount_;
|
||||
if (lineCount) {
|
||||
map.lineCount = lineCount;
|
||||
} else {
|
||||
if (map.lineCount <= map.line[last]) map.lineCount = map.line[last] + 1;
|
||||
if (map.lineCount < map.line[last]) map.lineCount = map.line[last];
|
||||
}
|
||||
if (map.percent[last] < 1) {
|
||||
map.line.push(lineCount || 1e10);
|
||||
|
||||
20
packages/app-desktop/loadResources.testEnv.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* A Jest custom test Environment to load the resources for the tests.
|
||||
* Use this test envirenment when you work with resources like images, files.
|
||||
* See gui/NoteEditor/utils/contextMenu.test.ts for an example.
|
||||
*/
|
||||
|
||||
const JSDOMEnvironment = require('jest-environment-jsdom');
|
||||
import type { EnvironmentContext } from '@jest/environment';
|
||||
import type { Config } from '@jest/types';
|
||||
|
||||
|
||||
export default class CustomEnvironment extends JSDOMEnvironment {
|
||||
constructor(config: Config.ProjectConfig, context?: EnvironmentContext) {
|
||||
// Resources is set to 'usable' to enable fetching of resources like images and fonts while testing
|
||||
// Which does not happen by default in jest
|
||||
// https://stackoverflow.com/a/49482563
|
||||
config.testEnvironmentOptions.resources = 'usable';
|
||||
super(config, context);
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,15 @@ document.addEventListener('auxclick', event => event.preventDefault());
|
||||
// Each link (rendered as a button or list item) has its own custom click event
|
||||
// so disable the default. In particular this will disable Ctrl+Clicking a link
|
||||
// which would open a new browser window.
|
||||
document.addEventListener('click', (event) => event.preventDefault());
|
||||
document.addEventListener('click', (event) => {
|
||||
// We don't apply this to labels and inputs because it would break
|
||||
// checkboxes. Such a global event handler is probably not a good idea
|
||||
// anyway but keeping it for now, as it doesn't seem to break anything else.
|
||||
// https://github.com/facebook/react/issues/13477#issuecomment-489274045
|
||||
if (['LABEL', 'INPUT'].includes(event.target.nodeName)) return;
|
||||
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
app().start(bridge().processArgv()).then((result) => {
|
||||
if (!result || !result.action) {
|
||||
|
||||
@@ -180,6 +180,22 @@ h2 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.form > .form-input-group-checkbox {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form > .form-input-group-checkbox > input {
|
||||
display: flex;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.form > .form-input-group-checkbox > label {
|
||||
display: flex;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "2.7.12",
|
||||
"version": "2.7.14",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.js",
|
||||
"private": true,
|
||||
@@ -67,7 +67,7 @@
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"allowToChangeInstallationDirectory": false,
|
||||
"differentialPackage": false
|
||||
},
|
||||
"portable": {
|
||||
@@ -116,6 +116,7 @@
|
||||
"app-builder-bin": "^1.9.11",
|
||||
"babel-cli": "^6.26.0",
|
||||
"babel-preset-react": "^6.24.1",
|
||||
"canvas": "^2.9.0",
|
||||
"electron": "14.1.0",
|
||||
"electron-builder": "^22.11.7",
|
||||
"electron-notarize": "^1.0.0",
|
||||
|
||||
@@ -210,7 +210,7 @@ class CameraView extends Component {
|
||||
<View style={{ flex: 1, flexDirection: 'row', justifyContent: 'center', alignItems: 'flex-end' }}>
|
||||
<View style={{ flex: 1, flexDirection: 'row', justifyContent: 'center', alignItems: 'center', marginBottom: 20 }}>
|
||||
{ reverseCameraButton }
|
||||
<TouchableOpacity onPress={this.photo_onPress}>
|
||||
<TouchableOpacity onPress={this.photo_onPress} disabled={this.state.snapping}>
|
||||
<View style={{ flexDirection: 'row', borderRadius: 90, width: 90, height: 90, backgroundColor: '#ffffffaa', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Icon
|
||||
name={photoIcon}
|
||||
|
||||
@@ -38,10 +38,9 @@ class Dropdown extends React.Component {
|
||||
const listTop = Math.min(maxListTop, this.state.headerSize.y + this.state.headerSize.height);
|
||||
|
||||
const wrapperStyle = {
|
||||
width: this.state.headerSize.width,
|
||||
height: listHeight + 2, // +2 for the border (otherwise it makes the scrollbar appear)
|
||||
marginTop: listTop,
|
||||
marginLeft: this.state.headerSize.x,
|
||||
alignSelf: 'center',
|
||||
};
|
||||
|
||||
const itemListStyle = Object.assign({}, this.props.itemListStyle ? this.props.itemListStyle : {}, {
|
||||
@@ -87,6 +86,7 @@ class Dropdown extends React.Component {
|
||||
if (this.props.labelTransform && this.props.labelTransform === 'trim') headerLabel = headerLabel.trim();
|
||||
|
||||
const closeList = () => {
|
||||
if (this.props.onClose()) this.props.onClose();
|
||||
this.setState({ listVisible: false });
|
||||
};
|
||||
|
||||
@@ -116,6 +116,7 @@ class Dropdown extends React.Component {
|
||||
onPress={() => {
|
||||
this.updateHeaderCoordinates();
|
||||
this.setState({ listVisible: true });
|
||||
if (this.props.onOpen()) this.props.onOpen();
|
||||
}}
|
||||
>
|
||||
<Text ellipsizeMode="tail" numberOfLines={1} style={headerStyle}>
|
||||
|
||||
@@ -19,6 +19,8 @@ interface CodeMirrorResult {
|
||||
editor: EditorView;
|
||||
undo: Function;
|
||||
redo: Function;
|
||||
select: (anchor: number, head: number)=> void;
|
||||
insertText: (text: string)=> void;
|
||||
}
|
||||
|
||||
function postMessage(name: string, data: any) {
|
||||
@@ -36,25 +38,53 @@ function logMessage(...msg: any[]) {
|
||||
//
|
||||
// https://github.com/codemirror/theme-one-dark/blob/main/src/one-dark.ts
|
||||
//
|
||||
// For a tutorial, see:
|
||||
//
|
||||
// https://codemirror.net/6/examples/styling/#themes
|
||||
//
|
||||
// Use Safari developer tools to view the content of the CodeMirror iframe while
|
||||
// the app is running. It seems that what appears as ".ͼ1" in the CSS is the
|
||||
// equivalent of "&" in the theme object. So to target ".ͼ1.cm-focused", you'd
|
||||
// use '&.cm-focused' in the theme.
|
||||
const createTheme = (theme: any): Extension => {
|
||||
const isDarkTheme = theme.appearance === 'dark';
|
||||
|
||||
const baseGlobalStyle: Record<string, string> = {
|
||||
color: theme.color,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
fontFamily: theme.fontFamily,
|
||||
fontSize: `${theme.fontSize}px`,
|
||||
};
|
||||
const baseCursorStyle: Record<string, string> = { };
|
||||
const baseContentStyle: Record<string, string> = { };
|
||||
const baseSelectionStyle: Record<string, string> = { };
|
||||
|
||||
// If we're in dark mode, the caret and selection are difficult to see.
|
||||
// Adjust them appropriately
|
||||
if (isDarkTheme) {
|
||||
// Styling the caret requires styling both the caret itself
|
||||
// and the CodeMirror caret.
|
||||
// See https://codemirror.net/6/examples/styling/#themes
|
||||
baseContentStyle.caretColor = 'white';
|
||||
baseCursorStyle.borderLeftColor = 'white';
|
||||
|
||||
baseSelectionStyle.backgroundColor = '#6b6b6b';
|
||||
}
|
||||
|
||||
const baseTheme = EditorView.baseTheme({
|
||||
'&': {
|
||||
color: theme.color,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
fontFamily: theme.fontFamily,
|
||||
fontSize: `${theme.fontSize}px`,
|
||||
},
|
||||
'&': baseGlobalStyle,
|
||||
|
||||
// These must be !important or more specific than CodeMirror's built-ins
|
||||
'.cm-content': baseContentStyle,
|
||||
'&.cm-focused .cm-cursor': baseCursorStyle,
|
||||
'&.cm-focused .cm-selectionBackground, ::selection': baseSelectionStyle,
|
||||
|
||||
'&.cm-focused': {
|
||||
outline: 'none',
|
||||
},
|
||||
});
|
||||
|
||||
const appearanceTheme = EditorView.theme({}, { dark: theme.appearance === 'dark' });
|
||||
const appearanceTheme = EditorView.theme({}, { dark: isDarkTheme });
|
||||
|
||||
const baseHeadingStyle = {
|
||||
fontWeight: 'bold',
|
||||
@@ -152,6 +182,13 @@ export function initCodeMirror(parentElement: any, initialText: string, theme: a
|
||||
postMessage('onChange', { value: editor.state.doc.toString() });
|
||||
schedulePostUndoRedoDepthChange(editor);
|
||||
}
|
||||
|
||||
if (!viewUpdate.state.selection.eq(viewUpdate.startState.selection)) {
|
||||
const mainRange = viewUpdate.state.selection.main;
|
||||
const selStart = mainRange.from;
|
||||
const selEnd = mainRange.to;
|
||||
postMessage('onSelectionChange', { selection: { start: selStart, end: selEnd } });
|
||||
}
|
||||
}),
|
||||
],
|
||||
doc: initialText,
|
||||
@@ -169,5 +206,14 @@ export function initCodeMirror(parentElement: any, initialText: string, theme: a
|
||||
redo(editor);
|
||||
schedulePostUndoRedoDepthChange(editor, true);
|
||||
},
|
||||
select: (anchor: number, head: number) => {
|
||||
editor.dispatch(editor.state.update({
|
||||
selection: { anchor, head },
|
||||
scrollIntoView: true,
|
||||
}));
|
||||
},
|
||||
insertText: (text: string) => {
|
||||
editor.dispatch(editor.state.replaceSelection(text));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import Setting from '@joplin/lib/models/Setting';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
const React = require('react');
|
||||
const { forwardRef, useImperativeHandle, useEffect, useState, useCallback, useRef } = require('react');
|
||||
const { forwardRef, useImperativeHandle, useEffect, useMemo, useState, useCallback, useRef } = require('react');
|
||||
const { WebView } = require('react-native-webview');
|
||||
const { editorFont } = require('../global-style');
|
||||
|
||||
@@ -15,14 +15,27 @@ export interface UndoRedoDepthChangeEvent {
|
||||
redoDepth: number;
|
||||
}
|
||||
|
||||
export interface Selection {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface SelectionChangeEvent {
|
||||
selection: Selection;
|
||||
}
|
||||
|
||||
type ChangeEventHandler = (event: ChangeEvent)=> void;
|
||||
type UndoRedoDepthChangeHandler = (event: UndoRedoDepthChangeEvent)=> void;
|
||||
type SelectionChangeEventHandler = (event: SelectionChangeEvent)=> void;
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
initialText: string;
|
||||
initialSelection?: Selection;
|
||||
style: any;
|
||||
|
||||
onChange: ChangeEventHandler;
|
||||
onSelectionChange: SelectionChangeEventHandler;
|
||||
onUndoRedoDepthChange: UndoRedoDepthChangeHandler;
|
||||
}
|
||||
|
||||
@@ -31,6 +44,7 @@ function fontFamilyFromSettings() {
|
||||
return [f, 'sans-serif'].join(', ');
|
||||
}
|
||||
|
||||
// Obsolete with CodeMirror 6. See ./CodeMirror.ts for styling.
|
||||
// function useCss(themeId:number):string {
|
||||
// const [css, setCss] = useState('');
|
||||
|
||||
@@ -169,6 +183,17 @@ function fontFamilyFromSettings() {
|
||||
// return css;
|
||||
// }
|
||||
|
||||
function useCss(themeId: number): string {
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
return `
|
||||
:root {
|
||||
background-color: ${theme.backgroundColor};
|
||||
}
|
||||
`;
|
||||
}, [themeId]);
|
||||
}
|
||||
|
||||
function useHtml(css: string): string {
|
||||
const [html, setHtml] = useState('');
|
||||
|
||||
@@ -211,11 +236,15 @@ function NoteEditor(props: Props, ref: any) {
|
||||
const [source, setSource] = useState(undefined);
|
||||
const webviewRef = useRef(null);
|
||||
|
||||
const setInitialSelectionJS = props.initialSelection ? `
|
||||
cm.select(${props.initialSelection.start}, ${props.initialSelection.end});
|
||||
` : '';
|
||||
|
||||
const injectedJavaScript = `
|
||||
function postMessage(name, data) {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({
|
||||
data,
|
||||
name,
|
||||
name,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -226,7 +255,7 @@ function NoteEditor(props: Props, ref: any) {
|
||||
// This variable is not used within this script
|
||||
// but is called using "injectJavaScript" from
|
||||
// the wrapper component.
|
||||
let cm = null;
|
||||
window.cm = null;
|
||||
|
||||
try {
|
||||
${shim.injectedJs('codeMirrorBundle')};
|
||||
@@ -236,6 +265,7 @@ function NoteEditor(props: Props, ref: any) {
|
||||
const initialText = ${JSON.stringify(props.initialText)};
|
||||
|
||||
cm = codeMirrorBundle.initCodeMirror(parentElement, initialText, theme);
|
||||
${setInitialSelectionJS}
|
||||
} catch (e) {
|
||||
window.ReactNativeWebView.postMessage("error:" + e.message + ": " + JSON.stringify(e))
|
||||
} finally {
|
||||
@@ -243,8 +273,8 @@ function NoteEditor(props: Props, ref: any) {
|
||||
}
|
||||
`;
|
||||
|
||||
// const css = useCss(props.themeId);
|
||||
const html = useHtml('');
|
||||
const css = useCss(props.themeId);
|
||||
const html = useHtml(css);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
return {
|
||||
@@ -254,6 +284,14 @@ function NoteEditor(props: Props, ref: any) {
|
||||
redo: function() {
|
||||
webviewRef.current.injectJavaScript('cm.redo(); true;');
|
||||
},
|
||||
select: (anchor: number, head: number) => {
|
||||
webviewRef.current.injectJavaScript(
|
||||
`cm.select(${JSON.stringify(anchor)}, ${JSON.stringify(head)}); true;`
|
||||
);
|
||||
},
|
||||
insertText: (text: string) => {
|
||||
webviewRef.current.injectJavaScript(`cm.insertText(${JSON.stringify(text)}); true;`);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -300,6 +338,10 @@ function NoteEditor(props: Props, ref: any) {
|
||||
console.info('onUndoRedoDepthChange', event);
|
||||
props.onUndoRedoDepthChange(event);
|
||||
},
|
||||
|
||||
onSelectionChange: (event: SelectionChangeEvent) => {
|
||||
props.onSelectionChange(event);
|
||||
},
|
||||
};
|
||||
|
||||
if (handlers[msg.name]) {
|
||||
|
||||
@@ -103,7 +103,7 @@ export default class SelectDateTimeDialog extends React.PureComponent<any, any>
|
||||
return (
|
||||
<View style={{ flex: 0, margin: 20, alignItems: 'center' }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
{ this.state.date && <Text style={{ ...theme.normalText, marginRight: 10 }}>{time.formatDateToLocal(this.state.date)}</Text> }
|
||||
{ this.state.date && <Text style={{ ...theme.normalText,color: theme.color, marginRight: 10 }}>{time.formatDateToLocal(this.state.date)}</Text> }
|
||||
<Button title="Set date" onPress={this.onSetDate} />
|
||||
</View>
|
||||
<DateTimePickerModal
|
||||
|
||||
@@ -29,8 +29,10 @@ class ScreenHeaderComponent extends React.PureComponent {
|
||||
constructor() {
|
||||
super();
|
||||
this.styles_ = {};
|
||||
this.state = { showUndoRedoButtons: true };
|
||||
}
|
||||
|
||||
|
||||
styles() {
|
||||
const themeId = Setting.value('theme');
|
||||
if (this.styles_[themeId]) return this.styles_[themeId];
|
||||
@@ -256,7 +258,7 @@ class ScreenHeaderComponent extends React.PureComponent {
|
||||
}
|
||||
|
||||
const renderTopButton = (options) => {
|
||||
if (!options.visible) return null;
|
||||
if (!options.visible || !this.state.showUndoRedoButtons) return null;
|
||||
|
||||
const icon = <Icon name={options.iconName} style={this.styles().topIcon} />;
|
||||
const viewStyle = options.disabled ? this.styles().iconButtonDisabled : this.styles().iconButton;
|
||||
@@ -422,6 +424,16 @@ class ScreenHeaderComponent extends React.PureComponent {
|
||||
color: theme.color,
|
||||
fontSize: theme.fontSize,
|
||||
}}
|
||||
onOpen={() => {
|
||||
this.setState({
|
||||
showUndoRedoButtons: false,
|
||||
});
|
||||
}}
|
||||
onClose={() => {
|
||||
this.setState({
|
||||
showUndoRedoButtons: true,
|
||||
});
|
||||
}}
|
||||
onValueChange={async (folderId, itemIndex) => {
|
||||
// If onValueChange is specified, use this as a callback, otherwise do the default
|
||||
// which is to take the selectedNoteIds from the state and move them to the
|
||||
|
||||
@@ -530,9 +530,9 @@ class ConfigScreenComponent extends BaseScreenComponent {
|
||||
if (this.state.profileExportStatus === 'prompt') {
|
||||
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: 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>
|
||||
<Text style={{ ...this.styles().settingText, flex: 0 }}>Path:</Text>
|
||||
<TextInput style={{ ...this.styles().textInput, paddingRight: 20, width: '75%', marginRight: 'auto' }} onChange={(event: any) => this.setState({ profileExportPath: event.nativeEvent.text })} value={this.state.profileExportPath} placeholder="/path/to/sdcard" keyboardAppearance={theme.keyboardAppearance} />
|
||||
<Button title="OK" onPress={this.exportProfileButtonPress2_} />
|
||||
</View>
|
||||
);
|
||||
|
||||
|
||||
@@ -488,7 +488,11 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
}
|
||||
|
||||
body_selectionChange(event: any) {
|
||||
this.selection = event.nativeEvent.selection;
|
||||
if (this.useEditorBeta()) {
|
||||
this.selection = event.selection;
|
||||
} else {
|
||||
this.selection = event.nativeEvent.selection;
|
||||
}
|
||||
}
|
||||
|
||||
makeSaveAction() {
|
||||
@@ -708,9 +712,17 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
const newNote = Object.assign({}, this.state.note);
|
||||
|
||||
if (this.state.mode == 'edit' && !!this.selection) {
|
||||
const newText = `\n${resourceTag}\n`;
|
||||
|
||||
const prefix = newNote.body.substring(0, this.selection.start);
|
||||
const suffix = newNote.body.substring(this.selection.end);
|
||||
newNote.body = `${prefix}\n${resourceTag}\n${suffix}`;
|
||||
newNote.body = `${prefix}${newText}${suffix}`;
|
||||
|
||||
if (this.useEditorBeta()) {
|
||||
// The beta editor needs to be explicitly informed of changes
|
||||
// to the note's body
|
||||
this.editorRef.current.insertText(newText);
|
||||
}
|
||||
} else {
|
||||
newNote.body += `\n${resourceTag}`;
|
||||
}
|
||||
@@ -879,11 +891,6 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
output.push({
|
||||
title: _('Attach...'),
|
||||
onPress: async () => {
|
||||
if (this.state.mode === 'edit' && this.useEditorBeta()) {
|
||||
alert('Attaching files from the beta editor is not yet supported. You may do so from the viewer mode instead.');
|
||||
return;
|
||||
}
|
||||
|
||||
const buttons = [];
|
||||
|
||||
// On iOS, it will show "local files", which means certain files saved from the browser
|
||||
@@ -1125,7 +1132,9 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
ref={this.editorRef}
|
||||
themeId={this.props.themeId}
|
||||
initialText={note.body}
|
||||
initialSelection={this.selection}
|
||||
onChange={this.onBodyChange}
|
||||
onSelectionChange={this.body_selectionChange}
|
||||
onUndoRedoDepthChange={this.onUndoRedoDepthChange}
|
||||
style={this.styles().bodyTextInput}
|
||||
/>;
|
||||
|
||||
@@ -21,6 +21,7 @@ class NoteTagsDialogComponent extends React.Component {
|
||||
tagListData: [],
|
||||
newTags: '',
|
||||
savingTags: false,
|
||||
tagFilter: '',
|
||||
};
|
||||
|
||||
const noteHasTag = tagId => {
|
||||
@@ -88,6 +89,10 @@ class NoteTagsDialogComponent extends React.Component {
|
||||
this.cancelButton_press = () => {
|
||||
if (this.props.onCloseRequested) this.props.onCloseRequested();
|
||||
};
|
||||
|
||||
this.filterTags = (allTags) => {
|
||||
return allTags.filter((tag) => tag.title.includes(this.state.tagFilter.toLowerCase()), allTags);
|
||||
};
|
||||
}
|
||||
|
||||
UNSAFE_componentWillMount() {
|
||||
@@ -140,16 +145,16 @@ class NoteTagsDialogComponent extends React.Component {
|
||||
fontSize: 20,
|
||||
color: theme.color,
|
||||
},
|
||||
newTagBox: {
|
||||
tagBox: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingLeft: theme.marginLeft,
|
||||
paddingRight: theme.marginRight,
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.dividerColor,
|
||||
},
|
||||
newTagBoxLabel: Object.assign({}, theme.normalText, { marginRight: 8 }),
|
||||
newTagBoxInput: Object.assign({}, theme.lineInput, { flex: 1 }),
|
||||
tagBoxInput: Object.assign({}, theme.lineInput, { flex: 1 }),
|
||||
};
|
||||
|
||||
this.styles_[themeId] = StyleSheet.create(styles);
|
||||
@@ -161,7 +166,7 @@ class NoteTagsDialogComponent extends React.Component {
|
||||
|
||||
const dialogContent = (
|
||||
<View style={{ flex: 1 }}>
|
||||
<View style={this.styles().newTagBox}>
|
||||
<View style={this.styles().tagBox}>
|
||||
<Text style={this.styles().newTagBoxLabel}>{_('New tags:')}</Text>
|
||||
<TextInput
|
||||
selectionColor={theme.textSelectionColor}
|
||||
@@ -170,10 +175,23 @@ class NoteTagsDialogComponent extends React.Component {
|
||||
onChangeText={value => {
|
||||
this.setState({ newTags: value });
|
||||
}}
|
||||
style={this.styles().newTagBoxInput}
|
||||
style={this.styles().tagBoxInput}
|
||||
placeholder={_('tag1,tag2,...')}
|
||||
/>
|
||||
</View>
|
||||
<FlatList data={this.state.tagListData} renderItem={this.renderTag} keyExtractor={this.tagKeyExtractor} />
|
||||
<View style={this.styles().tagBox}>
|
||||
<TextInput
|
||||
selectionColor={theme.textSelectionColor}
|
||||
keyboardAppearance={theme.keyboardAppearance}
|
||||
value={this.state.tagFilter}
|
||||
onChangeText={value => {
|
||||
this.setState({ tagFilter: value });
|
||||
}}
|
||||
placeholder={_('Filter tags')}
|
||||
style={this.styles().tagBoxInput}
|
||||
/>
|
||||
</View>
|
||||
<FlatList data={this.filterTags(this.state.tagListData)} renderItem={this.renderTag} keyExtractor={this.tagKeyExtractor} />
|
||||
</View>
|
||||
);
|
||||
|
||||
|
||||
@@ -8,5 +8,6 @@
|
||||
"author": "<%= pluginAuthor %>",
|
||||
"homepage_url": "<%= pluginHomepageUrl %>",
|
||||
"repository_url": "<%= pluginRepositoryUrl %>",
|
||||
"keywords": []
|
||||
"keywords": [],
|
||||
"categories": []
|
||||
}
|
||||
@@ -29,6 +29,7 @@ const userConfig = Object.assign({}, {
|
||||
|
||||
const manifestPath = `${srcDir}/manifest.json`;
|
||||
const packageJsonPath = `${rootDir}/package.json`;
|
||||
const allPossibleCategories = ['appearance', 'developer tools', 'productivity', 'themes', 'integrations', 'viewer', 'search', 'tags', 'editor', 'files', 'personal knowledge management'];
|
||||
const manifest = readManifest(manifestPath);
|
||||
const pluginArchiveFilePath = path.resolve(publishDir, `${manifest.id}.jpl`);
|
||||
const pluginInfoFilePath = path.resolve(publishDir, `${manifest.id}.json`);
|
||||
@@ -67,10 +68,19 @@ function currentGitInfo() {
|
||||
}
|
||||
}
|
||||
|
||||
function validateCategories(categories) {
|
||||
if (!categories) return null;
|
||||
if ((categories.length !== new Set(categories).size)) throw new Error('Repeated categories are not allowed');
|
||||
categories.forEach(category => {
|
||||
if (!allPossibleCategories.includes(category)) throw new Error(`${category} is not a valid category. Please make sure that the category name is lowercase. Valid Categories are: \n${allPossibleCategories}\n`);
|
||||
});
|
||||
}
|
||||
|
||||
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}`);
|
||||
validateCategories(output.categories);
|
||||
return output;
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,8 @@ import SyncTargetNone from './SyncTargetNone';
|
||||
import { setRSA } from './services/e2ee/ppk';
|
||||
import RSA from './services/e2ee/RSA.node';
|
||||
import Resource from './models/Resource';
|
||||
import { ProfileConfig } from './services/profileConfig/types';
|
||||
import initProfile from './services/profileConfig/initProfile';
|
||||
|
||||
const appLogger: LoggerWrapper = Logger.create('App');
|
||||
|
||||
@@ -70,6 +72,7 @@ export default class BaseApplication {
|
||||
private eventEmitter_: any;
|
||||
private scheduleAutoAddResourcesIID_: any = null;
|
||||
private database_: any = null;
|
||||
private profileConfig_: ProfileConfig = null;
|
||||
|
||||
protected showStackTraces_: boolean = false;
|
||||
protected showPromptString_: boolean = false;
|
||||
@@ -646,6 +649,12 @@ export default class BaseApplication {
|
||||
public initRedux() {
|
||||
this.store_ = createStore(this.reducer, applyMiddleware(this.generalMiddlewareFn() as any));
|
||||
setStore(this.store_);
|
||||
|
||||
this.store_.dispatch({
|
||||
type: 'PROFILE_CONFIG_SET',
|
||||
value: this.profileConfig_,
|
||||
});
|
||||
|
||||
BaseModel.dispatch = this.store().dispatch;
|
||||
FoldersScreenUtils.dispatch = this.store().dispatch;
|
||||
// reg.dispatch = this.store().dispatch;
|
||||
@@ -714,14 +723,16 @@ export default class BaseApplication {
|
||||
// https://immerjs.github.io/immer/docs/freezing
|
||||
setAutoFreeze(initArgs.env === 'dev');
|
||||
|
||||
const profileDir = this.determineProfileDir(initArgs);
|
||||
const rootProfileDir = this.determineProfileDir(initArgs);
|
||||
const { profileDir, profileConfig, isSubProfile } = await initProfile(rootProfileDir);
|
||||
this.profileConfig_ = profileConfig;
|
||||
|
||||
const resourceDirName = 'resources';
|
||||
const resourceDir = `${profileDir}/${resourceDirName}`;
|
||||
const tempDir = `${profileDir}/tmp`;
|
||||
const cacheDir = `${profileDir}/cache`;
|
||||
|
||||
Setting.setConstant('env', initArgs.env);
|
||||
Setting.setConstant('profileDir', profileDir);
|
||||
Setting.setConstant('resourceDirName', resourceDirName);
|
||||
Setting.setConstant('resourceDir', resourceDir);
|
||||
Setting.setConstant('tempDir', tempDir);
|
||||
@@ -778,6 +789,7 @@ export default class BaseApplication {
|
||||
|
||||
|
||||
appLogger.info(`Profile directory: ${profileDir}`);
|
||||
appLogger.info(`Root profile directory: ${rootProfileDir}`);
|
||||
|
||||
this.database_ = new JoplinDatabase(new DatabaseDriverNode());
|
||||
this.database_.setLogExcludedQueryTypes(['SELECT']);
|
||||
@@ -820,7 +832,7 @@ export default class BaseApplication {
|
||||
// Setting.setValue('sync.10.path', 'https://api.joplincloud.com');
|
||||
// Setting.setValue('sync.10.userContentPath', 'https://joplinusercontent.com');
|
||||
Setting.setValue('sync.10.path', 'http://api.joplincloud.local:22300');
|
||||
Setting.setValue('sync.10.userContentPath', 'http://joplinusercontent.local:22300');
|
||||
Setting.setValue('sync.10.userContentPath', 'http://joplincloud.local:22300');
|
||||
}
|
||||
|
||||
// For now always disable fuzzy search due to performance issues:
|
||||
@@ -838,6 +850,7 @@ export default class BaseApplication {
|
||||
}
|
||||
|
||||
if ('welcomeDisabled' in initArgs) Setting.setValue('welcome.enabled', !initArgs.welcomeDisabled);
|
||||
if (isSubProfile) Setting.setValue('welcome.enabled', false);
|
||||
|
||||
if (!Setting.value('api.token')) {
|
||||
void EncryptionService.instance()
|
||||
|
||||
@@ -33,6 +33,10 @@ export default class BaseSyncTarget {
|
||||
return true;
|
||||
}
|
||||
|
||||
public static supportsRecursiveLinkedNotes(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
public option(name: string, defaultValue: any = null) {
|
||||
return this.options_ && name in this.options_ ? this.options_[name] : defaultValue;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,10 @@ export default class SyncTargetJoplinCloud extends BaseSyncTarget {
|
||||
return false;
|
||||
}
|
||||
|
||||
public static supportsRecursiveLinkedNotes(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
public async isAuthenticated() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -4,33 +4,16 @@ export interface SyncTargetInfo {
|
||||
label: string;
|
||||
supportsSelfHosted: boolean;
|
||||
supportsConfigCheck: boolean;
|
||||
supportsRecursiveLinkedNotes: boolean;
|
||||
description: string;
|
||||
classRef: any;
|
||||
}
|
||||
|
||||
// const syncTargetOrder = [
|
||||
// 'joplinCloud',
|
||||
// 'dropbox',
|
||||
// 'onedrive',
|
||||
// ];
|
||||
|
||||
export default class SyncTargetRegistry {
|
||||
|
||||
private static reg_: Record<number, SyncTargetInfo> = {};
|
||||
|
||||
private static get reg() {
|
||||
// if (!this.reg_[0]) {
|
||||
// this.reg_[0] = {
|
||||
// id: 0,
|
||||
// name: SyncTargetNone.targetName(),
|
||||
// label: SyncTargetNone.label(),
|
||||
// classRef: SyncTargetNone,
|
||||
// description: SyncTargetNone.description(),
|
||||
// supportsSelfHosted: false,
|
||||
// supportsConfigCheck: false,
|
||||
// };
|
||||
// }
|
||||
|
||||
return this.reg_;
|
||||
}
|
||||
|
||||
@@ -47,6 +30,10 @@ export default class SyncTargetRegistry {
|
||||
throw new Error(`Unknown name: ${name}`);
|
||||
}
|
||||
|
||||
public static infoById(id: number): SyncTargetInfo {
|
||||
return this.infoByName(this.idToName(id));
|
||||
}
|
||||
|
||||
public static addClass(SyncTargetClass: any) {
|
||||
this.reg[SyncTargetClass.id()] = {
|
||||
id: SyncTargetClass.id(),
|
||||
@@ -56,6 +43,7 @@ export default class SyncTargetRegistry {
|
||||
description: SyncTargetClass.description(),
|
||||
supportsSelfHosted: SyncTargetClass.supportsSelfHosted(),
|
||||
supportsConfigCheck: SyncTargetClass.supportsConfigCheck(),
|
||||
supportsRecursiveLinkedNotes: SyncTargetClass.supportsRecursiveLinkedNotes(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
"Aritim Dark": "Aritim Dark",
|
||||
"Attach file": "Liitä tiedosto",
|
||||
"Attach photo": "Liitä valokuva",
|
||||
"Attach...": "Liittää...",
|
||||
"Attach...": "Liitä...",
|
||||
"Attaches the given file to the note.": "Liittää annetun tiedoston muistiinpanoon.",
|
||||
"attachment": "liite",
|
||||
"Attachment conflict: \"%s\"": "Liitteen ristiriita: \"%s\"",
|
||||
@@ -78,6 +78,7 @@
|
||||
"Authorisation token:": "Valtuutuksen tunnus:",
|
||||
"Auto": "Automaattinen",
|
||||
"Auto-pair braces, parenthesis, quotations, etc.": "Yhdistä sulut, sulkeet, lainaukset jne.",
|
||||
"Automatically check for updates": "Tarkista päivitykset automaattisesti",
|
||||
"Automatically switch theme to match system theme": "Vaihda teema automaattisesti vastaamaan järjestelmän teemaa",
|
||||
"Back": "Takaisin",
|
||||
"Bold": "Lihavoitu",
|
||||
@@ -142,6 +143,7 @@
|
||||
"Convert to todo": "Muunna tehtäväksi",
|
||||
"Copy": "Kopioi",
|
||||
"Copy dev mode command to clipboard": "Kopioi kehitystila komento leikepöydälle",
|
||||
"Copy image": "Kopioi kuva",
|
||||
"Copy Link Address": "Kopioi linkin osoite",
|
||||
"Copy Markdown link": "Kopioi Merkinnän linkki",
|
||||
"Copy path to clipboard": "Kopioi polku leikepöydälle",
|
||||
@@ -153,6 +155,8 @@
|
||||
"Could not install plugin: %s": "Laajennuksen asentaminen epäonnistui: %s",
|
||||
"Could not upgrade master key: %s": "Pääavainta ei voitu päivittää: %s",
|
||||
"Create a notebook": "Luo muistikirja",
|
||||
"Create notebook": "Luo muistikirja",
|
||||
"Create user": "Luo käyttäjä",
|
||||
"Created": "Luotu",
|
||||
"created date": "luotu päivämäärä",
|
||||
"Created local items: %d.": "Luodut paikalliset kohteet: %d.",
|
||||
@@ -248,6 +252,8 @@
|
||||
"Editor monospace font family": "Editorin monospace fonttiperhe",
|
||||
"Either \"text\" or \"json\"": "Joko \"text\" tai \"json\"",
|
||||
"Emacs": "Emacs",
|
||||
"Email": "Sähköposti",
|
||||
"Emails": "Sähköpostit",
|
||||
"emphasised text": "korostettu teksti",
|
||||
"Enable": "Ota käyttöön",
|
||||
"Enable ++insert++ syntax": "Ota käyttöön ++insert++ syntax",
|
||||
@@ -279,6 +285,8 @@
|
||||
"Encryption": "Salaus",
|
||||
"Encryption Config": "Salauksen määritys",
|
||||
"Encryption is: %s": "Salaus on: %s",
|
||||
"Encryption keys": "Salausavaimet",
|
||||
"Encryption:": "Salaus:",
|
||||
"Enter code here": "Syötä koodi tähän",
|
||||
"Enter master password:": "Syötä pääsalasana:",
|
||||
"Enter notebook title": "Anna muistikirjan otsikko",
|
||||
@@ -346,6 +354,7 @@
|
||||
"Idle": "Käyttämättömänä",
|
||||
"Ignore": "Ohita",
|
||||
"Ignore TLS certificate errors": "Ohita TLS varmenteen virheet",
|
||||
"Images": "Kuvat",
|
||||
"Import": "Tuo",
|
||||
"Importing from \"%s\" as \"%s\" format. Please wait...": "Tuodaan kohteesta \"%s\" as \"%s\" muodossa. Odota...",
|
||||
"Importing notes...": "Muistiinpanojen tuominen...",
|
||||
@@ -374,6 +383,7 @@
|
||||
"Invalid answer: %s": "Virheellinen vastaus: %s",
|
||||
"Invalid command: \"%s\"": "Virheellinen komento: \"%s\"",
|
||||
"Invalid option value: \"%s\". Possible values are: %s.": "Virheellinen asetusarvo: \"%s\". Mahdolliset arvot ovat: %s.",
|
||||
"Invalid password": "Virheellinen salasana",
|
||||
"Italic": "Kursiivi",
|
||||
"Item \"%s\" could not be downloaded: %s": "Kohdetta \"%s\" ei voitu ladata: %s",
|
||||
"Items that cannot be decrypted": "Kohteet, joita ei voi purkaa",
|
||||
@@ -419,6 +429,8 @@
|
||||
"Login with Dropbox": "Kirjaudu sisään Dropbox",
|
||||
"Login with OneDrive": "Kirjaudu sisään OneDrive",
|
||||
"Make a donation": "Tee lahjoitus",
|
||||
"Manage master password": "Pääsalasanan hallinta",
|
||||
"Manage master password...": "Pääsalasanan hallinta...",
|
||||
"Manage your plugins": "Hallitse laajennuksia",
|
||||
"Manual": "Manuaalinen",
|
||||
"Markdown": "Markdown",
|
||||
@@ -426,6 +438,8 @@
|
||||
"Marks a to-do as non-completed.": "Merkitsee tehtävän keskeneräiseksi.",
|
||||
"Markup": "Merkintä",
|
||||
"Master Key %s": "Pääavain %s",
|
||||
"Master password": "Pääsalasana",
|
||||
"Master password:": "Pääsalasana:",
|
||||
"Max concurrent connections": "Samanaikaiset yhteydet enintään",
|
||||
"Missing Master Keys": "Puuttuvat pääavaimet",
|
||||
"Missing required argument: %s": "Vaadittu argumentti puuttuu: %s",
|
||||
@@ -475,7 +489,7 @@
|
||||
"Note body": "Muistiinpanon kappale",
|
||||
"Note does not exist: \"%s\". Create it?": "Huomautusta ei ole: \"%s\". Luodaanko se?",
|
||||
"Note has been saved.": "Huomautus on tallennettu.",
|
||||
"Note History": "Muistiinpano historia",
|
||||
"Note History": "Muistiinpanohistoria",
|
||||
"Note is not a to-do: \"%s\"": "Huomautus ei ole tehtävä: \"%s\"",
|
||||
"Note list": "Muistiinpanot",
|
||||
"Note list growth factor": "Huomautus luettelon kasvutekijä",
|
||||
@@ -484,10 +498,12 @@
|
||||
"Note&book": "&Muistikirjat",
|
||||
"Note: Does not work in all desktop environments.": "Huomautus: Ei toimi kaikissa työpöytäympäristöissä.",
|
||||
"Note: When a note is shared, it will no longer be encrypted on the server.": "Huomautus: Kun muistiinpano on jaettu, sitä ei enää salata palvelimella.",
|
||||
"Notebook": "Muistikirja",
|
||||
"Notebook list growth factor": "Muistikirjaluettelon kasvutekijä",
|
||||
"Notebook: %s": "Muistikirja: %s",
|
||||
"Notebooks": "Muistikirjat",
|
||||
"Notebooks cannot be named \"%s\", which is a reserved title.": "Muistikirjoja ei voi nimetä \"%s\", joka on varattu otsikko.",
|
||||
"Notes": "Muistiinpanot",
|
||||
"Notes and settings are stored in: %s": "Muistiinpanot ja oletusasetukset tallennetaan: %s",
|
||||
"Notes can only be created within a notebook.": "Muistiinpanoja voi luoda vain muistikirjaan.",
|
||||
"Numbered List": "Numeroitu luettelo",
|
||||
@@ -601,6 +617,7 @@
|
||||
"See the pre-release page for more details: %s": "Lisätietoja on ennakkojulkaisusivulla: %s",
|
||||
"Select": "Valitse",
|
||||
"Select all": "Valitse kaikki",
|
||||
"Select file...": "Valitse tiedosto...",
|
||||
"Server is already running on port %d": "Palvelin on jo käynnissä portissa %d",
|
||||
"Server is not running.": "Palvelin ei ole käynnissä.",
|
||||
"Server is running on port %d": "Palvelin on käynnissä portissa %d",
|
||||
@@ -797,6 +814,7 @@
|
||||
"Use this to rebuild the search index if there is a problem with search. It may take a long time depending on the number of notes.": "Tämän avulla voit muodostaa hakuindeksin uudelleen, jos haussa on ongelma. Se voi kestää kauan muistiinpanojen määrästä riippuen.",
|
||||
"Used for most text in the markdown editor. If not found, a generic proportional (variable width) font is used.": "Käytetään useimmissa teksteissä markdown editorissa. Jos sitä ei löydy, käytetään yleistä suhteellista fonttia (vaihtelevaa leveyttä).",
|
||||
"Used where a fixed width font is needed to lay out text legibly (e.g. tables, checkboxes, code). If not found, a generic monospace (fixed width) font is used.": "Käytetään, kun tekstiin tarvitaan kiinteäleveyksinen fontti (esim. taulukot, valintaruudut, koodi). Jos sitä ei löydy, käytetään yleistä monospace fonttia (kiinteäleveyksinen).",
|
||||
"Users": "Käyttäjät",
|
||||
"Valid": "Kelvollinen",
|
||||
"View": "Näytä",
|
||||
"View on map": "Näytä kartalla",
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
"Auto": "Auto",
|
||||
"Auto-add disabled accounts for deletion": "Supprimer automatiquement les comptes désactivés",
|
||||
"Auto-pair braces, parenthesis, quotations, etc.": "Auto‑compléter les paires de parenthèses, guillemets, etc.",
|
||||
"Automatically check for updates": "Vérifier automatiquement les mises à jour…",
|
||||
"Automatically check for updates": "Vérifier automatiquement les mises à jour",
|
||||
"Automatically switch theme to match system theme": "Changer le thème automatiquement pour correspondre au thème système",
|
||||
"Back": "Retour",
|
||||
"Bold": "Gras",
|
||||
@@ -663,7 +663,7 @@
|
||||
"Select": "Sélectionner",
|
||||
"Select all": "Sélectionner tout",
|
||||
"Select emoji...": "Sélectionner émoji...",
|
||||
"Select file...": "Sélectionner fichier",
|
||||
"Select file...": "Sélectionner fichier...",
|
||||
"Server is already running on port %d": "Le serveur tourne déjà sur le port %d",
|
||||
"Server is not running.": "Le serveur est arrêté.",
|
||||
"Server is running on port %d": "Le serveur tourne sur le port %d",
|
||||
|
||||
@@ -55,7 +55,7 @@ stats['en_GB'] = {"percentDone":100};
|
||||
stats['en_US'] = {"percentDone":100};
|
||||
stats['es_ES'] = {"percentDone":96};
|
||||
stats['eo'] = {"percentDone":29};
|
||||
stats['fi_FI'] = {"percentDone":91};
|
||||
stats['fi_FI'] = {"percentDone":92};
|
||||
stats['fr_FR'] = {"percentDone":100};
|
||||
stats['gl_ES'] = {"percentDone":33};
|
||||
stats['id_ID'] = {"percentDone":90};
|
||||
|
||||
@@ -15,8 +15,17 @@ export class MarkupLanguageUtils {
|
||||
throw new Error(`Unsupported markup language: ${language}`);
|
||||
}
|
||||
|
||||
public extractImageUrls(language: MarkupLanguage, text: string) {
|
||||
return this.lib_(language).extractImageUrls(text);
|
||||
public extractImageUrls(language: MarkupLanguage, text: string): string[] {
|
||||
let urls: string[] = [];
|
||||
|
||||
if (language === MarkupLanguage.Any) {
|
||||
urls = urls.concat(this.lib_(MarkupLanguage.Markdown).extractImageUrls(text));
|
||||
urls = urls.concat(this.lib_(MarkupLanguage.Html).extractImageUrls(text));
|
||||
} else {
|
||||
urls = this.lib_(language).extractImageUrls(text);
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
// Create a new MarkupToHtml instance while injecting options specific to Joplin
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expectNotThrow, naughtyStrings, setupDatabaseAndSynchronizer, switchClient } from '../testing/test-utils';
|
||||
import Note from '../models/Note';
|
||||
import Revision from '../models/Revision';
|
||||
import Revision, { ObjectPatch } from '../models/Revision';
|
||||
|
||||
describe('models/Revision', function() {
|
||||
|
||||
@@ -139,6 +139,53 @@ describe('models/Revision', function() {
|
||||
expect(JSON.stringify(merged)).toBe(JSON.stringify(newObject));
|
||||
}));
|
||||
|
||||
it('should handle invalid object patch', (async () => {
|
||||
const oldObject = {
|
||||
one: '123',
|
||||
two: '456',
|
||||
three: '789',
|
||||
};
|
||||
|
||||
const brokenPatch = `{"new":{"four":"444
|
||||
"},"deleted":["one"]}`;
|
||||
|
||||
const expected = {
|
||||
two: '456',
|
||||
three: '789',
|
||||
four: '444',
|
||||
};
|
||||
|
||||
const merged = Revision.applyObjectPatch(oldObject, brokenPatch);
|
||||
|
||||
expect(JSON.stringify(merged)).toBe(JSON.stringify(expected));
|
||||
}));
|
||||
|
||||
it('should not strip off newlines from object values', (async () => {
|
||||
const oldObject = {
|
||||
one: '123',
|
||||
two: '456',
|
||||
three: '789',
|
||||
};
|
||||
|
||||
const patch: ObjectPatch = {
|
||||
'new': {
|
||||
'four': 'one line\ntwo line',
|
||||
},
|
||||
'deleted': [],
|
||||
};
|
||||
|
||||
const expected = {
|
||||
one: '123',
|
||||
two: '456',
|
||||
three: '789',
|
||||
four: 'one line\ntwo line',
|
||||
};
|
||||
|
||||
const merged = Revision.applyObjectPatch(oldObject, JSON.stringify(patch));
|
||||
|
||||
expect(JSON.stringify(merged)).toBe(JSON.stringify(expected));
|
||||
}));
|
||||
|
||||
it('should move target revision to the top', (async () => {
|
||||
const revs = [
|
||||
{ id: '123' },
|
||||
@@ -182,6 +229,10 @@ describe('models/Revision', function() {
|
||||
%0A%0A# `,
|
||||
expected: [-(19 + 27 + 2), 17 + 67 + 4],
|
||||
},
|
||||
{
|
||||
patch: '',
|
||||
expected: [-0, +0],
|
||||
},
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
|
||||
@@ -8,6 +8,11 @@ const { sprintf } = require('sprintf-js');
|
||||
|
||||
const dmp = new DiffMatchPatch();
|
||||
|
||||
export interface ObjectPatch {
|
||||
new: Record<string, any>;
|
||||
deleted: string[];
|
||||
}
|
||||
|
||||
export default class Revision extends BaseItem {
|
||||
static tableName() {
|
||||
return 'revisions';
|
||||
@@ -48,7 +53,7 @@ export default class Revision extends BaseItem {
|
||||
|
||||
private static isNewPatch(patch: string): boolean {
|
||||
if (!patch) return true;
|
||||
return patch.indexOf('[{') === 0;
|
||||
return patch.indexOf('[{') === 0 || patch === '[]';
|
||||
}
|
||||
|
||||
public static applyTextPatch(text: string, patch: string): string {
|
||||
@@ -58,7 +63,7 @@ export default class Revision extends BaseItem {
|
||||
// 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);
|
||||
const result = dmp.patch_apply(this.parsePatch(patch), text);
|
||||
if (!result || !result.length) throw new Error('Could not apply patch');
|
||||
return result[0];
|
||||
}
|
||||
@@ -78,10 +83,10 @@ export default class Revision extends BaseItem {
|
||||
return true;
|
||||
}
|
||||
|
||||
static createObjectPatch(oldObject: any, newObject: any) {
|
||||
public static createObjectPatch(oldObject: any, newObject: any) {
|
||||
if (!oldObject) oldObject = {};
|
||||
|
||||
const output: any = {
|
||||
const output: ObjectPatch = {
|
||||
new: {},
|
||||
deleted: [],
|
||||
};
|
||||
@@ -100,16 +105,22 @@ export default class Revision extends BaseItem {
|
||||
return JSON.stringify(output);
|
||||
}
|
||||
|
||||
static applyObjectPatch(object: any, patch: any) {
|
||||
patch = JSON.parse(patch);
|
||||
// We need to sanitise the object patch because it seems some are broken and
|
||||
// may contain new lines: https://github.com/laurent22/joplin/issues/6209
|
||||
private static sanitizeObjectPatch(patch: string): string {
|
||||
return patch.replace(/[\n\r]/g, '');
|
||||
}
|
||||
|
||||
public static applyObjectPatch(object: any, patch: string) {
|
||||
const parsedPatch: ObjectPatch = JSON.parse(this.sanitizeObjectPatch(patch));
|
||||
const output = Object.assign({}, object);
|
||||
|
||||
for (const k in patch.new) {
|
||||
output[k] = patch.new[k];
|
||||
for (const k in parsedPatch.new) {
|
||||
output[k] = parsedPatch.new[k];
|
||||
}
|
||||
|
||||
for (let i = 0; i < patch.deleted.length; i++) {
|
||||
delete output[patch.deleted[i]];
|
||||
for (let i = 0; i < parsedPatch.deleted.length; i++) {
|
||||
delete output[parsedPatch.deleted[i]];
|
||||
}
|
||||
|
||||
return output;
|
||||
@@ -120,7 +131,7 @@ export default class Revision extends BaseItem {
|
||||
// line, so that it can be processed by patchStats().
|
||||
private static newPatchToDiffFormat(patch: string): string {
|
||||
const changeList: string[] = [];
|
||||
const patchArray = JSON.parse(patch);
|
||||
const patchArray = this.parsePatch(patch);
|
||||
for (const patchItem of patchArray) {
|
||||
for (const d of patchItem.diffs) {
|
||||
if (d[0] !== 0) changeList.push(d[0] < 0 ? `-${d[1].replace(/[\n\r]/g, ' ')}` : `+${d[1].trim().replace(/[\n\r]/g, ' ')}`);
|
||||
@@ -237,7 +248,7 @@ export default class Revision extends BaseItem {
|
||||
}
|
||||
|
||||
// Note: revs must be sorted by update_time ASC (as returned by allByType)
|
||||
static async mergeDiffs(revision: RevisionEntity, revs: RevisionEntity[] = null) {
|
||||
public static async mergeDiffs(revision: RevisionEntity, revs: RevisionEntity[] = null) {
|
||||
if (!('encryption_applied' in revision) || !!revision.encryption_applied) throw new JoplinError('Target revision is encrypted', 'revision_encrypted');
|
||||
|
||||
if (!revs) {
|
||||
@@ -273,7 +284,12 @@ export default class Revision extends BaseItem {
|
||||
if (rev.encryption_applied) throw new JoplinError(sprintf('Revision "%s" is encrypted', rev.id), 'revision_encrypted');
|
||||
output.title = this.applyTextPatch(output.title, rev.title_diff);
|
||||
output.body = this.applyTextPatch(output.body, rev.body_diff);
|
||||
output.metadata = this.applyObjectPatch(output.metadata, rev.metadata_diff);
|
||||
try {
|
||||
output.metadata = this.applyObjectPatch(output.metadata, rev.metadata_diff);
|
||||
} catch (error) {
|
||||
error.message = `Revision ${rev.id}: Could not apply patch: ${error.message}: ${rev.metadata_diff}`;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
@@ -330,4 +346,9 @@ export default class Revision extends BaseItem {
|
||||
const existingRev = await Revision.latestRevision(itemType, itemId);
|
||||
return existingRev && existingRev.item_updated_time === updatedTime;
|
||||
}
|
||||
|
||||
private static parsePatch(patch: any): any[] {
|
||||
return patch ? JSON.parse(patch) : [];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
import Setting, { SettingSectionSource } from '../models/Setting';
|
||||
import Setting, { SettingSectionSource, SettingStorage } from '../models/Setting';
|
||||
import { setupDatabaseAndSynchronizer, switchClient, expectThrow, expectNotThrow, msleep } from '../testing/test-utils';
|
||||
import * as fs from 'fs-extra';
|
||||
import { readFile, stat, mkdirp, writeFile, pathExists, readdir } from 'fs-extra';
|
||||
import Logger from '../Logger';
|
||||
import { defaultProfileConfig } from '../services/profileConfig/types';
|
||||
import { createNewProfile, saveProfileConfig } from '../services/profileConfig';
|
||||
import initProfile from '../services/profileConfig/initProfile';
|
||||
|
||||
async function loadSettingsFromFile(): Promise<any> {
|
||||
return JSON.parse(await fs.readFile(Setting.settingFilePath, 'utf8'));
|
||||
return JSON.parse(await readFile(Setting.settingFilePath, 'utf8'));
|
||||
}
|
||||
|
||||
const switchToSubProfileSettings = async () => {
|
||||
await Setting.reset();
|
||||
const rootProfileDir = Setting.value('profileDir');
|
||||
const profileConfigPath = `${rootProfileDir}/profiles.json`;
|
||||
let profileConfig = defaultProfileConfig();
|
||||
profileConfig = createNewProfile(profileConfig, 'Sub-profile');
|
||||
profileConfig.currentProfile = 1;
|
||||
await saveProfileConfig(profileConfigPath, profileConfig);
|
||||
const { profileDir } = await initProfile(rootProfileDir);
|
||||
await mkdirp(profileDir);
|
||||
await Setting.load();
|
||||
};
|
||||
|
||||
describe('models/Setting', function() {
|
||||
|
||||
beforeEach(async (done) => {
|
||||
@@ -180,19 +196,19 @@ describe('models/Setting', function() {
|
||||
{
|
||||
// Double-check that timestamp is indeed changed when the content is
|
||||
// changed.
|
||||
const beforeStat = await fs.stat(Setting.settingFilePath);
|
||||
const beforeStat = await stat(Setting.settingFilePath);
|
||||
await msleep(1001);
|
||||
Setting.setValue('sync.mobileWifiOnly', false);
|
||||
await Setting.saveAll();
|
||||
const afterStat = await fs.stat(Setting.settingFilePath);
|
||||
const afterStat = await stat(Setting.settingFilePath);
|
||||
expect(afterStat.mtime.getTime()).toBeGreaterThan(beforeStat.mtime.getTime());
|
||||
}
|
||||
|
||||
{
|
||||
const beforeStat = await fs.stat(Setting.settingFilePath);
|
||||
const beforeStat = await stat(Setting.settingFilePath);
|
||||
await msleep(1001);
|
||||
Setting.setValue('sync.mobileWifiOnly', false);
|
||||
const afterStat = await fs.stat(Setting.settingFilePath);
|
||||
const afterStat = await stat(Setting.settingFilePath);
|
||||
await Setting.saveAll();
|
||||
expect(afterStat.mtime.getTime()).toBe(beforeStat.mtime.getTime());
|
||||
}
|
||||
@@ -200,7 +216,7 @@ describe('models/Setting', function() {
|
||||
|
||||
it('should handle invalid JSON', (async () => {
|
||||
const badContent = '{ oopsIforgotTheQuotes: true}';
|
||||
await fs.writeFile(Setting.settingFilePath, badContent, 'utf8');
|
||||
await writeFile(Setting.settingFilePath, badContent, 'utf8');
|
||||
await Setting.reset();
|
||||
|
||||
Logger.globalLogger.enabled = false;
|
||||
@@ -208,12 +224,12 @@ describe('models/Setting', function() {
|
||||
Logger.globalLogger.enabled = true;
|
||||
|
||||
// Invalid JSON file has been moved to .bak file
|
||||
expect(await fs.pathExists(Setting.settingFilePath)).toBe(false);
|
||||
expect(await pathExists(Setting.settingFilePath)).toBe(false);
|
||||
|
||||
const files = await fs.readdir(Setting.value('profileDir'));
|
||||
const files = await readdir(Setting.value('profileDir'));
|
||||
expect(files.length).toBe(1);
|
||||
expect(files[0].endsWith('.bak')).toBe(true);
|
||||
expect(await fs.readFile(`${Setting.value('profileDir')}/${files[0]}`, 'utf8')).toBe(badContent);
|
||||
expect(await readFile(`${Setting.value('profileDir')}/${files[0]}`, 'utf8')).toBe(badContent);
|
||||
}));
|
||||
|
||||
it('should allow applying default migrations', (async () => {
|
||||
@@ -256,4 +272,67 @@ describe('models/Setting', function() {
|
||||
expect(Setting.value('style.editor.contentMaxWidth')).toBe(600); // Changed
|
||||
}));
|
||||
|
||||
it('should load sub-profile settings', async () => {
|
||||
await Setting.reset();
|
||||
|
||||
Setting.setValue('locale', 'fr_FR'); // Global setting
|
||||
Setting.setValue('theme', Setting.THEME_DARK); // Global setting
|
||||
Setting.setValue('sync.target', 9); // Local setting
|
||||
await Setting.saveAll();
|
||||
|
||||
await switchToSubProfileSettings();
|
||||
|
||||
expect(Setting.value('locale')).toBe('fr_FR'); // Should come from the root profile
|
||||
expect(Setting.value('theme')).toBe(Setting.THEME_DARK); // Should come from the root profile
|
||||
expect(Setting.value('sync.target')).toBe(0); // Should come from the local profile
|
||||
|
||||
// Also check that the special loadOne() function works as expected
|
||||
|
||||
expect((await Setting.loadOne('locale')).value).toBe('fr_FR');
|
||||
expect((await Setting.loadOne('theme')).value).toBe(Setting.THEME_DARK);
|
||||
expect((await Setting.loadOne('sync.target')).value).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should save sub-profile settings', async () => {
|
||||
await Setting.reset();
|
||||
Setting.setValue('locale', 'fr_FR'); // Global setting
|
||||
Setting.setValue('theme', Setting.THEME_DARK); // Global setting
|
||||
await Setting.saveAll();
|
||||
|
||||
await switchToSubProfileSettings();
|
||||
|
||||
Setting.setValue('locale', 'en_GB'); // Should be saved to global
|
||||
Setting.setValue('sync.target', 8); // Should be saved to local
|
||||
|
||||
await Setting.saveAll();
|
||||
await Setting.reset();
|
||||
await Setting.load();
|
||||
|
||||
expect(Setting.value('locale')).toBe('en_GB');
|
||||
expect(Setting.value('theme')).toBe(Setting.THEME_DARK);
|
||||
expect(Setting.value('sync.target')).toBe(8);
|
||||
|
||||
// Double-check that actual file content is correct
|
||||
|
||||
const globalSettings = JSON.parse(await readFile(`${Setting.value('rootProfileDir')}/settings-1.json`, 'utf8'));
|
||||
const localSettings = JSON.parse(await readFile(`${Setting.value('profileDir')}/settings-1.json`, 'utf8'));
|
||||
|
||||
expect(globalSettings).toEqual({
|
||||
'$schema': 'https://joplinapp.org/schema/settings.json',
|
||||
locale: 'en_GB',
|
||||
theme: 2,
|
||||
});
|
||||
|
||||
expect(localSettings).toEqual({
|
||||
'$schema': 'https://joplinapp.org/schema/settings.json',
|
||||
'sync.target': 8,
|
||||
});
|
||||
});
|
||||
|
||||
it('all global settings should be saved to file', async () => {
|
||||
for (const [k, v] of Object.entries(Setting.metadata())) {
|
||||
if (v.isGlobal && v.storage !== SettingStorage.File) throw new Error(`Setting "${k}" is global but storage is not "file"`);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ import SyncTargetRegistry from '../SyncTargetRegistry';
|
||||
import time from '../time';
|
||||
import FileHandler, { SettingValues } from './settings/FileHandler';
|
||||
import Logger from '../Logger';
|
||||
import mergeGlobalAndLocalSettings from '../services/profileConfig/mergeGlobalAndLocalSettings';
|
||||
import splitGlobalAndLocalSettings from '../services/profileConfig/splitGlobalAndLocalSettings';
|
||||
const { sprintf } = require('sprintf-js');
|
||||
const ObjectUtils = require('../ObjectUtils');
|
||||
const { toTitleCase } = require('../string-utils.js');
|
||||
@@ -59,6 +61,18 @@ export interface SettingItem {
|
||||
autoSave?: boolean;
|
||||
storage?: SettingStorage;
|
||||
hideLabel?: boolean;
|
||||
|
||||
// In a multi-profile context, all settings are by default local - they take
|
||||
// their value from the current profile. This flag can be set to specify
|
||||
// that the setting is global and that its value should come from the root
|
||||
// profile. This flag only applies to sub-profiles.
|
||||
//
|
||||
// At the moment, all global settings must be saved to file (have the
|
||||
// storage attribute set to "file") because it's simpler to load the root
|
||||
// profile settings.json than load the whole SQLite database. This
|
||||
// restriction is not an issue normally since all settings that are
|
||||
// considered global are also the user-facing ones.
|
||||
isGlobal?: boolean;
|
||||
}
|
||||
|
||||
interface SettingItems {
|
||||
@@ -112,6 +126,7 @@ export interface Constants {
|
||||
resourceDirName: string;
|
||||
resourceDir: string;
|
||||
profileDir: string;
|
||||
rootProfileDir: string;
|
||||
tempDir: string;
|
||||
pluginDataDir: string;
|
||||
cacheDir: string;
|
||||
@@ -119,6 +134,7 @@ export interface Constants {
|
||||
flagOpenDevTools: boolean;
|
||||
syncVersion: number;
|
||||
startupDevPlugins: string[];
|
||||
isSubProfile: boolean;
|
||||
}
|
||||
|
||||
interface SettingSections {
|
||||
@@ -243,6 +259,7 @@ class Setting extends BaseModel {
|
||||
resourceDirName: '',
|
||||
resourceDir: '',
|
||||
profileDir: '',
|
||||
rootProfileDir: '',
|
||||
tempDir: '',
|
||||
pluginDataDir: '',
|
||||
cacheDir: '',
|
||||
@@ -250,6 +267,7 @@ class Setting extends BaseModel {
|
||||
flagOpenDevTools: false,
|
||||
syncVersion: 3,
|
||||
startupDevPlugins: [],
|
||||
isSubProfile: false,
|
||||
};
|
||||
|
||||
public static autoSaveEnabled = true;
|
||||
@@ -264,6 +282,7 @@ class Setting extends BaseModel {
|
||||
private static customSections_: SettingSections = {};
|
||||
private static changedKeys_: string[] = [];
|
||||
private static fileHandler_: FileHandler = null;
|
||||
private static rootFileHandler_: FileHandler = null;
|
||||
private static settingFilename_: string = 'settings.json';
|
||||
|
||||
static tableName() {
|
||||
@@ -291,6 +310,10 @@ class Setting extends BaseModel {
|
||||
return `${this.value('profileDir')}/${this.settingFilename_}`;
|
||||
}
|
||||
|
||||
public static get rootSettingFilePath(): string {
|
||||
return `${this.value('rootProfileDir')}/${this.settingFilename_}`;
|
||||
}
|
||||
|
||||
public static get settingFilename(): string {
|
||||
return this.settingFilename_;
|
||||
}
|
||||
@@ -306,6 +329,13 @@ class Setting extends BaseModel {
|
||||
return this.fileHandler_;
|
||||
}
|
||||
|
||||
public static get rootFileHandler(): FileHandler {
|
||||
if (!this.rootFileHandler_) {
|
||||
this.rootFileHandler_ = new FileHandler(this.rootSettingFilePath);
|
||||
}
|
||||
return this.rootFileHandler_;
|
||||
}
|
||||
|
||||
static keychainService() {
|
||||
if (!this.keychainService_) throw new Error('keychainService has not been set!!');
|
||||
return this.keychainService_;
|
||||
@@ -359,6 +389,7 @@ class Setting extends BaseModel {
|
||||
public: false,
|
||||
appTypes: [AppType.Desktop],
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
'sync.openSyncWizard': {
|
||||
@@ -669,6 +700,7 @@ class Setting extends BaseModel {
|
||||
};
|
||||
},
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
'sync.3.auth': { value: '', type: SettingItemType.String, public: false },
|
||||
@@ -687,7 +719,7 @@ class Setting extends BaseModel {
|
||||
'sync.9.context': { value: '', type: SettingItemType.String, public: false },
|
||||
'sync.10.context': { value: '', type: SettingItemType.String, public: false },
|
||||
|
||||
'sync.maxConcurrentConnections': { value: 5, type: SettingItemType.Int, storage: SettingStorage.File, public: true, advanced: true, section: 'sync', label: () => _('Max concurrent connections'), minimum: 1, maximum: 20, step: 1 },
|
||||
'sync.maxConcurrentConnections': { value: 5, type: SettingItemType.Int, storage: SettingStorage.File, isGlobal: true, public: true, advanced: true, section: 'sync', label: () => _('Max concurrent connections'), minimum: 1, maximum: 20, step: 1 },
|
||||
|
||||
// The active folder ID is guaranteed to be valid as long as there's at least one
|
||||
// existing folder, so it is a good default in contexts where there's no currently
|
||||
@@ -695,7 +727,7 @@ class Setting extends BaseModel {
|
||||
// to the last folder that was selected.
|
||||
activeFolderId: { value: '', type: SettingItemType.String, public: false },
|
||||
|
||||
richTextBannerDismissed: { value: false, type: SettingItemType.Bool, public: false },
|
||||
richTextBannerDismissed: { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, public: false },
|
||||
|
||||
firstStart: { value: true, type: SettingItemType.Bool, public: false },
|
||||
locale: {
|
||||
@@ -708,6 +740,7 @@ class Setting extends BaseModel {
|
||||
return ObjectUtils.sortByValue(supportedLocalesToLanguages({ includeStats: true }));
|
||||
},
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
dateFormat: {
|
||||
value: Setting.DATE_FORMAT_1,
|
||||
@@ -730,6 +763,7 @@ class Setting extends BaseModel {
|
||||
return options;
|
||||
},
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
timeFormat: {
|
||||
value: Setting.TIME_FORMAT_1,
|
||||
@@ -746,6 +780,7 @@ class Setting extends BaseModel {
|
||||
return options;
|
||||
},
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
theme: {
|
||||
@@ -761,6 +796,7 @@ class Setting extends BaseModel {
|
||||
section: 'appearance',
|
||||
options: () => themeOptions(),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
themeAutoDetect: {
|
||||
@@ -771,6 +807,7 @@ class Setting extends BaseModel {
|
||||
public: true,
|
||||
label: () => _('Automatically switch theme to match system theme'),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
preferredLightTheme: {
|
||||
@@ -786,6 +823,7 @@ class Setting extends BaseModel {
|
||||
section: 'appearance',
|
||||
options: () => themeOptions(),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
preferredDarkTheme: {
|
||||
@@ -801,6 +839,7 @@ class Setting extends BaseModel {
|
||||
section: 'appearance',
|
||||
options: () => themeOptions(),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
notificationPermission: {
|
||||
@@ -809,7 +848,7 @@ class Setting extends BaseModel {
|
||||
public: false,
|
||||
},
|
||||
|
||||
showNoteCounts: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, public: false, advanced: true, appTypes: [AppType.Desktop], label: () => _('Show note counts') },
|
||||
showNoteCounts: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, public: false, advanced: true, appTypes: [AppType.Desktop], label: () => _('Show note counts') },
|
||||
|
||||
layoutButtonSequence: {
|
||||
value: Setting.LAYOUT_ALL,
|
||||
@@ -824,9 +863,10 @@ class Setting extends BaseModel {
|
||||
[Setting.LAYOUT_VIEWER_SPLIT]: _('%s / %s', _('Viewer'), _('Split View')),
|
||||
}),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
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') },
|
||||
uncompletedTodosOnTop: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, section: 'note', public: true, appTypes: [AppType.Cli], label: () => _('Uncompleted to-dos on top') },
|
||||
showCompletedTodos: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, section: 'note', public: true, appTypes: [AppType.Cli], label: () => _('Show completed to-dos') },
|
||||
'notes.sortOrder.field': {
|
||||
value: 'user_updated_time',
|
||||
type: SettingItemType.String,
|
||||
@@ -845,6 +885,7 @@ class Setting extends BaseModel {
|
||||
return options;
|
||||
},
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
'editor.autoMatchingBraces': {
|
||||
value: true,
|
||||
@@ -854,8 +895,9 @@ class Setting extends BaseModel {
|
||||
appTypes: [AppType.Desktop],
|
||||
label: () => _('Auto-pair braces, parenthesis, quotations, etc.'),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
'notes.sortOrder.reverse': { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'note', public: true, label: () => _('Reverse sort order'), appTypes: [AppType.Cli] },
|
||||
'notes.sortOrder.reverse': { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, section: 'note', public: true, label: () => _('Reverse sort order'), appTypes: [AppType.Cli] },
|
||||
// NOTE: A setting whose name starts with 'notes.sortOrder' is special,
|
||||
// which implies changing the setting automatically triggers the reflesh of notes.
|
||||
// See lib/BaseApplication.ts/generalMiddleware() for details.
|
||||
@@ -868,6 +910,7 @@ class Setting extends BaseModel {
|
||||
label: () => _('Show sort order buttons'),
|
||||
// description: () => _('If true, sort order buttons (field + reverse) for notes are shown at the top of Note List.'),
|
||||
appTypes: [AppType.Desktop],
|
||||
isGlobal: true,
|
||||
},
|
||||
'notes.perFieldReversalEnabled': {
|
||||
value: true,
|
||||
@@ -931,8 +974,8 @@ 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: [AppType.Cli] },
|
||||
trackLocation: { value: true, type: SettingItemType.Bool, section: 'note', storage: SettingStorage.File, public: true, label: () => _('Save geo-location with notes') },
|
||||
'folders.sortOrder.reverse': { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, public: true, label: () => _('Reverse sort order'), appTypes: [AppType.Cli] },
|
||||
trackLocation: { value: true, type: SettingItemType.Bool, section: 'note', storage: SettingStorage.File, isGlobal: true, public: true, label: () => _('Save geo-location with notes') },
|
||||
|
||||
// 2020-10-29: For now disable the beta editor due to
|
||||
// underlying bugs in the TextInput component which we cannot
|
||||
@@ -947,6 +990,8 @@ class Setting extends BaseModel {
|
||||
appTypes: [AppType.Mobile],
|
||||
label: () => 'Opt-in to the editor beta',
|
||||
description: () => 'This beta adds list continuation and syntax highlighting. If you find bugs, please report them in the Discourse forum.',
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
newTodoFocus: {
|
||||
@@ -964,6 +1009,7 @@ class Setting extends BaseModel {
|
||||
};
|
||||
},
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
newNoteFocus: {
|
||||
value: 'body',
|
||||
@@ -980,6 +1026,7 @@ class Setting extends BaseModel {
|
||||
};
|
||||
},
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
'plugins.states': {
|
||||
@@ -1005,31 +1052,31 @@ class Setting extends BaseModel {
|
||||
},
|
||||
|
||||
// Deprecated - use markdown.plugin.*
|
||||
'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] },
|
||||
'markdown.softbreaks': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, public: false, appTypes: [AppType.Mobile, AppType.Desktop] },
|
||||
'markdown.typographer': { storage: SettingStorage.File, isGlobal: true, 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: [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.softbreaks': { storage: SettingStorage.File, isGlobal: true, 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, isGlobal: true, 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, isGlobal: true, 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: [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.katex': { storage: SettingStorage.File, isGlobal: true, 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, isGlobal: true, 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, isGlobal: true, 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: [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}` },
|
||||
'markdown.plugin.audioPlayer': { storage: SettingStorage.File, isGlobal: true, 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, isGlobal: true, 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, isGlobal: true, value: !mobilePlatform, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Desktop], label: () => `${_('Enable PDF viewer')}${wysiwygNo}` },
|
||||
'markdown.plugin.mark': { storage: SettingStorage.File, isGlobal: true, 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, isGlobal: true, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable footnotes')}${wysiwygNo}` },
|
||||
'markdown.plugin.toc': { storage: SettingStorage.File, isGlobal: true, 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, isGlobal: true, 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, isGlobal: true, 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, isGlobal: true, 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, isGlobal: true, 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, isGlobal: true, 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, isGlobal: true, 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, isGlobal: true, 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
|
||||
@@ -1046,9 +1093,10 @@ class Setting extends BaseModel {
|
||||
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.');
|
||||
},
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
startMinimized: { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'application', public: true, appTypes: [AppType.Desktop], label: () => _('Start application minimised in the tray icon') },
|
||||
startMinimized: { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, section: 'application', public: true, appTypes: [AppType.Desktop], label: () => _('Start application minimised in the tray icon') },
|
||||
|
||||
collapsedFolderIds: { value: [], type: SettingItemType.Array, public: false },
|
||||
|
||||
@@ -1072,9 +1120,9 @@ class Setting extends BaseModel {
|
||||
},
|
||||
|
||||
// Deprecated in favour of windowContentZoomFactor
|
||||
'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.zoom': { value: 100, type: SettingItemType.Int, public: false, storage: SettingStorage.File, isGlobal: true, appTypes: [AppType.Desktop], section: 'appearance', label: () => '', minimum: 50, maximum: 500, step: 10 },
|
||||
|
||||
'style.editor.fontSize': { value: 15, 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.fontSize': { value: 15, type: SettingItemType.Int, public: true, storage: SettingStorage.File, isGlobal: true, appTypes: [AppType.Desktop], section: 'appearance', label: () => _('Editor font size'), minimum: 4, maximum: 50, step: 1 },
|
||||
'style.editor.fontFamily':
|
||||
(mobilePlatform) ?
|
||||
({
|
||||
@@ -1101,6 +1149,7 @@ class Setting extends BaseModel {
|
||||
};
|
||||
},
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
}) : {
|
||||
value: '',
|
||||
type: SettingItemType.String,
|
||||
@@ -1111,6 +1160,7 @@ class Setting extends BaseModel {
|
||||
description: () =>
|
||||
_('Used for most text in the markdown editor. If not found, a generic proportional (variable width) font is used.'),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
'style.editor.monospaceFontFamily': {
|
||||
value: '',
|
||||
@@ -1122,9 +1172,10 @@ class Setting extends BaseModel {
|
||||
description: () =>
|
||||
_('Used where a fixed width font is needed to lay out text legibly (e.g. tables, checkboxes, code). If not found, a generic monospace (fixed width) font is used.'),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
'style.editor.contentMaxWidth': { value: 0, type: SettingItemType.Int, public: true, storage: SettingStorage.File, appTypes: [AppType.Desktop], section: 'appearance', label: () => _('Editor maximum width'), description: () => _('Set it to 0 to make it take the complete available space. Recommended width is 600.') },
|
||||
'style.editor.contentMaxWidth': { value: 0, type: SettingItemType.Int, public: true, storage: SettingStorage.File, isGlobal: true,appTypes: [AppType.Desktop], section: 'appearance', label: () => _('Editor maximum width'), description: () => _('Set it to 0 to make it take the complete available space. Recommended width is 600.') },
|
||||
|
||||
'ui.layout': { value: {}, type: SettingItemType.Object, storage: SettingStorage.File, public: false, appTypes: [AppType.Desktop] },
|
||||
|
||||
@@ -1149,6 +1200,8 @@ class Setting extends BaseModel {
|
||||
label: () => _('Custom stylesheet for rendered Markdown'),
|
||||
section: 'appearance',
|
||||
advanced: true,
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
'style.customCss.joplinApp': {
|
||||
value: null,
|
||||
@@ -1167,6 +1220,8 @@ class Setting extends BaseModel {
|
||||
section: 'appearance',
|
||||
advanced: true,
|
||||
description: () => 'CSS file support is provided for your convenience, but they are advanced settings, and styles you define may break from one version to the next. If you want to use them, please know that it might require regular development work from you to keep them working. The Joplin team cannot make a commitment to keep the application HTML structure stable.',
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
'sync.clearLocalSyncStateButton': {
|
||||
@@ -1192,9 +1247,9 @@ class Setting extends BaseModel {
|
||||
},
|
||||
|
||||
|
||||
autoUpdateEnabled: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'application', public: platform !== 'linux', appTypes: [AppType.Desktop], label: () => _('Automatically check for updates') },
|
||||
'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 },
|
||||
autoUpdateEnabled: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, section: 'application', public: platform !== 'linux', appTypes: [AppType.Desktop], label: () => _('Automatically check for updates') },
|
||||
'autoUpdate.includePreReleases': { value: false, type: SettingItemType.Bool, section: 'application', storage: SettingStorage.File, isGlobal: true, 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, isGlobal: true, public: false },
|
||||
'sync.interval': {
|
||||
value: 300,
|
||||
type: SettingItemType.Int,
|
||||
@@ -1214,6 +1269,7 @@ class Setting extends BaseModel {
|
||||
};
|
||||
},
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
'sync.mobileWifiOnly': {
|
||||
value: false,
|
||||
@@ -1223,12 +1279,13 @@ class Setting extends BaseModel {
|
||||
label: () => _('Synchronise only over WiFi connection'),
|
||||
storage: SettingStorage.File,
|
||||
appTypes: [AppType.Mobile],
|
||||
isGlobal: true,
|
||||
},
|
||||
noteVisiblePanes: { value: ['editor', 'viewer'], type: SettingItemType.Array, storage: SettingStorage.File, public: false, appTypes: [AppType.Desktop] },
|
||||
noteVisiblePanes: { value: ['editor', 'viewer'], type: SettingItemType.Array, storage: SettingStorage.File, isGlobal: true, 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: () => {
|
||||
editor: { value: '', type: SettingItemType.String, subType: 'file_path_and_args', storage: SettingStorage.File, isGlobal: true, 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, isGlobal: true, isEnum: true, public: true, appTypes: [AppType.Desktop], label: () => _('Page size for PDF export'), options: () => {
|
||||
return {
|
||||
'A4': _('A4'),
|
||||
'Letter': _('Letter'),
|
||||
@@ -1238,7 +1295,7 @@ class Setting extends BaseModel {
|
||||
'Legal': _('Legal'),
|
||||
};
|
||||
} },
|
||||
'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: () => {
|
||||
'export.pdfPageOrientation': { value: 'portrait', type: SettingItemType.String, storage: SettingStorage.File, isGlobal: true, advanced: true, isEnum: true, public: true, appTypes: [AppType.Desktop], label: () => _('Page orientation for PDF export'), options: () => {
|
||||
return {
|
||||
'portrait': _('Portrait'),
|
||||
'landscape': _('Landscape'),
|
||||
@@ -1261,6 +1318,7 @@ class Setting extends BaseModel {
|
||||
return output;
|
||||
},
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
'editor.spellcheckBeta': {
|
||||
@@ -1270,6 +1328,8 @@ class Setting extends BaseModel {
|
||||
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)',
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
'net.customCertificates': {
|
||||
@@ -1324,8 +1384,8 @@ class Setting extends BaseModel {
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
|
||||
'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: [AppType.Cli], description: () => _('Specify the port that should be used by the API server. If not set, a default will be used.') },
|
||||
'api.token': { value: null, type: SettingItemType.String, public: false, storage: SettingStorage.File, isGlobal: true },
|
||||
'api.port': { value: null, type: SettingItemType.Int, storage: SettingStorage.File, isGlobal: true, 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 },
|
||||
@@ -1357,8 +1417,8 @@ class Setting extends BaseModel {
|
||||
'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 },
|
||||
'spellChecker.enabled': { value: true, type: SettingItemType.Bool, isGlobal: true, storage: SettingStorage.File, public: false },
|
||||
'spellChecker.language': { value: '', type: SettingItemType.String, isGlobal: true, storage: SettingStorage.File, public: false },
|
||||
|
||||
windowContentZoomFactor: {
|
||||
value: 100,
|
||||
@@ -1369,6 +1429,7 @@ class Setting extends BaseModel {
|
||||
maximum: 300,
|
||||
step: 10,
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
'layout.folderList.factor': {
|
||||
@@ -1384,6 +1445,7 @@ class Setting extends BaseModel {
|
||||
'Thus an item with a factor of 2 will take twice as much space as an item with a factor of 1.' +
|
||||
'Restart app to see changes.'),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
'layout.noteList.factor': {
|
||||
value: 1,
|
||||
@@ -1398,6 +1460,7 @@ class Setting extends BaseModel {
|
||||
'Thus an item with a factor of 2 will take twice as much space as an item with a factor of 1.' +
|
||||
'Restart app to see changes.'),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
'layout.note.factor': {
|
||||
value: 2,
|
||||
@@ -1412,6 +1475,7 @@ class Setting extends BaseModel {
|
||||
'Thus an item with a factor of 2 will take twice as much space as an item with a factor of 1.' +
|
||||
'Restart app to see changes.'),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
'syncInfoCache': {
|
||||
@@ -1452,9 +1516,17 @@ class Setting extends BaseModel {
|
||||
|
||||
this.metadata_ = Object.assign(this.metadata_, this.customMetadata_);
|
||||
|
||||
if (this.value('env') === Env.Dev) this.validateMetadata(this.metadata_);
|
||||
|
||||
return this.metadata_;
|
||||
}
|
||||
|
||||
private static validateMetadata(md: SettingItems) {
|
||||
for (const [k, v] of Object.entries(md)) {
|
||||
if (v.isGlobal && v.storage !== SettingStorage.File) throw new Error(`Setting "${k}" is global but storage is not "file"`);
|
||||
}
|
||||
}
|
||||
|
||||
public static skipDefaultMigrations() {
|
||||
logger.info('Skipping all default migrations...');
|
||||
|
||||
@@ -1594,10 +1666,17 @@ 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): Promise<CacheItem | null> {
|
||||
if (this.keyStorage(key) === SettingStorage.File) {
|
||||
const fromFile = await this.fileHandler.load();
|
||||
let fileSettings = await this.fileHandler.load();
|
||||
|
||||
const md = this.settingMetadata(key);
|
||||
if (md.isGlobal) {
|
||||
const rootFileSettings = await this.rootFileHandler.load();
|
||||
fileSettings = mergeGlobalAndLocalSettings(rootFileSettings, fileSettings);
|
||||
}
|
||||
|
||||
return {
|
||||
key,
|
||||
value: fromFile[key],
|
||||
value: fileSettings[key],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1664,11 +1743,17 @@ class Setting extends BaseModel {
|
||||
const itemsFromFile: CacheItem[] = [];
|
||||
|
||||
if (this.canUseFileStorage()) {
|
||||
const fromFile = await this.fileHandler.load();
|
||||
for (const k of Object.keys(fromFile)) {
|
||||
let fileSettings = await this.fileHandler.load();
|
||||
|
||||
if (this.value('isSubProfile')) {
|
||||
const rootFileSettings = await this.rootFileHandler.load();
|
||||
fileSettings = mergeGlobalAndLocalSettings(rootFileSettings, fileSettings);
|
||||
}
|
||||
|
||||
for (const k of Object.keys(fileSettings)) {
|
||||
itemsFromFile.push({
|
||||
key: k,
|
||||
value: fromFile[k],
|
||||
value: fileSettings[k],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2018,7 +2103,15 @@ class Setting extends BaseModel {
|
||||
|
||||
await BaseModel.db().transactionExecBatch(queries);
|
||||
|
||||
if (this.canUseFileStorage()) await this.fileHandler.save(valuesForFile);
|
||||
if (this.canUseFileStorage()) {
|
||||
if (this.value('isSubProfile')) {
|
||||
const { globalSettings, localSettings } = splitGlobalAndLocalSettings(valuesForFile);
|
||||
await this.rootFileHandler.save(globalSettings);
|
||||
await this.fileHandler.save(localSettings);
|
||||
} else {
|
||||
await this.fileHandler.save(valuesForFile);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('Settings have been saved.');
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"@joplin/renderer": "~2.7",
|
||||
"@joplin/turndown": "^4.0.61",
|
||||
"@joplin/turndown-plugin-gfm": "^1.0.43",
|
||||
"@types/nanoid": "^3.0.0",
|
||||
"async-mutex": "^0.1.3",
|
||||
"base-64": "^0.1.0",
|
||||
"base64-stream": "^1.0.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const { setupDatabaseAndSynchronizer, switchClient, createNTestNotes, createNTestFolders, createNTestTags } = require('./testing/test-utils.js');
|
||||
const reducer = require('./reducer').default;
|
||||
const { defaultState, MAX_HISTORY } = require('./reducer');
|
||||
const { ALL_NOTES_FILTER_ID } = require('./reserved-ids');
|
||||
// const { ALL_NOTES_FILTER_ID } = require('./reserved-ids');
|
||||
|
||||
function initTestState(folders, selectedFolderIndex, notes, selectedNoteIndexes, tags = null, selectedTagIndex = null) {
|
||||
let state = defaultState;
|
||||
@@ -571,24 +571,24 @@ describe('reducer', function() {
|
||||
expect(state.forwardHistoryNotes.map(x => x.id)).toEqual([]);
|
||||
}));
|
||||
|
||||
it('should not change folders when all notes filter is on', async () => {
|
||||
const folders = await createNTestFolders(2);
|
||||
const notes = [];
|
||||
for (let i = 0; i < folders.length; i++) {
|
||||
notes.push(...await createNTestNotes(1, folders[i]));
|
||||
}
|
||||
// initialize state with no folders selected
|
||||
let state = initTestState(folders, null, notes.slice(0,2), null);
|
||||
// it('should not change folders when all notes filter is on', async () => {
|
||||
// const folders = await createNTestFolders(2);
|
||||
// const notes = [];
|
||||
// for (let i = 0; i < folders.length; i++) {
|
||||
// notes.push(...await createNTestNotes(1, folders[i]));
|
||||
// }
|
||||
// // initialize state with no folders selected
|
||||
// let state = initTestState(folders, null, notes.slice(0,2), null);
|
||||
|
||||
// turn on 'All Notes' filter
|
||||
state = reducer(state, { type: 'SMART_FILTER_SELECT', id: ALL_NOTES_FILTER_ID });
|
||||
// // turn on 'All Notes' filter
|
||||
// state = reducer(state, { type: 'SMART_FILTER_SELECT', id: ALL_NOTES_FILTER_ID });
|
||||
|
||||
// change folder
|
||||
state = reducer(state, { type: 'FOLDER_AND_NOTE_SELECT', folderId: folders[1].id, noteId: notes[1].id });
|
||||
// // change folder
|
||||
// state = reducer(state, { type: 'FOLDER_AND_NOTE_SELECT', folderId: folders[1].id, noteId: notes[1].id });
|
||||
|
||||
expect(state.selectedFolderId).toEqual(null);
|
||||
expect(state.selectedNoteIds[0]).toEqual(notes[1].id);
|
||||
});
|
||||
// expect(state.selectedFolderId).toEqual(null);
|
||||
// expect(state.selectedNoteIds[0]).toEqual(notes[1].id);
|
||||
// });
|
||||
|
||||
// tests for NOTE_UPDATE_ALL about issue #5447
|
||||
it('should not change selectedNoteIds object when selections are not changed', async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import Note from './models/Note';
|
||||
import Folder from './models/Folder';
|
||||
import BaseModel from './BaseModel';
|
||||
import { Store } from 'redux';
|
||||
import { ProfileConfig } from './services/profileConfig/types';
|
||||
const ArrayUtils = require('./ArrayUtils.js');
|
||||
const { ALL_NOTES_FILTER_ID } = require('./reserved-ids');
|
||||
const { createSelectorCreator, defaultMemoize } = require('reselect');
|
||||
@@ -92,6 +93,7 @@ export interface State {
|
||||
isInsertingNotes: boolean;
|
||||
hasEncryptedItems: boolean;
|
||||
needApiAuth: boolean;
|
||||
profileConfig: ProfileConfig;
|
||||
|
||||
// Extra reducer keys go here:
|
||||
pluginService: PluginServiceState;
|
||||
@@ -162,6 +164,7 @@ export const defaultState: State = {
|
||||
isInsertingNotes: false,
|
||||
hasEncryptedItems: false,
|
||||
needApiAuth: false,
|
||||
profileConfig: null,
|
||||
|
||||
pluginService: pluginServiceDefaultState,
|
||||
shareService: shareServiceDefaultState,
|
||||
@@ -754,15 +757,9 @@ const reducer = produce((draft: Draft<State> = defaultState, action: any) => {
|
||||
|
||||
case 'FOLDER_AND_NOTE_SELECT':
|
||||
{
|
||||
changeSelectedFolder(draft, action);
|
||||
const noteSelectAction = Object.assign({}, action, { type: 'NOTE_SELECT' });
|
||||
|
||||
if (draft.notesParentType === 'SmartFilter' && draft.selectedSmartFilterId === ALL_NOTES_FILTER_ID) {
|
||||
// we don't want to change folder when 'All Notes' filter is on
|
||||
changeSelectedNotes(draft, noteSelectAction);
|
||||
} else {
|
||||
changeSelectedFolder(draft, action);
|
||||
changeSelectedNotes(draft, noteSelectAction);
|
||||
}
|
||||
changeSelectedNotes(draft, noteSelectAction);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -1144,6 +1141,10 @@ const reducer = produce((draft: Draft<State> = defaultState, action: any) => {
|
||||
draft.needApiAuth = action.value;
|
||||
break;
|
||||
|
||||
case 'PROFILE_CONFIG_SET':
|
||||
draft.profileConfig = action.value;
|
||||
break;
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
error.message = `In reducer: ${error.message} Action: ${JSON.stringify(action)}`;
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import Logger from '../Logger';
|
||||
import Setting from '../models/Setting';
|
||||
import shim from '../shim';
|
||||
import { fileExtension, basename, toSystemSlashes } from '../path-utils';
|
||||
import { basename, toSystemSlashes } from '../path-utils';
|
||||
import time from '../time';
|
||||
import { NoteEntity } from './database/types';
|
||||
|
||||
import Note from '../models/Note';
|
||||
import { openFileWithExternalEditor } from './ExternalEditWatcher/utils';
|
||||
const EventEmitter = require('events');
|
||||
const { splitCommandString } = require('../string-utils');
|
||||
const spawn = require('child_process').spawn;
|
||||
const chokidar = require('chokidar');
|
||||
const { ErrorNotFound } = require('./rest/utils/errors');
|
||||
|
||||
@@ -213,68 +211,6 @@ export default class ExternalEditWatcher {
|
||||
return false;
|
||||
}
|
||||
|
||||
textEditorCommand() {
|
||||
const editorCommand = Setting.value('editor');
|
||||
if (!editorCommand) return null;
|
||||
|
||||
const s = splitCommandString(editorCommand, { handleEscape: false });
|
||||
const path = s.splice(0, 1);
|
||||
if (!path.length) throw new Error(`Invalid editor command: ${editorCommand}`);
|
||||
|
||||
return {
|
||||
path: path[0],
|
||||
args: s,
|
||||
};
|
||||
}
|
||||
|
||||
async spawnCommand(path: string, args: string[], options: any) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// App bundles need to be opened using the `open` command.
|
||||
// Additional args can be specified after --args, and the
|
||||
// -n flag is needed to ensure that the app is always launched
|
||||
// with the arguments. Without it, if the app is already opened,
|
||||
// it will just bring it to the foreground without opening the file.
|
||||
// So the full command is:
|
||||
//
|
||||
// open -n /path/to/editor.app --args -app-flag -bla /path/to/file.md
|
||||
//
|
||||
if (shim.isMac() && fileExtension(path) === 'app') {
|
||||
args = args.slice();
|
||||
args.splice(0, 0, '--args');
|
||||
args.splice(0, 0, path);
|
||||
args.splice(0, 0, '-n');
|
||||
path = 'open';
|
||||
}
|
||||
|
||||
const wrapError = (error: any) => {
|
||||
if (!error) return error;
|
||||
const msg = error.message ? [error.message] : [];
|
||||
msg.push(`Command was: "${path}" ${args.join(' ')}`);
|
||||
error.message = msg.join('\n\n');
|
||||
return error;
|
||||
};
|
||||
|
||||
try {
|
||||
const subProcess = spawn(path, args, options);
|
||||
|
||||
const iid = shim.setInterval(() => {
|
||||
if (subProcess && subProcess.pid) {
|
||||
this.logger().debug(`Started editor with PID ${subProcess.pid}`);
|
||||
shim.clearInterval(iid);
|
||||
resolve(null);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
subProcess.on('error', (error: any) => {
|
||||
shim.clearInterval(iid);
|
||||
reject(wrapError(error));
|
||||
});
|
||||
} catch (error) {
|
||||
throw wrapError(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async openAndWatch(note: NoteEntity) {
|
||||
if (!note || !note.id) {
|
||||
this.logger().warn('ExternalEditWatcher: Cannot open note: ', note);
|
||||
@@ -285,13 +221,7 @@ export default class ExternalEditWatcher {
|
||||
if (!filePath) return;
|
||||
this.watch(filePath);
|
||||
|
||||
const cmd = this.textEditorCommand();
|
||||
if (!cmd) {
|
||||
this.bridge_().openExternal(`file://${filePath}`);
|
||||
} else {
|
||||
cmd.args.push(filePath);
|
||||
await this.spawnCommand(cmd.path, cmd.args, { detached: true });
|
||||
}
|
||||
await openFileWithExternalEditor(filePath, this.bridge_());
|
||||
|
||||
this.dispatch({
|
||||
type: 'NOTE_FILE_WATCHER_ADD',
|
||||
|
||||
82
packages/lib/services/ExternalEditWatcher/utils.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
const { splitCommandString } = require('../../string-utils');
|
||||
import { spawn } from 'child_process';
|
||||
import Logger from '../../Logger';
|
||||
import Setting from '../../models/Setting';
|
||||
import { fileExtension } from '../../path-utils';
|
||||
import shim from '../../shim';
|
||||
|
||||
const logger = Logger.create('ExternalEditWatcher/utils');
|
||||
|
||||
const spawnCommand = async (path: string, args: string[], options: any) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// App bundles need to be opened using the `open` command.
|
||||
// Additional args can be specified after --args, and the
|
||||
// -n flag is needed to ensure that the app is always launched
|
||||
// with the arguments. Without it, if the app is already opened,
|
||||
// it will just bring it to the foreground without opening the file.
|
||||
// So the full command is:
|
||||
//
|
||||
// open -n /path/to/editor.app --args -app-flag -bla /path/to/file.md
|
||||
//
|
||||
if (shim.isMac() && fileExtension(path) === 'app') {
|
||||
args = args.slice();
|
||||
args.splice(0, 0, '--args');
|
||||
args.splice(0, 0, path);
|
||||
args.splice(0, 0, '-n');
|
||||
path = 'open';
|
||||
}
|
||||
|
||||
const wrapError = (error: any) => {
|
||||
if (!error) return error;
|
||||
const msg = error.message ? [error.message] : [];
|
||||
msg.push(`Command was: "${path}" ${args.join(' ')}`);
|
||||
error.message = msg.join('\n\n');
|
||||
return error;
|
||||
};
|
||||
|
||||
try {
|
||||
const subProcess = spawn(path, args, options);
|
||||
|
||||
const iid = shim.setInterval(() => {
|
||||
if (subProcess && subProcess.pid) {
|
||||
logger.debug(`Started editor with PID ${subProcess.pid}`);
|
||||
shim.clearInterval(iid);
|
||||
resolve(null);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
subProcess.on('error', (error: any) => {
|
||||
shim.clearInterval(iid);
|
||||
reject(wrapError(error));
|
||||
});
|
||||
} catch (error) {
|
||||
throw wrapError(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const textEditorCommand = () => {
|
||||
const editorCommand = Setting.value('editor');
|
||||
if (!editorCommand) return null;
|
||||
|
||||
const s = splitCommandString(editorCommand, { handleEscape: false });
|
||||
const path = s.splice(0, 1);
|
||||
if (!path.length) throw new Error(`Invalid editor command: ${editorCommand}`);
|
||||
|
||||
return {
|
||||
path: path[0],
|
||||
args: s,
|
||||
};
|
||||
};
|
||||
|
||||
export const openFileWithExternalEditor = async (filePath: string, bridge: any) => {
|
||||
const cmd = textEditorCommand();
|
||||
if (!cmd) {
|
||||
bridge.openExternal(`file://${filePath}`);
|
||||
} else {
|
||||
cmd.args.push(filePath);
|
||||
await spawnCommand(cmd.path, cmd.args, { detached: true });
|
||||
}
|
||||
};
|
||||
@@ -55,6 +55,9 @@ const defaultKeymapItems = {
|
||||
{ accelerator: 'Option+Cmd+A', command: 'editor.sortSelectedLines' },
|
||||
{ accelerator: 'Option+Up', command: 'editor.swapLineUp' },
|
||||
{ accelerator: 'Option+Down', command: 'editor.swapLineDown' },
|
||||
{ accelerator: 'Option+Cmd+1', command: 'switchProfile1' },
|
||||
{ accelerator: 'Option+Cmd+2', command: 'switchProfile2' },
|
||||
{ accelerator: 'Option+Cmd+3', command: 'switchProfile3' },
|
||||
],
|
||||
default: [
|
||||
{ accelerator: 'Ctrl+N', command: 'newNote' },
|
||||
@@ -97,6 +100,9 @@ const defaultKeymapItems = {
|
||||
{ accelerator: 'Ctrl+Alt+S', command: 'editor.sortSelectedLines' },
|
||||
{ accelerator: 'Alt+Up', command: 'editor.swapLineUp' },
|
||||
{ accelerator: 'Alt+Down', command: 'editor.swapLineDown' },
|
||||
{ accelerator: 'Ctrl+Alt+1', command: 'switchProfile1' },
|
||||
{ accelerator: 'Ctrl+Alt+2', command: 'switchProfile2' },
|
||||
{ accelerator: 'Ctrl+Alt+3', command: 'switchProfile3' },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@ 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 BaseModel, { ModelType } from '../BaseModel';
|
||||
import RevisionService from '../services/RevisionService';
|
||||
import { MarkupLanguage } from '../../renderer';
|
||||
|
||||
describe('services_Revision', function() {
|
||||
describe('services/RevisionService', function() {
|
||||
|
||||
beforeEach(async (done) => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
@@ -472,4 +473,82 @@ describe('services_Revision', function() {
|
||||
expect(Date.now() - interval < timeRev2).toBe(true); // check the computer is not too slow for this test
|
||||
expect((await Revision.all()).length).toBe(2);
|
||||
}));
|
||||
|
||||
it('should give a detailed error when a patch cannot be applied', async () => {
|
||||
const n1_v0 = await Note.save({ title: '', is_todo: 1, todo_completed: 0 });
|
||||
const n1_v1 = await Note.save({ id: n1_v0.id, title: 'hello' });
|
||||
await revisionService().collectRevisions(); // REV 1
|
||||
await time.msleep(100);
|
||||
|
||||
await Note.save({ id: n1_v1.id, title: 'hello welcome', todo_completed: 1000 });
|
||||
await revisionService().collectRevisions(); // REV 2
|
||||
|
||||
// Corrupt the metadata diff to generate the error - we assume that it's
|
||||
// been truncated for whatever reason.
|
||||
|
||||
const corruptedMetadata = '{"new":{"todo_completed":10';
|
||||
const revId2 = (await Revision.all())[1].id;
|
||||
await Revision.save({ id: revId2, metadata_diff: corruptedMetadata });
|
||||
|
||||
const note = await Note.load(n1_v0.id);
|
||||
let error = null;
|
||||
try {
|
||||
await revisionService().createNoteRevision_(note);
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
expect(error).toBeTruthy();
|
||||
expect(error.message).toContain(revId2);
|
||||
expect(error.message).toContain(note.id);
|
||||
expect(error.message).toContain(corruptedMetadata);
|
||||
});
|
||||
|
||||
it('note revisions should include certain required properties', async () => {
|
||||
const revisions = [
|
||||
{
|
||||
id: '2b7d7aa51f944aa5b63b8453e1182cb0',
|
||||
parent_id: '',
|
||||
item_type: 1,
|
||||
item_id: 'cc333327a8d64456a73773b13f22a1ce',
|
||||
item_updated_time: 1647101206511,
|
||||
title_diff: '[{"diffs":[[1,"hello"]],"start1":0,"start2":0,"length1":0,"length2":5}]',
|
||||
body_diff: '[]',
|
||||
metadata_diff: '{"new":{},"deleted":[]}',
|
||||
encryption_applied: 0,
|
||||
type_: 13,
|
||||
},
|
||||
{
|
||||
id: 'd2e1cd8433364bcba8e689aaa20dfef2',
|
||||
parent_id: '2b7d7aa51f944aa5b63b8453e1182cb0',
|
||||
item_type: 1,
|
||||
item_id: 'cc333327a8d64456a73773b13f22a1ce',
|
||||
item_updated_time: 1647101206622,
|
||||
title_diff: '[{"diffs":[[0,"hello"],[1," welcome"]],"start1":0,"start2":0,"length1":5,"length2":13}]',
|
||||
body_diff: '[]',
|
||||
metadata_diff: '{"new":{},"deleted":[]}',
|
||||
encryption_applied: 0,
|
||||
type_: 13,
|
||||
},
|
||||
];
|
||||
|
||||
const note1 = await revisionService().revisionNote(revisions, 1);
|
||||
|
||||
expect(note1.title).toBe('hello welcome');
|
||||
expect(note1.body).toBe('');
|
||||
expect(note1.markup_language).toBe(MarkupLanguage.Markdown);
|
||||
expect(note1.type_).toBe(ModelType.Note);
|
||||
|
||||
// Check that it's not overidding the property if it's already set
|
||||
|
||||
const revisions2 = revisions.slice();
|
||||
revisions2[0] = {
|
||||
...revisions2[0],
|
||||
metadata_diff: '{"new":{"markup_language":2},"deleted":[]}',
|
||||
};
|
||||
|
||||
const note2 = await revisionService().revisionNote(revisions2, 1);
|
||||
expect(note2.markup_language).toBe(MarkupLanguage.Html);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import BaseService from './BaseService';
|
||||
import { _ } from '../locale';
|
||||
import { ItemChangeEntity, NoteEntity, RevisionEntity } from './database/types';
|
||||
import Logger from '../Logger';
|
||||
import { MarkupLanguage } from '../../renderer';
|
||||
const { substrWithEllipsis } = require('../string-utils');
|
||||
const { sprintf } = require('sprintf-js');
|
||||
const { wrapError } = require('../errorUtils');
|
||||
@@ -137,32 +138,56 @@ export default class RevisionService extends BaseService {
|
||||
const change = changes[i];
|
||||
const noteId = change.item_id;
|
||||
|
||||
if (change.type === ItemChange.TYPE_UPDATE && doneNoteIds.indexOf(noteId) < 0) {
|
||||
const note = BaseModel.byId(notes, noteId);
|
||||
const oldNote = change.before_change_item ? JSON.parse(change.before_change_item) : null;
|
||||
try {
|
||||
if (change.type === ItemChange.TYPE_UPDATE && doneNoteIds.indexOf(noteId) < 0) {
|
||||
const note = BaseModel.byId(notes, noteId);
|
||||
const oldNote = change.before_change_item ? JSON.parse(change.before_change_item) : null;
|
||||
|
||||
if (note) {
|
||||
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) logger.debug(sprintf('RevisionService::collectRevisions: Saved revision %s (old note)', rev.id));
|
||||
if (note) {
|
||||
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) logger.debug(sprintf('collectRevisions: Saved revision %s (old note)', rev.id));
|
||||
}
|
||||
|
||||
const rev = await this.createNoteRevision_(note);
|
||||
if (rev) logger.debug(sprintf('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;
|
||||
}
|
||||
}
|
||||
|
||||
const rev = await this.createNoteRevision_(note);
|
||||
if (rev) logger.debug(sprintf('RevisionService::collectRevisions: Saved revision %s (Last rev was more than %d ms ago)', rev.id, Setting.value('revisionService.intervalBetweenRevisions')));
|
||||
if (change.type === ItemChange.TYPE_DELETE && !!change.before_change_item) {
|
||||
const note = JSON.parse(change.before_change_item);
|
||||
const revExists = await Revision.revisionExists(BaseModel.TYPE_NOTE, note.id, note.updated_time);
|
||||
if (!revExists) {
|
||||
const rev = await this.createNoteRevision_(note);
|
||||
if (rev) logger.debug(sprintf('collectRevisions: Saved revision %s (for deleted note)', rev.id));
|
||||
}
|
||||
doneNoteIds.push(noteId);
|
||||
this.isOldNotesCache_[noteId] = false;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code === 'revision_encrypted') {
|
||||
throw error;
|
||||
} else {
|
||||
// If any revision creation fails, we continue
|
||||
// processing the other changes. It seems a rare bug
|
||||
// in diff-match-patch can cause the creation of
|
||||
// revisions to fail in some case. It should be rare
|
||||
// and it's best to continue processing the other
|
||||
// changes. The alternative would be to stop here
|
||||
// and fix the bug, but in the meantime revisions
|
||||
// will no longer be generated.
|
||||
|
||||
if (change.type === ItemChange.TYPE_DELETE && !!change.before_change_item) {
|
||||
const note = JSON.parse(change.before_change_item);
|
||||
const revExists = await Revision.revisionExists(BaseModel.TYPE_NOTE, note.id, note.updated_time);
|
||||
if (!revExists) {
|
||||
const rev = await this.createNoteRevision_(note);
|
||||
if (rev) logger.debug(sprintf('RevisionService::collectRevisions: Saved revision %s (for deleted note)', rev.id));
|
||||
// The drawback is that once a change has been
|
||||
// skipped it will never be processed again because
|
||||
// the error will be in the past (before
|
||||
// revisionService.lastProcessedChangeId)
|
||||
//
|
||||
// https://github.com/laurent22/joplin/issues/5531
|
||||
logger.error(`collectRevisions: Processing one of the changes for note ${noteId} failed. Other changes will still be processed. Error was: `, error);
|
||||
logger.error('collectRevisions: Change was:', change);
|
||||
}
|
||||
doneNoteIds.push(noteId);
|
||||
}
|
||||
|
||||
Setting.setValue('revisionService.lastProcessedChangeId', change.id);
|
||||
@@ -173,15 +198,11 @@ 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.
|
||||
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('collectRevisions: One or more revision was encrypted. Processing was stopped but will resume later when the revision is decrypted.', error);
|
||||
} else {
|
||||
// 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);
|
||||
// This should not happen anymore because we handle the error in
|
||||
// the loop above.
|
||||
logger.error('collectRevisions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,14 +211,14 @@ export default class RevisionService extends BaseService {
|
||||
|
||||
this.isCollecting_ = false;
|
||||
|
||||
logger.info(`RevisionService::collectRevisions: Created revisions for ${doneNoteIds.length} notes`);
|
||||
logger.info(`collectRevisions: Created revisions for ${doneNoteIds.length} notes`);
|
||||
}
|
||||
|
||||
async deleteOldRevisions(ttl: number) {
|
||||
return Revision.deleteOldRevisions(ttl);
|
||||
}
|
||||
|
||||
async revisionNote(revisions: RevisionEntity[], index: number) {
|
||||
public async revisionNote(revisions: RevisionEntity[], index: number) {
|
||||
if (index < 0 || index >= revisions.length) throw new Error(`Invalid revision index: ${index}`);
|
||||
|
||||
const rev = revisions[index];
|
||||
@@ -213,6 +234,7 @@ export default class RevisionService extends BaseService {
|
||||
output.updated_time = output.user_updated_time;
|
||||
output.created_time = output.user_created_time;
|
||||
(output as any).type_ = BaseModel.TYPE_NOTE;
|
||||
if (!('markup_language' in output)) output.markup_language = MarkupLanguage.Markdown;
|
||||
|
||||
return output;
|
||||
}
|
||||
@@ -264,23 +286,23 @@ export default class RevisionService extends BaseService {
|
||||
this.maintenanceCalls_.push(true);
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
logger.info('RevisionService::maintenance: Starting...');
|
||||
logger.info('maintenance: Starting...');
|
||||
|
||||
if (!Setting.value('revisionService.enabled')) {
|
||||
logger.info('RevisionService::maintenance: Service is disabled');
|
||||
logger.info('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 {
|
||||
logger.info('RevisionService::maintenance: Service is enabled');
|
||||
logger.info('maintenance: Service is enabled');
|
||||
await this.collectRevisions();
|
||||
await this.deleteOldRevisions(Setting.value('revisionService.ttlDays') * 24 * 60 * 60 * 1000);
|
||||
|
||||
logger.info(`RevisionService::maintenance: Done in ${Date.now() - startTime}ms`);
|
||||
logger.info(`maintenance: Done in ${Date.now() - startTime}ms`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('RevisionService::maintenance:', error);
|
||||
logger.error('maintenance:', error);
|
||||
} finally {
|
||||
this.maintenanceCalls_.pop();
|
||||
}
|
||||
@@ -292,7 +314,7 @@ export default class RevisionService extends BaseService {
|
||||
|
||||
if (collectRevisionInterval === null) collectRevisionInterval = 1000 * 60 * 10;
|
||||
|
||||
logger.info(`RevisionService::runInBackground: Starting background service with revision collection interval ${collectRevisionInterval}`);
|
||||
logger.info(`runInBackground: Starting background service with revision collection interval ${collectRevisionInterval}`);
|
||||
|
||||
this.maintenanceTimer1_ = shim.setTimeout(() => {
|
||||
void this.maintenance();
|
||||
|
||||
@@ -6,13 +6,15 @@ import propsHaveChanged from './propsHaveChanged';
|
||||
const { createSelectorCreator, defaultMemoize } = require('reselect');
|
||||
const { createCachedSelector } = require('re-reselect');
|
||||
|
||||
interface MenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
click: Function;
|
||||
export interface MenuItem {
|
||||
id?: string;
|
||||
label?: string;
|
||||
click?: Function;
|
||||
role?: any;
|
||||
type?: string;
|
||||
accelerator?: string;
|
||||
enabled: boolean;
|
||||
checked?: boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface MenuItems {
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface WhenClauseContext {
|
||||
folderIsShared: boolean;
|
||||
folderIsShareRoot: boolean;
|
||||
joplinServerConnected: boolean;
|
||||
hasMultiProfiles: boolean;
|
||||
}
|
||||
|
||||
export default function stateToWhenClauseContext(state: State, options: WhenClauseContextOptions = null): WhenClauseContext {
|
||||
@@ -82,5 +83,7 @@ export default function stateToWhenClauseContext(state: State, options: WhenClau
|
||||
folderIsShared: commandFolder ? !!commandFolder.share_id : false,
|
||||
|
||||
joplinServerConnected: [9, 10].includes(state.settings['sync.target']),
|
||||
|
||||
hasMultiProfiles: state.profileConfig && state.profileConfig.profiles.length > 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -413,9 +413,9 @@ export default class InteropService {
|
||||
|
||||
const ItemClass = BaseItem.getClassByItemType(itemType);
|
||||
const itemOrId = itemsToExport[i].itemOrId;
|
||||
const item = this.normalizeItemForExport(itemType, typeof itemOrId === 'object' ? itemOrId : await ItemClass.load(itemOrId));
|
||||
const rawItem = typeof itemOrId === 'object' ? itemOrId : await ItemClass.load(itemOrId);
|
||||
|
||||
if (!item) {
|
||||
if (!rawItem) {
|
||||
if (itemType === BaseModel.TYPE_RESOURCE) {
|
||||
result.warnings.push(sprintf('A resource that does not exist is referenced in a note. The resource was skipped. Resource ID: %s', itemOrId));
|
||||
} else {
|
||||
@@ -424,6 +424,8 @@ export default class InteropService {
|
||||
continue;
|
||||
}
|
||||
|
||||
const item = this.normalizeItemForExport(itemType, rawItem);
|
||||
|
||||
if (item.encryption_applied || item.encryption_blob_encrypted) {
|
||||
result.warnings.push(sprintf('This item is currently encrypted: %s "%s" (%s) and was not exported. You may wait for it to be decrypted and try again.', BaseModel.modelTypeToName(itemType), item.title ? item.title : item.id, item.id));
|
||||
continue;
|
||||
|
||||
@@ -1,21 +1,33 @@
|
||||
import InteropService_Importer_Md from '../../services/interop/InteropService_Importer_Md';
|
||||
import Note from '../../models/Note';
|
||||
import { setupDatabaseAndSynchronizer, supportDir, switchClient } from '../../testing/test-utils';
|
||||
import Folder from '../../models/Folder';
|
||||
import * as fs from 'fs-extra';
|
||||
import { createTempDir, setupDatabaseAndSynchronizer, supportDir, switchClient } from '../../testing/test-utils';
|
||||
import { MarkupToHtml } from '@joplin/renderer';
|
||||
import { FolderEntity } from '../database/types';
|
||||
|
||||
|
||||
describe('InteropService_Importer_Md: importLocalImages', function() {
|
||||
describe('InteropService_Importer_Md', function() {
|
||||
let tempDir: string;
|
||||
async function importNote(path: string) {
|
||||
const importer = new InteropService_Importer_Md();
|
||||
importer.setMetadata({ fileExtensions: ['md', 'html'] });
|
||||
return await importer.importFile(path, 'notebook');
|
||||
}
|
||||
|
||||
async function importNoteDirectory(path: string) {
|
||||
const importer = new InteropService_Importer_Md();
|
||||
importer.setMetadata({ fileExtensions: ['md', 'html'] });
|
||||
return await importer.importDirectory(path, 'notebook');
|
||||
}
|
||||
beforeEach(async (done) => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
tempDir = await createTempDir();
|
||||
done();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fs.remove(tempDir);
|
||||
});
|
||||
it('should import linked files and modify tags appropriately', async function() {
|
||||
const note = await importNote(`${supportDir}/test_notes/md/sample.md`);
|
||||
|
||||
@@ -117,4 +129,30 @@ describe('InteropService_Importer_Md: importLocalImages', function() {
|
||||
const preservedAlt = note.body.includes('alt="../../photo.jpg"');
|
||||
expect(preservedAlt).toBe(true);
|
||||
});
|
||||
it('should import non-empty directory', async function() {
|
||||
await fs.mkdirp(`${tempDir}/non-empty/non-empty`);
|
||||
await fs.writeFile(`${tempDir}/non-empty/non-empty/sample.md`, '# Sample');
|
||||
|
||||
await importNoteDirectory(`${tempDir}/non-empty`);
|
||||
const allFolders = await Folder.all();
|
||||
expect(allFolders.map((f: FolderEntity) => f.title).indexOf('non-empty')).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
it('should not import empty directory', async function() {
|
||||
await fs.mkdirp(`${tempDir}/empty/empty`);
|
||||
|
||||
await importNoteDirectory(`${tempDir}/empty`);
|
||||
const allFolders = await Folder.all();
|
||||
expect(allFolders.map((f: FolderEntity) => f.title).indexOf('empty')).toBe(-1);
|
||||
});
|
||||
it('should import directory with non-empty subdirectory', async function() {
|
||||
await fs.mkdirp(`${tempDir}/non-empty-subdir/non-empty-subdir/subdir-empty`);
|
||||
await fs.mkdirp(`${tempDir}/non-empty-subdir/non-empty-subdir/subdir-non-empty`);
|
||||
await fs.writeFile(`${tempDir}/non-empty-subdir/non-empty-subdir/subdir-non-empty/sample.md`, '# Sample');
|
||||
|
||||
await importNoteDirectory(`${tempDir}/non-empty-subdir`);
|
||||
const allFolders = await Folder.all();
|
||||
expect(allFolders.map((f: FolderEntity) => f.title).indexOf('non-empty-subdir')).toBeGreaterThanOrEqual(0);
|
||||
expect(allFolders.map((f: FolderEntity) => f.title).indexOf('subdir-empty')).toBe(-1);
|
||||
expect(allFolders.map((f: FolderEntity) => f.title).indexOf('subdir-non-empty')).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,13 +47,16 @@ export default class InteropService_Importer_Md extends InteropService_Importer_
|
||||
|
||||
async importDirectory(dirPath: string, parentFolderId: string) {
|
||||
console.info(`Import: ${dirPath}`);
|
||||
|
||||
const supportedFileExtension = this.metadata().fileExtensions;
|
||||
const stats = await shim.fsDriver().readDirStats(dirPath);
|
||||
for (let i = 0; i < stats.length; i++) {
|
||||
const stat = stats[i];
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
if (await this.isDirectoryEmpty(`${dirPath}/${stat.path}`)) {
|
||||
console.info(`Ignoring empty directory: ${stat.path}`);
|
||||
continue;
|
||||
}
|
||||
const folderTitle = await Folder.findUniqueItemTitle(basename(stat.path));
|
||||
const folder = await Folder.save({ title: folderTitle, parent_id: parentFolderId });
|
||||
await this.importDirectory(`${dirPath}/${basename(stat.path)}`, folder.id);
|
||||
@@ -63,6 +66,24 @@ export default class InteropService_Importer_Md extends InteropService_Importer_
|
||||
}
|
||||
}
|
||||
|
||||
private async isDirectoryEmpty(dirPath: string) {
|
||||
const supportedFileExtension = this.metadata().fileExtensions;
|
||||
const innerStats = await shim.fsDriver().readDirStats(dirPath);
|
||||
for (let i = 0; i < innerStats.length; i++) {
|
||||
const innerStat = innerStats[i];
|
||||
|
||||
if (innerStat.isDirectory()) {
|
||||
if (!(await this.isDirectoryEmpty(`${dirPath}/${innerStat.path}`))) {
|
||||
return false;
|
||||
}
|
||||
} else if (supportedFileExtension.indexOf(fileExtension(innerStat.path).toLowerCase()) >= 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
private trimAnchorLink(link: string) {
|
||||
if (link.indexOf('#') <= 0) return link;
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
//
|
||||
// If the userContentBaseUrl is an empty string, the baseUrl is returned instead.
|
||||
export default function(userId: string, baseUrl: string, userContentBaseUrl: string) {
|
||||
// Special case for development, because it's difficult to get wildcard domains working locally.
|
||||
if (userContentBaseUrl === 'http://joplincloud.local:22300') return 'http://joplincloud.local:22300';
|
||||
|
||||
if (userContentBaseUrl && baseUrl !== userContentBaseUrl) {
|
||||
if (!userId) throw new Error('User ID must be specified');
|
||||
const url = new URL(userContentBaseUrl);
|
||||
|
||||
@@ -45,6 +45,7 @@ export default function manifestFromObject(o: any): PluginManifest {
|
||||
homepage_url: getString('homepage_url', false),
|
||||
repository_url: getString('repository_url', false),
|
||||
keywords: getStrings('keywords', false),
|
||||
categories: getStrings('categories', false),
|
||||
permissions: permissions,
|
||||
|
||||
_recommended: getBoolean('_recommended', false, false),
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface PluginManifest {
|
||||
homepage_url?: string;
|
||||
repository_url?: string;
|
||||
keywords?: string[];
|
||||
categories?: string[];
|
||||
permissions?: PluginPermission[];
|
||||
|
||||
// Private keys
|
||||
|
||||
85
packages/lib/services/profileConfig/index.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { writeFile } from 'fs-extra';
|
||||
import { createNewProfile, getProfileFullPath, loadProfileConfig, saveProfileConfig } from '.';
|
||||
import { tempFilePath } from '../../testing/test-utils';
|
||||
import { defaultProfile, defaultProfileConfig, ProfileConfig } from './types';
|
||||
|
||||
describe('profileConfig/index', () => {
|
||||
|
||||
it('should load a default profile config', async () => {
|
||||
const filePath = tempFilePath('json');
|
||||
const config = await loadProfileConfig(filePath);
|
||||
expect(config).toEqual(defaultProfileConfig());
|
||||
});
|
||||
|
||||
it('should load a profile config', async () => {
|
||||
const filePath = tempFilePath('json');
|
||||
const config = {
|
||||
profiles: [
|
||||
{
|
||||
name: 'Testing',
|
||||
path: '.',
|
||||
},
|
||||
],
|
||||
};
|
||||
await writeFile(filePath, JSON.stringify(config), 'utf8');
|
||||
|
||||
const loadedConfig = await loadProfileConfig(filePath);
|
||||
|
||||
const expected: ProfileConfig = {
|
||||
version: 1,
|
||||
currentProfile: 0,
|
||||
profiles: [
|
||||
{
|
||||
name: 'Testing',
|
||||
path: '.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(loadedConfig).toEqual(expected);
|
||||
});
|
||||
|
||||
|
||||
it('should load a save a config', async () => {
|
||||
const filePath = tempFilePath('json');
|
||||
const config = defaultProfileConfig();
|
||||
await saveProfileConfig(filePath, config);
|
||||
|
||||
const loadedConfig = await loadProfileConfig(filePath);
|
||||
expect(config).toEqual(loadedConfig);
|
||||
});
|
||||
|
||||
it('should get a profile full path', async () => {
|
||||
const profile1 = {
|
||||
...defaultProfile(),
|
||||
path: 'profile-abcd',
|
||||
};
|
||||
|
||||
const profile2 = {
|
||||
...defaultProfile(),
|
||||
path: '.',
|
||||
};
|
||||
|
||||
const profile3 = {
|
||||
...defaultProfile(),
|
||||
path: 'profiles/pro/',
|
||||
};
|
||||
|
||||
expect(getProfileFullPath(profile1, '/test/root')).toBe('/test/root/profile-abcd');
|
||||
expect(getProfileFullPath(profile2, '/test/root')).toBe('/test/root');
|
||||
expect(getProfileFullPath(profile3, '/test/root')).toBe('/test/root/profiles/pro');
|
||||
});
|
||||
|
||||
it('should create a new profile', async () => {
|
||||
let config = defaultProfileConfig();
|
||||
config = createNewProfile(config, 'new profile 1');
|
||||
config = createNewProfile(config, 'new profile 2');
|
||||
|
||||
expect(config.profiles.length).toBe(3);
|
||||
expect(config.profiles[1].name).toBe('new profile 1');
|
||||
expect(config.profiles[2].name).toBe('new profile 2');
|
||||
|
||||
expect(config.profiles[1].path).not.toBe(config.profiles[2].path);
|
||||
});
|
||||
|
||||
});
|
||||
64
packages/lib/services/profileConfig/index.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { rtrimSlashes, trimSlashes } from '../../path-utils';
|
||||
import shim from '../../shim';
|
||||
import { defaultProfile, defaultProfileConfig, Profile, ProfileConfig } from './types';
|
||||
import { customAlphabet } from 'nanoid/non-secure';
|
||||
|
||||
export const loadProfileConfig = async (profileConfigPath: string): Promise<ProfileConfig> => {
|
||||
if (!(await shim.fsDriver().exists(profileConfigPath))) {
|
||||
return defaultProfileConfig();
|
||||
}
|
||||
|
||||
try {
|
||||
const configContent = await shim.fsDriver().readFile(profileConfigPath, 'utf8');
|
||||
const parsed = JSON.parse(configContent) as ProfileConfig;
|
||||
if (!parsed.profiles || !parsed.profiles.length) throw new Error(`Profile config should contain at least one profile: ${profileConfigPath}`);
|
||||
|
||||
const output: ProfileConfig = {
|
||||
...defaultProfileConfig(),
|
||||
...parsed,
|
||||
};
|
||||
|
||||
for (let i = 0; i < output.profiles.length; i++) {
|
||||
output.profiles[i] = {
|
||||
...defaultProfile(),
|
||||
...output.profiles[i],
|
||||
};
|
||||
}
|
||||
|
||||
if (output.currentProfile < 0 || output.currentProfile >= output.profiles.length) throw new Error(`Profile index out of range: ${output.currentProfile}`);
|
||||
return output;
|
||||
} catch (error) {
|
||||
error.message = `Could not parse profile configuration: ${profileConfigPath}: ${error.message}`;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const saveProfileConfig = async (profileConfigPath: string, config: ProfileConfig) => {
|
||||
await shim.fsDriver().writeFile(profileConfigPath, JSON.stringify(config, null, '\t'), 'utf8');
|
||||
};
|
||||
|
||||
export const getCurrentProfile = (config: ProfileConfig): Profile => {
|
||||
return { ...config.profiles[config.currentProfile] };
|
||||
};
|
||||
|
||||
export const getProfileFullPath = (profile: Profile, rootProfilePath: string): string => {
|
||||
let p = trimSlashes(profile.path);
|
||||
if (p === '.') p = '';
|
||||
return rtrimSlashes(`${rtrimSlashes(rootProfilePath)}/${p}`);
|
||||
};
|
||||
|
||||
const profileIdGenerator = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 8);
|
||||
|
||||
export const createNewProfile = (config: ProfileConfig, profileName: string) => {
|
||||
const newConfig = {
|
||||
...config,
|
||||
profiles: config.profiles.slice(),
|
||||
};
|
||||
|
||||
newConfig.profiles.push({
|
||||
name: profileName,
|
||||
path: `profile-${profileIdGenerator()}`,
|
||||
});
|
||||
|
||||
return newConfig;
|
||||
};
|
||||
16
packages/lib/services/profileConfig/initProfile.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { getCurrentProfile, getProfileFullPath, loadProfileConfig } from '.';
|
||||
import Setting from '../../models/Setting';
|
||||
|
||||
export default async (rootProfileDir: string) => {
|
||||
const profileConfig = await loadProfileConfig(`${rootProfileDir}/profiles.json`);
|
||||
const profileDir = getProfileFullPath(getCurrentProfile(profileConfig), rootProfileDir);
|
||||
const isSubProfile = profileConfig.currentProfile !== 0;
|
||||
Setting.setConstant('isSubProfile', isSubProfile);
|
||||
Setting.setConstant('rootProfileDir', rootProfileDir);
|
||||
Setting.setConstant('profileDir', profileDir);
|
||||
return {
|
||||
profileConfig,
|
||||
profileDir,
|
||||
isSubProfile,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import Setting from '../../models/Setting';
|
||||
|
||||
export default (rootSettings: Record<string, any>, subProfileSettings: Record<string, any>) => {
|
||||
const output: Record<string, any> = { ...subProfileSettings };
|
||||
|
||||
for (const k of Object.keys(output)) {
|
||||
const md = Setting.settingMetadata(k);
|
||||
if (md.isGlobal) {
|
||||
delete output[k];
|
||||
if (k in rootSettings) output[k] = rootSettings[k];
|
||||
}
|
||||
}
|
||||
|
||||
for (const k of Object.keys(rootSettings)) {
|
||||
const md = Setting.settingMetadata(k);
|
||||
if (md.isGlobal) {
|
||||
output[k] = rootSettings[k];
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import Setting from '../../models/Setting';
|
||||
import { SettingValues } from '../../models/settings/FileHandler';
|
||||
|
||||
export default (settings: SettingValues) => {
|
||||
const globalSettings: SettingValues = {};
|
||||
const localSettings: SettingValues = {};
|
||||
|
||||
for (const [k, v] of Object.entries(settings)) {
|
||||
const md = Setting.settingMetadata(k);
|
||||
|
||||
if (md.isGlobal) {
|
||||
globalSettings[k] = v;
|
||||
} else {
|
||||
localSettings[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
return { globalSettings, localSettings };
|
||||
};
|
||||
27
packages/lib/services/profileConfig/types.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export interface Profile {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ProfileConfig {
|
||||
version: number;
|
||||
currentProfile: number;
|
||||
profiles: Profile[];
|
||||
}
|
||||
|
||||
export const defaultProfile = (): Profile => {
|
||||
return {
|
||||
name: 'Default',
|
||||
path: '.',
|
||||
};
|
||||
};
|
||||
|
||||
export const defaultProfileConfig = (): ProfileConfig => {
|
||||
return {
|
||||
version: 1,
|
||||
currentProfile: 0,
|
||||
profiles: [defaultProfile()],
|
||||
};
|
||||
};
|
||||
|
||||
export type ProfileSwitchClickHandler = (profileIndex: number)=> void;
|
||||