You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-08-27 20:29:45 +02:00
Compare commits
163 Commits
android-v2
...
server_app
Author | SHA1 | Date | |
---|---|---|---|
|
047a5c0acf | ||
|
e0379464fc | ||
|
756e646615 | ||
|
d68c0bf5de | ||
|
fd57ec10a9 | ||
|
052ef1617f | ||
|
f776b68594 | ||
|
0d19f64734 | ||
|
fb5b73c582 | ||
|
e735dba5f5 | ||
|
7f3bab278f | ||
|
61ae86c11c | ||
|
8d7ecff4ed | ||
|
2a3f542cb9 | ||
|
f2860ed011 | ||
|
4b8a429252 | ||
|
1721be3072 | ||
|
3c00e96501 | ||
|
814b4d49d4 | ||
|
9974107941 | ||
|
5164e20f7c | ||
|
e9362c92f6 | ||
|
9a0ea2aa47 | ||
|
0117088f7f | ||
|
e96ee8f13e | ||
|
e404af0812 | ||
|
3f964fc8af | ||
|
134c36922d | ||
|
1686ae77f6 | ||
|
063fa8e92e | ||
|
88abebe044 | ||
|
c4f073cb77 | ||
|
2d8eced79d | ||
|
8176c78fb1 | ||
|
97e7313de4 | ||
|
b7289f0acb | ||
|
0c6222ad76 | ||
|
14d6336521 | ||
|
91c0372f16 | ||
|
b999222fe3 | ||
|
53f63f3e86 | ||
|
3ad36d4a56 | ||
|
1fb90dbbce | ||
|
2201a8865d | ||
|
17e80ef48f | ||
|
7ee8efb67f | ||
|
320fcc1c3e | ||
|
127fc3cb9a | ||
|
dd4ddd5d36 | ||
|
fa6491bf78 | ||
|
610631596d | ||
|
71cc77b30c | ||
|
765bbf5295 | ||
|
e9d3864022 | ||
|
631c2af353 | ||
|
b24f4d2a1d | ||
|
b6028a7014 | ||
|
166ff3866c | ||
|
af13ed6b95 | ||
|
b8d5a8b025 | ||
|
425a17e777 | ||
|
8d5c538e18 | ||
|
1c2d4306f9 | ||
|
99270bc1ea | ||
|
6130fcc5a8 | ||
|
614359c267 | ||
|
f438fcb50d | ||
|
bff42b8a82 | ||
|
c5a9208594 | ||
|
a43be838b5 | ||
|
9ae31583af | ||
|
0fa141881b | ||
|
6152f19712 | ||
|
2ade4c7951 | ||
|
85c887d004 | ||
|
47d49935bf | ||
|
14d1d8ca72 | ||
|
e903b17a3f | ||
|
10138260d9 | ||
|
6ddef009aa | ||
|
425d8acb2d | ||
|
11b00ccb70 | ||
|
c9288e117f | ||
|
da2bc801a6 | ||
|
95a9dc0de4 | ||
|
f437b8ee60 | ||
|
3e82fdab04 | ||
|
9555875b53 | ||
|
9c9d30e73f | ||
|
9bb920a98a | ||
|
8e8ad3193c | ||
|
5eb4193451 | ||
|
2d373f0c31 | ||
|
e78d3cfd3d | ||
|
21b4ce97dc | ||
|
2e9192d10a | ||
|
5820261577 | ||
|
d86a4f7437 | ||
|
13d77e44cf | ||
|
5895c8364f | ||
|
88183a4660 | ||
|
c6a1aaee72 | ||
|
4f8539fceb | ||
|
6a05349412 | ||
|
81e71e08be | ||
|
50bdbc165a | ||
|
9261d9c36b | ||
|
a19549573d | ||
|
e4435f52fc | ||
|
090518bb76 | ||
|
856f45507c | ||
|
5dc4551d60 | ||
|
a3f59691e5 | ||
|
e0aa8bd7fb | ||
|
9333b37147 | ||
|
bcbcf583c1 | ||
|
189aef7f91 | ||
|
1286716007 | ||
|
003f60aff1 | ||
|
9373aec3cf | ||
|
e89836ff9d | ||
|
c7996f66ee | ||
|
b4bac8ff31 | ||
|
c7c8631333 | ||
|
48cda86f54 | ||
|
fe54b383ca | ||
|
e904f9d90a | ||
|
41a3f9c052 | ||
|
c40b4eab17 | ||
|
5a186146ab | ||
|
d389886394 | ||
|
09930bc8a6 | ||
|
67dcd41bf7 | ||
|
f2bf1375bf | ||
|
7bb00e1338 | ||
|
8562909a4f | ||
|
48a499f741 | ||
|
4702976ceb | ||
|
2006288108 | ||
|
8446693e91 | ||
|
b3579d70e9 | ||
|
73ce9b2443 | ||
|
15f5b90211 | ||
|
0011b570aa | ||
|
aeb3c4a98d | ||
|
58a464d040 | ||
|
8e13ccb665 | ||
|
6dd14ff04b | ||
|
2022b5bc48 | ||
|
7ade9b2948 | ||
|
4157dad9f1 | ||
|
a088061de9 | ||
|
439d29387f | ||
|
2f15e4db59 | ||
|
0b37e99132 | ||
|
6d41787a29 | ||
|
28fc0374c5 | ||
|
726ee4a574 | ||
|
25e32226ef | ||
|
9efdbf9854 | ||
|
09c95f10f4 | ||
|
a6453af3e5 | ||
|
b8c8178b26 |
@@ -18,6 +18,7 @@ packages/turndown-plugin-gfm/
|
|||||||
node_modules/
|
node_modules/
|
||||||
packages/lib/lib/lib.js
|
packages/lib/lib/lib.js
|
||||||
packages/lib/locales/index.js
|
packages/lib/locales/index.js
|
||||||
|
packages/lib/services/database/types.ts
|
||||||
packages/app-cli/build
|
packages/app-cli/build
|
||||||
packages/app-cli/build/
|
packages/app-cli/build/
|
||||||
packages/app-cli/locales
|
packages/app-cli/locales
|
||||||
@@ -112,6 +113,9 @@ packages/app-cli/tests/Synchronizer.tools.js.map
|
|||||||
packages/app-cli/tests/dateTimeFormats.d.ts
|
packages/app-cli/tests/dateTimeFormats.d.ts
|
||||||
packages/app-cli/tests/dateTimeFormats.js
|
packages/app-cli/tests/dateTimeFormats.js
|
||||||
packages/app-cli/tests/dateTimeFormats.js.map
|
packages/app-cli/tests/dateTimeFormats.js.map
|
||||||
|
packages/app-cli/tests/file-api-driver.d.ts
|
||||||
|
packages/app-cli/tests/file-api-driver.js
|
||||||
|
packages/app-cli/tests/file-api-driver.js.map
|
||||||
packages/app-cli/tests/fsDriver.d.ts
|
packages/app-cli/tests/fsDriver.d.ts
|
||||||
packages/app-cli/tests/fsDriver.js
|
packages/app-cli/tests/fsDriver.js
|
||||||
packages/app-cli/tests/fsDriver.js.map
|
packages/app-cli/tests/fsDriver.js.map
|
||||||
@@ -121,6 +125,9 @@ packages/app-cli/tests/htmlUtils.js.map
|
|||||||
packages/app-cli/tests/models_Folder.d.ts
|
packages/app-cli/tests/models_Folder.d.ts
|
||||||
packages/app-cli/tests/models_Folder.js
|
packages/app-cli/tests/models_Folder.js
|
||||||
packages/app-cli/tests/models_Folder.js.map
|
packages/app-cli/tests/models_Folder.js.map
|
||||||
|
packages/app-cli/tests/models_Folder.sharing.d.ts
|
||||||
|
packages/app-cli/tests/models_Folder.sharing.js
|
||||||
|
packages/app-cli/tests/models_Folder.sharing.js.map
|
||||||
packages/app-cli/tests/models_Note.d.ts
|
packages/app-cli/tests/models_Note.d.ts
|
||||||
packages/app-cli/tests/models_Note.js
|
packages/app-cli/tests/models_Note.js
|
||||||
packages/app-cli/tests/models_Note.js.map
|
packages/app-cli/tests/models_Note.js.map
|
||||||
@@ -250,6 +257,15 @@ packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.js.ma
|
|||||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.d.ts
|
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.d.ts
|
||||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.js
|
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.js
|
||||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.js.map
|
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.js.map
|
||||||
|
packages/app-desktop/gui/Dialog.d.ts
|
||||||
|
packages/app-desktop/gui/Dialog.js
|
||||||
|
packages/app-desktop/gui/Dialog.js.map
|
||||||
|
packages/app-desktop/gui/DialogButtonRow.d.ts
|
||||||
|
packages/app-desktop/gui/DialogButtonRow.js
|
||||||
|
packages/app-desktop/gui/DialogButtonRow.js.map
|
||||||
|
packages/app-desktop/gui/DialogTitle.d.ts
|
||||||
|
packages/app-desktop/gui/DialogTitle.js
|
||||||
|
packages/app-desktop/gui/DialogTitle.js.map
|
||||||
packages/app-desktop/gui/DropboxLoginScreen.d.ts
|
packages/app-desktop/gui/DropboxLoginScreen.d.ts
|
||||||
packages/app-desktop/gui/DropboxLoginScreen.js
|
packages/app-desktop/gui/DropboxLoginScreen.js
|
||||||
packages/app-desktop/gui/DropboxLoginScreen.js.map
|
packages/app-desktop/gui/DropboxLoginScreen.js.map
|
||||||
@@ -337,6 +353,9 @@ packages/app-desktop/gui/MainScreen/commands/showNoteContentProperties.js.map
|
|||||||
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.d.ts
|
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.d.ts
|
||||||
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.js
|
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.js.map
|
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.js.map
|
||||||
|
packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.d.ts
|
||||||
|
packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.js
|
||||||
|
packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.js.map
|
||||||
packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.d.ts
|
packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.d.ts
|
||||||
packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.js
|
packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.js.map
|
packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.js.map
|
||||||
@@ -589,6 +608,9 @@ packages/app-desktop/gui/Root_UpgradeSyncTarget.js.map
|
|||||||
packages/app-desktop/gui/SearchBar/SearchBar.d.ts
|
packages/app-desktop/gui/SearchBar/SearchBar.d.ts
|
||||||
packages/app-desktop/gui/SearchBar/SearchBar.js
|
packages/app-desktop/gui/SearchBar/SearchBar.js
|
||||||
packages/app-desktop/gui/SearchBar/SearchBar.js.map
|
packages/app-desktop/gui/SearchBar/SearchBar.js.map
|
||||||
|
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.d.ts
|
||||||
|
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js
|
||||||
|
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js.map
|
||||||
packages/app-desktop/gui/ShareNoteDialog.d.ts
|
packages/app-desktop/gui/ShareNoteDialog.d.ts
|
||||||
packages/app-desktop/gui/ShareNoteDialog.js
|
packages/app-desktop/gui/ShareNoteDialog.js
|
||||||
packages/app-desktop/gui/ShareNoteDialog.js.map
|
packages/app-desktop/gui/ShareNoteDialog.js.map
|
||||||
@@ -646,9 +668,15 @@ packages/app-desktop/gui/lib/ToggleButton/ToggleButton.js.map
|
|||||||
packages/app-desktop/gui/menuCommandNames.d.ts
|
packages/app-desktop/gui/menuCommandNames.d.ts
|
||||||
packages/app-desktop/gui/menuCommandNames.js
|
packages/app-desktop/gui/menuCommandNames.js
|
||||||
packages/app-desktop/gui/menuCommandNames.js.map
|
packages/app-desktop/gui/menuCommandNames.js.map
|
||||||
|
packages/app-desktop/gui/style/StyledFormLabel.d.ts
|
||||||
|
packages/app-desktop/gui/style/StyledFormLabel.js
|
||||||
|
packages/app-desktop/gui/style/StyledFormLabel.js.map
|
||||||
packages/app-desktop/gui/style/StyledInput.d.ts
|
packages/app-desktop/gui/style/StyledInput.d.ts
|
||||||
packages/app-desktop/gui/style/StyledInput.js
|
packages/app-desktop/gui/style/StyledInput.js
|
||||||
packages/app-desktop/gui/style/StyledInput.js.map
|
packages/app-desktop/gui/style/StyledInput.js.map
|
||||||
|
packages/app-desktop/gui/style/StyledMessage.d.ts
|
||||||
|
packages/app-desktop/gui/style/StyledMessage.js
|
||||||
|
packages/app-desktop/gui/style/StyledMessage.js.map
|
||||||
packages/app-desktop/gui/style/StyledTextInput.d.ts
|
packages/app-desktop/gui/style/StyledTextInput.d.ts
|
||||||
packages/app-desktop/gui/style/StyledTextInput.js
|
packages/app-desktop/gui/style/StyledTextInput.js
|
||||||
packages/app-desktop/gui/style/StyledTextInput.js.map
|
packages/app-desktop/gui/style/StyledTextInput.js.map
|
||||||
@@ -847,6 +875,9 @@ packages/lib/InMemoryCache.js.map
|
|||||||
packages/lib/JoplinDatabase.d.ts
|
packages/lib/JoplinDatabase.d.ts
|
||||||
packages/lib/JoplinDatabase.js
|
packages/lib/JoplinDatabase.js
|
||||||
packages/lib/JoplinDatabase.js.map
|
packages/lib/JoplinDatabase.js.map
|
||||||
|
packages/lib/JoplinError.d.ts
|
||||||
|
packages/lib/JoplinError.js
|
||||||
|
packages/lib/JoplinError.js.map
|
||||||
packages/lib/JoplinServerApi.d.ts
|
packages/lib/JoplinServerApi.d.ts
|
||||||
packages/lib/JoplinServerApi.js
|
packages/lib/JoplinServerApi.js
|
||||||
packages/lib/JoplinServerApi.js.map
|
packages/lib/JoplinServerApi.js.map
|
||||||
@@ -877,6 +908,9 @@ packages/lib/commands/synchronize.js.map
|
|||||||
packages/lib/database.d.ts
|
packages/lib/database.d.ts
|
||||||
packages/lib/database.js
|
packages/lib/database.js
|
||||||
packages/lib/database.js.map
|
packages/lib/database.js.map
|
||||||
|
packages/lib/debug/DebugService.d.ts
|
||||||
|
packages/lib/debug/DebugService.js
|
||||||
|
packages/lib/debug/DebugService.js.map
|
||||||
packages/lib/dummy.test.d.ts
|
packages/lib/dummy.test.d.ts
|
||||||
packages/lib/dummy.test.js
|
packages/lib/dummy.test.js
|
||||||
packages/lib/dummy.test.js.map
|
packages/lib/dummy.test.js.map
|
||||||
@@ -1333,6 +1367,12 @@ packages/lib/services/searchengine/filterParser.js.map
|
|||||||
packages/lib/services/searchengine/queryBuilder.d.ts
|
packages/lib/services/searchengine/queryBuilder.d.ts
|
||||||
packages/lib/services/searchengine/queryBuilder.js
|
packages/lib/services/searchengine/queryBuilder.js
|
||||||
packages/lib/services/searchengine/queryBuilder.js.map
|
packages/lib/services/searchengine/queryBuilder.js.map
|
||||||
|
packages/lib/services/share/ShareService.d.ts
|
||||||
|
packages/lib/services/share/ShareService.js
|
||||||
|
packages/lib/services/share/ShareService.js.map
|
||||||
|
packages/lib/services/share/reducer.d.ts
|
||||||
|
packages/lib/services/share/reducer.js
|
||||||
|
packages/lib/services/share/reducer.js.map
|
||||||
packages/lib/services/spellChecker/SpellCheckerService.d.ts
|
packages/lib/services/spellChecker/SpellCheckerService.d.ts
|
||||||
packages/lib/services/spellChecker/SpellCheckerService.js
|
packages/lib/services/spellChecker/SpellCheckerService.js
|
||||||
packages/lib/services/spellChecker/SpellCheckerService.js.map
|
packages/lib/services/spellChecker/SpellCheckerService.js.map
|
||||||
|
39
.gitignore
vendored
39
.gitignore
vendored
@@ -99,6 +99,9 @@ packages/app-cli/tests/Synchronizer.tools.js.map
|
|||||||
packages/app-cli/tests/dateTimeFormats.d.ts
|
packages/app-cli/tests/dateTimeFormats.d.ts
|
||||||
packages/app-cli/tests/dateTimeFormats.js
|
packages/app-cli/tests/dateTimeFormats.js
|
||||||
packages/app-cli/tests/dateTimeFormats.js.map
|
packages/app-cli/tests/dateTimeFormats.js.map
|
||||||
|
packages/app-cli/tests/file-api-driver.d.ts
|
||||||
|
packages/app-cli/tests/file-api-driver.js
|
||||||
|
packages/app-cli/tests/file-api-driver.js.map
|
||||||
packages/app-cli/tests/fsDriver.d.ts
|
packages/app-cli/tests/fsDriver.d.ts
|
||||||
packages/app-cli/tests/fsDriver.js
|
packages/app-cli/tests/fsDriver.js
|
||||||
packages/app-cli/tests/fsDriver.js.map
|
packages/app-cli/tests/fsDriver.js.map
|
||||||
@@ -108,6 +111,9 @@ packages/app-cli/tests/htmlUtils.js.map
|
|||||||
packages/app-cli/tests/models_Folder.d.ts
|
packages/app-cli/tests/models_Folder.d.ts
|
||||||
packages/app-cli/tests/models_Folder.js
|
packages/app-cli/tests/models_Folder.js
|
||||||
packages/app-cli/tests/models_Folder.js.map
|
packages/app-cli/tests/models_Folder.js.map
|
||||||
|
packages/app-cli/tests/models_Folder.sharing.d.ts
|
||||||
|
packages/app-cli/tests/models_Folder.sharing.js
|
||||||
|
packages/app-cli/tests/models_Folder.sharing.js.map
|
||||||
packages/app-cli/tests/models_Note.d.ts
|
packages/app-cli/tests/models_Note.d.ts
|
||||||
packages/app-cli/tests/models_Note.js
|
packages/app-cli/tests/models_Note.js
|
||||||
packages/app-cli/tests/models_Note.js.map
|
packages/app-cli/tests/models_Note.js.map
|
||||||
@@ -237,6 +243,15 @@ packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.js.ma
|
|||||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.d.ts
|
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.d.ts
|
||||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.js
|
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.js
|
||||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.js.map
|
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.js.map
|
||||||
|
packages/app-desktop/gui/Dialog.d.ts
|
||||||
|
packages/app-desktop/gui/Dialog.js
|
||||||
|
packages/app-desktop/gui/Dialog.js.map
|
||||||
|
packages/app-desktop/gui/DialogButtonRow.d.ts
|
||||||
|
packages/app-desktop/gui/DialogButtonRow.js
|
||||||
|
packages/app-desktop/gui/DialogButtonRow.js.map
|
||||||
|
packages/app-desktop/gui/DialogTitle.d.ts
|
||||||
|
packages/app-desktop/gui/DialogTitle.js
|
||||||
|
packages/app-desktop/gui/DialogTitle.js.map
|
||||||
packages/app-desktop/gui/DropboxLoginScreen.d.ts
|
packages/app-desktop/gui/DropboxLoginScreen.d.ts
|
||||||
packages/app-desktop/gui/DropboxLoginScreen.js
|
packages/app-desktop/gui/DropboxLoginScreen.js
|
||||||
packages/app-desktop/gui/DropboxLoginScreen.js.map
|
packages/app-desktop/gui/DropboxLoginScreen.js.map
|
||||||
@@ -324,6 +339,9 @@ packages/app-desktop/gui/MainScreen/commands/showNoteContentProperties.js.map
|
|||||||
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.d.ts
|
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.d.ts
|
||||||
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.js
|
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.js.map
|
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.js.map
|
||||||
|
packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.d.ts
|
||||||
|
packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.js
|
||||||
|
packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.js.map
|
||||||
packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.d.ts
|
packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.d.ts
|
||||||
packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.js
|
packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.js.map
|
packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.js.map
|
||||||
@@ -576,6 +594,9 @@ packages/app-desktop/gui/Root_UpgradeSyncTarget.js.map
|
|||||||
packages/app-desktop/gui/SearchBar/SearchBar.d.ts
|
packages/app-desktop/gui/SearchBar/SearchBar.d.ts
|
||||||
packages/app-desktop/gui/SearchBar/SearchBar.js
|
packages/app-desktop/gui/SearchBar/SearchBar.js
|
||||||
packages/app-desktop/gui/SearchBar/SearchBar.js.map
|
packages/app-desktop/gui/SearchBar/SearchBar.js.map
|
||||||
|
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.d.ts
|
||||||
|
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js
|
||||||
|
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js.map
|
||||||
packages/app-desktop/gui/ShareNoteDialog.d.ts
|
packages/app-desktop/gui/ShareNoteDialog.d.ts
|
||||||
packages/app-desktop/gui/ShareNoteDialog.js
|
packages/app-desktop/gui/ShareNoteDialog.js
|
||||||
packages/app-desktop/gui/ShareNoteDialog.js.map
|
packages/app-desktop/gui/ShareNoteDialog.js.map
|
||||||
@@ -633,9 +654,15 @@ packages/app-desktop/gui/lib/ToggleButton/ToggleButton.js.map
|
|||||||
packages/app-desktop/gui/menuCommandNames.d.ts
|
packages/app-desktop/gui/menuCommandNames.d.ts
|
||||||
packages/app-desktop/gui/menuCommandNames.js
|
packages/app-desktop/gui/menuCommandNames.js
|
||||||
packages/app-desktop/gui/menuCommandNames.js.map
|
packages/app-desktop/gui/menuCommandNames.js.map
|
||||||
|
packages/app-desktop/gui/style/StyledFormLabel.d.ts
|
||||||
|
packages/app-desktop/gui/style/StyledFormLabel.js
|
||||||
|
packages/app-desktop/gui/style/StyledFormLabel.js.map
|
||||||
packages/app-desktop/gui/style/StyledInput.d.ts
|
packages/app-desktop/gui/style/StyledInput.d.ts
|
||||||
packages/app-desktop/gui/style/StyledInput.js
|
packages/app-desktop/gui/style/StyledInput.js
|
||||||
packages/app-desktop/gui/style/StyledInput.js.map
|
packages/app-desktop/gui/style/StyledInput.js.map
|
||||||
|
packages/app-desktop/gui/style/StyledMessage.d.ts
|
||||||
|
packages/app-desktop/gui/style/StyledMessage.js
|
||||||
|
packages/app-desktop/gui/style/StyledMessage.js.map
|
||||||
packages/app-desktop/gui/style/StyledTextInput.d.ts
|
packages/app-desktop/gui/style/StyledTextInput.d.ts
|
||||||
packages/app-desktop/gui/style/StyledTextInput.js
|
packages/app-desktop/gui/style/StyledTextInput.js
|
||||||
packages/app-desktop/gui/style/StyledTextInput.js.map
|
packages/app-desktop/gui/style/StyledTextInput.js.map
|
||||||
@@ -834,6 +861,9 @@ packages/lib/InMemoryCache.js.map
|
|||||||
packages/lib/JoplinDatabase.d.ts
|
packages/lib/JoplinDatabase.d.ts
|
||||||
packages/lib/JoplinDatabase.js
|
packages/lib/JoplinDatabase.js
|
||||||
packages/lib/JoplinDatabase.js.map
|
packages/lib/JoplinDatabase.js.map
|
||||||
|
packages/lib/JoplinError.d.ts
|
||||||
|
packages/lib/JoplinError.js
|
||||||
|
packages/lib/JoplinError.js.map
|
||||||
packages/lib/JoplinServerApi.d.ts
|
packages/lib/JoplinServerApi.d.ts
|
||||||
packages/lib/JoplinServerApi.js
|
packages/lib/JoplinServerApi.js
|
||||||
packages/lib/JoplinServerApi.js.map
|
packages/lib/JoplinServerApi.js.map
|
||||||
@@ -864,6 +894,9 @@ packages/lib/commands/synchronize.js.map
|
|||||||
packages/lib/database.d.ts
|
packages/lib/database.d.ts
|
||||||
packages/lib/database.js
|
packages/lib/database.js
|
||||||
packages/lib/database.js.map
|
packages/lib/database.js.map
|
||||||
|
packages/lib/debug/DebugService.d.ts
|
||||||
|
packages/lib/debug/DebugService.js
|
||||||
|
packages/lib/debug/DebugService.js.map
|
||||||
packages/lib/dummy.test.d.ts
|
packages/lib/dummy.test.d.ts
|
||||||
packages/lib/dummy.test.js
|
packages/lib/dummy.test.js
|
||||||
packages/lib/dummy.test.js.map
|
packages/lib/dummy.test.js.map
|
||||||
@@ -1320,6 +1353,12 @@ packages/lib/services/searchengine/filterParser.js.map
|
|||||||
packages/lib/services/searchengine/queryBuilder.d.ts
|
packages/lib/services/searchengine/queryBuilder.d.ts
|
||||||
packages/lib/services/searchengine/queryBuilder.js
|
packages/lib/services/searchengine/queryBuilder.js
|
||||||
packages/lib/services/searchengine/queryBuilder.js.map
|
packages/lib/services/searchengine/queryBuilder.js.map
|
||||||
|
packages/lib/services/share/ShareService.d.ts
|
||||||
|
packages/lib/services/share/ShareService.js
|
||||||
|
packages/lib/services/share/ShareService.js.map
|
||||||
|
packages/lib/services/share/reducer.d.ts
|
||||||
|
packages/lib/services/share/reducer.js
|
||||||
|
packages/lib/services/share/reducer.js.map
|
||||||
packages/lib/services/spellChecker/SpellCheckerService.d.ts
|
packages/lib/services/spellChecker/SpellCheckerService.d.ts
|
||||||
packages/lib/services/spellChecker/SpellCheckerService.js
|
packages/lib/services/spellChecker/SpellCheckerService.js
|
||||||
packages/lib/services/spellChecker/SpellCheckerService.js.map
|
packages/lib/services/spellChecker/SpellCheckerService.js.map
|
||||||
|
12
LICENSE
12
LICENSE
@@ -1,3 +1,15 @@
|
|||||||
|
All code in this repository is licensed under the MIT License **unless a
|
||||||
|
directory contains a LICENSE file**, in which case that LICENSE file applies to
|
||||||
|
the code in that sub-directory.
|
||||||
|
|
||||||
|
For example, packages/fork-sax contains a ISC LICENSE file, thus all files
|
||||||
|
under the packages/fork-sax directory are licensed under ISC.
|
||||||
|
|
||||||
|
For example, packages/app-cli does NOT contain a LICENSE file, thus all files
|
||||||
|
under that directory are licensed under the default license, which is MIT.
|
||||||
|
|
||||||
|
* * *
|
||||||
|
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2016-2020 Laurent Cozic
|
Copyright (c) 2016-2020 Laurent Cozic
|
||||||
|
@@ -35,7 +35,6 @@ module.exports = {
|
|||||||
'<rootDir>/build/',
|
'<rootDir>/build/',
|
||||||
'<rootDir>/tests/test-utils.js',
|
'<rootDir>/tests/test-utils.js',
|
||||||
'<rootDir>/tests/test-utils-synchronizer.js',
|
'<rootDir>/tests/test-utils-synchronizer.js',
|
||||||
'<rootDir>/tests/file_api_driver.js',
|
|
||||||
'<rootDir>/tests/tmp/',
|
'<rootDir>/tests/tmp/',
|
||||||
'<rootDir>/tests/test data/',
|
'<rootDir>/tests/test data/',
|
||||||
],
|
],
|
||||||
|
@@ -9,6 +9,7 @@ import Resource from '@joplin/lib/models/Resource';
|
|||||||
import ResourceFetcher from '@joplin/lib/services/ResourceFetcher';
|
import ResourceFetcher from '@joplin/lib/services/ResourceFetcher';
|
||||||
import MasterKey from '@joplin/lib/models/MasterKey';
|
import MasterKey from '@joplin/lib/models/MasterKey';
|
||||||
import BaseItem from '@joplin/lib/models/BaseItem';
|
import BaseItem from '@joplin/lib/models/BaseItem';
|
||||||
|
import { createFolderTree } from './test-utils';
|
||||||
|
|
||||||
let insideBeforeEach = false;
|
let insideBeforeEach = false;
|
||||||
|
|
||||||
@@ -361,13 +362,26 @@ describe('Synchronizer.e2ee', function() {
|
|||||||
expect((await decryptionWorker().decryptionDisabledItems()).length).toBe(0);
|
expect((await decryptionWorker().decryptionDisabledItems()).length).toBe(0);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it('should not encrypt notes that are shared', (async () => {
|
it('should not encrypt notes that are shared by link', (async () => {
|
||||||
Setting.setValue('encryption.enabled', true);
|
Setting.setValue('encryption.enabled', true);
|
||||||
await loadEncryptionMasterKey();
|
await loadEncryptionMasterKey();
|
||||||
|
|
||||||
const folder1 = await Folder.save({ title: 'folder1' });
|
await createFolderTree('', [
|
||||||
const note1 = await Note.save({ title: 'un', parent_id: folder1.id });
|
{
|
||||||
let note2 = await Note.save({ title: 'deux', parent_id: folder1.id });
|
title: 'folder1',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: 'un',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'deux',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const note1 = await Note.loadByTitle('un');
|
||||||
|
let note2 = await Note.loadByTitle('deux');
|
||||||
await synchronizerStart();
|
await synchronizerStart();
|
||||||
|
|
||||||
await switchClient(2);
|
await switchClient(2);
|
||||||
@@ -400,4 +414,61 @@ describe('Synchronizer.e2ee', function() {
|
|||||||
expect(note1_2.title).toBe('');
|
expect(note1_2.title).toBe('');
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
it('should not encrypt items that are shared by folder', (async () => {
|
||||||
|
Setting.setValue('encryption.enabled', true);
|
||||||
|
await loadEncryptionMasterKey();
|
||||||
|
|
||||||
|
const folder1 = await createFolderTree('', [
|
||||||
|
{
|
||||||
|
title: 'folder1',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: 'note1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'folder2',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: 'note2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await synchronizerStart();
|
||||||
|
|
||||||
|
await switchClient(2);
|
||||||
|
|
||||||
|
await synchronizerStart();
|
||||||
|
|
||||||
|
await switchClient(1);
|
||||||
|
|
||||||
|
// Simulate that the folder has been shared
|
||||||
|
await Folder.save({ id: folder1.id, share_id: 'abcd' });
|
||||||
|
|
||||||
|
await synchronizerStart();
|
||||||
|
|
||||||
|
await switchClient(2);
|
||||||
|
|
||||||
|
await synchronizerStart();
|
||||||
|
|
||||||
|
// The shared items should be decrypted
|
||||||
|
{
|
||||||
|
const folder1 = await Folder.loadByTitle('folder1');
|
||||||
|
const note1 = await Note.loadByTitle('note1');
|
||||||
|
expect(folder1.title).toBe('folder1');
|
||||||
|
expect(note1.title).toBe('note1');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The non-shared items should be encrypted
|
||||||
|
{
|
||||||
|
const folder2 = await Folder.loadByTitle('folder2');
|
||||||
|
const note2 = await Note.loadByTitle('note2');
|
||||||
|
expect(folder2).toBeFalsy();
|
||||||
|
expect(note2).toBeFalsy();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
});
|
});
|
||||||
|
290
packages/app-cli/tests/Synchronizer.share.js
Normal file
290
packages/app-cli/tests/Synchronizer.share.js
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
'use strict';
|
||||||
|
const __awaiter = (this && this.__awaiter) || function(thisArg, _arguments, P, generator) {
|
||||||
|
function adopt(value) { return value instanceof P ? value : new P(function(resolve) { resolve(value); }); }
|
||||||
|
return new (P || (P = Promise))(function(resolve, reject) {
|
||||||
|
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||||
|
function rejected(value) { try { step(generator['throw'](value)); } catch (e) { reject(e); } }
|
||||||
|
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||||
|
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, '__esModule', { value: true });
|
||||||
|
const Setting_1 = require('@joplin/lib/models/Setting');
|
||||||
|
const test_utils_synchronizer_1 = require('./test-utils-synchronizer');
|
||||||
|
const { syncTargetName, afterAllCleanUp, synchronizerStart, setupDatabaseAndSynchronizer, synchronizer, sleep, switchClient, syncTargetId, fileApi } = require('./test-utils.js');
|
||||||
|
const Folder_1 = require('@joplin/lib/models/Folder');
|
||||||
|
const Note_1 = require('@joplin/lib/models/Note');
|
||||||
|
const BaseItem_1 = require('@joplin/lib/models/BaseItem');
|
||||||
|
const WelcomeUtils = require('@joplin/lib/WelcomeUtils');
|
||||||
|
describe('Synchronizer.basics', function() {
|
||||||
|
beforeEach((done) => __awaiter(this, void 0, void 0, function* () {
|
||||||
|
yield setupDatabaseAndSynchronizer(1);
|
||||||
|
yield setupDatabaseAndSynchronizer(2);
|
||||||
|
yield switchClient(1);
|
||||||
|
done();
|
||||||
|
}));
|
||||||
|
afterAll(() => __awaiter(this, void 0, void 0, function* () {
|
||||||
|
yield afterAllCleanUp();
|
||||||
|
}));
|
||||||
|
it('should create remote items', (() => __awaiter(this, void 0, void 0, function* () {
|
||||||
|
const folder = yield Folder_1.default.save({ title: 'folder1' });
|
||||||
|
yield Note_1.default.save({ title: 'un', parent_id: folder.id });
|
||||||
|
const all = yield test_utils_synchronizer_1.allNotesFolders();
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield test_utils_synchronizer_1.localNotesFoldersSameAsRemote(all, expect);
|
||||||
|
})));
|
||||||
|
it('should update remote items', (() => __awaiter(this, void 0, void 0, function* () {
|
||||||
|
const folder = yield Folder_1.default.save({ title: 'folder1' });
|
||||||
|
const note = yield Note_1.default.save({ title: 'un', parent_id: folder.id });
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield Note_1.default.save({ title: 'un UPDATE', id: note.id });
|
||||||
|
const all = yield test_utils_synchronizer_1.allNotesFolders();
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield test_utils_synchronizer_1.localNotesFoldersSameAsRemote(all, expect);
|
||||||
|
})));
|
||||||
|
it('should create local items', (() => __awaiter(this, void 0, void 0, function* () {
|
||||||
|
const folder = yield Folder_1.default.save({ title: 'folder1' });
|
||||||
|
yield Note_1.default.save({ title: 'un', parent_id: folder.id });
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield switchClient(2);
|
||||||
|
yield synchronizerStart();
|
||||||
|
const all = yield test_utils_synchronizer_1.allNotesFolders();
|
||||||
|
yield test_utils_synchronizer_1.localNotesFoldersSameAsRemote(all, expect);
|
||||||
|
})));
|
||||||
|
it('should update local items', (() => __awaiter(this, void 0, void 0, function* () {
|
||||||
|
const folder1 = yield Folder_1.default.save({ title: 'folder1' });
|
||||||
|
const note1 = yield Note_1.default.save({ title: 'un', parent_id: folder1.id });
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield switchClient(2);
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield sleep(0.1);
|
||||||
|
let note2 = yield Note_1.default.load(note1.id);
|
||||||
|
note2.title = 'Updated on client 2';
|
||||||
|
yield Note_1.default.save(note2);
|
||||||
|
note2 = yield Note_1.default.load(note2.id);
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield switchClient(1);
|
||||||
|
yield synchronizerStart();
|
||||||
|
const all = yield test_utils_synchronizer_1.allNotesFolders();
|
||||||
|
yield test_utils_synchronizer_1.localNotesFoldersSameAsRemote(all, expect);
|
||||||
|
})));
|
||||||
|
it('should delete remote notes', (() => __awaiter(this, void 0, void 0, function* () {
|
||||||
|
const folder1 = yield Folder_1.default.save({ title: 'folder1' });
|
||||||
|
const note1 = yield Note_1.default.save({ title: 'un', parent_id: folder1.id });
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield switchClient(2);
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield sleep(0.1);
|
||||||
|
yield Note_1.default.delete(note1.id);
|
||||||
|
yield synchronizerStart();
|
||||||
|
const remotes = yield test_utils_synchronizer_1.remoteNotesAndFolders();
|
||||||
|
expect(remotes.length).toBe(1);
|
||||||
|
expect(remotes[0].id).toBe(folder1.id);
|
||||||
|
const deletedItems = yield BaseItem_1.default.deletedItems(syncTargetId());
|
||||||
|
expect(deletedItems.length).toBe(0);
|
||||||
|
})));
|
||||||
|
it('should not created deleted_items entries for items deleted via sync', (() => __awaiter(this, void 0, void 0, function* () {
|
||||||
|
const folder1 = yield Folder_1.default.save({ title: 'folder1' });
|
||||||
|
yield Note_1.default.save({ title: 'un', parent_id: folder1.id });
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield switchClient(2);
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield Folder_1.default.delete(folder1.id);
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield switchClient(1);
|
||||||
|
yield synchronizerStart();
|
||||||
|
const deletedItems = yield BaseItem_1.default.deletedItems(syncTargetId());
|
||||||
|
expect(deletedItems.length).toBe(0);
|
||||||
|
})));
|
||||||
|
it('should delete local notes', (() => __awaiter(this, void 0, void 0, function* () {
|
||||||
|
const folder1 = yield Folder_1.default.save({ title: 'folder1' });
|
||||||
|
const note1 = yield Note_1.default.save({ title: 'un', parent_id: folder1.id });
|
||||||
|
const note2 = yield Note_1.default.save({ title: 'deux', parent_id: folder1.id });
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield switchClient(2);
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield Note_1.default.delete(note1.id);
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield switchClient(1);
|
||||||
|
yield synchronizerStart();
|
||||||
|
const items = yield test_utils_synchronizer_1.allNotesFolders();
|
||||||
|
expect(items.length).toBe(2);
|
||||||
|
const deletedItems = yield BaseItem_1.default.deletedItems(syncTargetId());
|
||||||
|
expect(deletedItems.length).toBe(0);
|
||||||
|
yield Note_1.default.delete(note2.id);
|
||||||
|
yield synchronizerStart();
|
||||||
|
})));
|
||||||
|
it('should delete remote folder', (() => __awaiter(this, void 0, void 0, function* () {
|
||||||
|
yield Folder_1.default.save({ title: 'folder1' });
|
||||||
|
const folder2 = yield Folder_1.default.save({ title: 'folder2' });
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield switchClient(2);
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield sleep(0.1);
|
||||||
|
yield Folder_1.default.delete(folder2.id);
|
||||||
|
yield synchronizerStart();
|
||||||
|
const all = yield test_utils_synchronizer_1.allNotesFolders();
|
||||||
|
yield test_utils_synchronizer_1.localNotesFoldersSameAsRemote(all, expect);
|
||||||
|
})));
|
||||||
|
it('should delete local folder', (() => __awaiter(this, void 0, void 0, function* () {
|
||||||
|
yield Folder_1.default.save({ title: 'folder1' });
|
||||||
|
const folder2 = yield Folder_1.default.save({ title: 'folder2' });
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield switchClient(2);
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield Folder_1.default.delete(folder2.id);
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield switchClient(1);
|
||||||
|
yield synchronizerStart();
|
||||||
|
const items = yield test_utils_synchronizer_1.allNotesFolders();
|
||||||
|
yield test_utils_synchronizer_1.localNotesFoldersSameAsRemote(items, expect);
|
||||||
|
})));
|
||||||
|
it('should cross delete all folders', (() => __awaiter(this, void 0, void 0, function* () {
|
||||||
|
// If client1 and 2 have two folders, client 1 deletes item 1 and client
|
||||||
|
// 2 deletes item 2, they should both end up with no items after sync.
|
||||||
|
const folder1 = yield Folder_1.default.save({ title: 'folder1' });
|
||||||
|
const folder2 = yield Folder_1.default.save({ title: 'folder2' });
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield switchClient(2);
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield sleep(0.1);
|
||||||
|
yield Folder_1.default.delete(folder1.id);
|
||||||
|
yield switchClient(1);
|
||||||
|
yield Folder_1.default.delete(folder2.id);
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield switchClient(2);
|
||||||
|
yield synchronizerStart();
|
||||||
|
const items2 = yield test_utils_synchronizer_1.allNotesFolders();
|
||||||
|
yield switchClient(1);
|
||||||
|
yield synchronizerStart();
|
||||||
|
const items1 = yield test_utils_synchronizer_1.allNotesFolders();
|
||||||
|
expect(items1.length).toBe(0);
|
||||||
|
expect(items1.length).toBe(items2.length);
|
||||||
|
})));
|
||||||
|
it('items should be downloaded again when user cancels in the middle of delta operation', (() => __awaiter(this, void 0, void 0, function* () {
|
||||||
|
const folder1 = yield Folder_1.default.save({ title: 'folder1' });
|
||||||
|
yield Note_1.default.save({ title: 'un', is_todo: 1, parent_id: folder1.id });
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield switchClient(2);
|
||||||
|
synchronizer().testingHooks_ = ['cancelDeltaLoop2'];
|
||||||
|
yield synchronizerStart();
|
||||||
|
let notes = yield Note_1.default.all();
|
||||||
|
expect(notes.length).toBe(0);
|
||||||
|
synchronizer().testingHooks_ = [];
|
||||||
|
yield synchronizerStart();
|
||||||
|
notes = yield Note_1.default.all();
|
||||||
|
expect(notes.length).toBe(1);
|
||||||
|
})));
|
||||||
|
it('should skip items that cannot be synced', (() => __awaiter(this, void 0, void 0, function* () {
|
||||||
|
const folder1 = yield Folder_1.default.save({ title: 'folder1' });
|
||||||
|
const note1 = yield Note_1.default.save({ title: 'un', is_todo: 1, parent_id: folder1.id });
|
||||||
|
const noteId = note1.id;
|
||||||
|
yield synchronizerStart();
|
||||||
|
let disabledItems = yield BaseItem_1.default.syncDisabledItems(syncTargetId());
|
||||||
|
expect(disabledItems.length).toBe(0);
|
||||||
|
yield Note_1.default.save({ id: noteId, title: 'un mod' });
|
||||||
|
synchronizer().testingHooks_ = ['notesRejectedByTarget'];
|
||||||
|
yield synchronizerStart();
|
||||||
|
synchronizer().testingHooks_ = [];
|
||||||
|
yield synchronizerStart(); // Another sync to check that this item is now excluded from sync
|
||||||
|
yield switchClient(2);
|
||||||
|
yield synchronizerStart();
|
||||||
|
const notes = yield Note_1.default.all();
|
||||||
|
expect(notes.length).toBe(1);
|
||||||
|
expect(notes[0].title).toBe('un');
|
||||||
|
yield switchClient(1);
|
||||||
|
disabledItems = yield BaseItem_1.default.syncDisabledItems(syncTargetId());
|
||||||
|
expect(disabledItems.length).toBe(1);
|
||||||
|
})));
|
||||||
|
it('should allow duplicate folder titles', (() => __awaiter(this, void 0, void 0, function* () {
|
||||||
|
yield Folder_1.default.save({ title: 'folder' });
|
||||||
|
yield switchClient(2);
|
||||||
|
let remoteF2 = yield Folder_1.default.save({ title: 'folder' });
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield switchClient(1);
|
||||||
|
yield sleep(0.1);
|
||||||
|
yield synchronizerStart();
|
||||||
|
const localF2 = yield Folder_1.default.load(remoteF2.id);
|
||||||
|
expect(localF2.title == remoteF2.title).toBe(true);
|
||||||
|
// Then that folder that has been renamed locally should be set in such a way
|
||||||
|
// that synchronizing it applies the title change remotely, and that new title
|
||||||
|
// should be retrieved by client 2.
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield switchClient(2);
|
||||||
|
yield sleep(0.1);
|
||||||
|
yield synchronizerStart();
|
||||||
|
remoteF2 = yield Folder_1.default.load(remoteF2.id);
|
||||||
|
expect(remoteF2.title == localF2.title).toBe(true);
|
||||||
|
})));
|
||||||
|
it('should create remote items with UTF-8 content', (() => __awaiter(this, void 0, void 0, function* () {
|
||||||
|
const folder = yield Folder_1.default.save({ title: 'Fahrräder' });
|
||||||
|
yield Note_1.default.save({ title: 'Fahrräder', body: 'Fahrräder', parent_id: folder.id });
|
||||||
|
const all = yield test_utils_synchronizer_1.allNotesFolders();
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield test_utils_synchronizer_1.localNotesFoldersSameAsRemote(all, expect);
|
||||||
|
})));
|
||||||
|
it('should update remote items but not pull remote changes', (() => __awaiter(this, void 0, void 0, function* () {
|
||||||
|
const folder = yield Folder_1.default.save({ title: 'folder1' });
|
||||||
|
const note = yield Note_1.default.save({ title: 'un', parent_id: folder.id });
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield switchClient(2);
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield Note_1.default.save({ title: 'deux', parent_id: folder.id });
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield switchClient(1);
|
||||||
|
yield Note_1.default.save({ title: 'un UPDATE', id: note.id });
|
||||||
|
yield synchronizerStart(null, { syncSteps: ['update_remote'] });
|
||||||
|
const all = yield test_utils_synchronizer_1.allNotesFolders();
|
||||||
|
expect(all.length).toBe(2);
|
||||||
|
yield switchClient(2);
|
||||||
|
yield synchronizerStart();
|
||||||
|
const note2 = yield Note_1.default.load(note.id);
|
||||||
|
expect(note2.title).toBe('un UPDATE');
|
||||||
|
})));
|
||||||
|
it('should create a new Welcome notebook on each client', (() => __awaiter(this, void 0, void 0, function* () {
|
||||||
|
// Create the Welcome items on two separate clients
|
||||||
|
yield WelcomeUtils.createWelcomeItems();
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield switchClient(2);
|
||||||
|
yield WelcomeUtils.createWelcomeItems();
|
||||||
|
const beforeFolderCount = (yield Folder_1.default.all()).length;
|
||||||
|
const beforeNoteCount = (yield Note_1.default.all()).length;
|
||||||
|
expect(beforeFolderCount === 1).toBe(true);
|
||||||
|
expect(beforeNoteCount > 1).toBe(true);
|
||||||
|
yield synchronizerStart();
|
||||||
|
const afterFolderCount = (yield Folder_1.default.all()).length;
|
||||||
|
const afterNoteCount = (yield Note_1.default.all()).length;
|
||||||
|
expect(afterFolderCount).toBe(beforeFolderCount * 2);
|
||||||
|
expect(afterNoteCount).toBe(beforeNoteCount * 2);
|
||||||
|
// Changes to the Welcome items should be synced to all clients
|
||||||
|
const f1 = (yield Folder_1.default.all())[0];
|
||||||
|
yield Folder_1.default.save({ id: f1.id, title: 'Welcome MOD' });
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield switchClient(1);
|
||||||
|
yield synchronizerStart();
|
||||||
|
const f1_1 = yield Folder_1.default.load(f1.id);
|
||||||
|
expect(f1_1.title).toBe('Welcome MOD');
|
||||||
|
})));
|
||||||
|
it('should not wipe out user data when syncing with an empty target', (() => __awaiter(this, void 0, void 0, function* () {
|
||||||
|
// Only these targets support the wipeOutFailSafe flag (in other words, the targets that use basicDelta)
|
||||||
|
if (!['nextcloud', 'memory', 'filesystem', 'amazon_s3'].includes(syncTargetName())) { return; }
|
||||||
|
for (let i = 0; i < 10; i++) { yield Note_1.default.save({ title: 'note' }); }
|
||||||
|
Setting_1.default.setValue('sync.wipeOutFailSafe', true);
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield fileApi().clearRoot(); // oops
|
||||||
|
yield synchronizerStart();
|
||||||
|
expect((yield Note_1.default.all()).length).toBe(10); // but since the fail-safe if on, the notes have not been deleted
|
||||||
|
Setting_1.default.setValue('sync.wipeOutFailSafe', false); // Now switch it off
|
||||||
|
yield synchronizerStart();
|
||||||
|
expect((yield Note_1.default.all()).length).toBe(0); // Since the fail-safe was off, the data has been cleared
|
||||||
|
// Handle case where the sync target has been wiped out, then the user creates one note and sync.
|
||||||
|
for (let i = 0; i < 10; i++) { yield Note_1.default.save({ title: 'note' }); }
|
||||||
|
Setting_1.default.setValue('sync.wipeOutFailSafe', true);
|
||||||
|
yield synchronizerStart();
|
||||||
|
yield fileApi().clearRoot();
|
||||||
|
yield Note_1.default.save({ title: 'ma note encore' });
|
||||||
|
yield synchronizerStart();
|
||||||
|
expect((yield Note_1.default.all()).length).toBe(11);
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
// # sourceMappingURL=Synchronizer.share.js.map
|
@@ -1,39 +1,70 @@
|
|||||||
import { afterAllCleanUp, synchronizerStart, setupDatabaseAndSynchronizer, switchClient } from './test-utils';
|
|
||||||
import Note from '@joplin/lib/models/Note';
|
|
||||||
import BaseItem from '@joplin/lib/models/BaseItem';
|
|
||||||
import shim from '@joplin/lib/shim';
|
|
||||||
import Resource from '@joplin/lib/models/Resource';
|
|
||||||
|
|
||||||
describe('Synchronizer.sharing', function() {
|
describe('Synchronizer.sharing', function() {
|
||||||
|
|
||||||
beforeEach(async (done) => {
|
it('should skip', (async () => {
|
||||||
await setupDatabaseAndSynchronizer(1);
|
expect(true).toBe(true);
|
||||||
await setupDatabaseAndSynchronizer(2);
|
|
||||||
await switchClient(1);
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await afterAllCleanUp();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should mark link resources as shared before syncing', (async () => {
|
|
||||||
let note1 = await Note.save({ title: 'note1' });
|
|
||||||
note1 = await shim.attachFileToNote(note1, `${__dirname}/../tests/support/photo.jpg`);
|
|
||||||
const resourceId1 = (await Note.linkedResourceIds(note1.body))[0];
|
|
||||||
|
|
||||||
const note2 = await Note.save({ title: 'note2' });
|
|
||||||
await shim.attachFileToNote(note2, `${__dirname}/../tests/support/photo.jpg`);
|
|
||||||
|
|
||||||
expect((await Resource.sharedResourceIds()).length).toBe(0);
|
|
||||||
|
|
||||||
await BaseItem.updateShareStatus(note1, true);
|
|
||||||
|
|
||||||
await synchronizerStart();
|
|
||||||
|
|
||||||
const sharedResourceIds = await Resource.sharedResourceIds();
|
|
||||||
expect(sharedResourceIds.length).toBe(1);
|
|
||||||
expect(sharedResourceIds[0]).toBe(resourceId1);
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// import { afterAllCleanUp, synchronizerStart, setupDatabaseAndSynchronizer, switchClient, joplinServerApi } from './test-utils';
|
||||||
|
// import Note from '@joplin/lib/models/Note';
|
||||||
|
// import BaseItem from '@joplin/lib/models/BaseItem';
|
||||||
|
// import shim from '@joplin/lib/shim';
|
||||||
|
// import Resource from '@joplin/lib/models/Resource';
|
||||||
|
// import Folder from '@joplin/lib/models/Folder';
|
||||||
|
|
||||||
|
// describe('Synchronizer.sharing', function() {
|
||||||
|
|
||||||
|
// beforeEach(async (done) => {
|
||||||
|
// await setupDatabaseAndSynchronizer(1);
|
||||||
|
// await switchClient(1);
|
||||||
|
// done();
|
||||||
|
// });
|
||||||
|
|
||||||
|
// afterAll(async () => {
|
||||||
|
// await afterAllCleanUp();
|
||||||
|
// });
|
||||||
|
|
||||||
|
// it('should mark link resources as shared before syncing', (async () => {
|
||||||
|
// let note1 = await Note.save({ title: 'note1' });
|
||||||
|
// note1 = await shim.attachFileToNote(note1, `${__dirname}/../tests/support/photo.jpg`);
|
||||||
|
// const resourceId1 = (await Note.linkedResourceIds(note1.body))[0];
|
||||||
|
|
||||||
|
// const note2 = await Note.save({ title: 'note2' });
|
||||||
|
// await shim.attachFileToNote(note2, `${__dirname}/../tests/support/photo.jpg`);
|
||||||
|
|
||||||
|
// expect((await Resource.sharedResourceIds()).length).toBe(0);
|
||||||
|
|
||||||
|
// await BaseItem.updateShareStatus(note1, true);
|
||||||
|
|
||||||
|
// await synchronizerStart();
|
||||||
|
|
||||||
|
// const sharedResourceIds = await Resource.sharedResourceIds();
|
||||||
|
// expect(sharedResourceIds.length).toBe(1);
|
||||||
|
// expect(sharedResourceIds[0]).toBe(resourceId1);
|
||||||
|
// }));
|
||||||
|
|
||||||
|
// it('should share items', (async () => {
|
||||||
|
// await setupDatabaseAndSynchronizer(1, { userEmail: 'user1@example.com' });
|
||||||
|
// await switchClient(1);
|
||||||
|
|
||||||
|
// const api = joplinServerApi();
|
||||||
|
// await api.exec('POST', 'api/debug', null, { action: 'createTestUsers' });
|
||||||
|
// await api.clearSession();
|
||||||
|
|
||||||
|
// const folder1 = await Folder.save({ title: 'folder1' });
|
||||||
|
// await Note.save({ title: 'note1', parent_id: folder1.id });
|
||||||
|
|
||||||
|
// await synchronizerStart();
|
||||||
|
|
||||||
|
// await setupDatabaseAndSynchronizer(2, { userEmail: 'user2@example.com' });
|
||||||
|
// await switchClient(2);
|
||||||
|
|
||||||
|
// await synchronizerStart();
|
||||||
|
|
||||||
|
// await switchClient(1);
|
||||||
|
|
||||||
|
// console.info(await Note.all());
|
||||||
|
// }));
|
||||||
|
|
||||||
|
// });
|
||||||
|
69
packages/app-cli/tests/file-api-driver.ts
Normal file
69
packages/app-cli/tests/file-api-driver.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { afterAllCleanUp, setupDatabaseAndSynchronizer, switchClient, fileApi } from './test-utils';
|
||||||
|
|
||||||
|
describe('file-api-driver', function() {
|
||||||
|
|
||||||
|
beforeEach(async (done) => {
|
||||||
|
await setupDatabaseAndSynchronizer(1);
|
||||||
|
await switchClient(1);
|
||||||
|
await fileApi().clearRoot();
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await afterAllCleanUp();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a file', (async () => {
|
||||||
|
await fileApi().put('test.txt', 'testing');
|
||||||
|
const content = await fileApi().get('test.txt');
|
||||||
|
expect(content).toBe('testing');
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should get a file info', (async () => {
|
||||||
|
await fileApi().put('test1.txt', 'testing');
|
||||||
|
await fileApi().mkdir('sub');
|
||||||
|
await fileApi().put('sub/test2.txt', 'testing');
|
||||||
|
|
||||||
|
// Note: Although the stat object includes an "isDir" property, this is
|
||||||
|
// not actually used by the synchronizer so not required by any sync
|
||||||
|
// target.
|
||||||
|
|
||||||
|
{
|
||||||
|
const stat = await fileApi().stat('test1.txt');
|
||||||
|
expect(stat.path).toBe('test1.txt');
|
||||||
|
expect(!!stat.updated_time).toBe(true);
|
||||||
|
expect(stat.isDir).toBe(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const stat = await fileApi().stat('sub/test2.txt');
|
||||||
|
expect(stat.path).toBe('sub/test2.txt');
|
||||||
|
expect(!!stat.updated_time).toBe(true);
|
||||||
|
expect(stat.isDir).toBe(false);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should create a file in a subdirectory', (async () => {
|
||||||
|
await fileApi().mkdir('subdir');
|
||||||
|
await fileApi().put('subdir/test.txt', 'testing');
|
||||||
|
const content = await fileApi().get('subdir/test.txt');
|
||||||
|
expect(content).toBe('testing');
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should list files', (async () => {
|
||||||
|
await fileApi().mkdir('subdir');
|
||||||
|
await fileApi().put('subdir/test1.txt', 'testing1');
|
||||||
|
await fileApi().put('subdir/test2.txt', 'testing2');
|
||||||
|
const files = await fileApi().list('subdir');
|
||||||
|
expect(files.items.length).toBe(2);
|
||||||
|
expect(files.items.map((f: any) => f.path).sort()).toEqual(['test1.txt', 'test2.txt'].sort());
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should delete a file', (async () => {
|
||||||
|
await fileApi().put('test1.txt', 'testing1');
|
||||||
|
await fileApi().delete('test1.txt');
|
||||||
|
const files = await fileApi().list('');
|
||||||
|
expect(files.items.length).toBe(0);
|
||||||
|
}));
|
||||||
|
|
||||||
|
});
|
@@ -1,135 +0,0 @@
|
|||||||
/* eslint-disable no-unused-vars */
|
|
||||||
|
|
||||||
|
|
||||||
const uuid = require('@joplin/lib/uuid').default;
|
|
||||||
const time = require('@joplin/lib/time').default;
|
|
||||||
const { sleep, fileApi, fileContentEqual, checkThrowAsync } = require('./test-utils.js');
|
|
||||||
const shim = require('@joplin/lib/shim').default;
|
|
||||||
const fs = require('fs-extra');
|
|
||||||
const Setting = require('@joplin/lib/models/Setting').default;
|
|
||||||
|
|
||||||
const api = null;
|
|
||||||
|
|
||||||
// Adding empty test for Jest
|
|
||||||
it('will pass', () => {
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// NOTE: These tests work with S3 and memory driver, but not
|
|
||||||
// with other targets like file system or Nextcloud.
|
|
||||||
// All this is tested in an indirect way in tests/synchronizer
|
|
||||||
// anyway.
|
|
||||||
// We keep the file here as it could be useful as a spec for
|
|
||||||
// what calls a sync target should support, but it would
|
|
||||||
// need to be fixed first.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// To test out an FileApi implementation:
|
|
||||||
// * add a SyncTarget for your driver in `test-utils.js`
|
|
||||||
// * set `syncTargetId_` to your New SyncTarget:
|
|
||||||
// `const syncTargetId_ = SyncTargetRegistry.nameToId('memory');`
|
|
||||||
// describe('fileApi', function() {
|
|
||||||
|
|
||||||
// beforeEach(async (done) => {
|
|
||||||
// api = new fileApi();
|
|
||||||
// api.clearRoot();
|
|
||||||
// done();
|
|
||||||
// });
|
|
||||||
|
|
||||||
// describe('list', function() {
|
|
||||||
// it('should return items with relative path', (async () => {
|
|
||||||
// await api.mkdir('.subfolder');
|
|
||||||
// await api.put('1', 'something on root 1');
|
|
||||||
// await api.put('.subfolder/1', 'something subfolder 1');
|
|
||||||
// await api.put('.subfolder/2', 'something subfolder 2');
|
|
||||||
// await api.put('.subfolder/3', 'something subfolder 3');
|
|
||||||
// sleep(0.8);
|
|
||||||
|
|
||||||
// const response = await api.list('.subfolder');
|
|
||||||
// const items = response.items;
|
|
||||||
// expect(items.length).toBe(3);
|
|
||||||
// expect(items[0].path).toBe('1');
|
|
||||||
// expect(items[0].updated_time).toMatch(/^\d+$/); // make sure it's using epoch timestamp
|
|
||||||
// }));
|
|
||||||
|
|
||||||
// it('should default to only files on root directory', (async () => {
|
|
||||||
// await api.mkdir('.subfolder');
|
|
||||||
// await api.put('.subfolder/1', 'something subfolder 1');
|
|
||||||
// await api.put('file1', 'something 1');
|
|
||||||
// await api.put('file2', 'something 2');
|
|
||||||
// sleep(0.6);
|
|
||||||
|
|
||||||
// const response = await api.list();
|
|
||||||
// expect(response.items.length).toBe(2);
|
|
||||||
// }));
|
|
||||||
// }); // list
|
|
||||||
|
|
||||||
// describe('delete', function() {
|
|
||||||
// it('should not error if file does not exist', (async () => {
|
|
||||||
// const hasThrown = await checkThrowAsync(async () => await api.delete('nonexistant_file'));
|
|
||||||
// expect(hasThrown).toBe(false);
|
|
||||||
// }));
|
|
||||||
|
|
||||||
// it('should delete specific file given full path', (async () => {
|
|
||||||
// await api.mkdir('deleteDir');
|
|
||||||
// await api.put('deleteDir/1', 'something 1');
|
|
||||||
// await api.put('deleteDir/2', 'something 2');
|
|
||||||
// sleep(0.4);
|
|
||||||
|
|
||||||
// await api.delete('deleteDir/1');
|
|
||||||
// let response = await api.list('deleteDir');
|
|
||||||
// expect(response.items.length).toBe(1);
|
|
||||||
// response = await api.list('deleteDir/1');
|
|
||||||
// expect(response.items.length).toBe(0);
|
|
||||||
// }));
|
|
||||||
// }); // delete
|
|
||||||
|
|
||||||
// describe('get', function() {
|
|
||||||
// it('should return null if object does not exist', (async () => {
|
|
||||||
// const response = await api.get('nonexistant_file');
|
|
||||||
// expect(response).toBe(null);
|
|
||||||
// }));
|
|
||||||
|
|
||||||
// it('should return UTF-8 encoded string by default', (async () => {
|
|
||||||
// await api.put('testnote.md', 'something 2');
|
|
||||||
|
|
||||||
// const response = await api.get('testnote.md');
|
|
||||||
// expect(response).toBe('something 2');
|
|
||||||
// }));
|
|
||||||
|
|
||||||
// it('should return a Response object and writes file to options.path, if options.target is "file"', (async () => {
|
|
||||||
// const localFilePath = `${Setting.value('tempDir')}/${uuid.create()}.md`;
|
|
||||||
// await api.put('testnote.md', 'something 2');
|
|
||||||
// sleep(0.2);
|
|
||||||
|
|
||||||
// const response = await api.get('testnote.md', { target: 'file', path: localFilePath });
|
|
||||||
// expect(typeof response).toBe('object');
|
|
||||||
// // expect(response.path).toBe(localFilePath);
|
|
||||||
// expect(fs.existsSync(localFilePath)).toBe(true);
|
|
||||||
// expect(fs.readFileSync(localFilePath, 'utf8')).toBe('something 2');
|
|
||||||
// }));
|
|
||||||
// }); // get
|
|
||||||
|
|
||||||
// describe('put', function() {
|
|
||||||
// it('should create file to remote path and content', (async () => {
|
|
||||||
// await api.put('putTest.md', 'I am your content');
|
|
||||||
// sleep(0.2);
|
|
||||||
|
|
||||||
// const response = await api.get('putTest.md');
|
|
||||||
// expect(response).toBe('I am your content');
|
|
||||||
// }));
|
|
||||||
|
|
||||||
// it('should upload file in options.path to remote path, if options.source is "file"', (async () => {
|
|
||||||
// const localFilePath = `${Setting.value('tempDir')}/${uuid.create()}.md`;
|
|
||||||
// fs.writeFileSync(localFilePath, 'I am the local file.');
|
|
||||||
|
|
||||||
// await api.put('testfile', 'ignore me', { source: 'file', path: localFilePath });
|
|
||||||
// sleep(0.2);
|
|
||||||
|
|
||||||
// const response = await api.get('testfile');
|
|
||||||
// expect(response).toBe('I am the local file.');
|
|
||||||
// }));
|
|
||||||
// }); // put
|
|
||||||
|
|
||||||
// });
|
|
362
packages/app-cli/tests/models_Folder.sharing.ts
Normal file
362
packages/app-cli/tests/models_Folder.sharing.ts
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
import { setupDatabaseAndSynchronizer, switchClient, createFolderTree } from './test-utils';
|
||||||
|
import Folder from '@joplin/lib/models/Folder';
|
||||||
|
import { allNotesFolders } from './test-utils-synchronizer';
|
||||||
|
import Note from '@joplin/lib/models/Note';
|
||||||
|
import shim from '@joplin/lib/shim';
|
||||||
|
import Resource from '@joplin/lib/models/Resource';
|
||||||
|
import { FolderEntity, NoteEntity, ResourceEntity } from '@joplin/lib/services/database/types';
|
||||||
|
import ResourceService from '@joplin/lib/services/ResourceService';
|
||||||
|
|
||||||
|
const testImagePath = `${__dirname}/../tests/support/photo.jpg`;
|
||||||
|
|
||||||
|
describe('models_Folder.sharing', function() {
|
||||||
|
|
||||||
|
beforeEach(async (done) => {
|
||||||
|
await setupDatabaseAndSynchronizer(1);
|
||||||
|
await switchClient(1);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply the share ID to all children', (async () => {
|
||||||
|
const folder = await createFolderTree('', [
|
||||||
|
{
|
||||||
|
title: 'folder 1',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: 'note 1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'note 2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'folder 2',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: 'note 3',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await Folder.save({ id: folder.id, share_id: 'abcd1234' });
|
||||||
|
await Folder.updateAllShareIds();
|
||||||
|
|
||||||
|
const allItems = await allNotesFolders();
|
||||||
|
for (const item of allItems) {
|
||||||
|
expect(item.share_id).toBe('abcd1234');
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should apply the share ID to all sub-folders', (async () => {
|
||||||
|
let folder1 = await createFolderTree('', [
|
||||||
|
{
|
||||||
|
title: 'folder 1',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: 'note 1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'note 2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'folder 2',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: 'note 3',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'folder 3',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: 'folder 4',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'folder 5',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await Folder.save({ id: folder1.id, share_id: 'abcd1234' });
|
||||||
|
|
||||||
|
await Folder.updateAllShareIds();
|
||||||
|
|
||||||
|
folder1 = await Folder.loadByTitle('folder 1');
|
||||||
|
const folder2 = await Folder.loadByTitle('folder 2');
|
||||||
|
const folder3 = await Folder.loadByTitle('folder 3');
|
||||||
|
const folder4 = await Folder.loadByTitle('folder 4');
|
||||||
|
const folder5 = await Folder.loadByTitle('folder 5');
|
||||||
|
|
||||||
|
expect(folder1.share_id).toBe('abcd1234');
|
||||||
|
expect(folder2.share_id).toBe('abcd1234');
|
||||||
|
expect(folder3.share_id).toBe('abcd1234');
|
||||||
|
expect(folder4.share_id).toBe('abcd1234');
|
||||||
|
expect(folder5.share_id).toBe('');
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should update the share ID when a folder is moved in or out of shared folder', (async () => {
|
||||||
|
let folder1 = await createFolderTree('', [
|
||||||
|
{
|
||||||
|
title: 'folder 1',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: 'folder 2',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'folder 3',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await Folder.save({ id: folder1.id, share_id: 'abcd1234' });
|
||||||
|
|
||||||
|
await Folder.updateAllShareIds();
|
||||||
|
|
||||||
|
folder1 = await Folder.loadByTitle('folder 1');
|
||||||
|
let folder2 = await Folder.loadByTitle('folder 2');
|
||||||
|
const folder3 = await Folder.loadByTitle('folder 3');
|
||||||
|
|
||||||
|
expect(folder1.share_id).toBe('abcd1234');
|
||||||
|
expect(folder2.share_id).toBe('abcd1234');
|
||||||
|
|
||||||
|
// Move the folder outside the shared folder
|
||||||
|
|
||||||
|
await Folder.save({ id: folder2.id, parent_id: folder3.id });
|
||||||
|
await Folder.updateAllShareIds();
|
||||||
|
folder2 = await Folder.loadByTitle('folder 2');
|
||||||
|
expect(folder2.share_id).toBe('');
|
||||||
|
|
||||||
|
// Move the folder inside the shared folder
|
||||||
|
|
||||||
|
{
|
||||||
|
await Folder.save({ id: folder2.id, parent_id: folder1.id });
|
||||||
|
await Folder.updateAllShareIds();
|
||||||
|
folder2 = await Folder.loadByTitle('folder 2');
|
||||||
|
expect(folder2.share_id).toBe('abcd1234');
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should apply the share ID to all notes', (async () => {
|
||||||
|
const folder1 = await createFolderTree('', [
|
||||||
|
{
|
||||||
|
title: 'folder 1',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: 'note 1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'note 2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'folder 2',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: 'note 3',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'folder 5',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: 'note 4',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await Folder.save({ id: folder1.id, share_id: 'abcd1234' });
|
||||||
|
|
||||||
|
await Folder.updateAllShareIds();
|
||||||
|
|
||||||
|
const note1: NoteEntity = await Note.loadByTitle('note 1');
|
||||||
|
const note2: NoteEntity = await Note.loadByTitle('note 2');
|
||||||
|
const note3: NoteEntity = await Note.loadByTitle('note 3');
|
||||||
|
const note4: NoteEntity = await Note.loadByTitle('note 4');
|
||||||
|
|
||||||
|
expect(note1.share_id).toBe('abcd1234');
|
||||||
|
expect(note2.share_id).toBe('abcd1234');
|
||||||
|
expect(note3.share_id).toBe('abcd1234');
|
||||||
|
expect(note4.share_id).toBe('');
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should remove the share ID when a note is moved in or out of shared folder', (async () => {
|
||||||
|
const folder1 = await createFolderTree('', [
|
||||||
|
{
|
||||||
|
title: 'folder 1',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: 'note 1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'folder 2',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await Folder.save({ id: folder1.id, share_id: 'abcd1234' });
|
||||||
|
await Folder.updateAllShareIds();
|
||||||
|
const note1: NoteEntity = await Note.loadByTitle('note 1');
|
||||||
|
const folder2: FolderEntity = await Folder.loadByTitle('folder 2');
|
||||||
|
expect(note1.share_id).toBe('abcd1234');
|
||||||
|
|
||||||
|
// Move the note outside of the shared folder
|
||||||
|
|
||||||
|
await Note.save({ id: note1.id, parent_id: folder2.id });
|
||||||
|
await Folder.updateAllShareIds();
|
||||||
|
|
||||||
|
{
|
||||||
|
const note1: NoteEntity = await Note.loadByTitle('note 1');
|
||||||
|
expect(note1.share_id).toBe('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move the note back inside the shared folder
|
||||||
|
|
||||||
|
await Note.save({ id: note1.id, parent_id: folder1.id });
|
||||||
|
await Folder.updateAllShareIds();
|
||||||
|
|
||||||
|
{
|
||||||
|
const note1: NoteEntity = await Note.loadByTitle('note 1');
|
||||||
|
expect(note1.share_id).toBe('abcd1234');
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should not remove the share ID of non-modified notes', (async () => {
|
||||||
|
const folder1 = await createFolderTree('', [
|
||||||
|
{
|
||||||
|
title: 'folder 1',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: 'note 1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'note 2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'folder 2',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await Folder.save({ id: folder1.id, share_id: 'abcd1234' });
|
||||||
|
await Folder.updateAllShareIds();
|
||||||
|
|
||||||
|
let note1: NoteEntity = await Note.loadByTitle('note 1');
|
||||||
|
let note2: NoteEntity = await Note.loadByTitle('note 2');
|
||||||
|
const folder2: FolderEntity = await Folder.loadByTitle('folder 2');
|
||||||
|
|
||||||
|
expect(note1.share_id).toBe('abcd1234');
|
||||||
|
expect(note2.share_id).toBe('abcd1234');
|
||||||
|
|
||||||
|
await Note.save({ id: note1.id, parent_id: folder2.id });
|
||||||
|
await Folder.updateAllShareIds();
|
||||||
|
|
||||||
|
note1 = await Note.loadByTitle('note 1');
|
||||||
|
note2 = await Note.loadByTitle('note 2');
|
||||||
|
expect(note1.share_id).toBe('');
|
||||||
|
expect(note2.share_id).toBe('abcd1234');
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should apply the note share ID to its resources', async () => {
|
||||||
|
const resourceService = new ResourceService();
|
||||||
|
|
||||||
|
const folder = await createFolderTree('', [
|
||||||
|
{
|
||||||
|
title: 'folder 1',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: 'note 1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'note 2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'folder 2',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await Folder.save({ id: folder.id, share_id: 'abcd1234' });
|
||||||
|
await Folder.updateAllShareIds();
|
||||||
|
|
||||||
|
const folder2: FolderEntity = await Folder.loadByTitle('folder 2');
|
||||||
|
const note1: NoteEntity = await Note.loadByTitle('note 1');
|
||||||
|
await shim.attachFileToNote(note1, testImagePath);
|
||||||
|
|
||||||
|
// We need to index the resources to populate the note_resources table
|
||||||
|
await resourceService.indexNoteResources();
|
||||||
|
|
||||||
|
const resourceId: string = (await Resource.all())[0].id;
|
||||||
|
|
||||||
|
{
|
||||||
|
const resource: ResourceEntity = await Resource.load(resourceId);
|
||||||
|
expect(resource.share_id).toBe('');
|
||||||
|
}
|
||||||
|
|
||||||
|
await Folder.updateAllShareIds();
|
||||||
|
|
||||||
|
// await NoteResource.updateResourceShareIds();
|
||||||
|
|
||||||
|
{
|
||||||
|
const resource: ResourceEntity = await Resource.load(resourceId);
|
||||||
|
expect(resource.share_id).toBe(note1.share_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Note.save({ id: note1.id, parent_id: folder2.id });
|
||||||
|
await resourceService.indexNoteResources();
|
||||||
|
|
||||||
|
await Folder.updateAllShareIds();
|
||||||
|
|
||||||
|
// await NoteResource.updateResourceShareIds();
|
||||||
|
|
||||||
|
{
|
||||||
|
const resource: ResourceEntity = await Resource.load(resourceId);
|
||||||
|
expect(resource.share_id).toBe('');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// it('should not recursively delete when non-owner deletes a shared folder', async () => {
|
||||||
|
// const folder = await createFolderTree('', [
|
||||||
|
// {
|
||||||
|
// title: 'folder 1',
|
||||||
|
// children: [
|
||||||
|
// {
|
||||||
|
// title: 'note 1',
|
||||||
|
// },
|
||||||
|
// ],
|
||||||
|
// },
|
||||||
|
// ]);
|
||||||
|
|
||||||
|
// BaseItem.shareService_ = {
|
||||||
|
// isSharedFolderOwner: (_folderId: string) => false,
|
||||||
|
// } as any;
|
||||||
|
|
||||||
|
// await Folder.save({ id: folder.id, share_id: 'abcd1234' });
|
||||||
|
// await Folder.updateAllShareIds();
|
||||||
|
|
||||||
|
// await Folder.delete(folder.id);
|
||||||
|
|
||||||
|
// expect((await Folder.all()).length).toBe(0);
|
||||||
|
// expect((await Note.all()).length).toBe(1);
|
||||||
|
// });
|
||||||
|
|
||||||
|
});
|
@@ -1,5 +1,5 @@
|
|||||||
import { FolderEntity } from '@joplin/lib/services/database/types';
|
import { FolderEntity } from '@joplin/lib/services/database/types';
|
||||||
import { createNTestNotes, setupDatabaseAndSynchronizer, sleep, switchClient, checkThrowAsync } from './test-utils';
|
import { createNTestNotes, setupDatabaseAndSynchronizer, sleep, switchClient, checkThrowAsync, createFolderTree } from './test-utils';
|
||||||
import Folder from '@joplin/lib/models/Folder';
|
import Folder from '@joplin/lib/models/Folder';
|
||||||
import Note from '@joplin/lib/models/Note';
|
import Note from '@joplin/lib/models/Note';
|
||||||
|
|
||||||
@@ -225,4 +225,61 @@ describe('models_Folder', function() {
|
|||||||
const hasThrown = await checkThrowAsync(() => Folder.save({ id: f1.id, parent_id: f1.id }, { userSideValidation: true }));
|
const hasThrown = await checkThrowAsync(() => Folder.save({ id: f1.id, parent_id: f1.id }, { userSideValidation: true }));
|
||||||
expect(hasThrown).toBe(true);
|
expect(hasThrown).toBe(true);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
it('should get all the children of a folder', (async () => {
|
||||||
|
const folder = await createFolderTree('', [
|
||||||
|
{
|
||||||
|
title: 'folder 1',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: 'note 1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'note 2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'folder 2',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: 'note 3',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'folder 3',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'folder 4',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: 'folder 5',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const folder2 = await Folder.loadByTitle('folder 2');
|
||||||
|
const folder3 = await Folder.loadByTitle('folder 3');
|
||||||
|
const folder4 = await Folder.loadByTitle('folder 4');
|
||||||
|
const folder5 = await Folder.loadByTitle('folder 5');
|
||||||
|
|
||||||
|
{
|
||||||
|
const children = await Folder.allChildrenFolders(folder.id);
|
||||||
|
expect(children.map(c => c.id).sort()).toEqual([folder2.id, folder3.id].sort());
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const children = await Folder.allChildrenFolders(folder4.id);
|
||||||
|
expect(children.map(c => c.id).sort()).toEqual([folder5.id].sort());
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const children = await Folder.allChildrenFolders(folder5.id);
|
||||||
|
expect(children.map(c => c.id).sort()).toEqual([].sort());
|
||||||
|
}
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
@@ -1,17 +0,0 @@
|
|||||||
import { ExportModule, ImportModule } from './types';
|
|
||||||
/**
|
|
||||||
* Provides a way to create modules to import external data into Joplin or to export notes into any arbitrary format.
|
|
||||||
*
|
|
||||||
* [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/json_export)
|
|
||||||
*
|
|
||||||
* To implement an import or export module, you would simply define an object with various event handlers that are called
|
|
||||||
* by the application during the import/export process.
|
|
||||||
*
|
|
||||||
* See the documentation of the [[ExportModule]] and [[ImportModule]] for more information.
|
|
||||||
*
|
|
||||||
* You may also want to refer to the Joplin API documentation to see the list of properties for each item (note, notebook, etc.) - https://joplinapp.org/api/references/rest_api/
|
|
||||||
*/
|
|
||||||
export default class JoplinInterop {
|
|
||||||
registerExportModule(module: ExportModule): Promise<void>;
|
|
||||||
registerImportModule(module: ImportModule): Promise<void>;
|
|
||||||
}
|
|
@@ -53,6 +53,7 @@ import ResourceFetcher from '@joplin/lib/services/ResourceFetcher';
|
|||||||
const WebDavApi = require('@joplin/lib/WebDavApi');
|
const WebDavApi = require('@joplin/lib/WebDavApi');
|
||||||
const DropboxApi = require('@joplin/lib/DropboxApi');
|
const DropboxApi = require('@joplin/lib/DropboxApi');
|
||||||
import JoplinServerApi from '@joplin/lib/JoplinServerApi';
|
import JoplinServerApi from '@joplin/lib/JoplinServerApi';
|
||||||
|
import { FolderEntity } from '@joplin/lib/services/database/types';
|
||||||
const { loadKeychainServiceAndSettings } = require('@joplin/lib/services/SettingUtils');
|
const { loadKeychainServiceAndSettings } = require('@joplin/lib/services/SettingUtils');
|
||||||
const md5 = require('md5');
|
const md5 = require('md5');
|
||||||
const S3 = require('aws-sdk/clients/s3');
|
const S3 = require('aws-sdk/clients/s3');
|
||||||
@@ -346,6 +347,29 @@ async function setupDatabase(id: number = null, options: any = null) {
|
|||||||
await loadKeychainServiceAndSettings(options.keychainEnabled ? KeychainServiceDriver : KeychainServiceDriverDummy);
|
await loadKeychainServiceAndSettings(options.keychainEnabled ? KeychainServiceDriver : KeychainServiceDriverDummy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createFolderTree(parentId: string, tree: any[], num: number = 0): Promise<FolderEntity> {
|
||||||
|
let rootFolder: FolderEntity = null;
|
||||||
|
|
||||||
|
for (const item of tree) {
|
||||||
|
const isFolder = !!item.children;
|
||||||
|
|
||||||
|
num++;
|
||||||
|
|
||||||
|
const data = { ...item };
|
||||||
|
delete data.children;
|
||||||
|
|
||||||
|
if (isFolder) {
|
||||||
|
const folder = await Folder.save({ title: `Folder ${num}`, parent_id: parentId, ...data });
|
||||||
|
if (!rootFolder) rootFolder = folder;
|
||||||
|
if (item.children.length) await createFolderTree(folder.id, item.children, num);
|
||||||
|
} else {
|
||||||
|
await Note.save({ title: `Note ${num}`, parent_id: parentId, ...data });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rootFolder;
|
||||||
|
}
|
||||||
|
|
||||||
function exportDir(id: number = null) {
|
function exportDir(id: number = null) {
|
||||||
if (id === null) id = currentClient_;
|
if (id === null) id = currentClient_;
|
||||||
return `${dataDir}/export`;
|
return `${dataDir}/export`;
|
||||||
@@ -385,7 +409,7 @@ async function setupDatabaseAndSynchronizer(id: number, options: any = null) {
|
|||||||
if (!synchronizers_[id]) {
|
if (!synchronizers_[id]) {
|
||||||
const SyncTargetClass = SyncTargetRegistry.classById(syncTargetId_);
|
const SyncTargetClass = SyncTargetRegistry.classById(syncTargetId_);
|
||||||
const syncTarget = new SyncTargetClass(db(id));
|
const syncTarget = new SyncTargetClass(db(id));
|
||||||
await initFileApi(suiteName_);
|
await initFileApi();
|
||||||
syncTarget.setFileApi(fileApi());
|
syncTarget.setFileApi(fileApi());
|
||||||
syncTarget.setLogger(logger);
|
syncTarget.setLogger(logger);
|
||||||
synchronizers_[id] = await syncTarget.synchronizer();
|
synchronizers_[id] = await syncTarget.synchronizer();
|
||||||
@@ -484,7 +508,13 @@ async function loadEncryptionMasterKey(id: number = null, useExisting = false) {
|
|||||||
return masterKey;
|
return masterKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initFileApi(suiteName: string) {
|
function mustRunInBand() {
|
||||||
|
if (!process.argv.includes('--runInBand')) {
|
||||||
|
throw new Error('Tests must be run sequentially for this sync target, with the --runInBand arg. eg `npm test -- --runInBand`');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initFileApi() {
|
||||||
if (fileApis_[syncTargetId_]) return;
|
if (fileApis_[syncTargetId_]) return;
|
||||||
|
|
||||||
let fileApi = null;
|
let fileApi = null;
|
||||||
@@ -524,9 +554,7 @@ async function initFileApi(suiteName: string) {
|
|||||||
// OneDrive app directory, and it's not clear how to get that
|
// OneDrive app directory, and it's not clear how to get that
|
||||||
// working.
|
// working.
|
||||||
|
|
||||||
if (!process.argv.includes('--runInBand')) {
|
mustRunInBand();
|
||||||
throw new Error('OneDrive tests must be run sequentially, with the --runInBand arg. eg `npm test -- --runInBand`');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { parameters, setEnvOverride } = require('@joplin/lib/parameters.js');
|
const { parameters, setEnvOverride } = require('@joplin/lib/parameters.js');
|
||||||
Setting.setConstant('env', 'dev');
|
Setting.setConstant('env', 'dev');
|
||||||
@@ -550,6 +578,8 @@ async function initFileApi(suiteName: string) {
|
|||||||
const api = new S3({ accessKeyId: amazonS3Creds.accessKeyId, secretAccessKey: amazonS3Creds.secretAccessKey, s3UseArnRegion: true });
|
const api = new S3({ accessKeyId: amazonS3Creds.accessKeyId, secretAccessKey: amazonS3Creds.secretAccessKey, s3UseArnRegion: true });
|
||||||
fileApi = new FileApi('', new FileApiDriverAmazonS3(api, amazonS3Creds.bucket));
|
fileApi = new FileApi('', new FileApiDriverAmazonS3(api, amazonS3Creds.bucket));
|
||||||
} else if (syncTargetId_ == SyncTargetRegistry.nameToId('joplinServer')) {
|
} else if (syncTargetId_ == SyncTargetRegistry.nameToId('joplinServer')) {
|
||||||
|
mustRunInBand();
|
||||||
|
|
||||||
// Note that to test the API in parallel mode, you need to use Postgres
|
// Note that to test the API in parallel mode, you need to use Postgres
|
||||||
// as database, as the SQLite database is not reliable when being
|
// as database, as the SQLite database is not reliable when being
|
||||||
// read/write from multiple processes at the same time.
|
// read/write from multiple processes at the same time.
|
||||||
@@ -558,7 +588,8 @@ async function initFileApi(suiteName: string) {
|
|||||||
username: () => 'admin@localhost',
|
username: () => 'admin@localhost',
|
||||||
password: () => 'admin',
|
password: () => 'admin',
|
||||||
});
|
});
|
||||||
fileApi = new FileApi(`Apps/Joplin-${suiteName}`, new FileApiDriverJoplinServer(api));
|
|
||||||
|
fileApi = new FileApi('', new FileApiDriverJoplinServer(api));
|
||||||
}
|
}
|
||||||
|
|
||||||
fileApi.setLogger(logger);
|
fileApi.setLogger(logger);
|
||||||
|
3
packages/app-desktop/.gitignore
vendored
3
packages/app-desktop/.gitignore
vendored
@@ -7,4 +7,5 @@ gui/note-viewer/pluginAssets/
|
|||||||
pluginAssets/
|
pluginAssets/
|
||||||
gui/note-viewer/fonts/
|
gui/note-viewer/fonts/
|
||||||
gui/note-viewer/lib.js
|
gui/note-viewer/lib.js
|
||||||
gui/NoteEditor/NoteBody/TinyMCE/supportedLocales.js
|
gui/NoteEditor/NoteBody/TinyMCE/supportedLocales.js
|
||||||
|
runForSharingCommands-*
|
||||||
|
@@ -13,6 +13,7 @@ import Logger, { TargetType } from '@joplin/lib/Logger';
|
|||||||
import Setting from '@joplin/lib/models/Setting';
|
import Setting from '@joplin/lib/models/Setting';
|
||||||
import actionApi from '@joplin/lib/services/rest/actionApi.desktop';
|
import actionApi from '@joplin/lib/services/rest/actionApi.desktop';
|
||||||
import BaseApplication from '@joplin/lib/BaseApplication';
|
import BaseApplication from '@joplin/lib/BaseApplication';
|
||||||
|
import DebugService from '@joplin/lib/debug/DebugService';
|
||||||
import { _, setLocale } from '@joplin/lib/locale';
|
import { _, setLocale } from '@joplin/lib/locale';
|
||||||
import SpellCheckerService from '@joplin/lib/services/spellChecker/SpellCheckerService';
|
import SpellCheckerService from '@joplin/lib/services/spellChecker/SpellCheckerService';
|
||||||
import SpellCheckerServiceDriverNative from './services/spellChecker/SpellCheckerServiceDriverNative';
|
import SpellCheckerServiceDriverNative from './services/spellChecker/SpellCheckerServiceDriverNative';
|
||||||
@@ -58,6 +59,7 @@ const commands = [
|
|||||||
require('./gui/MainScreen/commands/openTag'),
|
require('./gui/MainScreen/commands/openTag'),
|
||||||
require('./gui/MainScreen/commands/print'),
|
require('./gui/MainScreen/commands/print'),
|
||||||
require('./gui/MainScreen/commands/renameFolder'),
|
require('./gui/MainScreen/commands/renameFolder'),
|
||||||
|
require('./gui/MainScreen/commands/showShareFolderDialog'),
|
||||||
require('./gui/MainScreen/commands/renameTag'),
|
require('./gui/MainScreen/commands/renameTag'),
|
||||||
require('./gui/MainScreen/commands/search'),
|
require('./gui/MainScreen/commands/search'),
|
||||||
require('./gui/MainScreen/commands/selectTemplate'),
|
require('./gui/MainScreen/commands/selectTemplate'),
|
||||||
@@ -100,6 +102,7 @@ const globalCommands = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
import editorCommandDeclarations from './gui/NoteEditor/commands/editorCommandDeclarations';
|
import editorCommandDeclarations from './gui/NoteEditor/commands/editorCommandDeclarations';
|
||||||
|
import ShareService from '@joplin/lib/services/share/ShareService';
|
||||||
|
|
||||||
const pluginClasses = [
|
const pluginClasses = [
|
||||||
require('./plugins/GotoAnything').default,
|
require('./plugins/GotoAnything').default,
|
||||||
@@ -730,6 +733,8 @@ class Application extends BaseApplication {
|
|||||||
bridge().window().show();
|
bridge().window().show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ShareService.instance().maintenance();
|
||||||
|
|
||||||
ResourceService.runInBackground();
|
ResourceService.runInBackground();
|
||||||
|
|
||||||
if (Setting.value('env') === 'dev') {
|
if (Setting.value('env') === 'dev') {
|
||||||
@@ -764,15 +769,16 @@ class Application extends BaseApplication {
|
|||||||
RevisionService.instance().runInBackground();
|
RevisionService.instance().runInBackground();
|
||||||
|
|
||||||
// Make it available to the console window - useful to call revisionService.collectRevisions()
|
// Make it available to the console window - useful to call revisionService.collectRevisions()
|
||||||
(window as any).joplin = () => {
|
if (Setting.value('env') === 'dev') {
|
||||||
return {
|
(window as any).joplin = {
|
||||||
revisionService: RevisionService.instance(),
|
revisionService: RevisionService.instance(),
|
||||||
migrationService: MigrationService.instance(),
|
migrationService: MigrationService.instance(),
|
||||||
decryptionWorker: DecryptionWorker.instance(),
|
decryptionWorker: DecryptionWorker.instance(),
|
||||||
commandService: CommandService.instance(),
|
commandService: CommandService.instance(),
|
||||||
bridge: bridge(),
|
bridge: bridge(),
|
||||||
|
debug: new DebugService(reg.db()),
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
bridge().addEventListener('nativeThemeUpdated', this.bridge_nativeThemeUpdated);
|
bridge().addEventListener('nativeThemeUpdated', this.bridge_nativeThemeUpdated);
|
||||||
|
|
||||||
|
40
packages/app-desktop/gui/Dialog.tsx
Normal file
40
packages/app-desktop/gui/Dialog.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
const DialogModalLayer = styled.div`
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0,0,0,0.6);
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DialogRoot = styled.div`
|
||||||
|
background-color: ${props => props.theme.backgroundColor};
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 6px 6px 20px rgba(0,0,0,0.5);
|
||||||
|
margin-top: 20px;
|
||||||
|
min-height: fit-content;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 50%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
renderContent: Function;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Dialog(props: Props) {
|
||||||
|
return (
|
||||||
|
<DialogModalLayer>
|
||||||
|
<DialogRoot>
|
||||||
|
{props.renderContent()}
|
||||||
|
</DialogRoot>
|
||||||
|
</DialogModalLayer>
|
||||||
|
);
|
||||||
|
}
|
@@ -2,7 +2,26 @@ const React = require('react');
|
|||||||
const { _ } = require('@joplin/lib/locale');
|
const { _ } = require('@joplin/lib/locale');
|
||||||
const { themeStyle } = require('@joplin/lib/theme');
|
const { themeStyle } = require('@joplin/lib/theme');
|
||||||
|
|
||||||
function DialogButtonRow(props) {
|
export interface ButtonSpec {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClickEvent {
|
||||||
|
buttonName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
themeId: number;
|
||||||
|
onClick?: (event: ClickEvent)=> void;
|
||||||
|
okButtonShow?: boolean;
|
||||||
|
cancelButtonShow?: boolean;
|
||||||
|
cancelButtonLabel?: string;
|
||||||
|
okButtonRef?: any;
|
||||||
|
customButtons?: ButtonSpec[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DialogButtonRow(props: Props) {
|
||||||
const theme = themeStyle(props.themeId);
|
const theme = themeStyle(props.themeId);
|
||||||
|
|
||||||
const okButton_click = () => {
|
const okButton_click = () => {
|
||||||
@@ -13,7 +32,11 @@ function DialogButtonRow(props) {
|
|||||||
if (props.onClick) props.onClick({ buttonName: 'cancel' });
|
if (props.onClick) props.onClick({ buttonName: 'cancel' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const onKeyDown = (event) => {
|
const customButton_click = (event: ClickEvent) => {
|
||||||
|
if (props.onClick) props.onClick(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (event: any) => {
|
||||||
if (event.keyCode === 13) {
|
if (event.keyCode === 13) {
|
||||||
okButton_click();
|
okButton_click();
|
||||||
} else if (event.keyCode === 27) {
|
} else if (event.keyCode === 27) {
|
||||||
@@ -23,6 +46,16 @@ function DialogButtonRow(props) {
|
|||||||
|
|
||||||
const buttonComps = [];
|
const buttonComps = [];
|
||||||
|
|
||||||
|
if (props.customButtons) {
|
||||||
|
for (const b of props.customButtons) {
|
||||||
|
buttonComps.push(
|
||||||
|
<button key={b.name} style={theme.buttonStyle} onClick={() => customButton_click({ buttonName: b.name })} onKeyDown={onKeyDown}>
|
||||||
|
{b.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (props.okButtonShow !== false) {
|
if (props.okButtonShow !== false) {
|
||||||
buttonComps.push(
|
buttonComps.push(
|
||||||
<button key="ok" style={theme.buttonStyle} onClick={okButton_click} ref={props.okButtonRef} onKeyDown={onKeyDown}>
|
<button key="ok" style={theme.buttonStyle} onClick={okButton_click} ref={props.okButtonRef} onKeyDown={onKeyDown}>
|
||||||
@@ -41,5 +74,3 @@ function DialogButtonRow(props) {
|
|||||||
|
|
||||||
return <div style={{ textAlign: 'right', marginTop: 10 }}>{buttonComps}</div>;
|
return <div style={{ textAlign: 'right', marginTop: 10 }}>{buttonComps}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = DialogButtonRow;
|
|
21
packages/app-desktop/gui/DialogTitle.tsx
Normal file
21
packages/app-desktop/gui/DialogTitle.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
const Root = styled.div`
|
||||||
|
font-family: ${props => props.theme.fontFamily};
|
||||||
|
font-size: ${props => props.theme.fontSize * 1.5};
|
||||||
|
line-height: 1.6em;
|
||||||
|
color: ${props => props.theme.color};
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 1.2em;
|
||||||
|
`;
|
||||||
|
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DialogTitle(props: Props) {
|
||||||
|
return (
|
||||||
|
<Root>{props.title}</Root>
|
||||||
|
);
|
||||||
|
}
|
@@ -29,12 +29,16 @@ import { themeStyle } from '@joplin/lib/theme';
|
|||||||
import validateLayout from '../ResizableLayout/utils/validateLayout';
|
import validateLayout from '../ResizableLayout/utils/validateLayout';
|
||||||
import iterateItems from '../ResizableLayout/utils/iterateItems';
|
import iterateItems from '../ResizableLayout/utils/iterateItems';
|
||||||
import removeItem from '../ResizableLayout/utils/removeItem';
|
import removeItem from '../ResizableLayout/utils/removeItem';
|
||||||
|
import EncryptionService from '@joplin/lib/services/EncryptionService';
|
||||||
|
import ShareFolderDialog from '../ShareFolderDialog/ShareFolderDialog';
|
||||||
|
import { ShareInvitation } from '@joplin/lib/services/share/reducer';
|
||||||
|
import ShareService from '@joplin/lib/services/share/ShareService';
|
||||||
|
import { reg } from '@joplin/lib/registry';
|
||||||
|
|
||||||
const { connect } = require('react-redux');
|
const { connect } = require('react-redux');
|
||||||
const { PromptDialog } = require('../PromptDialog.min.js');
|
const { PromptDialog } = require('../PromptDialog.min.js');
|
||||||
const NotePropertiesDialog = require('../NotePropertiesDialog.min.js');
|
const NotePropertiesDialog = require('../NotePropertiesDialog.min.js');
|
||||||
const PluginManager = require('@joplin/lib/services/PluginManager');
|
const PluginManager = require('@joplin/lib/services/PluginManager');
|
||||||
import EncryptionService from '@joplin/lib/services/EncryptionService';
|
|
||||||
const ipcRenderer = require('electron').ipcRenderer;
|
const ipcRenderer = require('electron').ipcRenderer;
|
||||||
|
|
||||||
interface LayerModalState {
|
interface LayerModalState {
|
||||||
@@ -63,15 +67,22 @@ interface Props {
|
|||||||
settingEditorCodeView: boolean;
|
settingEditorCodeView: boolean;
|
||||||
pluginsLegacy: any;
|
pluginsLegacy: any;
|
||||||
startupPluginsLoaded: boolean;
|
startupPluginsLoaded: boolean;
|
||||||
|
shareInvitations: ShareInvitation[];
|
||||||
isSafeMode: boolean;
|
isSafeMode: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ShareFolderDialogOptions {
|
||||||
|
folderId: string;
|
||||||
|
visible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
promptOptions: any;
|
promptOptions: any;
|
||||||
modalLayer: LayerModalState;
|
modalLayer: LayerModalState;
|
||||||
notePropertiesDialogOptions: any;
|
notePropertiesDialogOptions: any;
|
||||||
noteContentPropertiesDialogOptions: any;
|
noteContentPropertiesDialogOptions: any;
|
||||||
shareNoteDialogOptions: any;
|
shareNoteDialogOptions: any;
|
||||||
|
shareFolderDialogOptions: ShareFolderDialogOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StyledUserWebviewDialogContainer = styled.div`
|
const StyledUserWebviewDialogContainer = styled.div`
|
||||||
@@ -105,6 +116,7 @@ const commands = [
|
|||||||
require('./commands/newTodo'),
|
require('./commands/newTodo'),
|
||||||
require('./commands/print'),
|
require('./commands/print'),
|
||||||
require('./commands/renameFolder'),
|
require('./commands/renameFolder'),
|
||||||
|
require('./commands/showShareFolderDialog'),
|
||||||
require('./commands/renameTag'),
|
require('./commands/renameTag'),
|
||||||
require('./commands/search'),
|
require('./commands/search'),
|
||||||
require('./commands/selectTemplate'),
|
require('./commands/selectTemplate'),
|
||||||
@@ -144,6 +156,10 @@ class MainScreenComponent extends React.Component<Props, State> {
|
|||||||
notePropertiesDialogOptions: {},
|
notePropertiesDialogOptions: {},
|
||||||
noteContentPropertiesDialogOptions: {},
|
noteContentPropertiesDialogOptions: {},
|
||||||
shareNoteDialogOptions: {},
|
shareNoteDialogOptions: {},
|
||||||
|
shareFolderDialogOptions: {
|
||||||
|
visible: false,
|
||||||
|
folderId: '',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
this.updateMainLayout(this.buildLayout(props.plugins));
|
this.updateMainLayout(this.buildLayout(props.plugins));
|
||||||
@@ -155,6 +171,7 @@ class MainScreenComponent extends React.Component<Props, State> {
|
|||||||
this.notePropertiesDialog_close = this.notePropertiesDialog_close.bind(this);
|
this.notePropertiesDialog_close = this.notePropertiesDialog_close.bind(this);
|
||||||
this.noteContentPropertiesDialog_close = this.noteContentPropertiesDialog_close.bind(this);
|
this.noteContentPropertiesDialog_close = this.noteContentPropertiesDialog_close.bind(this);
|
||||||
this.shareNoteDialog_close = this.shareNoteDialog_close.bind(this);
|
this.shareNoteDialog_close = this.shareNoteDialog_close.bind(this);
|
||||||
|
this.shareFolderDialog_close = this.shareFolderDialog_close.bind(this);
|
||||||
this.resizableLayout_resize = this.resizableLayout_resize.bind(this);
|
this.resizableLayout_resize = this.resizableLayout_resize.bind(this);
|
||||||
this.resizableLayout_renderItem = this.resizableLayout_renderItem.bind(this);
|
this.resizableLayout_renderItem = this.resizableLayout_renderItem.bind(this);
|
||||||
this.resizableLayout_moveButtonClick = this.resizableLayout_moveButtonClick.bind(this);
|
this.resizableLayout_moveButtonClick = this.resizableLayout_moveButtonClick.bind(this);
|
||||||
@@ -204,6 +221,10 @@ class MainScreenComponent extends React.Component<Props, State> {
|
|||||||
return newLayout !== layout ? validateLayout(newLayout) : layout;
|
return newLayout !== layout ? validateLayout(newLayout) : layout;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private showShareInvitationNotification(props: Props): boolean {
|
||||||
|
return !!props.shareInvitations.find(i => i.status === 0);
|
||||||
|
}
|
||||||
|
|
||||||
private buildLayout(plugins: PluginStates): LayoutItem {
|
private buildLayout(plugins: PluginStates): LayoutItem {
|
||||||
const rootLayoutSize = this.rootLayoutSize();
|
const rootLayoutSize = this.rootLayoutSize();
|
||||||
|
|
||||||
@@ -268,10 +289,14 @@ class MainScreenComponent extends React.Component<Props, State> {
|
|||||||
this.setState({ noteContentPropertiesDialogOptions: {} });
|
this.setState({ noteContentPropertiesDialogOptions: {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
shareNoteDialog_close() {
|
private shareNoteDialog_close() {
|
||||||
this.setState({ shareNoteDialogOptions: {} });
|
this.setState({ shareNoteDialogOptions: {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private shareFolderDialog_close() {
|
||||||
|
this.setState({ shareFolderDialogOptions: { visible: false, folderId: '' } });
|
||||||
|
}
|
||||||
|
|
||||||
updateMainLayout(layout: LayoutItem) {
|
updateMainLayout(layout: LayoutItem) {
|
||||||
this.props.dispatch({
|
this.props.dispatch({
|
||||||
type: 'MAIN_LAYOUT_SET',
|
type: 'MAIN_LAYOUT_SET',
|
||||||
@@ -321,6 +346,13 @@ class MainScreenComponent extends React.Component<Props, State> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.state.shareFolderDialogOptions !== prevState.shareFolderDialogOptions) {
|
||||||
|
this.props.dispatch({
|
||||||
|
type: this.state.shareFolderDialogOptions && this.state.shareFolderDialogOptions.visible ? 'VISIBLE_DIALOGS_ADD' : 'VISIBLE_DIALOGS_REMOVE',
|
||||||
|
name: 'shareFolder',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (this.props.mainLayout !== prevProps.mainLayout) {
|
if (this.props.mainLayout !== prevProps.mainLayout) {
|
||||||
const toSave = saveLayout(this.props.mainLayout);
|
const toSave = saveLayout(this.props.mainLayout);
|
||||||
Setting.setValue('ui.layout', toSave);
|
Setting.setValue('ui.layout', toSave);
|
||||||
@@ -496,6 +528,12 @@ class MainScreenComponent extends React.Component<Props, State> {
|
|||||||
bridge().restart();
|
bridge().restart();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onInvitationRespond = async (shareUserId: string, accept: boolean) => {
|
||||||
|
await ShareService.instance().respondInvitation(shareUserId, accept);
|
||||||
|
await ShareService.instance().refreshShareInvitations();
|
||||||
|
void reg.scheduleSync(1000);
|
||||||
|
};
|
||||||
|
|
||||||
let msg = null;
|
let msg = null;
|
||||||
|
|
||||||
if (this.props.isSafeMode) {
|
if (this.props.isSafeMode) {
|
||||||
@@ -516,15 +554,6 @@ class MainScreenComponent extends React.Component<Props, State> {
|
|||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
} else if (this.props.hasDisabledSyncItems) {
|
|
||||||
msg = (
|
|
||||||
<span>
|
|
||||||
{_('Some items cannot be synchronised.')}{' '}
|
|
||||||
<a href="#" onClick={() => onViewStatusScreen()}>
|
|
||||||
{_('View them now')}
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
} else if (this.props.hasDisabledEncryptionItems) {
|
} else if (this.props.hasDisabledEncryptionItems) {
|
||||||
msg = (
|
msg = (
|
||||||
<span>
|
<span>
|
||||||
@@ -534,15 +563,6 @@ class MainScreenComponent extends React.Component<Props, State> {
|
|||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
} else if (this.props.showMissingMasterKeyMessage) {
|
|
||||||
msg = (
|
|
||||||
<span>
|
|
||||||
{_('One or more master keys need a password.')}{' '}
|
|
||||||
<a href="#" onClick={() => onViewEncryptionConfigScreen()}>
|
|
||||||
{_('Set the password')}
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
} else if (this.props.showNeedUpgradingMasterKeyMessage) {
|
} else if (this.props.showNeedUpgradingMasterKeyMessage) {
|
||||||
msg = (
|
msg = (
|
||||||
<span>
|
<span>
|
||||||
@@ -561,6 +581,40 @@ class MainScreenComponent extends React.Component<Props, State> {
|
|||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
} else if (this.showShareInvitationNotification(this.props)) {
|
||||||
|
const invitation = this.props.shareInvitations[0];
|
||||||
|
const sharer = invitation.share.user;
|
||||||
|
|
||||||
|
msg = (
|
||||||
|
<span>
|
||||||
|
{_('%s (%s) would like to share a notebook with you.', sharer.full_name, sharer.email)}{' '}
|
||||||
|
<a href="#" onClick={() => onInvitationRespond(invitation.id, true)}>
|
||||||
|
{_('Accept')}
|
||||||
|
</a>
|
||||||
|
{' / '}
|
||||||
|
<a href="#" onClick={() => onInvitationRespond(invitation.id,true)}>
|
||||||
|
{_('Reject')}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
} else if (this.props.hasDisabledSyncItems) {
|
||||||
|
msg = (
|
||||||
|
<span>
|
||||||
|
{_('Some items cannot be synchronised.')}{' '}
|
||||||
|
<a href="#" onClick={() => onViewStatusScreen()}>
|
||||||
|
{_('View them now')}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
} else if (this.props.showMissingMasterKeyMessage) {
|
||||||
|
msg = (
|
||||||
|
<span>
|
||||||
|
{_('One or more master keys need a password.')}{' '}
|
||||||
|
<a href="#" onClick={() => onViewEncryptionConfigScreen()}>
|
||||||
|
{_('Set the password')}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -572,7 +626,7 @@ class MainScreenComponent extends React.Component<Props, State> {
|
|||||||
|
|
||||||
messageBoxVisible(props: Props = null) {
|
messageBoxVisible(props: Props = null) {
|
||||||
if (!props) props = this.props;
|
if (!props) props = this.props;
|
||||||
return props.hasDisabledSyncItems || props.showMissingMasterKeyMessage || props.showNeedUpgradingMasterKeyMessage || props.showShouldReencryptMessage || props.hasDisabledEncryptionItems || this.props.shouldUpgradeSyncTarget || props.isSafeMode;
|
return props.hasDisabledSyncItems || props.showMissingMasterKeyMessage || props.showNeedUpgradingMasterKeyMessage || props.showShouldReencryptMessage || props.hasDisabledEncryptionItems || this.props.shouldUpgradeSyncTarget || props.isSafeMode || this.showShareInvitationNotification(props);
|
||||||
}
|
}
|
||||||
|
|
||||||
registerCommands() {
|
registerCommands() {
|
||||||
@@ -739,6 +793,7 @@ class MainScreenComponent extends React.Component<Props, State> {
|
|||||||
const notePropertiesDialogOptions = this.state.notePropertiesDialogOptions;
|
const notePropertiesDialogOptions = this.state.notePropertiesDialogOptions;
|
||||||
const noteContentPropertiesDialogOptions = this.state.noteContentPropertiesDialogOptions;
|
const noteContentPropertiesDialogOptions = this.state.noteContentPropertiesDialogOptions;
|
||||||
const shareNoteDialogOptions = this.state.shareNoteDialogOptions;
|
const shareNoteDialogOptions = this.state.shareNoteDialogOptions;
|
||||||
|
const shareFolderDialogOptions = this.state.shareFolderDialogOptions;
|
||||||
|
|
||||||
const layoutComp = this.props.mainLayout ? (
|
const layoutComp = this.props.mainLayout ? (
|
||||||
<ResizableLayout
|
<ResizableLayout
|
||||||
@@ -759,6 +814,7 @@ class MainScreenComponent extends React.Component<Props, State> {
|
|||||||
{noteContentPropertiesDialogOptions.visible && <NoteContentPropertiesDialog markupLanguage={noteContentPropertiesDialogOptions.markupLanguage} themeId={this.props.themeId} onClose={this.noteContentPropertiesDialog_close} text={noteContentPropertiesDialogOptions.text}/>}
|
{noteContentPropertiesDialogOptions.visible && <NoteContentPropertiesDialog markupLanguage={noteContentPropertiesDialogOptions.markupLanguage} themeId={this.props.themeId} onClose={this.noteContentPropertiesDialog_close} text={noteContentPropertiesDialogOptions.text}/>}
|
||||||
{notePropertiesDialogOptions.visible && <NotePropertiesDialog themeId={this.props.themeId} noteId={notePropertiesDialogOptions.noteId} onClose={this.notePropertiesDialog_close} onRevisionLinkClick={notePropertiesDialogOptions.onRevisionLinkClick} />}
|
{notePropertiesDialogOptions.visible && <NotePropertiesDialog themeId={this.props.themeId} noteId={notePropertiesDialogOptions.noteId} onClose={this.notePropertiesDialog_close} onRevisionLinkClick={notePropertiesDialogOptions.onRevisionLinkClick} />}
|
||||||
{shareNoteDialogOptions.visible && <ShareNoteDialog themeId={this.props.themeId} noteIds={shareNoteDialogOptions.noteIds} onClose={this.shareNoteDialog_close} />}
|
{shareNoteDialogOptions.visible && <ShareNoteDialog themeId={this.props.themeId} noteIds={shareNoteDialogOptions.noteIds} onClose={this.shareNoteDialog_close} />}
|
||||||
|
{shareFolderDialogOptions.visible && <ShareFolderDialog themeId={this.props.themeId} folderId={shareFolderDialogOptions.folderId} onClose={this.shareFolderDialog_close} />}
|
||||||
|
|
||||||
<PromptDialog autocomplete={promptOptions && 'autocomplete' in promptOptions ? promptOptions.autocomplete : null} defaultValue={promptOptions && promptOptions.value ? promptOptions.value : ''} themeId={this.props.themeId} style={styles.prompt} onClose={this.promptOnClose_} label={promptOptions ? promptOptions.label : ''} description={promptOptions ? promptOptions.description : null} visible={!!this.state.promptOptions} buttons={promptOptions && 'buttons' in promptOptions ? promptOptions.buttons : null} inputType={promptOptions && 'inputType' in promptOptions ? promptOptions.inputType : null} />
|
<PromptDialog autocomplete={promptOptions && 'autocomplete' in promptOptions ? promptOptions.autocomplete : null} defaultValue={promptOptions && promptOptions.value ? promptOptions.value : ''} themeId={this.props.themeId} style={styles.prompt} onClose={this.promptOnClose_} label={promptOptions ? promptOptions.label : ''} description={promptOptions ? promptOptions.description : null} visible={!!this.state.promptOptions} buttons={promptOptions && 'buttons' in promptOptions ? promptOptions.buttons : null} inputType={promptOptions && 'inputType' in promptOptions ? promptOptions.inputType : null} />
|
||||||
|
|
||||||
@@ -794,6 +850,7 @@ const mapStateToProps = (state: AppState) => {
|
|||||||
layoutMoveMode: state.layoutMoveMode,
|
layoutMoveMode: state.layoutMoveMode,
|
||||||
mainLayout: state.mainLayout,
|
mainLayout: state.mainLayout,
|
||||||
startupPluginsLoaded: state.startupPluginsLoaded,
|
startupPluginsLoaded: state.startupPluginsLoaded,
|
||||||
|
shareInvitations: state.shareService.shareInvitations,
|
||||||
isSafeMode: state.settings.isSafeMode,
|
isSafeMode: state.settings.isSafeMode,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@@ -0,0 +1,23 @@
|
|||||||
|
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||||
|
import { _ } from '@joplin/lib/locale';
|
||||||
|
|
||||||
|
export const declaration: CommandDeclaration = {
|
||||||
|
name: 'showShareFolderDialog',
|
||||||
|
label: () => _('Share notebook...'),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runtime = (comp: any): CommandRuntime => {
|
||||||
|
return {
|
||||||
|
execute: async (context: CommandContext, folderId: string = null) => {
|
||||||
|
folderId = folderId || context.state.selectedFolderId;
|
||||||
|
|
||||||
|
comp.setState({
|
||||||
|
shareFolderDialogOptions: {
|
||||||
|
folderId,
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
enabledCondition: 'folderIsShareRootAndOwnedByUser || !folderIsShared',
|
||||||
|
};
|
||||||
|
};
|
@@ -1,8 +1,8 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { _ } from '@joplin/lib/locale';
|
import { _ } from '@joplin/lib/locale';
|
||||||
|
import DialogButtonRow from './DialogButtonRow';
|
||||||
const { themeStyle } = require('@joplin/lib/theme');
|
const { themeStyle } = require('@joplin/lib/theme');
|
||||||
const DialogButtonRow = require('./DialogButtonRow.min');
|
|
||||||
const Countable = require('countable');
|
const Countable = require('countable');
|
||||||
import markupLanguageUtils from '../utils/markupLanguageUtils';
|
import markupLanguageUtils from '../utils/markupLanguageUtils';
|
||||||
|
|
||||||
|
@@ -2,7 +2,7 @@ const React = require('react');
|
|||||||
const { _ } = require('@joplin/lib/locale');
|
const { _ } = require('@joplin/lib/locale');
|
||||||
const { themeStyle } = require('@joplin/lib/theme');
|
const { themeStyle } = require('@joplin/lib/theme');
|
||||||
const time = require('@joplin/lib/time').default;
|
const time = require('@joplin/lib/time').default;
|
||||||
const DialogButtonRow = require('./DialogButtonRow.min');
|
const DialogButtonRow = require('./DialogButtonRow').default;
|
||||||
const Datetime = require('react-datetime');
|
const Datetime = require('react-datetime');
|
||||||
const Note = require('@joplin/lib/models/Note').default;
|
const Note = require('@joplin/lib/models/Note').default;
|
||||||
const formatcoords = require('formatcoords');
|
const formatcoords = require('formatcoords');
|
||||||
|
336
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.tsx
Normal file
336
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.tsx
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
import Dialog from '../Dialog';
|
||||||
|
import DialogButtonRow, { ClickEvent, ButtonSpec } from '../DialogButtonRow';
|
||||||
|
import DialogTitle from '../DialogTitle';
|
||||||
|
import { _ } from '@joplin/lib/locale';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { FolderEntity } from '@joplin/lib/services/database/types';
|
||||||
|
import Folder from '@joplin/lib/models/Folder';
|
||||||
|
import ShareService from '@joplin/lib/services/share/ShareService';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import StyledFormLabel from '../style/StyledFormLabel';
|
||||||
|
import StyledInput from '../style/StyledInput';
|
||||||
|
import Button from '../Button/Button';
|
||||||
|
import Logger from '@joplin/lib/Logger';
|
||||||
|
import StyledMessage from '../style/StyledMessage';
|
||||||
|
import { ShareUserStatus, StateShare, StateShareUser } from '@joplin/lib/services/share/reducer';
|
||||||
|
import { State } from '@joplin/lib/reducer';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { reg } from '@joplin/lib/registry';
|
||||||
|
|
||||||
|
const logger = Logger.create('ShareFolderDialog');
|
||||||
|
|
||||||
|
const StyledFolder = styled.div`
|
||||||
|
border: 1px solid ${(props) => props.theme.dividerColor};
|
||||||
|
padding: 0.5em;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledRecipientControls = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledRecipientInput = styled(StyledInput)`
|
||||||
|
width: 100%;
|
||||||
|
margin-right: 10px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledAddRecipient = styled.div`
|
||||||
|
margin-bottom: 1em;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledRecipient = styled(StyledMessage)`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
padding: .6em 1em;
|
||||||
|
background-color: ${props => props.index % 2 === 0 ? props.theme.backgroundColor : props.theme.oddBackgroundColor};
|
||||||
|
align-items: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledRecipientName = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledRecipientStatusIcon = styled.i`
|
||||||
|
margin-right: .6em;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledRecipients = styled.div`
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledRecipientList = styled.div`
|
||||||
|
border: 1px solid ${(props: any) => props.theme.dividerColor};
|
||||||
|
border-radius: 3px;
|
||||||
|
height: 300px;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: scroll;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledError = styled(StyledMessage)`
|
||||||
|
word-break: break-all;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledShareState = styled(StyledMessage)`
|
||||||
|
word-break: break-all;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledIcon = styled.i`
|
||||||
|
margin-right: 8px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
themeId: number;
|
||||||
|
folderId: string;
|
||||||
|
onClose(): void;
|
||||||
|
shares: StateShare[];
|
||||||
|
shareUsers: Record<string, StateShareUser[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecipientDeleteEvent {
|
||||||
|
shareUserId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AsyncEffectEvent {
|
||||||
|
cancelled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useAsyncEffect(effect: Function, dependencies: any[]) {
|
||||||
|
useEffect(() => {
|
||||||
|
const event = { cancelled: false };
|
||||||
|
effect(event);
|
||||||
|
return () => {
|
||||||
|
event.cancelled = true;
|
||||||
|
};
|
||||||
|
}, dependencies);
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ShareState {
|
||||||
|
Idle = 0,
|
||||||
|
Synchronizing = 1,
|
||||||
|
Creating = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShareFolderDialog(props: Props) {
|
||||||
|
const [folder, setFolder] = useState<FolderEntity>(null);
|
||||||
|
const [recipientEmail, setRecipientEmail] = useState<string>('');
|
||||||
|
const [latestError, setLatestError] = useState<Error>(null);
|
||||||
|
const [share, setShare] = useState<StateShare>(null);
|
||||||
|
const [shareUsers, setShareUsers] = useState<StateShareUser[]>([]);
|
||||||
|
const [shareState, setShareState] = useState<ShareState>(ShareState.Idle);
|
||||||
|
const [customButtons, setCustomButtons] = useState<ButtonSpec[]>([]);
|
||||||
|
|
||||||
|
async function synchronize(event: AsyncEffectEvent = null) {
|
||||||
|
setShareState(ShareState.Synchronizing);
|
||||||
|
await reg.waitForSyncFinishedThenSync();
|
||||||
|
if (event && event.cancelled) return;
|
||||||
|
setShareState(ShareState.Idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
useAsyncEffect(async (event: AsyncEffectEvent) => {
|
||||||
|
const f = await Folder.load(props.folderId);
|
||||||
|
if (event.cancelled) return;
|
||||||
|
setFolder(f);
|
||||||
|
}, [props.folderId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void ShareService.instance().refreshShares();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useAsyncEffect(async (event: AsyncEffectEvent) => {
|
||||||
|
await synchronize(event);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const s = props.shares.find(s => s.folder_id === props.folderId);
|
||||||
|
setShare(s);
|
||||||
|
}, [props.shares]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!share) return;
|
||||||
|
void ShareService.instance().refreshShareUsers(share.id);
|
||||||
|
}, [share]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCustomButtons(share ? [{
|
||||||
|
name: 'unshare',
|
||||||
|
label: _('Unshare'),
|
||||||
|
}] : []);
|
||||||
|
}, [share]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!share) return;
|
||||||
|
const sus = props.shareUsers[share.id];
|
||||||
|
if (!sus) return;
|
||||||
|
setShareUsers(sus);
|
||||||
|
}, [share, props.shareUsers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void ShareService.instance().refreshShares();
|
||||||
|
}, [props.folderId]);
|
||||||
|
|
||||||
|
async function shareRecipient_click() {
|
||||||
|
setShareState(ShareState.Creating);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLatestError(null);
|
||||||
|
const share = await ShareService.instance().shareFolder(props.folderId);
|
||||||
|
await ShareService.instance().addShareRecipient(share.id, recipientEmail);
|
||||||
|
await Promise.all([
|
||||||
|
ShareService.instance().refreshShares(),
|
||||||
|
ShareService.instance().refreshShareUsers(share.id),
|
||||||
|
]);
|
||||||
|
setRecipientEmail('');
|
||||||
|
|
||||||
|
await synchronize();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
setLatestError(error);
|
||||||
|
} finally {
|
||||||
|
setShareState(ShareState.Idle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recipientEmail_change(event: any) {
|
||||||
|
setRecipientEmail(event.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recipient_delete(event: RecipientDeleteEvent) {
|
||||||
|
if (!confirm(_('Delete this invitation? The recipient will no longer have access to this shared notebook.'))) return;
|
||||||
|
|
||||||
|
await ShareService.instance().deleteShareRecipient(event.shareUserId);
|
||||||
|
await ShareService.instance().refreshShareUsers(share.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFolder() {
|
||||||
|
return (
|
||||||
|
<StyledFolder>
|
||||||
|
<StyledIcon className="icon-notebooks"/>{folder ? folder.title : '...'}
|
||||||
|
</StyledFolder>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAddRecipient() {
|
||||||
|
const disabled = shareState !== ShareState.Idle;
|
||||||
|
return (
|
||||||
|
<StyledAddRecipient>
|
||||||
|
<StyledFormLabel>{_('Add recipient:')}</StyledFormLabel>
|
||||||
|
<StyledRecipientControls>
|
||||||
|
<StyledRecipientInput disabled={disabled} type="email" placeholder="example@domain.com" value={recipientEmail} onChange={recipientEmail_change} />
|
||||||
|
<Button disabled={disabled} title={_('Share')} onClick={shareRecipient_click}></Button>
|
||||||
|
</StyledRecipientControls>
|
||||||
|
</StyledAddRecipient>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecipient(index: number, shareUser: StateShareUser) {
|
||||||
|
const statusToIcon = {
|
||||||
|
[ShareUserStatus.Waiting]: 'fas fa-question',
|
||||||
|
[ShareUserStatus.Rejected]: 'fas fa-times',
|
||||||
|
[ShareUserStatus.Accepted]: 'fas fa-check',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusToMessage = {
|
||||||
|
[ShareUserStatus.Waiting]: _('Recipient has not yet accepted the invitation'),
|
||||||
|
[ShareUserStatus.Rejected]: _('Recipient has rejected the invitation'),
|
||||||
|
[ShareUserStatus.Accepted]: _('Recipient has accepted the invitation'),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledRecipient key={shareUser.user.email} index={index}>
|
||||||
|
<StyledRecipientName>{shareUser.user.email}</StyledRecipientName>
|
||||||
|
<StyledRecipientStatusIcon title={statusToMessage[shareUser.status]} className={statusToIcon[shareUser.status]}></StyledRecipientStatusIcon>
|
||||||
|
<Button iconName="far fa-times-circle" onClick={() => recipient_delete({ shareUserId: shareUser.id })}/>
|
||||||
|
</StyledRecipient>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecipients() {
|
||||||
|
const listItems = shareUsers.map((su: StateShareUser, index: number) => renderRecipient(index, su));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledRecipients>
|
||||||
|
<StyledFormLabel>{_('Recipients:')}</StyledFormLabel>
|
||||||
|
<StyledRecipientList>
|
||||||
|
{listItems}
|
||||||
|
</StyledRecipientList>
|
||||||
|
</StyledRecipients>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderError() {
|
||||||
|
if (!latestError) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledError type="error">
|
||||||
|
{latestError.message}
|
||||||
|
</StyledError>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderShareState() {
|
||||||
|
if (shareState === ShareState.Idle) return null;
|
||||||
|
|
||||||
|
const messages = {
|
||||||
|
[ShareState.Synchronizing]: _('Synchronizing...'),
|
||||||
|
[ShareState.Creating]: _('Sharing notebook...'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const message = messages[shareState];
|
||||||
|
if (!message) throw new Error(`Unsupported state: ${shareState}`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledShareState>
|
||||||
|
{message}
|
||||||
|
</StyledShareState>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buttonRow_click(event: ClickEvent) {
|
||||||
|
if (event.buttonName === 'unshare') {
|
||||||
|
if (!confirm(_('Unshare this notebook? The recipients will no longer have access to its content.'))) return;
|
||||||
|
await ShareService.instance().unshareFolder(props.folderId);
|
||||||
|
void synchronize();
|
||||||
|
}
|
||||||
|
|
||||||
|
props.onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderContent() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<DialogTitle title={_('Share Notebook')}/>
|
||||||
|
{renderFolder()}
|
||||||
|
{renderAddRecipient()}
|
||||||
|
{renderShareState()}
|
||||||
|
{renderError()}
|
||||||
|
{renderRecipients()}
|
||||||
|
<DialogButtonRow
|
||||||
|
themeId={props.themeId}
|
||||||
|
onClick={buttonRow_click}
|
||||||
|
okButtonShow={false}
|
||||||
|
cancelButtonLabel={_('Close')}
|
||||||
|
customButtons={customButtons}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog renderContent={renderContent}/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapStateToProps = (state: State) => {
|
||||||
|
return {
|
||||||
|
shares: state.shareService.shares,
|
||||||
|
shareUsers: state.shareService.shareUsers,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps)(ShareFolderDialog as any);
|
@@ -4,12 +4,12 @@ import JoplinServerApi from '@joplin/lib/JoplinServerApi';
|
|||||||
import { _, _n } from '@joplin/lib/locale';
|
import { _, _n } from '@joplin/lib/locale';
|
||||||
import Note from '@joplin/lib/models/Note';
|
import Note from '@joplin/lib/models/Note';
|
||||||
import Setting from '@joplin/lib/models/Setting';
|
import Setting from '@joplin/lib/models/Setting';
|
||||||
import BaseItem from '@joplin/lib/models/BaseItem';
|
import DialogButtonRow from './DialogButtonRow';
|
||||||
import SyncTargetJoplinServer from '@joplin/lib/SyncTargetJoplinServer';
|
import { themeStyle, buildStyle } from '@joplin/lib/theme';
|
||||||
|
|
||||||
const { themeStyle, buildStyle } = require('@joplin/lib/theme');
|
|
||||||
const DialogButtonRow = require('./DialogButtonRow.min');
|
|
||||||
import { reg } from '@joplin/lib/registry';
|
import { reg } from '@joplin/lib/registry';
|
||||||
|
import Dialog from './Dialog';
|
||||||
|
import DialogTitle from './DialogTitle';
|
||||||
|
import ShareService from '@joplin/lib/services/share/ShareService';
|
||||||
const { clipboard } = require('electron');
|
const { clipboard } = require('electron');
|
||||||
|
|
||||||
interface ShareNoteDialogProps {
|
interface ShareNoteDialogProps {
|
||||||
@@ -83,26 +83,19 @@ export default function ShareNoteDialog(props: ShareNoteDialogProps) {
|
|||||||
void fetchNotes();
|
void fetchNotes();
|
||||||
}, [props.noteIds]);
|
}, [props.noteIds]);
|
||||||
|
|
||||||
const fileApi = async () => {
|
|
||||||
const syncTarget = reg.syncTarget() as SyncTargetJoplinServer;
|
|
||||||
return syncTarget.fileApi();
|
|
||||||
};
|
|
||||||
|
|
||||||
const joplinServerApi = async (): Promise<JoplinServerApi> => {
|
|
||||||
return (await fileApi()).driver().api();
|
|
||||||
};
|
|
||||||
|
|
||||||
const buttonRow_click = () => {
|
const buttonRow_click = () => {
|
||||||
props.onClose();
|
props.onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyLinksToClipboard = (api: JoplinServerApi, shares: SharesMap) => {
|
const copyLinksToClipboard = (shares: SharesMap) => {
|
||||||
const links = [];
|
const links = [];
|
||||||
for (const n in shares) links.push(api.shareUrl(shares[n]));
|
for (const n in shares) links.push(ShareService.instance().shareUrl(shares[n]));
|
||||||
clipboard.writeText(links.join('\n'));
|
clipboard.writeText(links.join('\n'));
|
||||||
};
|
};
|
||||||
|
|
||||||
const shareLinkButton_click = async () => {
|
const shareLinkButton_click = async () => {
|
||||||
|
const service = ShareService.instance();
|
||||||
|
|
||||||
let hasSynced = false;
|
let hasSynced = false;
|
||||||
let tryToSync = false;
|
let tryToSync = false;
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -116,29 +109,20 @@ export default function ShareNoteDialog(props: ShareNoteDialogProps) {
|
|||||||
|
|
||||||
setSharesState('creating');
|
setSharesState('creating');
|
||||||
|
|
||||||
const api = await joplinServerApi();
|
|
||||||
|
|
||||||
const newShares = Object.assign({}, shares);
|
const newShares = Object.assign({}, shares);
|
||||||
let sharedStatusChanged = false;
|
|
||||||
|
|
||||||
for (const note of notes) {
|
for (const note of notes) {
|
||||||
const fullPath = (await fileApi()).fullPath(BaseItem.systemPath(note.id));
|
const share = await service.shareNote(note.id);
|
||||||
const share = await api.shareFile(fullPath);
|
|
||||||
newShares[note.id] = share;
|
newShares[note.id] = share;
|
||||||
|
|
||||||
const changed = await BaseItem.updateShareStatus(note, true);
|
|
||||||
if (changed) sharedStatusChanged = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setShares(newShares);
|
setShares(newShares);
|
||||||
|
|
||||||
if (sharedStatusChanged) {
|
setSharesState('synchronizing');
|
||||||
setSharesState('synchronizing');
|
await reg.waitForSyncFinishedThenSync();
|
||||||
await reg.waitForSyncFinishedThenSync();
|
setSharesState('creating');
|
||||||
setSharesState('creating');
|
|
||||||
}
|
|
||||||
|
|
||||||
copyLinksToClipboard(api, newShares);
|
copyLinksToClipboard(newShares);
|
||||||
|
|
||||||
setSharesState('created');
|
setSharesState('created');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -202,24 +186,20 @@ export default function ShareNoteDialog(props: ShareNoteDialogProps) {
|
|||||||
return <div style={theme.textStyle}>{_('Note: When a note is shared, it will no longer be encrypted on the server.')}<hr/></div>;
|
return <div style={theme.textStyle}>{_('Note: When a note is shared, it will no longer be encrypted on the server.')}<hr/></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBetaWarningMessage() {
|
function renderContent() {
|
||||||
return <div style={theme.textStyle}>{'Sharing notes via Joplin Server is a Beta feature and the API might change later on. What it means is that if you share a note, the link might become invalid after an upgrade, and you will have to share it again.'}</div>;
|
return (
|
||||||
}
|
<div>
|
||||||
|
<DialogTitle title={_('Share Notes')}/>
|
||||||
const rootStyle = Object.assign({}, theme.dialogBox);
|
|
||||||
rootStyle.width = '50%';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={theme.dialogModalLayer}>
|
|
||||||
<div style={rootStyle}>
|
|
||||||
<div style={theme.dialogTitle}>{_('Share Notes')}</div>
|
|
||||||
{renderNoteList(notes)}
|
{renderNoteList(notes)}
|
||||||
<button disabled={['creating', 'synchronizing'].indexOf(sharesState) >= 0} style={styles.copyShareLinkButton} onClick={shareLinkButton_click}>{_n('Copy Shareable Link', 'Copy Shareable Links', noteCount)}</button>
|
<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>
|
<div style={theme.textStyle}>{statusMessage(sharesState)}</div>
|
||||||
{renderEncryptionWarningMessage()}
|
{renderEncryptionWarningMessage()}
|
||||||
{renderBetaWarningMessage()}
|
|
||||||
<DialogButtonRow themeId={props.themeId} onClick={buttonRow_click} okButtonShow={false} cancelButtonLabel={_('Close')}/>
|
<DialogButtonRow themeId={props.themeId} onClick={buttonRow_click} okButtonShow={false} cancelButtonLabel={_('Close')}/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog renderContent={renderContent}/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { StyledRoot, StyledAddButton, StyledHeader, StyledHeaderIcon, StyledAllNotesIcon, StyledHeaderLabel, StyledListItem, StyledListItemAnchor, StyledExpandLink, StyledNoteCount, StyledSyncReportText, StyledSyncReport, StyledSynchronizeButton } from './styles';
|
import { StyledRoot, StyledAddButton, StyledShareIcon, StyledHeader, StyledHeaderIcon, StyledAllNotesIcon, StyledHeaderLabel, StyledListItem, StyledListItemAnchor, StyledExpandLink, StyledNoteCount, StyledSyncReportText, StyledSyncReport, StyledSynchronizeButton } from './styles';
|
||||||
import { ButtonLevel } from '../Button/Button';
|
import { ButtonLevel } from '../Button/Button';
|
||||||
import CommandService from '@joplin/lib/services/CommandService';
|
import CommandService from '@joplin/lib/services/CommandService';
|
||||||
import InteropService from '@joplin/lib/services/interop/InteropService';
|
import InteropService from '@joplin/lib/services/interop/InteropService';
|
||||||
@@ -12,13 +12,16 @@ import { PluginStates, utils as pluginUtils } from '@joplin/lib/services/plugins
|
|||||||
import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types';
|
import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types';
|
||||||
import { AppState } from '../../app';
|
import { AppState } from '../../app';
|
||||||
import { ModelType } from '@joplin/lib/BaseModel';
|
import { ModelType } from '@joplin/lib/BaseModel';
|
||||||
|
|
||||||
const { connect } = require('react-redux');
|
|
||||||
const shared = require('@joplin/lib/components/shared/side-menu-shared.js');
|
|
||||||
import BaseModel from '@joplin/lib/BaseModel';
|
import BaseModel from '@joplin/lib/BaseModel';
|
||||||
import Folder from '@joplin/lib/models/Folder';
|
import Folder from '@joplin/lib/models/Folder';
|
||||||
import Note from '@joplin/lib/models/Note';
|
import Note from '@joplin/lib/models/Note';
|
||||||
import Tag from '@joplin/lib/models/Tag';
|
import Tag from '@joplin/lib/models/Tag';
|
||||||
|
import Logger from '@joplin/lib/Logger';
|
||||||
|
import { FolderEntity } from '@joplin/lib/services/database/types';
|
||||||
|
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
|
||||||
|
import { store } from '@joplin/lib/reducer';
|
||||||
|
const { connect } = require('react-redux');
|
||||||
|
const shared = require('@joplin/lib/components/shared/side-menu-shared.js');
|
||||||
const { themeStyle } = require('@joplin/lib/theme');
|
const { themeStyle } = require('@joplin/lib/theme');
|
||||||
const bridge = require('electron').remote.require('./bridge').default;
|
const bridge = require('electron').remote.require('./bridge').default;
|
||||||
const Menu = bridge().Menu;
|
const Menu = bridge().Menu;
|
||||||
@@ -26,6 +29,8 @@ const MenuItem = bridge().MenuItem;
|
|||||||
const { substrWithEllipsis } = require('@joplin/lib/string-utils');
|
const { substrWithEllipsis } = require('@joplin/lib/string-utils');
|
||||||
const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids');
|
const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids');
|
||||||
|
|
||||||
|
const logger = Logger.create('Sidebar');
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
themeId: number;
|
themeId: number;
|
||||||
dispatch: Function;
|
dispatch: Function;
|
||||||
@@ -70,10 +75,12 @@ function ExpandLink(props: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FolderItem(props: any) {
|
function FolderItem(props: any) {
|
||||||
const { hasChildren, isExpanded, depth, selected, folderId, folderTitle, anchorRef, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_ } = props;
|
const { hasChildren, isExpanded, parentId, depth, selected, folderId, folderTitle, anchorRef, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_, shareId } = props;
|
||||||
|
|
||||||
const noteCountComp = noteCount ? <StyledNoteCount>{noteCount}</StyledNoteCount> : null;
|
const noteCountComp = noteCount ? <StyledNoteCount>{noteCount}</StyledNoteCount> : null;
|
||||||
|
|
||||||
|
const shareIcon = shareId && !parentId ? <StyledShareIcon className="fas fa-share-alt"></StyledShareIcon> : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledListItem depth={depth} selected={selected} className={`list-item-container list-item-depth-${depth}`} onDragStart={onFolderDragStart_} onDragOver={onFolderDragOver_} onDrop={onFolderDrop_} draggable={true} data-folder-id={folderId}>
|
<StyledListItem depth={depth} selected={selected} className={`list-item-container list-item-depth-${depth}`} onDragStart={onFolderDragStart_} onDragOver={onFolderDragOver_} onDrop={onFolderDrop_} draggable={true} data-folder-id={folderId}>
|
||||||
<ExpandLink themeId={props.themeId} hasChildren={hasChildren} folderId={folderId} onClick={onFolderToggleClick_} isExpanded={isExpanded}/>
|
<ExpandLink themeId={props.themeId} hasChildren={hasChildren} folderId={folderId} onClick={onFolderToggleClick_} isExpanded={isExpanded}/>
|
||||||
@@ -83,6 +90,7 @@ function FolderItem(props: any) {
|
|||||||
isConflictFolder={folderId === Folder.conflictFolderId()}
|
isConflictFolder={folderId === Folder.conflictFolderId()}
|
||||||
href="#"
|
href="#"
|
||||||
selected={selected}
|
selected={selected}
|
||||||
|
shareId={shareId}
|
||||||
data-id={folderId}
|
data-id={folderId}
|
||||||
data-type={BaseModel.TYPE_FOLDER}
|
data-type={BaseModel.TYPE_FOLDER}
|
||||||
onContextMenu={itemContextMenu}
|
onContextMenu={itemContextMenu}
|
||||||
@@ -92,7 +100,7 @@ function FolderItem(props: any) {
|
|||||||
}}
|
}}
|
||||||
onDoubleClick={onFolderToggleClick_}
|
onDoubleClick={onFolderToggleClick_}
|
||||||
>
|
>
|
||||||
{folderTitle} {noteCountComp}
|
{folderTitle} {shareIcon} {noteCountComp}
|
||||||
</StyledListItemAnchor>
|
</StyledListItemAnchor>
|
||||||
</StyledListItem>
|
</StyledListItem>
|
||||||
);
|
);
|
||||||
@@ -158,22 +166,27 @@ class SidebarComponent extends React.Component<Props, State> {
|
|||||||
// to put the dropped folder at the root. But for notes, folderId needs to always be defined
|
// to put the dropped folder at the root. But for notes, folderId needs to always be defined
|
||||||
// since there's no such thing as a root note.
|
// since there's no such thing as a root note.
|
||||||
|
|
||||||
if (dt.types.indexOf('text/x-jop-note-ids') >= 0) {
|
try {
|
||||||
event.preventDefault();
|
if (dt.types.indexOf('text/x-jop-note-ids') >= 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
if (!folderId) return;
|
if (!folderId) return;
|
||||||
|
|
||||||
const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids'));
|
const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids'));
|
||||||
for (let i = 0; i < noteIds.length; i++) {
|
for (let i = 0; i < noteIds.length; i++) {
|
||||||
await Note.moveToFolder(noteIds[i], folderId);
|
await Note.moveToFolder(noteIds[i], folderId);
|
||||||
}
|
}
|
||||||
} else if (dt.types.indexOf('text/x-jop-folder-ids') >= 0) {
|
} else if (dt.types.indexOf('text/x-jop-folder-ids') >= 0) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const folderIds = JSON.parse(dt.getData('text/x-jop-folder-ids'));
|
const folderIds = JSON.parse(dt.getData('text/x-jop-folder-ids'));
|
||||||
for (let i = 0; i < folderIds.length; i++) {
|
for (let i = 0; i < folderIds.length; i++) {
|
||||||
await Folder.moveToFolder(folderIds[i], folderId);
|
await Folder.moveToFolder(folderIds[i], folderId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
alert(error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,12 +235,14 @@ class SidebarComponent extends React.Component<Props, State> {
|
|||||||
const itemType = Number(event.currentTarget.getAttribute('data-type'));
|
const itemType = Number(event.currentTarget.getAttribute('data-type'));
|
||||||
if (!itemId || !itemType) throw new Error('No data on element');
|
if (!itemId || !itemType) throw new Error('No data on element');
|
||||||
|
|
||||||
|
const state: AppState = store().getState();
|
||||||
|
|
||||||
let deleteMessage = '';
|
let deleteMessage = '';
|
||||||
let buttonLabel = _('Remove');
|
let deleteButtonLabel = _('Remove');
|
||||||
if (itemType === BaseModel.TYPE_FOLDER) {
|
if (itemType === BaseModel.TYPE_FOLDER) {
|
||||||
const folder = await Folder.load(itemId);
|
const folder = await Folder.load(itemId);
|
||||||
deleteMessage = _('Delete notebook "%s"?\n\nAll notes and sub-notebooks within this notebook will also be deleted.', substrWithEllipsis(folder.title, 0, 32));
|
deleteMessage = _('Delete notebook "%s"?\n\nAll notes and sub-notebooks within this notebook will also be deleted.', substrWithEllipsis(folder.title, 0, 32));
|
||||||
buttonLabel = _('Delete');
|
deleteButtonLabel = _('Delete');
|
||||||
} else if (itemType === BaseModel.TYPE_TAG) {
|
} else if (itemType === BaseModel.TYPE_TAG) {
|
||||||
const tag = await Tag.load(itemId);
|
const tag = await Tag.load(itemId);
|
||||||
deleteMessage = _('Remove tag "%s" from all notes?', substrWithEllipsis(tag.title, 0, 32));
|
deleteMessage = _('Remove tag "%s" from all notes?', substrWithEllipsis(tag.title, 0, 32));
|
||||||
@@ -250,10 +265,10 @@ class SidebarComponent extends React.Component<Props, State> {
|
|||||||
|
|
||||||
menu.append(
|
menu.append(
|
||||||
new MenuItem({
|
new MenuItem({
|
||||||
label: buttonLabel,
|
label: deleteButtonLabel,
|
||||||
click: async () => {
|
click: async () => {
|
||||||
const ok = bridge().showConfirmMessageBox(deleteMessage, {
|
const ok = bridge().showConfirmMessageBox(deleteMessage, {
|
||||||
buttons: [buttonLabel, _('Cancel')],
|
buttons: [deleteButtonLabel, _('Cancel')],
|
||||||
defaultId: 1,
|
defaultId: 1,
|
||||||
});
|
});
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
@@ -294,6 +309,14 @@ class SidebarComponent extends React.Component<Props, State> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We don't display the "Share notebook" menu item for sub-notebooks
|
||||||
|
// that are within a shared notebook. If user wants to do this,
|
||||||
|
// they'd have to move the notebook out of the shared notebook
|
||||||
|
// first.
|
||||||
|
if (CommandService.instance().isEnabled('showShareFolderDialog', stateToWhenClauseContext(state, { commandFolderId: itemId }))) {
|
||||||
|
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('showShareFolderDialog', itemId)));
|
||||||
|
}
|
||||||
|
|
||||||
menu.append(
|
menu.append(
|
||||||
new MenuItem({
|
new MenuItem({
|
||||||
label: _('Export'),
|
label: _('Export'),
|
||||||
@@ -387,10 +410,10 @@ class SidebarComponent extends React.Component<Props, State> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderFolderItem(folder: any, selected: boolean, hasChildren: boolean, depth: number) {
|
renderFolderItem(folder: FolderEntity, selected: boolean, hasChildren: boolean, depth: number) {
|
||||||
const anchorRef = this.anchorItemRef('folder', folder.id);
|
const anchorRef = this.anchorItemRef('folder', folder.id);
|
||||||
const isExpanded = this.props.collapsedFolderIds.indexOf(folder.id) < 0;
|
const isExpanded = this.props.collapsedFolderIds.indexOf(folder.id) < 0;
|
||||||
let noteCount = folder.note_count;
|
let noteCount = (folder as any).note_count;
|
||||||
|
|
||||||
// Thunderbird count: Subtract children note_count from parent folder if it expanded.
|
// Thunderbird count: Subtract children note_count from parent folder if it expanded.
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
@@ -418,6 +441,8 @@ class SidebarComponent extends React.Component<Props, State> {
|
|||||||
itemContextMenu={this.itemContextMenu}
|
itemContextMenu={this.itemContextMenu}
|
||||||
folderItem_click={this.folderItem_click}
|
folderItem_click={this.folderItem_click}
|
||||||
onFolderToggleClick_={this.onFolderToggleClick_}
|
onFolderToggleClick_={this.onFolderToggleClick_}
|
||||||
|
shareId={folder.share_id}
|
||||||
|
parentId={folder.parent_id}
|
||||||
/>;
|
/>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -63,6 +63,7 @@ export const StyledListItem = styled.div`
|
|||||||
function listItemTextColor(props: any) {
|
function listItemTextColor(props: any) {
|
||||||
if (props.isConflictFolder) return props.theme.colorError2;
|
if (props.isConflictFolder) return props.theme.colorError2;
|
||||||
if (props.isSpecialItem) return props.theme.colorFaded2;
|
if (props.isSpecialItem) return props.theme.colorFaded2;
|
||||||
|
if (props.shareId) return props.theme.colorWarn2;
|
||||||
return props.theme.color2;
|
return props.theme.color2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +73,7 @@ export const StyledListItemAnchor = styled.a`
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: ${(props: any) => listItemTextColor(props)};
|
color: ${(props: any) => listItemTextColor(props)};
|
||||||
cursor: default;
|
cursor: default;
|
||||||
opacity: ${(props: any) => props.selected ? 1 : 0.8};
|
opacity: ${(props: any) => props.selected || props.shareId ? 1 : 0.8};
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -81,6 +82,10 @@ export const StyledListItemAnchor = styled.a`
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const StyledShareIcon = styled.i`
|
||||||
|
margin-left: 8px;
|
||||||
|
`;
|
||||||
|
|
||||||
export const StyledExpandLink = styled.a`
|
export const StyledExpandLink = styled.a`
|
||||||
color: ${(props: any) => props.theme.color2};
|
color: ${(props: any) => props.theme.color2};
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
11
packages/app-desktop/gui/style/StyledFormLabel.tsx
Normal file
11
packages/app-desktop/gui/style/StyledFormLabel.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
const StyledFormLabel = styled.div`
|
||||||
|
font-size: ${props => props.theme.fontSize * 1.083333}px;
|
||||||
|
color: ${props => props.theme.color};
|
||||||
|
font-family: ${props => props.theme.fontFamily};
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: ${props => props.theme.mainPadding / 2}px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default StyledFormLabel;
|
12
packages/app-desktop/gui/style/StyledMessage.tsx
Normal file
12
packages/app-desktop/gui/style/StyledMessage.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
const StyledMessage = styled.div`
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: ${props => props.type === 'error' ? props.theme.warningBackgroundColor : 'transparent'};
|
||||||
|
font-size: ${props => props.theme.fontSize}px;
|
||||||
|
color: ${props => props.theme.color};
|
||||||
|
font-family: ${props => props.theme.fontFamily};
|
||||||
|
padding: ${props => props.type === 'error' ? props.theme.mainPadding : '0'}px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default StyledMessage;
|
4
packages/app-desktop/package-lock.json
generated
4
packages/app-desktop/package-lock.json
generated
@@ -3199,7 +3199,7 @@
|
|||||||
},
|
},
|
||||||
"ini": {
|
"ini": {
|
||||||
"version": "1.3.5",
|
"version": "1.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz",
|
"resolved": false,
|
||||||
"integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==",
|
"integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"optional": true
|
"optional": true
|
||||||
@@ -7659,7 +7659,7 @@
|
|||||||
},
|
},
|
||||||
"ini": {
|
"ini": {
|
||||||
"version": "1.3.5",
|
"version": "1.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz",
|
"resolved": false,
|
||||||
"integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==",
|
"integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"optional": true
|
"optional": true
|
||||||
|
44
packages/app-desktop/runForSharing.sh
Executable file
44
packages/app-desktop/runForSharing.sh
Executable file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Setup the sync parameters for user X and create a few folders and notes to
|
||||||
|
# allow sharing. Also calls the API to create the test users and clear the data.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
|
||||||
|
ROOT_DIR="$SCRIPT_DIR/../.."
|
||||||
|
|
||||||
|
if [ "$1" == "" ]; then
|
||||||
|
echo "User number is required"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
USER_NUM=$1
|
||||||
|
|
||||||
|
CMD_FILE="$SCRIPT_DIR/runForSharingCommands-$USER_NUM.txt"
|
||||||
|
rm -f "$CMD_FILE"
|
||||||
|
|
||||||
|
USER_EMAIL="user$USER_NUM@example.com"
|
||||||
|
PROFILE_DIR=~/.config/joplindev-desktop-$USER_NUM
|
||||||
|
rm -rf "$PROFILE_DIR"
|
||||||
|
|
||||||
|
echo "config keychain.supported 0" >> "$CMD_FILE"
|
||||||
|
echo "config sync.target 9" >> "$CMD_FILE"
|
||||||
|
echo "config sync.9.path http://localhost:22300" >> "$CMD_FILE"
|
||||||
|
echo "config sync.9.username $USER_EMAIL" >> "$CMD_FILE"
|
||||||
|
echo "config sync.9.password 123456" >> "$CMD_FILE"
|
||||||
|
|
||||||
|
if [ "$1" == "1" ]; then
|
||||||
|
curl --data '{"action": "createTestUsers"}' http://localhost:22300/api/debug
|
||||||
|
|
||||||
|
echo 'mkbook "shared"' >> "$CMD_FILE"
|
||||||
|
echo 'mkbook "other"' >> "$CMD_FILE"
|
||||||
|
echo 'use "shared"' >> "$CMD_FILE"
|
||||||
|
echo 'mknote "note 1"' >> "$CMD_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$ROOT_DIR/packages/app-cli"
|
||||||
|
npm start -- --profile "$PROFILE_DIR" batch "$CMD_FILE"
|
||||||
|
|
||||||
|
cd "$ROOT_DIR/packages/app-desktop"
|
||||||
|
npm start -- --profile "$PROFILE_DIR"
|
@@ -4,12 +4,12 @@
|
|||||||
// one.
|
// one.
|
||||||
|
|
||||||
import { AppState } from '../../app';
|
import { AppState } from '../../app';
|
||||||
import libStateToWhenClauseContext from '@joplin/lib/services/commands/stateToWhenClauseContext';
|
import libStateToWhenClauseContext, { WhenClauseContextOptions } from '@joplin/lib/services/commands/stateToWhenClauseContext';
|
||||||
import layoutItemProp from '../../gui/ResizableLayout/utils/layoutItemProp';
|
import layoutItemProp from '../../gui/ResizableLayout/utils/layoutItemProp';
|
||||||
|
|
||||||
export default function stateToWhenClauseContext(state: AppState) {
|
export default function stateToWhenClauseContext(state: AppState, options: WhenClauseContextOptions = null) {
|
||||||
return {
|
return {
|
||||||
...libStateToWhenClauseContext(state),
|
...libStateToWhenClauseContext(state, options),
|
||||||
|
|
||||||
// UI elements
|
// UI elements
|
||||||
markdownEditorVisible: !!state.settings['editor.codeView'],
|
markdownEditorVisible: !!state.settings['editor.codeView'],
|
||||||
|
@@ -372,7 +372,7 @@ class SideMenuContentComponent extends Component {
|
|||||||
items.push(this.renderSidebarButton('folder_header', _('Notebooks'), 'md-folder'));
|
items.push(this.renderSidebarButton('folder_header', _('Notebooks'), 'md-folder'));
|
||||||
|
|
||||||
if (this.props.folders.length) {
|
if (this.props.folders.length) {
|
||||||
const result = shared.renderFolders(this.props, this.renderFolderItem);
|
const result = shared.renderFolders(this.props, this.renderFolderItem, false);
|
||||||
const folderItems = result.items;
|
const folderItems = result.items;
|
||||||
items = items.concat(folderItems);
|
items = items.concat(folderItems);
|
||||||
}
|
}
|
||||||
|
@@ -36,7 +36,7 @@ const SafeAreaView = require('./components/SafeAreaView');
|
|||||||
const { connect, Provider } = require('react-redux');
|
const { connect, Provider } = require('react-redux');
|
||||||
const { BackButtonService } = require('./services/back-button.js');
|
const { BackButtonService } = require('./services/back-button.js');
|
||||||
import NavService from '@joplin/lib/services/NavService';
|
import NavService from '@joplin/lib/services/NavService';
|
||||||
const { createStore, applyMiddleware } = require('redux');
|
import { createStore, applyMiddleware } from 'redux';
|
||||||
const reduxSharedMiddleware = require('@joplin/lib/components/shared/reduxSharedMiddleware');
|
const reduxSharedMiddleware = require('@joplin/lib/components/shared/reduxSharedMiddleware');
|
||||||
const { shimInit } = require('./utils/shim-init-react.js');
|
const { shimInit } = require('./utils/shim-init-react.js');
|
||||||
const { AppNav } = require('./components/app-nav.js');
|
const { AppNav } = require('./components/app-nav.js');
|
||||||
@@ -97,6 +97,7 @@ import EncryptionService from '@joplin/lib/services/EncryptionService';
|
|||||||
import MigrationService from '@joplin/lib/services/MigrationService';
|
import MigrationService from '@joplin/lib/services/MigrationService';
|
||||||
import { clearSharedFilesCache } from './utils/ShareUtils';
|
import { clearSharedFilesCache } from './utils/ShareUtils';
|
||||||
import setIgnoreTlsErrors from './utils/TlsUtils';
|
import setIgnoreTlsErrors from './utils/TlsUtils';
|
||||||
|
import ShareService from '@joplin/lib/services/share/ShareService';
|
||||||
|
|
||||||
let storeDispatch = function(_action: any) {};
|
let storeDispatch = function(_action: any) {};
|
||||||
|
|
||||||
@@ -525,6 +526,7 @@ async function initialize(dispatch: Function) {
|
|||||||
EncryptionService.instance().setLogger(mainLogger);
|
EncryptionService.instance().setLogger(mainLogger);
|
||||||
// eslint-disable-next-line require-atomic-updates
|
// eslint-disable-next-line require-atomic-updates
|
||||||
BaseItem.encryptionService_ = EncryptionService.instance();
|
BaseItem.encryptionService_ = EncryptionService.instance();
|
||||||
|
BaseItem.shareService_ = ShareService.instance();
|
||||||
DecryptionWorker.instance().dispatch = dispatch;
|
DecryptionWorker.instance().dispatch = dispatch;
|
||||||
DecryptionWorker.instance().setLogger(mainLogger);
|
DecryptionWorker.instance().setLogger(mainLogger);
|
||||||
DecryptionWorker.instance().setKvStore(KvStore.instance());
|
DecryptionWorker.instance().setKvStore(KvStore.instance());
|
||||||
@@ -536,6 +538,8 @@ async function initialize(dispatch: Function) {
|
|||||||
// / E2EE SETUP
|
// / E2EE SETUP
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
await ShareService.instance().initialize(store);
|
||||||
|
|
||||||
reg.logger().info('Loading folders...');
|
reg.logger().info('Loading folders...');
|
||||||
|
|
||||||
await FoldersScreenUtils.refreshFolders();
|
await FoldersScreenUtils.refreshFolders();
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { Platform, NativeModules } from 'react-native';
|
const { Platform, NativeModules } = require('react-native');
|
||||||
|
|
||||||
export default async function setIgnoreTlsErrors(ignore: boolean): Promise<boolean> {
|
export default async function setIgnoreTlsErrors(ignore: boolean): Promise<boolean> {
|
||||||
if (Platform.OS === 'android') {
|
if (Platform.OS === 'android') {
|
||||||
|
@@ -2,14 +2,14 @@ import Setting from './models/Setting';
|
|||||||
import Logger, { TargetType, LoggerWrapper } from './Logger';
|
import Logger, { TargetType, LoggerWrapper } from './Logger';
|
||||||
import shim from './shim';
|
import shim from './shim';
|
||||||
import BaseService from './services/BaseService';
|
import BaseService from './services/BaseService';
|
||||||
import reducer from './reducer';
|
import reducer, { setStore } from './reducer';
|
||||||
import KeychainServiceDriver from './services/keychain/KeychainServiceDriver.node';
|
import KeychainServiceDriver from './services/keychain/KeychainServiceDriver.node';
|
||||||
import { _, setLocale } from './locale';
|
import { _, setLocale } from './locale';
|
||||||
import KvStore from './services/KvStore';
|
import KvStore from './services/KvStore';
|
||||||
import SyncTargetJoplinServer from './SyncTargetJoplinServer';
|
import SyncTargetJoplinServer from './SyncTargetJoplinServer';
|
||||||
import SyncTargetOneDrive from './SyncTargetOneDrive';
|
import SyncTargetOneDrive from './SyncTargetOneDrive';
|
||||||
|
|
||||||
const { createStore, applyMiddleware } = require('redux');
|
import { createStore, applyMiddleware, Store } from 'redux';
|
||||||
const { defaultState, stateUtils } = require('./reducer');
|
const { defaultState, stateUtils } = require('./reducer');
|
||||||
import JoplinDatabase from './JoplinDatabase';
|
import JoplinDatabase from './JoplinDatabase';
|
||||||
const { FoldersScreenUtils } = require('./folders-screen-utils.js');
|
const { FoldersScreenUtils } = require('./folders-screen-utils.js');
|
||||||
@@ -26,7 +26,7 @@ import BaseSyncTarget from './BaseSyncTarget';
|
|||||||
const reduxSharedMiddleware = require('./components/shared/reduxSharedMiddleware');
|
const reduxSharedMiddleware = require('./components/shared/reduxSharedMiddleware');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const fs = require('fs-extra');
|
const fs = require('fs-extra');
|
||||||
const JoplinError = require('./JoplinError');
|
import JoplinError from './JoplinError';
|
||||||
const EventEmitter = require('events');
|
const EventEmitter = require('events');
|
||||||
const syswidecas = require('./vendor/syswide-cas');
|
const syswidecas = require('./vendor/syswide-cas');
|
||||||
const SyncTargetRegistry = require('./SyncTargetRegistry.js');
|
const SyncTargetRegistry = require('./SyncTargetRegistry.js');
|
||||||
@@ -44,6 +44,7 @@ import ResourceService from './services/ResourceService';
|
|||||||
import DecryptionWorker from './services/DecryptionWorker';
|
import DecryptionWorker from './services/DecryptionWorker';
|
||||||
const { loadKeychainServiceAndSettings } = require('./services/SettingUtils');
|
const { loadKeychainServiceAndSettings } = require('./services/SettingUtils');
|
||||||
import MigrationService from './services/MigrationService';
|
import MigrationService from './services/MigrationService';
|
||||||
|
import ShareService from './services/share/ShareService';
|
||||||
import handleSyncStartupOperation from './services/synchronizer/utils/handleSyncStartupOperation';
|
import handleSyncStartupOperation from './services/synchronizer/utils/handleSyncStartupOperation';
|
||||||
const { toSystemSlashes } = require('./path-utils');
|
const { toSystemSlashes } = require('./path-utils');
|
||||||
const { setAutoFreeze } = require('immer');
|
const { setAutoFreeze } = require('immer');
|
||||||
@@ -67,7 +68,7 @@ export default class BaseApplication {
|
|||||||
// state and UI out of sync.
|
// state and UI out of sync.
|
||||||
private currentFolder_: any = null;
|
private currentFolder_: any = null;
|
||||||
|
|
||||||
protected store_: any = null;
|
protected store_: Store<any> = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.eventEmitter_ = new EventEmitter();
|
this.eventEmitter_ = new EventEmitter();
|
||||||
@@ -602,13 +603,15 @@ export default class BaseApplication {
|
|||||||
}
|
}
|
||||||
|
|
||||||
initRedux() {
|
initRedux() {
|
||||||
this.store_ = createStore(this.reducer, applyMiddleware(this.generalMiddlewareFn()));
|
this.store_ = createStore(this.reducer, applyMiddleware(this.generalMiddlewareFn() as any));
|
||||||
|
setStore(this.store_);
|
||||||
BaseModel.dispatch = this.store().dispatch;
|
BaseModel.dispatch = this.store().dispatch;
|
||||||
FoldersScreenUtils.dispatch = this.store().dispatch;
|
FoldersScreenUtils.dispatch = this.store().dispatch;
|
||||||
// reg.dispatch = this.store().dispatch;
|
// reg.dispatch = this.store().dispatch;
|
||||||
BaseSyncTarget.dispatch = this.store().dispatch;
|
BaseSyncTarget.dispatch = this.store().dispatch;
|
||||||
DecryptionWorker.instance().dispatch = this.store().dispatch;
|
DecryptionWorker.instance().dispatch = this.store().dispatch;
|
||||||
ResourceFetcher.instance().dispatch = this.store().dispatch;
|
ResourceFetcher.instance().dispatch = this.store().dispatch;
|
||||||
|
ShareService.instance().initialize(this.store());
|
||||||
}
|
}
|
||||||
|
|
||||||
deinitRedux() {
|
deinitRedux() {
|
||||||
@@ -793,6 +796,7 @@ export default class BaseApplication {
|
|||||||
|
|
||||||
EncryptionService.instance().setLogger(globalLogger);
|
EncryptionService.instance().setLogger(globalLogger);
|
||||||
BaseItem.encryptionService_ = EncryptionService.instance();
|
BaseItem.encryptionService_ = EncryptionService.instance();
|
||||||
|
BaseItem.shareService_ = ShareService.instance();
|
||||||
DecryptionWorker.instance().setLogger(globalLogger);
|
DecryptionWorker.instance().setLogger(globalLogger);
|
||||||
DecryptionWorker.instance().setEncryptionService(EncryptionService.instance());
|
DecryptionWorker.instance().setEncryptionService(EncryptionService.instance());
|
||||||
DecryptionWorker.instance().setKvStore(KvStore.instance());
|
DecryptionWorker.instance().setKvStore(KvStore.instance());
|
||||||
|
@@ -3,6 +3,7 @@ import Synchronizer from './Synchronizer';
|
|||||||
import EncryptionService from './services/EncryptionService';
|
import EncryptionService from './services/EncryptionService';
|
||||||
import shim from './shim';
|
import shim from './shim';
|
||||||
import ResourceService from './services/ResourceService';
|
import ResourceService from './services/ResourceService';
|
||||||
|
import ShareService from './services/share/ShareService';
|
||||||
|
|
||||||
export default class BaseSyncTarget {
|
export default class BaseSyncTarget {
|
||||||
|
|
||||||
@@ -113,6 +114,7 @@ export default class BaseSyncTarget {
|
|||||||
this.synchronizer_.setLogger(this.logger());
|
this.synchronizer_.setLogger(this.logger());
|
||||||
this.synchronizer_.setEncryptionService(EncryptionService.instance());
|
this.synchronizer_.setEncryptionService(EncryptionService.instance());
|
||||||
this.synchronizer_.setResourceService(ResourceService.instance());
|
this.synchronizer_.setResourceService(ResourceService.instance());
|
||||||
|
this.synchronizer_.setShareService(ShareService.instance());
|
||||||
this.synchronizer_.dispatch = BaseSyncTarget.dispatch;
|
this.synchronizer_.dispatch = BaseSyncTarget.dispatch;
|
||||||
this.initState_ = 'ready';
|
this.initState_ = 'ready';
|
||||||
return this.synchronizer_;
|
return this.synchronizer_;
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
const Logger = require('./Logger').default;
|
const Logger = require('./Logger').default;
|
||||||
const shim = require('./shim').default;
|
const shim = require('./shim').default;
|
||||||
const JoplinError = require('./JoplinError');
|
const JoplinError = require('./JoplinError').default;
|
||||||
const time = require('./time').default;
|
const time = require('./time').default;
|
||||||
const EventDispatcher = require('./EventDispatcher');
|
const EventDispatcher = require('./EventDispatcher');
|
||||||
|
|
||||||
|
@@ -138,20 +138,20 @@ export default class JoplinDatabase extends Database {
|
|||||||
private tableFieldNames_: Record<string, string[]> = {};
|
private tableFieldNames_: Record<string, string[]> = {};
|
||||||
private tableDescriptions_: any;
|
private tableDescriptions_: any;
|
||||||
|
|
||||||
constructor(driver: any) {
|
public constructor(driver: any) {
|
||||||
super(driver);
|
super(driver);
|
||||||
}
|
}
|
||||||
|
|
||||||
initialized() {
|
public initialized() {
|
||||||
return this.initialized_;
|
return this.initialized_;
|
||||||
}
|
}
|
||||||
|
|
||||||
async open(options: any) {
|
public async open(options: any) {
|
||||||
await super.open(options);
|
await super.open(options);
|
||||||
return this.initialize();
|
return this.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
tableFieldNames(tableName: string) {
|
public tableFieldNames(tableName: string) {
|
||||||
if (this.tableFieldNames_[tableName]) return this.tableFieldNames_[tableName].slice();
|
if (this.tableFieldNames_[tableName]) return this.tableFieldNames_[tableName].slice();
|
||||||
|
|
||||||
const tf = this.tableFields(tableName);
|
const tf = this.tableFields(tableName);
|
||||||
@@ -164,7 +164,7 @@ export default class JoplinDatabase extends Database {
|
|||||||
return output.slice();
|
return output.slice();
|
||||||
}
|
}
|
||||||
|
|
||||||
tableFields(tableName: string, options: any = null) {
|
public tableFields(tableName: string, options: any = null) {
|
||||||
if (options === null) options = {};
|
if (options === null) options = {};
|
||||||
|
|
||||||
if (!this.tableFields_) throw new Error('Fields have not been loaded yet');
|
if (!this.tableFields_) throw new Error('Fields have not been loaded yet');
|
||||||
@@ -180,7 +180,7 @@ export default class JoplinDatabase extends Database {
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
async clearForTesting() {
|
public async clearForTesting() {
|
||||||
const tableNames = [
|
const tableNames = [
|
||||||
'notes',
|
'notes',
|
||||||
'folders',
|
'folders',
|
||||||
@@ -220,7 +220,7 @@ export default class JoplinDatabase extends Database {
|
|||||||
await this.transactionExecBatch(queries);
|
await this.transactionExecBatch(queries);
|
||||||
}
|
}
|
||||||
|
|
||||||
createDefaultRow(tableName: string) {
|
public createDefaultRow(tableName: string) {
|
||||||
const row: any = {};
|
const row: any = {};
|
||||||
const fields = this.tableFields(tableName);
|
const fields = this.tableFields(tableName);
|
||||||
for (let i = 0; i < fields.length; i++) {
|
for (let i = 0; i < fields.length; i++) {
|
||||||
@@ -230,7 +230,7 @@ export default class JoplinDatabase extends Database {
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
fieldByName(tableName: string, fieldName: string) {
|
public fieldByName(tableName: string, fieldName: string) {
|
||||||
const fields = this.tableFields(tableName);
|
const fields = this.tableFields(tableName);
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
if (field.name === fieldName) return field;
|
if (field.name === fieldName) return field;
|
||||||
@@ -238,11 +238,11 @@ export default class JoplinDatabase extends Database {
|
|||||||
throw new Error(`No such field: ${tableName}: ${fieldName}`);
|
throw new Error(`No such field: ${tableName}: ${fieldName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
fieldDefaultValue(tableName: string, fieldName: string) {
|
public fieldDefaultValue(tableName: string, fieldName: string) {
|
||||||
return this.fieldByName(tableName, fieldName).default;
|
return this.fieldByName(tableName, fieldName).default;
|
||||||
}
|
}
|
||||||
|
|
||||||
fieldDescription(tableName: string, fieldName: string) {
|
public fieldDescription(tableName: string, fieldName: string) {
|
||||||
const sp = sprintf;
|
const sp = sprintf;
|
||||||
|
|
||||||
if (!this.tableDescriptions_) {
|
if (!this.tableDescriptions_) {
|
||||||
@@ -278,7 +278,7 @@ export default class JoplinDatabase extends Database {
|
|||||||
return d && d[fieldName] ? d[fieldName] : '';
|
return d && d[fieldName] ? d[fieldName] : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshTableFields(newVersion: number) {
|
public refreshTableFields(newVersion: number) {
|
||||||
this.logger().info('Initializing tables...');
|
this.logger().info('Initializing tables...');
|
||||||
const queries: SqlQuery[] = [];
|
const queries: SqlQuery[] = [];
|
||||||
queries.push(this.wrapQuery('DELETE FROM table_fields'));
|
queries.push(this.wrapQuery('DELETE FROM table_fields'));
|
||||||
@@ -323,12 +323,12 @@ export default class JoplinDatabase extends Database {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
addMigrationFile(num: number) {
|
public addMigrationFile(num: number) {
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
return { sql: 'INSERT INTO migrations (number, created_time, updated_time) VALUES (?, ?, ?)', params: [num, timestamp, timestamp] };
|
return { sql: 'INSERT INTO migrations (number, created_time, updated_time) VALUES (?, ?, ?)', params: [num, timestamp, timestamp] };
|
||||||
}
|
}
|
||||||
|
|
||||||
async upgradeDatabase(fromVersion: number) {
|
public async upgradeDatabase(fromVersion: number) {
|
||||||
// INSTRUCTIONS TO UPGRADE THE DATABASE:
|
// INSTRUCTIONS TO UPGRADE THE DATABASE:
|
||||||
//
|
//
|
||||||
// 1. Add the new version number to the existingDatabaseVersions array
|
// 1. Add the new version number to the existingDatabaseVersions array
|
||||||
@@ -343,7 +343,7 @@ export default class JoplinDatabase extends Database {
|
|||||||
// must be set in the synchronizer too.
|
// must be set in the synchronizer too.
|
||||||
|
|
||||||
// Note: v16 and v17 don't do anything. They were used to debug an issue.
|
// Note: v16 and v17 don't do anything. They were used to debug an issue.
|
||||||
const existingDatabaseVersions = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35];
|
const existingDatabaseVersions = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37];
|
||||||
|
|
||||||
let currentVersionIndex = existingDatabaseVersions.indexOf(fromVersion);
|
let currentVersionIndex = existingDatabaseVersions.indexOf(fromVersion);
|
||||||
|
|
||||||
@@ -870,6 +870,12 @@ export default class JoplinDatabase extends Database {
|
|||||||
queries.push(this.addMigrationFile(35));
|
queries.push(this.addMigrationFile(35));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (targetVersion == 36) {
|
||||||
|
queries.push('ALTER TABLE folders ADD COLUMN share_id TEXT NOT NULL DEFAULT ""');
|
||||||
|
queries.push('ALTER TABLE notes ADD COLUMN share_id TEXT NOT NULL DEFAULT ""');
|
||||||
|
queries.push('ALTER TABLE resources ADD COLUMN share_id TEXT NOT NULL DEFAULT ""');
|
||||||
|
}
|
||||||
|
|
||||||
const updateVersionQuery = { sql: 'UPDATE version SET version = ?', params: [targetVersion] };
|
const updateVersionQuery = { sql: 'UPDATE version SET version = ?', params: [targetVersion] };
|
||||||
|
|
||||||
queries.push(updateVersionQuery);
|
queries.push(updateVersionQuery);
|
||||||
@@ -906,7 +912,7 @@ export default class JoplinDatabase extends Database {
|
|||||||
return latestVersion;
|
return latestVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
async ftsEnabled() {
|
public async ftsEnabled() {
|
||||||
try {
|
try {
|
||||||
await this.selectOne('SELECT count(*) FROM notes_fts');
|
await this.selectOne('SELECT count(*) FROM notes_fts');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -919,7 +925,7 @@ export default class JoplinDatabase extends Database {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fuzzySearchEnabled() {
|
public async fuzzySearchEnabled() {
|
||||||
try {
|
try {
|
||||||
await this.selectOne('SELECT count(*) FROM notes_spellfix');
|
await this.selectOne('SELECT count(*) FROM notes_spellfix');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -930,11 +936,11 @@ export default class JoplinDatabase extends Database {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
version() {
|
public version() {
|
||||||
return this.version_;
|
return this.version_;
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize() {
|
public async initialize() {
|
||||||
this.logger().info('Checking for database schema update...');
|
this.logger().info('Checking for database schema update...');
|
||||||
|
|
||||||
let versionRow = null;
|
let versionRow = null;
|
||||||
|
@@ -1,8 +1,13 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
class JoplinError extends Error {
|
class JoplinError extends Error {
|
||||||
constructor(message, code = null) {
|
constructor(message, code = null, details = null) {
|
||||||
super(message);
|
super(message);
|
||||||
this.code = code;
|
this.code = null;
|
||||||
}
|
this.details = '';
|
||||||
|
this.code = code;
|
||||||
|
this.details = details;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
exports.default = JoplinError;
|
||||||
module.exports = JoplinError;
|
//# sourceMappingURL=JoplinError.js.map
|
12
packages/lib/JoplinError.ts
Normal file
12
packages/lib/JoplinError.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export default class JoplinError extends Error {
|
||||||
|
|
||||||
|
public code: any = null;
|
||||||
|
public details: string = '';
|
||||||
|
|
||||||
|
public constructor(message: string, code: any = null, details: string = null) {
|
||||||
|
super(message);
|
||||||
|
this.code = code;
|
||||||
|
this.details = details;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -1,13 +1,15 @@
|
|||||||
import shim from './shim';
|
import shim from './shim';
|
||||||
import { _ } from './locale';
|
import { _ } from './locale';
|
||||||
const { rtrimSlashes } = require('./path-utils.js');
|
const { rtrimSlashes } = require('./path-utils.js');
|
||||||
const JoplinError = require('./JoplinError');
|
import JoplinError from './JoplinError';
|
||||||
|
import { Env } from './models/Setting';
|
||||||
const { stringify } = require('query-string');
|
const { stringify } = require('query-string');
|
||||||
|
|
||||||
interface Options {
|
interface Options {
|
||||||
baseUrl(): string;
|
baseUrl(): string;
|
||||||
username(): string;
|
username(): string;
|
||||||
password(): string;
|
password(): string;
|
||||||
|
env?: Env;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ExecOptionsResponseFormat {
|
enum ExecOptionsResponseFormat {
|
||||||
@@ -27,22 +29,30 @@ interface ExecOptions {
|
|||||||
source?: string;
|
source?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default class JoplinServerApi {
|
export default class JoplinServerApi {
|
||||||
|
|
||||||
private options_: Options;
|
private options_: Options;
|
||||||
private session_: any;
|
private session_: Session;
|
||||||
private debugRequests_: boolean = false;
|
private debugRequests_: boolean = false;
|
||||||
|
|
||||||
public constructor(options: Options) {
|
public constructor(options: Options) {
|
||||||
this.options_ = options;
|
this.options_ = options;
|
||||||
|
|
||||||
|
if (options.env === Env.Dev) {
|
||||||
|
this.debugRequests_ = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private baseUrl() {
|
public baseUrl() {
|
||||||
return rtrimSlashes(this.options_.baseUrl());
|
return rtrimSlashes(this.options_.baseUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
private async session() {
|
private async session() {
|
||||||
// TODO: handle invalid session
|
|
||||||
if (this.session_) return this.session_;
|
if (this.session_) return this.session_;
|
||||||
|
|
||||||
this.session_ = await this.exec('POST', 'api/sessions', null, {
|
this.session_ = await this.exec('POST', 'api/sessions', null, {
|
||||||
@@ -58,11 +68,8 @@ export default class JoplinServerApi {
|
|||||||
return session ? session.id : '';
|
return session ? session.id : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
public async shareFile(pathOrId: string) {
|
public get userId(): string {
|
||||||
return this.exec('POST', 'api/shares', null, {
|
return this.session_ ? this.session_.user_id : '';
|
||||||
file_id: pathOrId,
|
|
||||||
type: 1, // ShareType.Link
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static connectionErrorMessage(error: any) {
|
public static connectionErrorMessage(error: any) {
|
||||||
@@ -70,10 +77,6 @@ export default class JoplinServerApi {
|
|||||||
return _('Could not connect to Joplin Server. Please check the Synchronisation options in the config screen. Full error was:\n\n%s', msg);
|
return _('Could not connect to Joplin Server. Please check the Synchronisation options in the config screen. Full error was:\n\n%s', msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
public shareUrl(share: any): string {
|
|
||||||
return `${this.baseUrl()}/shares/${share.id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private requestToCurl_(url: string, options: any) {
|
private requestToCurl_(url: string, options: any) {
|
||||||
const output = [];
|
const output = [];
|
||||||
output.push('curl');
|
output.push('curl');
|
||||||
@@ -85,8 +88,11 @@ export default class JoplinServerApi {
|
|||||||
output.push(`${'-H ' + '"'}${n}: ${options.headers[n]}"`);
|
output.push(`${'-H ' + '"'}${n}: ${options.headers[n]}"`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (options.body) output.push(`${'--data ' + '\''}${JSON.stringify(options.body)}'`);
|
if (options.body) {
|
||||||
output.push(url);
|
const serialized = typeof options.body !== 'string' ? JSON.stringify(options.body) : options.body;
|
||||||
|
output.push(`${'--data ' + '\''}${serialized}'`);
|
||||||
|
}
|
||||||
|
output.push(`'${url}'`);
|
||||||
|
|
||||||
return output.join(' ');
|
return output.join(' ');
|
||||||
}
|
}
|
||||||
@@ -150,14 +156,17 @@ export default class JoplinServerApi {
|
|||||||
|
|
||||||
const responseText = await response.text();
|
const responseText = await response.text();
|
||||||
|
|
||||||
// console.info('Joplin API Response', responseText);
|
if (this.debugRequests_) {
|
||||||
|
console.info('Joplin API Response', responseText);
|
||||||
|
}
|
||||||
|
|
||||||
// Creates an error object with as much data as possible as it will appear in the log, which will make debugging easier
|
// Creates an error object with as much data as possible as it will appear in the log, which will make debugging easier
|
||||||
const newError = (message: string, code: number = 0) => {
|
const newError = (message: string, code: number = 0) => {
|
||||||
// Gives a shorter response for error messages. Useful for cases where a full HTML page is accidentally loaded instead of
|
// Gives a shorter response for error messages. Useful for cases where a full HTML page is accidentally loaded instead of
|
||||||
// JSON. That way the error message will still show there's a problem but without filling up the log or screen.
|
// JSON. That way the error message will still show there's a problem but without filling up the log or screen.
|
||||||
const shortResponseText = (`${responseText}`).substr(0, 1024);
|
const shortResponseText = (`${responseText}`).substr(0, 1024);
|
||||||
return new JoplinError(`${method} ${path}: ${message} (${code}): ${shortResponseText}`, code);
|
// return new JoplinError(`${method} ${path}: ${message} (${code}): ${shortResponseText}`, code);
|
||||||
|
return new JoplinError(message, code, `${method} ${path}: ${message} (${code}): ${shortResponseText}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
let responseJson_: any = null;
|
let responseJson_: any = null;
|
||||||
|
@@ -10,7 +10,6 @@ interface FileApiOptions {
|
|||||||
path(): string;
|
path(): string;
|
||||||
username(): string;
|
username(): string;
|
||||||
password(): string;
|
password(): string;
|
||||||
directory(): string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class SyncTargetJoplinServer extends BaseSyncTarget {
|
export default class SyncTargetJoplinServer extends BaseSyncTarget {
|
||||||
@@ -28,7 +27,7 @@ export default class SyncTargetJoplinServer extends BaseSyncTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static label() {
|
public static label() {
|
||||||
return _('Joplin Server');
|
return `${_('Joplin Server')} (Beta)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async isAuthenticated() {
|
public async isAuthenticated() {
|
||||||
@@ -44,11 +43,12 @@ export default class SyncTargetJoplinServer extends BaseSyncTarget {
|
|||||||
baseUrl: () => options.path(),
|
baseUrl: () => options.path(),
|
||||||
username: () => options.username(),
|
username: () => options.username(),
|
||||||
password: () => options.password(),
|
password: () => options.password(),
|
||||||
|
env: Setting.value('env'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const api = new JoplinServerApi(apiOptions);
|
const api = new JoplinServerApi(apiOptions);
|
||||||
const driver = new FileApiDriverJoplinServer(api);
|
const driver = new FileApiDriverJoplinServer(api);
|
||||||
const fileApi = new FileApi(options.directory, driver);
|
const fileApi = new FileApi('', driver);
|
||||||
fileApi.setSyncTargetId(this.id());
|
fileApi.setSyncTargetId(this.id());
|
||||||
await fileApi.initialize();
|
await fileApi.initialize();
|
||||||
return fileApi;
|
return fileApi;
|
||||||
@@ -64,8 +64,10 @@ export default class SyncTargetJoplinServer extends BaseSyncTarget {
|
|||||||
const fileApi = await SyncTargetJoplinServer.newFileApi_(options);
|
const fileApi = await SyncTargetJoplinServer.newFileApi_(options);
|
||||||
fileApi.requestRepeatCount_ = 0;
|
fileApi.requestRepeatCount_ = 0;
|
||||||
|
|
||||||
const result = await fileApi.stat('');
|
await fileApi.put('testing.txt', 'testing');
|
||||||
if (!result) throw new Error(`Sync directory not found: "${options.directory()}" on server "${options.path()}"`);
|
const result = await fileApi.get('testing.txt');
|
||||||
|
if (result !== 'testing') throw new Error(`Could not access data on server "${options.path()}"`);
|
||||||
|
await fileApi.delete('testing.txt');
|
||||||
output.ok = true;
|
output.ok = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
output.errorMessage = error.message;
|
output.errorMessage = error.message;
|
||||||
@@ -80,7 +82,6 @@ export default class SyncTargetJoplinServer extends BaseSyncTarget {
|
|||||||
path: () => Setting.value('sync.9.path'),
|
path: () => Setting.value('sync.9.path'),
|
||||||
username: () => Setting.value('sync.9.username'),
|
username: () => Setting.value('sync.9.username'),
|
||||||
password: () => Setting.value('sync.9.password'),
|
password: () => Setting.value('sync.9.password'),
|
||||||
directory: () => Setting.value('sync.9.directory'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
fileApi.setLogger(this.logger());
|
fileApi.setLogger(this.logger());
|
||||||
|
@@ -5,7 +5,6 @@ import shim from './shim';
|
|||||||
import MigrationHandler from './services/synchronizer/MigrationHandler';
|
import MigrationHandler from './services/synchronizer/MigrationHandler';
|
||||||
import eventManager from './eventManager';
|
import eventManager from './eventManager';
|
||||||
import { _ } from './locale';
|
import { _ } from './locale';
|
||||||
|
|
||||||
import BaseItem from './models/BaseItem';
|
import BaseItem from './models/BaseItem';
|
||||||
import Folder from './models/Folder';
|
import Folder from './models/Folder';
|
||||||
import Note from './models/Note';
|
import Note from './models/Note';
|
||||||
@@ -14,12 +13,12 @@ import ItemChange from './models/ItemChange';
|
|||||||
import ResourceLocalState from './models/ResourceLocalState';
|
import ResourceLocalState from './models/ResourceLocalState';
|
||||||
import MasterKey from './models/MasterKey';
|
import MasterKey from './models/MasterKey';
|
||||||
import BaseModel from './BaseModel';
|
import BaseModel from './BaseModel';
|
||||||
const { sprintf } = require('sprintf-js');
|
|
||||||
import time from './time';
|
import time from './time';
|
||||||
import ResourceService from './services/ResourceService';
|
import ResourceService from './services/ResourceService';
|
||||||
import EncryptionService from './services/EncryptionService';
|
import EncryptionService from './services/EncryptionService';
|
||||||
import NoteResource from './models/NoteResource';
|
import JoplinError from './JoplinError';
|
||||||
const JoplinError = require('./JoplinError');
|
import ShareService from './services/share/ShareService';
|
||||||
|
const { sprintf } = require('sprintf-js');
|
||||||
const TaskQueue = require('./TaskQueue');
|
const TaskQueue = require('./TaskQueue');
|
||||||
const { Dirnames } = require('./services/synchronizer/utils/types');
|
const { Dirnames } = require('./services/synchronizer/utils/types');
|
||||||
|
|
||||||
@@ -45,6 +44,7 @@ export default class Synchronizer {
|
|||||||
private encryptionService_: EncryptionService = null;
|
private encryptionService_: EncryptionService = null;
|
||||||
private resourceService_: ResourceService = null;
|
private resourceService_: ResourceService = null;
|
||||||
private syncTargetIsLocked_: boolean = false;
|
private syncTargetIsLocked_: boolean = false;
|
||||||
|
private shareService_: ShareService = null;
|
||||||
|
|
||||||
// Debug flags are used to test certain hard-to-test conditions
|
// Debug flags are used to test certain hard-to-test conditions
|
||||||
// such as cancelling in the middle of a loop.
|
// such as cancelling in the middle of a loop.
|
||||||
@@ -108,6 +108,10 @@ export default class Synchronizer {
|
|||||||
return this.appType_ === 'mobile' ? 100 * 1000 * 1000 : Infinity;
|
return this.appType_ === 'mobile' ? 100 * 1000 * 1000 : Infinity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public setShareService(v: ShareService) {
|
||||||
|
this.shareService_ = v;
|
||||||
|
}
|
||||||
|
|
||||||
public setEncryptionService(v: any) {
|
public setEncryptionService(v: any) {
|
||||||
this.encryptionService_ = v;
|
this.encryptionService_ = v;
|
||||||
}
|
}
|
||||||
@@ -351,12 +355,15 @@ export default class Synchronizer {
|
|||||||
if (this.resourceService()) {
|
if (this.resourceService()) {
|
||||||
this.logger().info('Indexing resources...');
|
this.logger().info('Indexing resources...');
|
||||||
await this.resourceService().indexNoteResources();
|
await this.resourceService().indexNoteResources();
|
||||||
await NoteResource.applySharedStatusToLinkedResources();
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger().error('Error indexing resources:', error);
|
this.logger().error('Error indexing resources:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Before synchronising make sure all share_id properties are set
|
||||||
|
// correctly so as to share/unshare the right items.
|
||||||
|
await Folder.updateAllShareIds();
|
||||||
|
|
||||||
let errorToThrow = null;
|
let errorToThrow = null;
|
||||||
let syncLock = null;
|
let syncLock = null;
|
||||||
|
|
||||||
@@ -505,7 +512,7 @@ export default class Synchronizer {
|
|||||||
this.logger().warn(`Uploading a large resource (resourceId: ${local.id}, size:${local.size} bytes) which may tie up the sync process.`);
|
this.logger().warn(`Uploading a large resource (resourceId: ${local.id}, size:${local.size} bytes) which may tie up the sync process.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.apiCall('put', remoteContentPath, null, { path: localResourceContentPath, source: 'file' });
|
await this.apiCall('put', remoteContentPath, null, { path: localResourceContentPath, source: 'file', shareId: local.share_id });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error && ['rejectedByTarget', 'fileNotFound'].indexOf(error.code) >= 0) {
|
if (error && ['rejectedByTarget', 'fileNotFound'].indexOf(error.code) >= 0) {
|
||||||
await handleCannotSyncItem(ItemClass, syncTargetId, local, error.message);
|
await handleCannotSyncItem(ItemClass, syncTargetId, local, error.message);
|
||||||
@@ -924,6 +931,16 @@ export default class Synchronizer {
|
|||||||
this.cancelling_ = false;
|
this.cancelling_ = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After syncing, we run the share service maintenance, which is going
|
||||||
|
// to fetch share invitations, if any.
|
||||||
|
if (this.shareService_) {
|
||||||
|
try {
|
||||||
|
await this.shareService_.maintenance();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger().error('Could not run share service maintenance:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.progressReport_.completedTime = time.unixMs();
|
this.progressReport_.completedTime = time.unixMs();
|
||||||
|
|
||||||
this.logSyncOperation('finished', null, null, `Synchronisation finished [${synchronizationId}]`);
|
this.logSyncOperation('finished', null, null, `Synchronisation finished [${synchronizationId}]`);
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
const Logger = require('./Logger').default;
|
const Logger = require('./Logger').default;
|
||||||
const shim = require('./shim').default;
|
const shim = require('./shim').default;
|
||||||
const parseXmlString = require('xml2js').parseString;
|
const parseXmlString = require('xml2js').parseString;
|
||||||
const JoplinError = require('./JoplinError');
|
const JoplinError = require('./JoplinError').default;
|
||||||
const URL = require('url-parse');
|
const URL = require('url-parse');
|
||||||
const { rtrimSlashes } = require('./path-utils');
|
const { rtrimSlashes } = require('./path-utils');
|
||||||
const base64 = require('base-64');
|
const base64 = require('base-64');
|
||||||
|
@@ -4,7 +4,7 @@ import shim from './shim';
|
|||||||
|
|
||||||
const Mutex = require('async-mutex').Mutex;
|
const Mutex = require('async-mutex').Mutex;
|
||||||
|
|
||||||
type SqlParams = Record<string, any>;
|
type SqlParams = any[];
|
||||||
|
|
||||||
export interface SqlQuery {
|
export interface SqlQuery {
|
||||||
sql: string;
|
sql: string;
|
||||||
|
47
packages/lib/debug/DebugService.ts
Normal file
47
packages/lib/debug/DebugService.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import JoplinDatabase from '../JoplinDatabase';
|
||||||
|
import Setting from '../models/Setting';
|
||||||
|
import SyncTargetJoplinServer from '../SyncTargetJoplinServer';
|
||||||
|
|
||||||
|
export default class DebugService {
|
||||||
|
|
||||||
|
private db_: JoplinDatabase;
|
||||||
|
|
||||||
|
public constructor(db: JoplinDatabase) {
|
||||||
|
this.db_ = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get db(): JoplinDatabase {
|
||||||
|
return this.db_;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async clearSyncState() {
|
||||||
|
const tableNames = [
|
||||||
|
'item_changes',
|
||||||
|
'deleted_items',
|
||||||
|
'sync_items',
|
||||||
|
'key_values',
|
||||||
|
];
|
||||||
|
|
||||||
|
const queries = [];
|
||||||
|
for (const n of tableNames) {
|
||||||
|
queries.push(`DELETE FROM ${n}`);
|
||||||
|
queries.push(`DELETE FROM sqlite_sequence WHERE name="${n}"`); // Reset autoincremented IDs
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
queries.push(`DELETE FROM settings WHERE key="sync.${i}.context"`);
|
||||||
|
queries.push(`DELETE FROM settings WHERE key="sync.${i}.auth"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.db.transactionExecBatch(queries);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setupJoplinServerUser(num: number) {
|
||||||
|
const id = SyncTargetJoplinServer.id();
|
||||||
|
Setting.setValue('sync.target', id);
|
||||||
|
Setting.setValue(`sync.${id}.path`, 'http://localhost:22300');
|
||||||
|
Setting.setValue(`sync.${id}.username`, `user${num}@example.com`);
|
||||||
|
Setting.setValue(`sync.${id}.password`, '123456');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -1,7 +1,7 @@
|
|||||||
const { basicDelta } = require('./file-api');
|
const { basicDelta } = require('./file-api');
|
||||||
const { basename } = require('./path-utils');
|
const { basename } = require('./path-utils');
|
||||||
const shim = require('./shim').default;
|
const shim = require('./shim').default;
|
||||||
const JoplinError = require('./JoplinError');
|
const JoplinError = require('./JoplinError').default;
|
||||||
const { Buffer } = require('buffer');
|
const { Buffer } = require('buffer');
|
||||||
|
|
||||||
const S3_MAX_DELETES = 1000;
|
const S3_MAX_DELETES = 1000;
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
const time = require('./time').default;
|
const time = require('./time').default;
|
||||||
const shim = require('./shim').default;
|
const shim = require('./shim').default;
|
||||||
const JoplinError = require('./JoplinError');
|
const JoplinError = require('./JoplinError').default;
|
||||||
|
|
||||||
class FileApiDriverDropbox {
|
class FileApiDriverDropbox {
|
||||||
constructor(api) {
|
constructor(api) {
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import JoplinServerApi from './JoplinServerApi';
|
import JoplinServerApi from './JoplinServerApi';
|
||||||
import { trimSlashes, dirname, basename } from './path-utils';
|
import { trimSlashes } from './path-utils';
|
||||||
|
|
||||||
// All input paths should be in the format: "path/to/file". This is converted to
|
// All input paths should be in the format: "path/to/file". This is converted to
|
||||||
// "root:/path/to/file:" when doing the API call.
|
// "root:/path/to/file:" when doing the API call.
|
||||||
@@ -34,38 +34,35 @@ export default class FileApiDriverJoplinServer {
|
|||||||
return 3;
|
return 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
private metadataToStat_(md: any, path: string, isDeleted: boolean = false) {
|
private metadataToStat_(md: any, path: string, isDeleted: boolean = false, rootPath: string) {
|
||||||
const output = {
|
const output = {
|
||||||
path: path,
|
path: rootPath ? path.substr(rootPath.length + 1) : path,
|
||||||
updated_time: md.updated_time,
|
updated_time: md.updated_time,
|
||||||
isDir: !!md.is_directory,
|
isDir: false, // !!md.is_directory,
|
||||||
isDeleted: isDeleted,
|
isDeleted: isDeleted,
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO - HANDLE DELETED
|
|
||||||
// if (md['.tag'] === 'deleted') output.isDeleted = true;
|
|
||||||
|
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
private metadataToStats_(mds: any[]) {
|
private metadataToStats_(mds: any[], rootPath: string) {
|
||||||
const output = [];
|
const output = [];
|
||||||
for (let i = 0; i < mds.length; i++) {
|
for (let i = 0; i < mds.length; i++) {
|
||||||
output.push(this.metadataToStat_(mds[i], mds[i].name));
|
output.push(this.metadataToStat_(mds[i], mds[i].name, false, rootPath));
|
||||||
}
|
}
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transforms a path such as "Apps/Joplin/file.txt" to a complete a complete
|
// Transforms a path such as "Apps/Joplin/file.txt" to a complete a complete
|
||||||
// API URL path: "api/files/root:/Apps/Joplin/file.txt:"
|
// API URL path: "api/items/root:/Apps/Joplin/file.txt:"
|
||||||
private apiFilePath_(p: string) {
|
private apiFilePath_(p: string) {
|
||||||
return `api/files/root:/${trimSlashes(p)}:`;
|
return `api/items/root:/${trimSlashes(p)}:`;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async stat(path: string) {
|
public async stat(path: string) {
|
||||||
try {
|
try {
|
||||||
const response = await this.api().exec('GET', this.apiFilePath_(path));
|
const response = await this.api().exec('GET', this.apiFilePath_(path));
|
||||||
return this.metadataToStat_(response, path);
|
return this.metadataToStat_(response, path, false, '');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code === 404) return null;
|
if (error.code === 404) return null;
|
||||||
throw error;
|
throw error;
|
||||||
@@ -80,9 +77,13 @@ export default class FileApiDriverJoplinServer {
|
|||||||
try {
|
try {
|
||||||
const query = cursor ? { cursor } : {};
|
const query = cursor ? { cursor } : {};
|
||||||
const response = await this.api().exec('GET', `${this.apiFilePath_(path)}/delta`, query);
|
const response = await this.api().exec('GET', `${this.apiFilePath_(path)}/delta`, query);
|
||||||
const stats = response.items.map((item: any) => {
|
const stats = response.items
|
||||||
return this.metadataToStat_(item.item, item.item.name, item.type === 3);
|
.filter((item: any) => {
|
||||||
});
|
return item.item_name.indexOf('locks/') !== 0 && item.item_name.indexOf('temp/') !== 0;
|
||||||
|
})
|
||||||
|
.map((item: any) => {
|
||||||
|
return this.metadataToStat_(item, item.item_name, item.type === 3, '');
|
||||||
|
});
|
||||||
|
|
||||||
const output = {
|
const output = {
|
||||||
items: stats,
|
items: stats,
|
||||||
@@ -108,15 +109,22 @@ export default class FileApiDriverJoplinServer {
|
|||||||
...options,
|
...options,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let isUsingWildcard = false;
|
||||||
|
let searchPath = path;
|
||||||
|
if (searchPath) {
|
||||||
|
searchPath += '/*';
|
||||||
|
isUsingWildcard = true;
|
||||||
|
}
|
||||||
|
|
||||||
const query = options.context?.cursor ? { cursor: options.context.cursor } : null;
|
const query = options.context?.cursor ? { cursor: options.context.cursor } : null;
|
||||||
|
|
||||||
const results = await this.api().exec('GET', `${this.apiFilePath_(path)}/children`, query);
|
const results = await this.api().exec('GET', `${this.apiFilePath_(searchPath)}/children`, query);
|
||||||
|
|
||||||
const newContext: any = {};
|
const newContext: any = {};
|
||||||
if (results.cursor) newContext.cursor = results.cursor;
|
if (results.cursor) newContext.cursor = results.cursor;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: this.metadataToStats_(results.items),
|
items: this.metadataToStats_(results.items, isUsingWildcard ? path : ''),
|
||||||
hasMore: results.has_more,
|
hasMore: results.has_more,
|
||||||
context: newContext,
|
context: newContext,
|
||||||
} as any;
|
} as any;
|
||||||
@@ -134,32 +142,13 @@ export default class FileApiDriverJoplinServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private parentPath_(path: string) {
|
public async mkdir(_path: string) {
|
||||||
return dirname(path);
|
// This is a no-op because all items technically are at the root, but
|
||||||
}
|
// they can have names such as ".resources/xxxxxxxxxx'
|
||||||
|
|
||||||
private basename_(path: string) {
|
|
||||||
return basename(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async mkdir(path: string) {
|
|
||||||
const parentPath = this.parentPath_(path);
|
|
||||||
const filename = this.basename_(path);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await this.api().exec('POST', `${this.apiFilePath_(parentPath)}/children`, null, {
|
|
||||||
name: filename,
|
|
||||||
is_directory: 1,
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
// 409 is OK - directory already exists
|
|
||||||
if (error.code !== 409) throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async put(path: string, content: any, options: any = null) {
|
public async put(path: string, content: any, options: any = null) {
|
||||||
return this.api().exec('PUT', `${this.apiFilePath_(path)}/content`, null, content, {
|
return this.api().exec('PUT', `${this.apiFilePath_(path)}/content`, options && options.shareId ? { share_id: options.shareId } : null, content, {
|
||||||
'Content-Type': 'application/octet-stream',
|
'Content-Type': 'application/octet-stream',
|
||||||
}, options);
|
}, options);
|
||||||
}
|
}
|
||||||
@@ -174,6 +163,5 @@ export default class FileApiDriverJoplinServer {
|
|||||||
|
|
||||||
public async clearRoot(path: string) {
|
public async clearRoot(path: string) {
|
||||||
await this.delete(path);
|
await this.delete(path);
|
||||||
await this.mkdir(path);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
const { basicDelta } = require('./file-api');
|
const { basicDelta } = require('./file-api');
|
||||||
const { rtrimSlashes, ltrimSlashes } = require('./path-utils');
|
const { rtrimSlashes, ltrimSlashes } = require('./path-utils');
|
||||||
const JoplinError = require('./JoplinError');
|
const JoplinError = require('./JoplinError').default;
|
||||||
|
|
||||||
class FileApiDriverWebDav {
|
class FileApiDriverWebDav {
|
||||||
constructor(api) {
|
constructor(api) {
|
||||||
|
@@ -4,7 +4,7 @@ import BaseItem from './models/BaseItem';
|
|||||||
import time from './time';
|
import time from './time';
|
||||||
|
|
||||||
const { isHidden } = require('./path-utils');
|
const { isHidden } = require('./path-utils');
|
||||||
const JoplinError = require('./JoplinError');
|
import JoplinError from './JoplinError';
|
||||||
const ArrayUtils = require('./ArrayUtils');
|
const ArrayUtils = require('./ArrayUtils');
|
||||||
const { sprintf } = require('sprintf-js');
|
const { sprintf } = require('sprintf-js');
|
||||||
const Mutex = require('async-mutex').Mutex;
|
const Mutex = require('async-mutex').Mutex;
|
||||||
@@ -14,6 +14,10 @@ const logger = Logger.create('FileApi');
|
|||||||
function requestCanBeRepeated(error: any) {
|
function requestCanBeRepeated(error: any) {
|
||||||
const errorCode = typeof error === 'object' && error.code ? error.code : null;
|
const errorCode = typeof error === 'object' && error.code ? error.code : null;
|
||||||
|
|
||||||
|
// Unauthorized error - means username or password is incorrect or other
|
||||||
|
// permission issue, which won't be fixed by repeating the request.
|
||||||
|
if (errorCode === 403) return false;
|
||||||
|
|
||||||
// The target is explicitely rejecting the item so repeating wouldn't make a difference.
|
// The target is explicitely rejecting the item so repeating wouldn't make a difference.
|
||||||
if (errorCode === 'rejectedByTarget') return false;
|
if (errorCode === 'rejectedByTarget') return false;
|
||||||
|
|
||||||
|
@@ -8,10 +8,19 @@ import { _ } from '../locale';
|
|||||||
|
|
||||||
import Database from '../database';
|
import Database from '../database';
|
||||||
import ItemChange from './ItemChange';
|
import ItemChange from './ItemChange';
|
||||||
|
import ShareService from '../services/share/ShareService';
|
||||||
const JoplinError = require('../JoplinError.js');
|
const JoplinError = require('../JoplinError.js');
|
||||||
const { sprintf } = require('sprintf-js');
|
const { sprintf } = require('sprintf-js');
|
||||||
const moment = require('moment');
|
const moment = require('moment');
|
||||||
|
|
||||||
|
export interface BaseItemEntity {
|
||||||
|
id?: string;
|
||||||
|
encryption_applied?: boolean;
|
||||||
|
is_shared?: number;
|
||||||
|
share_id?: string;
|
||||||
|
type_?: ModelType;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ItemsThatNeedDecryptionResult {
|
export interface ItemsThatNeedDecryptionResult {
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
items: any[];
|
items: any[];
|
||||||
@@ -21,6 +30,7 @@ export default class BaseItem extends BaseModel {
|
|||||||
|
|
||||||
public static encryptionService_: any = null;
|
public static encryptionService_: any = null;
|
||||||
public static revisionService_: any = null;
|
public static revisionService_: any = null;
|
||||||
|
public static shareService_: ShareService = null;
|
||||||
|
|
||||||
// Also update:
|
// Also update:
|
||||||
// - itemsThatNeedSync()
|
// - itemsThatNeedSync()
|
||||||
@@ -382,14 +392,19 @@ export default class BaseItem extends BaseModel {
|
|||||||
return this.revisionService_;
|
return this.revisionService_;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async serializeForSync(item: any) {
|
protected static shareService() {
|
||||||
|
if (!this.shareService_) throw new Error('BaseItem.shareService_ is not set!!');
|
||||||
|
return this.shareService_;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async serializeForSync(item: BaseItemEntity) {
|
||||||
const ItemClass = this.itemClass(item);
|
const ItemClass = this.itemClass(item);
|
||||||
const shownKeys = ItemClass.fieldNames();
|
const shownKeys = ItemClass.fieldNames();
|
||||||
shownKeys.push('type_');
|
shownKeys.push('type_');
|
||||||
|
|
||||||
const serialized = await ItemClass.serialize(item, shownKeys);
|
const serialized = await ItemClass.serialize(item, shownKeys);
|
||||||
|
|
||||||
if (!Setting.value('encryption.enabled') || !ItemClass.encryptionSupported() || item.is_shared) {
|
if (!Setting.value('encryption.enabled') || !ItemClass.encryptionSupported() || item.is_shared || item.share_id) {
|
||||||
// Normally not possible since itemsThatNeedSync should only return decrypted items
|
// Normally not possible since itemsThatNeedSync should only return decrypted items
|
||||||
if (item.encryption_applied) throw new JoplinError('Item is encrypted but encryption is currently disabled', 'cannotSyncEncrypted');
|
if (item.encryption_applied) throw new JoplinError('Item is encrypted but encryption is currently disabled', 'cannotSyncEncrypted');
|
||||||
return serialized;
|
return serialized;
|
||||||
@@ -415,13 +430,13 @@ export default class BaseItem extends BaseModel {
|
|||||||
|
|
||||||
// List of keys that won't be encrypted - mostly foreign keys required to link items
|
// List of keys that won't be encrypted - mostly foreign keys required to link items
|
||||||
// with each others and timestamp required for synchronisation.
|
// with each others and timestamp required for synchronisation.
|
||||||
const keepKeys = ['id', 'note_id', 'tag_id', 'parent_id', 'updated_time', 'type_'];
|
const keepKeys = ['id', 'note_id', 'tag_id', 'parent_id', 'share_id', 'updated_time', 'type_'];
|
||||||
const reducedItem: any = {};
|
const reducedItem: any = {};
|
||||||
|
|
||||||
for (let i = 0; i < keepKeys.length; i++) {
|
for (let i = 0; i < keepKeys.length; i++) {
|
||||||
const n = keepKeys[i];
|
const n = keepKeys[i];
|
||||||
if (!item.hasOwnProperty(n)) continue;
|
if (!item.hasOwnProperty(n)) continue;
|
||||||
reducedItem[n] = item[n];
|
reducedItem[n] = (item as any)[n];
|
||||||
}
|
}
|
||||||
|
|
||||||
reducedItem.encryption_applied = 1;
|
reducedItem.encryption_applied = 1;
|
||||||
@@ -792,7 +807,7 @@ export default class BaseItem extends BaseModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async updateShareStatus(item: any, isShared: boolean) {
|
static async updateShareStatus(item: BaseItemEntity, isShared: boolean) {
|
||||||
if (!item.id || !item.type_) throw new Error('Item must have an ID and a type');
|
if (!item.id || !item.type_) throw new Error('Item must have an ID and a type');
|
||||||
if (!!item.is_shared === !!isShared) return false;
|
if (!!item.is_shared === !!isShared) return false;
|
||||||
const ItemClass = this.getClassByItemType(item.type_);
|
const ItemClass = this.getClassByItemType(item.type_);
|
||||||
|
@@ -2,10 +2,11 @@ import { FolderEntity } from '../services/database/types';
|
|||||||
import BaseModel from '../BaseModel';
|
import BaseModel from '../BaseModel';
|
||||||
import time from '../time';
|
import time from '../time';
|
||||||
import { _ } from '../locale';
|
import { _ } from '../locale';
|
||||||
|
|
||||||
import Note from './Note';
|
import Note from './Note';
|
||||||
import Database from '../database';
|
import Database from '../database';
|
||||||
import BaseItem from './BaseItem';
|
import BaseItem from './BaseItem';
|
||||||
|
import Resource from './Resource';
|
||||||
|
import { isRootSharedFolder } from '../services/share/reducer';
|
||||||
const { substrWithEllipsis } = require('../string-utils.js');
|
const { substrWithEllipsis } = require('../string-utils.js');
|
||||||
|
|
||||||
interface FolderEntityWithChildren extends FolderEntity {
|
interface FolderEntityWithChildren extends FolderEntity {
|
||||||
@@ -75,8 +76,10 @@ export default class Folder extends BaseItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async delete(folderId: string, options: any = null) {
|
static async delete(folderId: string, options: any = null) {
|
||||||
if (!options) options = {};
|
options = {
|
||||||
if (!('deleteChildren' in options)) options.deleteChildren = true;
|
deleteChildren: true,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
|
||||||
const folder = await Folder.load(folderId);
|
const folder = await Folder.load(folderId);
|
||||||
if (!folder) return; // noop
|
if (!folder) return; // noop
|
||||||
@@ -256,6 +259,120 @@ export default class Folder extends BaseItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async allChildrenFolders(folderId: string): Promise<FolderEntity[]> {
|
||||||
|
const sql = `
|
||||||
|
WITH RECURSIVE
|
||||||
|
folders_cte(id, parent_id, share_id) AS (
|
||||||
|
SELECT id, parent_id, share_id
|
||||||
|
FROM folders
|
||||||
|
WHERE parent_id = ?
|
||||||
|
UNION ALL
|
||||||
|
SELECT folders.id, folders.parent_id, folders.share_id
|
||||||
|
FROM folders
|
||||||
|
INNER JOIN folders_cte AS folders_cte ON (folders.parent_id = folders_cte.id)
|
||||||
|
)
|
||||||
|
SELECT id, parent_id, share_id FROM folders_cte;
|
||||||
|
`;
|
||||||
|
|
||||||
|
return this.db().selectAll(sql, [folderId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async rootSharedFolders(): Promise<FolderEntity[]> {
|
||||||
|
return this.db().selectAll('SELECT id, share_id FROM folders WHERE parent_id = "" AND share_id != ""');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async updateFolderShareIds(): Promise<void> {
|
||||||
|
// Get all the sub-folders of the shared folders, and set the share_id
|
||||||
|
// property.
|
||||||
|
const rootFolders = await this.rootSharedFolders();
|
||||||
|
|
||||||
|
let sharedFolderIds: string[] = [];
|
||||||
|
|
||||||
|
for (const rootFolder of rootFolders) {
|
||||||
|
const children = await this.allChildrenFolders(rootFolder.id);
|
||||||
|
|
||||||
|
for (const child of children) {
|
||||||
|
if (child.share_id !== rootFolder.share_id) {
|
||||||
|
await this.save({
|
||||||
|
id: child.id,
|
||||||
|
share_id: rootFolder.share_id,
|
||||||
|
updated_time: Date.now(),
|
||||||
|
}, { autoTimestamp: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sharedFolderIds.push(rootFolder.id);
|
||||||
|
sharedFolderIds = sharedFolderIds.concat(children.map(c => c.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now that we've set the share ID on all the sub-folders of the shared
|
||||||
|
// folders, those that remain should not be shared anymore. For example,
|
||||||
|
// if they've been moved out of a shared folder.
|
||||||
|
// await this.unshareItems(ModelType.Folder, sharedFolderIds);
|
||||||
|
|
||||||
|
const sql = ['SELECT id FROM folders WHERE share_id != ""'];
|
||||||
|
if (sharedFolderIds.length) {
|
||||||
|
sql.push(` AND id NOT IN ("${sharedFolderIds.join('","')}")`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const foldersToUnshare = await this.db().selectAll(sql.join(' '));
|
||||||
|
for (const item of foldersToUnshare) {
|
||||||
|
await this.save({
|
||||||
|
id: item.id,
|
||||||
|
share_id: '',
|
||||||
|
updated_time: Date.now(),
|
||||||
|
}, { autoTimestamp: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async updateNoteShareIds() {
|
||||||
|
// Find all the notes where the share_id is not the same as the
|
||||||
|
// parent share_id because we only need to update those.
|
||||||
|
const rows = await this.db().selectAll(`
|
||||||
|
SELECT notes.id, folders.share_id
|
||||||
|
FROM notes
|
||||||
|
LEFT JOIN folders ON notes.parent_id = folders.id
|
||||||
|
WHERE notes.share_id != folders.share_id
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
await Note.save({
|
||||||
|
id: row.id,
|
||||||
|
share_id: row.share_id || '',
|
||||||
|
updated_time: Date.now(),
|
||||||
|
}, { autoTimestamp: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async updateResourceShareIds() {
|
||||||
|
// Find all resources where share_id is different from parent note
|
||||||
|
// share_id. Then update share_id on all these resources. Essentially it
|
||||||
|
// makes it match the resource share_id to the note share_id.
|
||||||
|
const rows = await this.db().selectAll(`
|
||||||
|
SELECT r.id, n.share_id, n.is_shared
|
||||||
|
FROM note_resources nr
|
||||||
|
LEFT JOIN resources r ON nr.resource_id = r.id
|
||||||
|
LEFT JOIN notes n ON nr.note_id = n.id
|
||||||
|
WHERE n.share_id != r.share_id
|
||||||
|
OR n.is_shared != r.is_shared
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
await Resource.save({
|
||||||
|
id: row.id,
|
||||||
|
share_id: row.share_id || '',
|
||||||
|
is_shared: row.is_shared,
|
||||||
|
updated_time: Date.now(),
|
||||||
|
}, { autoTimestamp: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async updateAllShareIds() {
|
||||||
|
await this.updateFolderShareIds();
|
||||||
|
await this.updateNoteShareIds();
|
||||||
|
await this.updateResourceShareIds();
|
||||||
|
}
|
||||||
|
|
||||||
static async allAsTree(folders: FolderEntity[] = null, options: any = null) {
|
static async allAsTree(folders: FolderEntity[] = null, options: any = null) {
|
||||||
const all = folders ? folders : await this.all(options);
|
const all = folders ? folders : await this.all(options);
|
||||||
|
|
||||||
@@ -392,6 +509,9 @@ export default class Folder extends BaseItem {
|
|||||||
static async canNestUnder(folderId: string, targetFolderId: string) {
|
static async canNestUnder(folderId: string, targetFolderId: string) {
|
||||||
if (folderId === targetFolderId) return false;
|
if (folderId === targetFolderId) return false;
|
||||||
|
|
||||||
|
const folder = await Folder.load(folderId);
|
||||||
|
if (isRootSharedFolder(folder)) return false;
|
||||||
|
|
||||||
const conflictFolderId = Folder.conflictFolderId();
|
const conflictFolderId = Folder.conflictFolderId();
|
||||||
if (folderId == conflictFolderId || targetFolderId == conflictFolderId) return false;
|
if (folderId == conflictFolderId || targetFolderId == conflictFolderId) return false;
|
||||||
|
|
||||||
|
@@ -111,7 +111,7 @@ export default class Note extends BaseItem {
|
|||||||
return BaseModel.TYPE_NOTE;
|
return BaseModel.TYPE_NOTE;
|
||||||
}
|
}
|
||||||
|
|
||||||
static linkedItemIds(body: string): string[] {
|
public static linkedItemIds(body: string): string[] {
|
||||||
if (!body || body.length <= 32) return [];
|
if (!body || body.length <= 32) return [];
|
||||||
|
|
||||||
const links = urlUtils.extractResourceUrls(body);
|
const links = urlUtils.extractResourceUrls(body);
|
||||||
|
@@ -36,6 +36,40 @@ export default class NoteResource extends BaseModel {
|
|||||||
await this.db().transactionExecBatch(queries);
|
await this.db().transactionExecBatch(queries);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// public static async updateResourceShareIds() {
|
||||||
|
// // Find all resources where share_id is different from parent note
|
||||||
|
// // share_id. Then update share_id on all these resources. Essentially it
|
||||||
|
// // makes it match the resource share_id to the note share_id.
|
||||||
|
|
||||||
|
// const sql = `
|
||||||
|
// SELECT r.id, n.share_id
|
||||||
|
// FROM note_resources nr
|
||||||
|
// LEFT JOIN resources r ON nr.resource_id = r.id
|
||||||
|
// LEFT JOIN notes n ON nr.note_id = n.id
|
||||||
|
// WHERE n.share_id != r.share_id`;
|
||||||
|
|
||||||
|
// const rows = await this.db().selectAll(sql);
|
||||||
|
|
||||||
|
// const updatedTime = Date.now();
|
||||||
|
// const queries: SqlQuery[] = [];
|
||||||
|
|
||||||
|
// for (const row of rows) {
|
||||||
|
// queries.push({
|
||||||
|
// sql: `
|
||||||
|
// UPDATE resources
|
||||||
|
// SET share_id = ?, updated_time = ?
|
||||||
|
// WHERE id = ?`,
|
||||||
|
// params: [
|
||||||
|
// row.share_id || '',
|
||||||
|
// updatedTime,
|
||||||
|
// row.id,
|
||||||
|
// ],
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// await this.db().transactionExecBatch(queries);
|
||||||
|
// }
|
||||||
|
|
||||||
static async associatedNoteIds(resourceId: string): Promise<string[]> {
|
static async associatedNoteIds(resourceId: string): Promise<string[]> {
|
||||||
const rows = await this.modelSelectAll('SELECT note_id FROM note_resources WHERE resource_id = ? AND is_associated = 1', [resourceId]);
|
const rows = await this.modelSelectAll('SELECT note_id FROM note_resources WHERE resource_id = ? AND is_associated = 1', [resourceId]);
|
||||||
return rows.map((r: any) => r.note_id);
|
return rows.map((r: any) => r.note_id);
|
||||||
|
@@ -11,7 +11,7 @@ const pathUtils = require('../path-utils');
|
|||||||
const { mime } = require('../mime-utils.js');
|
const { mime } = require('../mime-utils.js');
|
||||||
const { filename, safeFilename } = require('../path-utils');
|
const { filename, safeFilename } = require('../path-utils');
|
||||||
const { FsDriverDummy } = require('../fs-driver-dummy.js');
|
const { FsDriverDummy } = require('../fs-driver-dummy.js');
|
||||||
const JoplinError = require('../JoplinError');
|
import JoplinError from '../JoplinError';
|
||||||
|
|
||||||
export default class Resource extends BaseItem {
|
export default class Resource extends BaseItem {
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ export default class Resource extends BaseItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static sharedResourceIds(): Promise<string[]> {
|
public static sharedResourceIds(): Promise<string[]> {
|
||||||
return this.db().selectAllFields('SELECT id FROM resources WHERE is_shared = 1', {}, 'id');
|
return this.db().selectAllFields('SELECT id FROM resources WHERE is_shared = 1', [], 'id');
|
||||||
}
|
}
|
||||||
|
|
||||||
static errorFetchStatuses() {
|
static errorFetchStatuses() {
|
||||||
|
@@ -3,7 +3,7 @@ import { RevisionEntity } from '../services/database/types';
|
|||||||
import BaseItem from './BaseItem';
|
import BaseItem from './BaseItem';
|
||||||
const DiffMatchPatch = require('diff-match-patch');
|
const DiffMatchPatch = require('diff-match-patch');
|
||||||
const ArrayUtils = require('../ArrayUtils.js');
|
const ArrayUtils = require('../ArrayUtils.js');
|
||||||
const JoplinError = require('../JoplinError');
|
import JoplinError from '../JoplinError';
|
||||||
const { sprintf } = require('sprintf-js');
|
const { sprintf } = require('sprintf-js');
|
||||||
|
|
||||||
const dmp = new DiffMatchPatch();
|
const dmp = new DiffMatchPatch();
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
import shim from '../shim';
|
import shim from '../shim';
|
||||||
import { _, supportedLocalesToLanguages, defaultLocale } from '../locale';
|
import { _, supportedLocalesToLanguages, defaultLocale } from '../locale';
|
||||||
import { ltrimSlashes } from '../path-utils';
|
|
||||||
import eventManager from '../eventManager';
|
import eventManager from '../eventManager';
|
||||||
import BaseModel from '../BaseModel';
|
import BaseModel from '../BaseModel';
|
||||||
import Database from '../database';
|
import Database from '../database';
|
||||||
@@ -88,6 +87,31 @@ export enum SyncStartupOperation {
|
|||||||
ClearLocalData = 2,
|
ClearLocalData = 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum Env {
|
||||||
|
Undefined = 'SET_ME',
|
||||||
|
Dev = 'dev',
|
||||||
|
Prod = 'prod',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Constants {
|
||||||
|
env: Env;
|
||||||
|
isDemo: boolean;
|
||||||
|
appName: string;
|
||||||
|
appId: string;
|
||||||
|
appType: string;
|
||||||
|
resourceDirName: string;
|
||||||
|
resourceDir: string;
|
||||||
|
profileDir: string;
|
||||||
|
templateDir: string;
|
||||||
|
tempDir: string;
|
||||||
|
pluginDataDir: string;
|
||||||
|
cacheDir: string;
|
||||||
|
pluginDir: string;
|
||||||
|
flagOpenDevTools: boolean;
|
||||||
|
syncVersion: number;
|
||||||
|
startupDevPlugins: string[];
|
||||||
|
}
|
||||||
|
|
||||||
interface SettingSections {
|
interface SettingSections {
|
||||||
[key: string]: SettingSection;
|
[key: string]: SettingSection;
|
||||||
}
|
}
|
||||||
@@ -151,8 +175,8 @@ class Setting extends BaseModel {
|
|||||||
|
|
||||||
// Contains constants that are set by the application and
|
// Contains constants that are set by the application and
|
||||||
// cannot be modified by the user:
|
// cannot be modified by the user:
|
||||||
public static constants_: any = {
|
public static constants_: Constants = {
|
||||||
env: 'SET_ME',
|
env: Env.Undefined,
|
||||||
isDemo: false,
|
isDemo: false,
|
||||||
appName: 'joplin',
|
appName: 'joplin',
|
||||||
appId: 'SET_ME', // Each app should set this identifier
|
appId: 'SET_ME', // Each app should set this identifier
|
||||||
@@ -452,20 +476,20 @@ class Setting extends BaseModel {
|
|||||||
description: () => emptyDirWarning,
|
description: () => emptyDirWarning,
|
||||||
storage: SettingStorage.File,
|
storage: SettingStorage.File,
|
||||||
},
|
},
|
||||||
'sync.9.directory': {
|
// 'sync.9.directory': {
|
||||||
value: 'Apps/Joplin',
|
// value: 'Apps/Joplin',
|
||||||
type: SettingItemType.String,
|
// type: SettingItemType.String,
|
||||||
section: 'sync',
|
// section: 'sync',
|
||||||
show: (settings: any) => {
|
// show: (settings: any) => {
|
||||||
return settings['sync.target'] == SyncTargetRegistry.nameToId('joplinServer');
|
// return settings['sync.target'] == SyncTargetRegistry.nameToId('joplinServer');
|
||||||
},
|
// },
|
||||||
filter: value => {
|
// filter: value => {
|
||||||
return value ? ltrimSlashes(rtrimSlashes(value)) : '';
|
// return value ? ltrimSlashes(rtrimSlashes(value)) : '';
|
||||||
},
|
// },
|
||||||
public: true,
|
// public: true,
|
||||||
label: () => _('Joplin Server Directory'),
|
// label: () => _('Joplin Server Directory'),
|
||||||
storage: SettingStorage.File,
|
// storage: SettingStorage.File,
|
||||||
},
|
// },
|
||||||
'sync.9.username': {
|
'sync.9.username': {
|
||||||
value: '',
|
value: '',
|
||||||
type: SettingItemType.String,
|
type: SettingItemType.String,
|
||||||
@@ -474,7 +498,7 @@ class Setting extends BaseModel {
|
|||||||
return settings['sync.target'] == SyncTargetRegistry.nameToId('joplinServer');
|
return settings['sync.target'] == SyncTargetRegistry.nameToId('joplinServer');
|
||||||
},
|
},
|
||||||
public: true,
|
public: true,
|
||||||
label: () => _('Joplin Server username'),
|
label: () => _('Joplin Server email'),
|
||||||
storage: SettingStorage.File,
|
storage: SettingStorage.File,
|
||||||
},
|
},
|
||||||
'sync.9.password': {
|
'sync.9.password': {
|
||||||
@@ -843,6 +867,12 @@ class Setting extends BaseModel {
|
|||||||
public: false,
|
public: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'sync.userId': {
|
||||||
|
value: '',
|
||||||
|
type: SettingItemType.String,
|
||||||
|
public: false,
|
||||||
|
},
|
||||||
|
|
||||||
// Deprecated in favour of windowContentZoomFactor
|
// Deprecated in favour of windowContentZoomFactor
|
||||||
'style.zoom': { value: 100, type: SettingItemType.Int, public: false, storage: SettingStorage.File, appTypes: ['desktop'], section: 'appearance', label: () => '', minimum: 50, maximum: 500, step: 10 },
|
'style.zoom': { value: 100, type: SettingItemType.Int, public: false, storage: SettingStorage.File, appTypes: ['desktop'], section: 'appearance', label: () => '', minimum: 50, maximum: 500, step: 10 },
|
||||||
|
|
||||||
@@ -1383,7 +1413,7 @@ class Setting extends BaseModel {
|
|||||||
|
|
||||||
static setConstant(key: string, value: any) {
|
static setConstant(key: string, value: any) {
|
||||||
if (!(key in this.constants_)) throw new Error(`Unknown constant key: ${key}`);
|
if (!(key in this.constants_)) throw new Error(`Unknown constant key: ${key}`);
|
||||||
this.constants_[key] = value;
|
(this.constants_ as any)[key] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static setValue(key: string, value: any) {
|
public static setValue(key: string, value: any) {
|
||||||
@@ -1546,7 +1576,7 @@ class Setting extends BaseModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (key in this.constants_) {
|
if (key in this.constants_) {
|
||||||
const v = this.constants_[key];
|
const v = (this.constants_ as any)[key];
|
||||||
const output = typeof v === 'function' ? v() : v;
|
const output = typeof v === 'function' ? v() : v;
|
||||||
if (output == 'SET_ME') throw new Error(`SET_ME constant has not been set: ${key}`);
|
if (output == 'SET_ME') throw new Error(`SET_ME constant has not been set: ${key}`);
|
||||||
return output;
|
return output;
|
||||||
|
@@ -1,8 +1,10 @@
|
|||||||
import produce, { Draft } from 'immer';
|
import produce, { Draft } from 'immer';
|
||||||
import pluginServiceReducer, { stateRootKey as pluginServiceStateRootKey, defaultState as pluginServiceDefaultState, State as PluginServiceState } from './services/plugins/reducer';
|
import pluginServiceReducer, { stateRootKey as pluginServiceStateRootKey, defaultState as pluginServiceDefaultState, State as PluginServiceState } from './services/plugins/reducer';
|
||||||
|
import shareServiceReducer, { stateRootKey as shareServiceStateRootKey, defaultState as shareServiceDefaultState, State as ShareServiceState } from './services/share/reducer';
|
||||||
import Note from './models/Note';
|
import Note from './models/Note';
|
||||||
import Folder from './models/Folder';
|
import Folder from './models/Folder';
|
||||||
import BaseModel from './BaseModel';
|
import BaseModel from './BaseModel';
|
||||||
|
import { Store } from 'redux';
|
||||||
const ArrayUtils = require('./ArrayUtils.js');
|
const ArrayUtils = require('./ArrayUtils.js');
|
||||||
const { ALL_NOTES_FILTER_ID } = require('./reserved-ids');
|
const { ALL_NOTES_FILTER_ID } = require('./reserved-ids');
|
||||||
const { createSelectorCreator, defaultMemoize } = require('reselect');
|
const { createSelectorCreator, defaultMemoize } = require('reselect');
|
||||||
@@ -16,6 +18,12 @@ additionalReducers.push({
|
|||||||
reducer: pluginServiceReducer,
|
reducer: pluginServiceReducer,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
additionalReducers.push({
|
||||||
|
stateRootKey: shareServiceStateRootKey,
|
||||||
|
defaultState: shareServiceDefaultState,
|
||||||
|
reducer: shareServiceReducer,
|
||||||
|
});
|
||||||
|
|
||||||
interface StateLastSelectedNotesIds {
|
interface StateLastSelectedNotesIds {
|
||||||
Folder: any;
|
Folder: any;
|
||||||
Tag: any;
|
Tag: any;
|
||||||
@@ -86,6 +94,7 @@ export interface State {
|
|||||||
|
|
||||||
// Extra reducer keys go here:
|
// Extra reducer keys go here:
|
||||||
pluginService: PluginServiceState;
|
pluginService: PluginServiceState;
|
||||||
|
shareService: ShareServiceState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultState: State = {
|
export const defaultState: State = {
|
||||||
@@ -153,12 +162,23 @@ export const defaultState: State = {
|
|||||||
hasEncryptedItems: false,
|
hasEncryptedItems: false,
|
||||||
|
|
||||||
pluginService: pluginServiceDefaultState,
|
pluginService: pluginServiceDefaultState,
|
||||||
|
shareService: shareServiceDefaultState,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const additionalReducer of additionalReducers) {
|
for (const additionalReducer of additionalReducers) {
|
||||||
(defaultState as any)[additionalReducer.stateRootKey] = additionalReducer.defaultState;
|
(defaultState as any)[additionalReducer.stateRootKey] = additionalReducer.defaultState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let store_: Store<any> = null;
|
||||||
|
|
||||||
|
export function setStore(v: Store<any>) {
|
||||||
|
store_ = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function store(): Store<any> {
|
||||||
|
return store_;
|
||||||
|
}
|
||||||
|
|
||||||
export const MAX_HISTORY = 200;
|
export const MAX_HISTORY = 200;
|
||||||
|
|
||||||
const derivedStateCache_: any = {};
|
const derivedStateCache_: any = {};
|
||||||
|
@@ -99,8 +99,8 @@ class Registry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (Setting.value('env') === 'dev' && delay !== 0) {
|
if (Setting.value('env') === 'dev' && delay !== 0) {
|
||||||
this.logger().info('Schedule sync DISABLED!!!');
|
// this.logger().info('Schedule sync DISABLED!!!');
|
||||||
return;
|
// return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger().debug('Scheduling sync operation...', delay);
|
this.logger().debug('Scheduling sync operation...', delay);
|
||||||
|
@@ -6,7 +6,7 @@ import MasterKey from '../models/MasterKey';
|
|||||||
import BaseItem from '../models/BaseItem';
|
import BaseItem from '../models/BaseItem';
|
||||||
|
|
||||||
const { padLeft } = require('../string-utils.js');
|
const { padLeft } = require('../string-utils.js');
|
||||||
const JoplinError = require('../JoplinError');
|
import JoplinError from '../JoplinError';
|
||||||
|
|
||||||
function hexPad(s: string, length: number) {
|
function hexPad(s: string, length: number) {
|
||||||
return padLeft(s, length, '0');
|
return padLeft(s, length, '0');
|
||||||
|
@@ -3,7 +3,11 @@ import ItemChange from '../models/ItemChange';
|
|||||||
|
|
||||||
export default class ItemChangeUtils {
|
export default class ItemChangeUtils {
|
||||||
static async deleteProcessedChanges() {
|
static async deleteProcessedChanges() {
|
||||||
const lastProcessedChangeIds = [Setting.value('resourceService.lastProcessedChangeId'), Setting.value('searchEngine.lastProcessedChangeId'), Setting.value('revisionService.lastProcessedChangeId')];
|
const lastProcessedChangeIds = [
|
||||||
|
Setting.value('resourceService.lastProcessedChangeId'),
|
||||||
|
Setting.value('searchEngine.lastProcessedChangeId'),
|
||||||
|
Setting.value('revisionService.lastProcessedChangeId'),
|
||||||
|
];
|
||||||
|
|
||||||
const lowestChangeId = Math.min(...lastProcessedChangeIds);
|
const lowestChangeId = Math.min(...lastProcessedChangeIds);
|
||||||
await ItemChange.deleteOldChanges(lowestChangeId);
|
await ItemChange.deleteOldChanges(lowestChangeId);
|
||||||
|
@@ -1,12 +1,49 @@
|
|||||||
import { State, stateUtils } from '../../reducer';
|
import { State, stateUtils } from '../../reducer';
|
||||||
|
|
||||||
import BaseModel from '../../BaseModel';
|
import BaseModel from '../../BaseModel';
|
||||||
import Folder from '../../models/Folder';
|
import Folder from '../../models/Folder';
|
||||||
import MarkupToHtml from '@joplin/renderer/MarkupToHtml';
|
import MarkupToHtml from '@joplin/renderer/MarkupToHtml';
|
||||||
|
import { isRootSharedFolder, isSharedFolderOwner } from '../share/reducer';
|
||||||
|
import { FolderEntity, NoteEntity } from '../database/types';
|
||||||
|
|
||||||
export default function stateToWhenClauseContext(state: State) {
|
export interface WhenClauseContextOptions {
|
||||||
const noteId = state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null;
|
commandFolderId?: string;
|
||||||
const note = noteId ? BaseModel.byId(state.notes, noteId) : null;
|
commandNoteId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WhenClauseContext {
|
||||||
|
notesAreBeingSaved: boolean;
|
||||||
|
syncStarted: boolean;
|
||||||
|
inConflictFolder: boolean;
|
||||||
|
oneNoteSelected: boolean;
|
||||||
|
someNotesSelected: boolean;
|
||||||
|
multipleNotesSelected: boolean;
|
||||||
|
noNotesSelected: boolean;
|
||||||
|
historyhasBackwardNotes: boolean;
|
||||||
|
historyhasForwardNotes: boolean;
|
||||||
|
oneFolderSelected: boolean;
|
||||||
|
noteIsTodo: boolean;
|
||||||
|
noteTodoCompleted: boolean;
|
||||||
|
noteIsMarkdown: boolean;
|
||||||
|
noteIsHtml: boolean;
|
||||||
|
folderIsShareRootAndOwnedByUser: boolean;
|
||||||
|
folderIsShared: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function stateToWhenClauseContext(state: State, options: WhenClauseContextOptions = null): WhenClauseContext {
|
||||||
|
options = {
|
||||||
|
commandFolderId: '',
|
||||||
|
commandNoteId: '',
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedNoteId = state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null;
|
||||||
|
const selectedNote: NoteEntity = selectedNoteId ? BaseModel.byId(state.notes, selectedNoteId) : null;
|
||||||
|
|
||||||
|
// const commandNoteId = options.commandNoteId || selectedNoteId;
|
||||||
|
// const commandNote:NoteEntity = commandNoteId ? BaseModel.byId(state.notes, commandNoteId) : null;
|
||||||
|
|
||||||
|
const commandFolderId = options.commandFolderId;
|
||||||
|
const commandFolder: FolderEntity = commandFolderId ? BaseModel.byId(state.folders, commandFolderId) : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Application state
|
// Application state
|
||||||
@@ -17,7 +54,7 @@ export default function stateToWhenClauseContext(state: State) {
|
|||||||
inConflictFolder: state.selectedFolderId === Folder.conflictFolderId(),
|
inConflictFolder: state.selectedFolderId === Folder.conflictFolderId(),
|
||||||
|
|
||||||
// Note selection
|
// Note selection
|
||||||
oneNoteSelected: !!note,
|
oneNoteSelected: !!selectedNote,
|
||||||
someNotesSelected: state.selectedNoteIds.length > 0,
|
someNotesSelected: state.selectedNoteIds.length > 0,
|
||||||
multipleNotesSelected: state.selectedNoteIds.length > 1,
|
multipleNotesSelected: state.selectedNoteIds.length > 1,
|
||||||
noNotesSelected: !state.selectedNoteIds.length,
|
noNotesSelected: !state.selectedNoteIds.length,
|
||||||
@@ -30,9 +67,13 @@ export default function stateToWhenClauseContext(state: State) {
|
|||||||
oneFolderSelected: !!state.selectedFolderId,
|
oneFolderSelected: !!state.selectedFolderId,
|
||||||
|
|
||||||
// Current note properties
|
// Current note properties
|
||||||
noteIsTodo: note ? !!note.is_todo : false,
|
noteIsTodo: selectedNote ? !!selectedNote.is_todo : false,
|
||||||
noteTodoCompleted: note ? !!note.todo_completed : false,
|
noteTodoCompleted: selectedNote ? !!selectedNote.todo_completed : false,
|
||||||
noteIsMarkdown: note ? note.markup_language === MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN : false,
|
noteIsMarkdown: selectedNote ? selectedNote.markup_language === MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN : false,
|
||||||
noteIsHtml: note ? note.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML : false,
|
noteIsHtml: selectedNote ? selectedNote.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML : false,
|
||||||
|
|
||||||
|
// Current context folder
|
||||||
|
folderIsShareRootAndOwnedByUser: commandFolder ? isRootSharedFolder(commandFolder) && isSharedFolderOwner(state, commandFolder.id) : false,
|
||||||
|
folderIsShared: commandFolder ? !!commandFolder.share_id : false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@@ -1,227 +1,235 @@
|
|||||||
// AUTO-GENERATED BY packages/tools/generate-database-types.js
|
// AUTO-GENERATED BY packages/tools/generate-database-types.js
|
||||||
|
|
||||||
// This file was generated by a tool.
|
/*
|
||||||
// Rerun sql-ts to regenerate this file.
|
* This file was generated by a tool.
|
||||||
|
* Rerun sql-ts to regenerate this file.
|
||||||
|
*/
|
||||||
export interface AlarmEntity {
|
export interface AlarmEntity {
|
||||||
'id'?: number | null;
|
"id"?: number | null
|
||||||
'note_id'?: string;
|
"note_id"?: string
|
||||||
'trigger_time'?: number;
|
"trigger_time"?: number
|
||||||
'type_'?: number;
|
"type_"?: number
|
||||||
}
|
}
|
||||||
export interface DeletedItemEntity {
|
export interface DeletedItemEntity {
|
||||||
'id'?: number | null;
|
"id"?: number | null
|
||||||
'item_type'?: number;
|
"item_type"?: number
|
||||||
'item_id'?: string;
|
"item_id"?: string
|
||||||
'deleted_time'?: number;
|
"deleted_time"?: number
|
||||||
'sync_target'?: number;
|
"sync_target"?: number
|
||||||
'type_'?: number;
|
"type_"?: number
|
||||||
}
|
}
|
||||||
export interface FolderEntity {
|
export interface FolderEntity {
|
||||||
'id'?: string | null;
|
"id"?: string | null
|
||||||
'title'?: string;
|
"title"?: string
|
||||||
'created_time'?: number;
|
"created_time"?: number
|
||||||
'updated_time'?: number;
|
"updated_time"?: number
|
||||||
'user_created_time'?: number;
|
"user_created_time"?: number
|
||||||
'user_updated_time'?: number;
|
"user_updated_time"?: number
|
||||||
'encryption_cipher_text'?: string;
|
"encryption_cipher_text"?: string
|
||||||
'encryption_applied'?: number;
|
"encryption_applied"?: number
|
||||||
'parent_id'?: string;
|
"parent_id"?: string
|
||||||
'is_shared'?: number;
|
"is_shared"?: number
|
||||||
'type_'?: number;
|
"is_linked_folder"?: number
|
||||||
|
"source_folder_owner_id"?: string
|
||||||
|
"share_id"?: string
|
||||||
|
"type_"?: number
|
||||||
}
|
}
|
||||||
export interface ItemChangeEntity {
|
export interface ItemChangeEntity {
|
||||||
'id'?: number | null;
|
"id"?: number | null
|
||||||
'item_type'?: number;
|
"item_type"?: number
|
||||||
'item_id'?: string;
|
"item_id"?: string
|
||||||
'type'?: number;
|
"type"?: number
|
||||||
'created_time'?: number;
|
"created_time"?: number
|
||||||
'source'?: number;
|
"source"?: number
|
||||||
'before_change_item'?: string;
|
"before_change_item"?: string
|
||||||
'type_'?: number;
|
"type_"?: number
|
||||||
}
|
}
|
||||||
export interface KeyValueEntity {
|
export interface KeyValueEntity {
|
||||||
'id'?: number | null;
|
"id"?: number | null
|
||||||
'key'?: string;
|
"key"?: string
|
||||||
'value'?: string;
|
"value"?: string
|
||||||
'type'?: number;
|
"type"?: number
|
||||||
'updated_time'?: number;
|
"updated_time"?: number
|
||||||
'type_'?: number;
|
"type_"?: number
|
||||||
}
|
}
|
||||||
export interface MasterKeyEntity {
|
export interface MasterKeyEntity {
|
||||||
'id'?: string | null;
|
"id"?: string | null
|
||||||
'created_time'?: number;
|
"created_time"?: number
|
||||||
'updated_time'?: number;
|
"updated_time"?: number
|
||||||
'source_application'?: string;
|
"source_application"?: string
|
||||||
'encryption_method'?: number;
|
"encryption_method"?: number
|
||||||
'checksum'?: string;
|
"checksum"?: string
|
||||||
'content'?: string;
|
"content"?: string
|
||||||
'type_'?: number;
|
"type_"?: number
|
||||||
}
|
}
|
||||||
export interface MigrationEntity {
|
export interface MigrationEntity {
|
||||||
'id'?: number | null;
|
"id"?: number | null
|
||||||
'number'?: number;
|
"number"?: number
|
||||||
'updated_time'?: number;
|
"updated_time"?: number
|
||||||
'created_time'?: number;
|
"created_time"?: number
|
||||||
'type_'?: number;
|
"type_"?: number
|
||||||
}
|
}
|
||||||
export interface NoteResourceEntity {
|
export interface NoteResourceEntity {
|
||||||
'id'?: number | null;
|
"id"?: number | null
|
||||||
'note_id'?: string;
|
"note_id"?: string
|
||||||
'resource_id'?: string;
|
"resource_id"?: string
|
||||||
'is_associated'?: number;
|
"is_associated"?: number
|
||||||
'last_seen_time'?: number;
|
"last_seen_time"?: number
|
||||||
'type_'?: number;
|
"type_"?: number
|
||||||
}
|
}
|
||||||
export interface NoteTagEntity {
|
export interface NoteTagEntity {
|
||||||
'id'?: string | null;
|
"id"?: string | null
|
||||||
'note_id'?: string;
|
"note_id"?: string
|
||||||
'tag_id'?: string;
|
"tag_id"?: string
|
||||||
'created_time'?: number;
|
"created_time"?: number
|
||||||
'updated_time'?: number;
|
"updated_time"?: number
|
||||||
'user_created_time'?: number;
|
"user_created_time"?: number
|
||||||
'user_updated_time'?: number;
|
"user_updated_time"?: number
|
||||||
'encryption_cipher_text'?: string;
|
"encryption_cipher_text"?: string
|
||||||
'encryption_applied'?: number;
|
"encryption_applied"?: number
|
||||||
'is_shared'?: number;
|
"is_shared"?: number
|
||||||
'type_'?: number;
|
"type_"?: number
|
||||||
}
|
}
|
||||||
export interface NoteEntity {
|
export interface NoteEntity {
|
||||||
'id'?: string | null;
|
"id"?: string | null
|
||||||
'parent_id'?: string;
|
"parent_id"?: string
|
||||||
'title'?: string;
|
"title"?: string
|
||||||
'body'?: string;
|
"body"?: string
|
||||||
'created_time'?: number;
|
"created_time"?: number
|
||||||
'updated_time'?: number;
|
"updated_time"?: number
|
||||||
'is_conflict'?: number;
|
"is_conflict"?: number
|
||||||
'latitude'?: number;
|
"latitude"?: number
|
||||||
'longitude'?: number;
|
"longitude"?: number
|
||||||
'altitude'?: number;
|
"altitude"?: number
|
||||||
'author'?: string;
|
"author"?: string
|
||||||
'source_url'?: string;
|
"source_url"?: string
|
||||||
'is_todo'?: number;
|
"is_todo"?: number
|
||||||
'todo_due'?: number;
|
"todo_due"?: number
|
||||||
'todo_completed'?: number;
|
"todo_completed"?: number
|
||||||
'source'?: string;
|
"source"?: string
|
||||||
'source_application'?: string;
|
"source_application"?: string
|
||||||
'application_data'?: string;
|
"application_data"?: string
|
||||||
'order'?: number;
|
"order"?: number
|
||||||
'user_created_time'?: number;
|
"user_created_time"?: number
|
||||||
'user_updated_time'?: number;
|
"user_updated_time"?: number
|
||||||
'encryption_cipher_text'?: string;
|
"encryption_cipher_text"?: string
|
||||||
'encryption_applied'?: number;
|
"encryption_applied"?: number
|
||||||
'markup_language'?: number;
|
"markup_language"?: number
|
||||||
'is_shared'?: number;
|
"is_shared"?: number
|
||||||
'type_'?: number;
|
"share_id"?: string
|
||||||
|
"type_"?: number
|
||||||
}
|
}
|
||||||
export interface NotesNormalizedEntity {
|
export interface NotesNormalizedEntity {
|
||||||
'id'?: string;
|
"id"?: string
|
||||||
'title'?: string;
|
"title"?: string
|
||||||
'body'?: string;
|
"body"?: string
|
||||||
'user_created_time'?: number;
|
"user_created_time"?: number
|
||||||
'user_updated_time'?: number;
|
"user_updated_time"?: number
|
||||||
'is_todo'?: number;
|
"is_todo"?: number
|
||||||
'todo_completed'?: number;
|
"todo_completed"?: number
|
||||||
'parent_id'?: string;
|
"parent_id"?: string
|
||||||
'latitude'?: number;
|
"latitude"?: number
|
||||||
'longitude'?: number;
|
"longitude"?: number
|
||||||
'altitude'?: number;
|
"altitude"?: number
|
||||||
'source_url'?: string;
|
"source_url"?: string
|
||||||
'type_'?: number;
|
"todo_due"?: number
|
||||||
|
"type_"?: number
|
||||||
}
|
}
|
||||||
export interface ResourceLocalStateEntity {
|
export interface ResourceLocalStateEntity {
|
||||||
'id'?: number | null;
|
"id"?: number | null
|
||||||
'resource_id'?: string;
|
"resource_id"?: string
|
||||||
'fetch_status'?: number;
|
"fetch_status"?: number
|
||||||
'fetch_error'?: string;
|
"fetch_error"?: string
|
||||||
'type_'?: number;
|
"type_"?: number
|
||||||
}
|
}
|
||||||
export interface ResourceEntity {
|
export interface ResourceEntity {
|
||||||
'id'?: string | null;
|
"id"?: string | null
|
||||||
'title'?: string;
|
"title"?: string
|
||||||
'mime'?: string;
|
"mime"?: string
|
||||||
'filename'?: string;
|
"filename"?: string
|
||||||
'created_time'?: number;
|
"created_time"?: number
|
||||||
'updated_time'?: number;
|
"updated_time"?: number
|
||||||
'user_created_time'?: number;
|
"user_created_time"?: number
|
||||||
'user_updated_time'?: number;
|
"user_updated_time"?: number
|
||||||
'file_extension'?: string;
|
"file_extension"?: string
|
||||||
'encryption_cipher_text'?: string;
|
"encryption_cipher_text"?: string
|
||||||
'encryption_applied'?: number;
|
"encryption_applied"?: number
|
||||||
'encryption_blob_encrypted'?: number;
|
"encryption_blob_encrypted"?: number
|
||||||
'size'?: number;
|
"size"?: number
|
||||||
'is_shared'?: number;
|
"is_shared"?: number
|
||||||
'type_'?: number;
|
"share_id"?: string
|
||||||
|
"type_"?: number
|
||||||
}
|
}
|
||||||
export interface ResourcesToDownloadEntity {
|
export interface ResourcesToDownloadEntity {
|
||||||
'id'?: number | null;
|
"id"?: number | null
|
||||||
'resource_id'?: string;
|
"resource_id"?: string
|
||||||
'updated_time'?: number;
|
"updated_time"?: number
|
||||||
'created_time'?: number;
|
"created_time"?: number
|
||||||
'type_'?: number;
|
"type_"?: number
|
||||||
}
|
}
|
||||||
export interface RevisionEntity {
|
export interface RevisionEntity {
|
||||||
'id'?: string | null;
|
"id"?: string | null
|
||||||
'parent_id'?: string;
|
"parent_id"?: string
|
||||||
'item_type'?: number;
|
"item_type"?: number
|
||||||
'item_id'?: string;
|
"item_id"?: string
|
||||||
'item_updated_time'?: number;
|
"item_updated_time"?: number
|
||||||
'title_diff'?: string;
|
"title_diff"?: string
|
||||||
'body_diff'?: string;
|
"body_diff"?: string
|
||||||
'metadata_diff'?: string;
|
"metadata_diff"?: string
|
||||||
'encryption_cipher_text'?: string;
|
"encryption_cipher_text"?: string
|
||||||
'encryption_applied'?: number;
|
"encryption_applied"?: number
|
||||||
'updated_time'?: number;
|
"updated_time"?: number
|
||||||
'created_time'?: number;
|
"created_time"?: number
|
||||||
'type_'?: number;
|
"type_"?: number
|
||||||
}
|
}
|
||||||
export interface SettingEntity {
|
export interface SettingEntity {
|
||||||
'key'?: string | null;
|
"key"?: string | null
|
||||||
'value'?: string | null;
|
"value"?: string | null
|
||||||
'type_'?: number;
|
"type_"?: number
|
||||||
}
|
}
|
||||||
export interface SyncItemEntity {
|
export interface SyncItemEntity {
|
||||||
'id'?: number | null;
|
"id"?: number | null
|
||||||
'sync_target'?: number;
|
"sync_target"?: number
|
||||||
'sync_time'?: number;
|
"sync_time"?: number
|
||||||
'item_type'?: number;
|
"item_type"?: number
|
||||||
'item_id'?: string;
|
"item_id"?: string
|
||||||
'sync_disabled'?: number;
|
"sync_disabled"?: number
|
||||||
'sync_disabled_reason'?: string;
|
"sync_disabled_reason"?: string
|
||||||
'force_sync'?: number;
|
"force_sync"?: number
|
||||||
'item_location'?: number;
|
"item_location"?: number
|
||||||
'type_'?: number;
|
"type_"?: number
|
||||||
}
|
}
|
||||||
export interface TableFieldEntity {
|
export interface TableFieldEntity {
|
||||||
'id'?: number | null;
|
"id"?: number | null
|
||||||
'table_name'?: string;
|
"table_name"?: string
|
||||||
'field_name'?: string;
|
"field_name"?: string
|
||||||
'field_type'?: number;
|
"field_type"?: number
|
||||||
'field_default'?: string | null;
|
"field_default"?: string | null
|
||||||
'type_'?: number;
|
"type_"?: number
|
||||||
}
|
}
|
||||||
export interface TagEntity {
|
export interface TagEntity {
|
||||||
'id'?: string | null;
|
"id"?: string | null
|
||||||
'title'?: string;
|
"title"?: string
|
||||||
'created_time'?: number;
|
"created_time"?: number
|
||||||
'updated_time'?: number;
|
"updated_time"?: number
|
||||||
'user_created_time'?: number;
|
"user_created_time"?: number
|
||||||
'user_updated_time'?: number;
|
"user_updated_time"?: number
|
||||||
'encryption_cipher_text'?: string;
|
"encryption_cipher_text"?: string
|
||||||
'encryption_applied'?: number;
|
"encryption_applied"?: number
|
||||||
'is_shared'?: number;
|
"is_shared"?: number
|
||||||
'parent_id'?: string;
|
"parent_id"?: string
|
||||||
'type_'?: number;
|
"type_"?: number
|
||||||
}
|
}
|
||||||
export interface TagsWithNoteCountEntity {
|
export interface TagsWithNoteCountEntity {
|
||||||
'id'?: string | null;
|
"id"?: string | null
|
||||||
'title'?: string | null;
|
"title"?: string | null
|
||||||
'created_time'?: number | null;
|
"created_time"?: number | null
|
||||||
'updated_time'?: number | null;
|
"updated_time"?: number | null
|
||||||
'note_count'?: any | null;
|
"note_count"?: any | null
|
||||||
'type_'?: number;
|
"type_"?: number
|
||||||
}
|
}
|
||||||
export interface VersionEntity {
|
export interface VersionEntity {
|
||||||
'version'?: number;
|
"version"?: number
|
||||||
'table_fields_version'?: number;
|
"table_fields_version"?: number
|
||||||
'type_'?: number;
|
"type_"?: number
|
||||||
}
|
}
|
||||||
|
@@ -18,11 +18,17 @@ export default class KeychainService extends BaseService {
|
|||||||
this.driver = driver;
|
this.driver = driver;
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is to programatically disable the keychain service, regardless whether keychain
|
// This is to programatically disable the keychain service, whether keychain
|
||||||
// is supported or not in the system (In other word, this might "enabled" but nothing
|
// is supported or not in the system (In other word, this be might "enabled"
|
||||||
// will be saved to the keychain if there isn't one).
|
// but nothing will be saved to the keychain if there isn't one).
|
||||||
public get enabled(): boolean {
|
public get enabled(): boolean {
|
||||||
return this.enabled_;
|
if (!this.enabled_) return false;
|
||||||
|
|
||||||
|
// Otherwise we assume it's enabled if "keychain.supported" is either -1
|
||||||
|
// (undetermined) or 1 (working). We make it work for -1 too because the
|
||||||
|
// setPassword() and password() functions need to work to test if the
|
||||||
|
// keychain is supported (in detectIfKeychainSupported).
|
||||||
|
return Setting.value('keychain.supported') !== 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public set enabled(v: boolean) {
|
public set enabled(v: boolean) {
|
||||||
|
200
packages/lib/services/share/ShareService.ts
Normal file
200
packages/lib/services/share/ShareService.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import { Store } from 'redux';
|
||||||
|
import JoplinServerApi from '../../JoplinServerApi';
|
||||||
|
import Folder from '../../models/Folder';
|
||||||
|
import Note from '../../models/Note';
|
||||||
|
import Setting from '../../models/Setting';
|
||||||
|
import { State, stateRootKey, StateShare } from './reducer';
|
||||||
|
|
||||||
|
export default class ShareService {
|
||||||
|
|
||||||
|
private static instance_: ShareService;
|
||||||
|
private api_: JoplinServerApi = null;
|
||||||
|
private store_: Store<any> = null;
|
||||||
|
|
||||||
|
public static instance(): ShareService {
|
||||||
|
if (this.instance_) return this.instance_;
|
||||||
|
this.instance_ = new ShareService();
|
||||||
|
return this.instance_;
|
||||||
|
}
|
||||||
|
|
||||||
|
public initialize(store: Store<any>) {
|
||||||
|
this.store_ = store;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get enabled(): boolean {
|
||||||
|
return Setting.value('sync.target') === 9; // Joplin Server target
|
||||||
|
}
|
||||||
|
|
||||||
|
private get store(): Store<any> {
|
||||||
|
return this.store_;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get state(): State {
|
||||||
|
return this.store.getState()[stateRootKey] as State;
|
||||||
|
}
|
||||||
|
|
||||||
|
private api(): JoplinServerApi {
|
||||||
|
if (this.api_) return this.api_;
|
||||||
|
|
||||||
|
this.api_ = new JoplinServerApi({
|
||||||
|
baseUrl: () => Setting.value('sync.9.path'),
|
||||||
|
username: () => Setting.value('sync.9.username'),
|
||||||
|
password: () => Setting.value('sync.9.password'),
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.api_;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async shareFolder(folderId: string) {
|
||||||
|
const folder = await Folder.load(folderId);
|
||||||
|
if (!folder) throw new Error(`No such folder: ${folderId}`);
|
||||||
|
|
||||||
|
if (folder.parent_id) {
|
||||||
|
await Folder.save({ id: folder.id, parent_id: '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const share = await this.api().exec('POST', 'api/shares', {}, { folder_id: folderId });
|
||||||
|
|
||||||
|
// Note: race condition if the share is created but the app crashes
|
||||||
|
// before setting share_id on the folder. See unshareFolder() for info.
|
||||||
|
await Folder.save({ id: folder.id, share_id: share.id });
|
||||||
|
await Folder.updateAllShareIds();
|
||||||
|
|
||||||
|
return share;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async unshareFolder(folderId: string) {
|
||||||
|
const folder = await Folder.load(folderId);
|
||||||
|
if (!folder) throw new Error(`No such folder: ${folderId}`);
|
||||||
|
|
||||||
|
const share = this.shares.find(s => s.folder_id === folderId);
|
||||||
|
if (!share) throw new Error(`No share for folder: ${folderId}`);
|
||||||
|
|
||||||
|
// First, delete the share - which in turns is going to remove the items
|
||||||
|
// for all users, except the owner.
|
||||||
|
await this.deleteShare(share.id);
|
||||||
|
|
||||||
|
// Then reset the "share_id" field for the folder and all sub-items.
|
||||||
|
// This could potentially be done server-side, when deleting the share,
|
||||||
|
// but since clients are normally responsible for maintaining the
|
||||||
|
// share_id property, we do it here for consistency. It will also avoid
|
||||||
|
// conflicts because changes will come only from the clients.
|
||||||
|
//
|
||||||
|
// Note that there could be a race condition here if the share is
|
||||||
|
// deleted, but the app crashes just before setting share_id to "". It's
|
||||||
|
// very unlikely to happen so we leave like this for now.
|
||||||
|
//
|
||||||
|
// We could potentially have a clean up process at some point:
|
||||||
|
//
|
||||||
|
// - It would download all share objects
|
||||||
|
// - Then look for all items where the share_id is not in any of these
|
||||||
|
// shares objects
|
||||||
|
// - And set those to ""
|
||||||
|
//
|
||||||
|
// Likewise, it could apply the share_id to folders based on
|
||||||
|
// share.folder_id
|
||||||
|
//
|
||||||
|
// Setting the share_id is not critical - what matters is that when the
|
||||||
|
// share is deleted, other users no longer have access to the item, so
|
||||||
|
// can't change or read them.
|
||||||
|
await Folder.save({ id: folder.id, share_id: '' });
|
||||||
|
|
||||||
|
// It's ok if updateAllShareIds() doesn't run because it's executed on
|
||||||
|
// each sync too.
|
||||||
|
await Folder.updateAllShareIds();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async shareNote(noteId: string) {
|
||||||
|
const note = await Note.load(noteId);
|
||||||
|
if (!note) throw new Error(`No such note: ${noteId}`);
|
||||||
|
|
||||||
|
const share = await this.api().exec('POST', 'api/shares', {}, { note_id: noteId });
|
||||||
|
|
||||||
|
await Note.save({ id: note.id, is_shared: 1 });
|
||||||
|
|
||||||
|
return share;
|
||||||
|
}
|
||||||
|
|
||||||
|
public shareUrl(share: StateShare): string {
|
||||||
|
return `${this.api().baseUrl()}/shares/${share.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get shares() {
|
||||||
|
return this.state.shares;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get shareLinkNoteIds(): string[] {
|
||||||
|
return this.shares.filter(s => !!s.note_id).map(s => s.note_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async addShareRecipient(shareId: string, recipientEmail: string) {
|
||||||
|
return this.api().exec('POST', `api/shares/${shareId}/users`, {}, {
|
||||||
|
email: recipientEmail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteShareRecipient(shareUserId: string) {
|
||||||
|
await this.api().exec('DELETE', `api/share_users/${shareUserId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteShare(shareId: string) {
|
||||||
|
await this.api().exec('DELETE', `api/shares/${shareId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadShares() {
|
||||||
|
return this.api().exec('GET', 'api/shares');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadShareUsers(shareId: string) {
|
||||||
|
return this.api().exec('GET', `api/shares/${shareId}/users`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadShareInvitations() {
|
||||||
|
return this.api().exec('GET', 'api/share_users');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async respondInvitation(shareUserId: string, accept: boolean) {
|
||||||
|
if (accept) {
|
||||||
|
await this.api().exec('PATCH', `api/share_users/${shareUserId}`, null, { status: 1 });
|
||||||
|
} else {
|
||||||
|
await this.api().exec('PATCH', `api/share_users/${shareUserId}`, null, { status: 2 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async refreshShareInvitations() {
|
||||||
|
const result = await this.loadShareInvitations();
|
||||||
|
|
||||||
|
this.store.dispatch({
|
||||||
|
type: 'SHARE_INVITATION_SET',
|
||||||
|
shareInvitations: result.items,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async refreshShares() {
|
||||||
|
const result = await this.loadShares();
|
||||||
|
|
||||||
|
this.store.dispatch({
|
||||||
|
type: 'SHARE_SET',
|
||||||
|
shares: result.items,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async refreshShareUsers(shareId: string) {
|
||||||
|
const result = await this.loadShareUsers(shareId);
|
||||||
|
|
||||||
|
this.store.dispatch({
|
||||||
|
type: 'SHARE_USER_SET',
|
||||||
|
shareId: shareId,
|
||||||
|
shareUsers: result.items,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async maintenance() {
|
||||||
|
if (this.enabled) {
|
||||||
|
await this.refreshShareInvitations();
|
||||||
|
await this.refreshShares();
|
||||||
|
Setting.setValue('sync.userId', this.api().userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
92
packages/lib/services/share/reducer.ts
Normal file
92
packages/lib/services/share/reducer.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { State as RootState } from '../../reducer';
|
||||||
|
import { Draft } from 'immer';
|
||||||
|
import { FolderEntity } from '../database/types';
|
||||||
|
|
||||||
|
interface StateShareUserUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
full_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ShareUserStatus {
|
||||||
|
Waiting = 0,
|
||||||
|
Accepted = 1,
|
||||||
|
Rejected = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StateShareUser {
|
||||||
|
id: string;
|
||||||
|
status: ShareUserStatus;
|
||||||
|
user: StateShareUserUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StateShare {
|
||||||
|
id: string;
|
||||||
|
type: number;
|
||||||
|
folder_id: string;
|
||||||
|
note_id: string;
|
||||||
|
user?: StateShareUserUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShareInvitation {
|
||||||
|
id: string;
|
||||||
|
share: StateShare;
|
||||||
|
status: ShareUserStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface State {
|
||||||
|
shares: StateShare[];
|
||||||
|
shareUsers: Record<string, StateShareUser>;
|
||||||
|
shareInvitations: ShareInvitation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stateRootKey = 'shareService';
|
||||||
|
|
||||||
|
export const defaultState: State = {
|
||||||
|
shares: [],
|
||||||
|
shareUsers: {},
|
||||||
|
shareInvitations: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isSharedFolderOwner(state: RootState, folderId: string): boolean {
|
||||||
|
const userId = state.settings['sync.userId'];
|
||||||
|
const share = state[stateRootKey].shares.find(s => s.folder_id === folderId);
|
||||||
|
if (!share) return false;
|
||||||
|
return share.user.id === userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRootSharedFolder(folder: FolderEntity): boolean {
|
||||||
|
return !!folder.share_id && !folder.parent_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reducer = (draftRoot: Draft<RootState>, action: any) => {
|
||||||
|
if (action.type.indexOf('SHARE_') !== 0) return;
|
||||||
|
|
||||||
|
const draft = draftRoot.shareService;
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (action.type) {
|
||||||
|
|
||||||
|
case 'SHARE_SET':
|
||||||
|
|
||||||
|
draft.shares = action.shares;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'SHARE_USER_SET':
|
||||||
|
|
||||||
|
draft.shareUsers[action.shareId] = action.shareUsers;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'SHARE_INVITATION_SET':
|
||||||
|
|
||||||
|
draft.shareInvitations = action.shareInvitations;
|
||||||
|
break;
|
||||||
|
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
error.message = `In share reducer: ${error.message} Action: ${JSON.stringify(action)}`;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default reducer;
|
@@ -1,7 +1,7 @@
|
|||||||
import { Dirnames } from './utils/types';
|
import { Dirnames } from './utils/types';
|
||||||
import shim from '../../shim';
|
import shim from '../../shim';
|
||||||
|
|
||||||
const JoplinError = require('../../JoplinError');
|
import JoplinError from '../../JoplinError';
|
||||||
import time from '../../time';
|
import time from '../../time';
|
||||||
const { fileExtension, filename } = require('../../path-utils');
|
const { fileExtension, filename } = require('../../path-utils');
|
||||||
|
|
||||||
|
@@ -15,7 +15,7 @@ const migrations = [
|
|||||||
|
|
||||||
import Setting from '../../models/Setting';
|
import Setting from '../../models/Setting';
|
||||||
const { sprintf } = require('sprintf-js');
|
const { sprintf } = require('sprintf-js');
|
||||||
const JoplinError = require('../../JoplinError');
|
import JoplinError from '../../JoplinError';
|
||||||
|
|
||||||
interface SyncTargetInfo {
|
interface SyncTargetInfo {
|
||||||
version: number;
|
version: number;
|
||||||
|
@@ -24,6 +24,7 @@ const theme: Theme = {
|
|||||||
color2: '#ffffff',
|
color2: '#ffffff',
|
||||||
selectedColor2: '#013F74',
|
selectedColor2: '#013F74',
|
||||||
colorError2: '#ff6c6c',
|
colorError2: '#ff6c6c',
|
||||||
|
colorWarn2: '#ffcb81',
|
||||||
|
|
||||||
// Color scheme "3" is used for the config screens for example/
|
// Color scheme "3" is used for the config screens for example/
|
||||||
// It's dark text over gray background.
|
// It's dark text over gray background.
|
||||||
|
@@ -8,7 +8,7 @@ const theme: Theme = {
|
|||||||
// content. It's basically dark gray text on white background
|
// content. It's basically dark gray text on white background
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
backgroundColorTransparent: 'rgba(255,255,255,0.9)',
|
backgroundColorTransparent: 'rgba(255,255,255,0.9)',
|
||||||
oddBackgroundColor: '#dddddd',
|
oddBackgroundColor: '#eeeeee',
|
||||||
color: '#32373F', // For regular text
|
color: '#32373F', // For regular text
|
||||||
colorError: 'red',
|
colorError: 'red',
|
||||||
colorWarn: '#9A5B00',
|
colorWarn: '#9A5B00',
|
||||||
@@ -24,6 +24,7 @@ const theme: Theme = {
|
|||||||
color2: '#ffffff',
|
color2: '#ffffff',
|
||||||
selectedColor2: '#131313',
|
selectedColor2: '#131313',
|
||||||
colorError2: '#ff6c6c',
|
colorError2: '#ff6c6c',
|
||||||
|
colorWarn2: '#ffcb81',
|
||||||
|
|
||||||
// Color scheme "3" is used for the config screens for example/
|
// Color scheme "3" is used for the config screens for example/
|
||||||
// It's dark text over gray background.
|
// It's dark text over gray background.
|
||||||
|
@@ -26,6 +26,7 @@ export interface Theme {
|
|||||||
color2: string;
|
color2: string;
|
||||||
selectedColor2: string;
|
selectedColor2: string;
|
||||||
colorError2: string;
|
colorError2: string;
|
||||||
|
colorWarn2: string;
|
||||||
|
|
||||||
// Color scheme "3" is used for the config screens for example/
|
// Color scheme "3" is used for the config screens for example/
|
||||||
// It's dark text over gray background.
|
// It's dark text over gray background.
|
||||||
|
7
packages/server/LICENSE
Normal file
7
packages/server/LICENSE
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
Copyright (c) 2017-2021 Laurent Cozic
|
||||||
|
|
||||||
|
Personal Use License
|
||||||
|
|
||||||
|
Joplin Server is available for personal use only. For example you may host the software on your own server for non-commercial activity.
|
||||||
|
|
||||||
|
To obtain a license for commercial purposes, please contact us.
|
317
packages/server/package-lock.json
generated
317
packages/server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,12 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start-dev": "nodemon --config nodemon.json dist/app.js --env dev",
|
"start-dev": "nodemon --config nodemon.json dist/app.js --env dev",
|
||||||
"start": "node dist/app.js",
|
"start": "node dist/app.js",
|
||||||
"generateTypes": "rm -f db-buildTypes.sqlite && npm run start -- --migrate-db --env buildTypes && node dist/tools/generateTypes.js && rm -f db-buildTypes.sqlite",
|
"generateTypes": "rm -f db-buildTypes.sqlite && npm run start -- --migrate-db --env buildTypes && node dist/tools/generateTypes.js && mv db-buildTypes.sqlite schema.sqlite",
|
||||||
"tsc": "tsc --project tsconfig.json",
|
"tsc": "tsc --project tsconfig.json",
|
||||||
"test": "jest",
|
"test": "jest --verbose=false",
|
||||||
"test-ci": "npm run test",
|
"test-ci": "npm run test",
|
||||||
|
"test-debug": "node --inspect node_modules/.bin/jest -- --verbose=false",
|
||||||
|
"clean": "rm -rf dist/",
|
||||||
"watch": "tsc --watch --project tsconfig.json"
|
"watch": "tsc --watch --project tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -22,7 +24,7 @@
|
|||||||
"formidable": "^1.2.2",
|
"formidable": "^1.2.2",
|
||||||
"fs-extra": "^8.1.0",
|
"fs-extra": "^8.1.0",
|
||||||
"html-entities": "^1.3.1",
|
"html-entities": "^1.3.1",
|
||||||
"knex": "^0.19.4",
|
"knex": "0.95.4",
|
||||||
"koa": "^2.8.1",
|
"koa": "^2.8.1",
|
||||||
"markdown-it": "^12.0.4",
|
"markdown-it": "^12.0.4",
|
||||||
"mustache": "^3.1.0",
|
"mustache": "^3.1.0",
|
||||||
|
@@ -10,12 +10,12 @@ function addPluginAssets(appBaseUrl, assets) {
|
|||||||
|
|
||||||
if (asset.mime === 'application/javascript') {
|
if (asset.mime === 'application/javascript') {
|
||||||
const script = document.createElement('script');
|
const script = document.createElement('script');
|
||||||
script.src = `${appBaseUrl}/${asset.path}`;
|
script.src = `${appBaseUrl}/js/${asset.path}`;
|
||||||
pluginAssetsContainer.appendChild(script);
|
pluginAssetsContainer.appendChild(script);
|
||||||
} else if (asset.mime === 'text/css') {
|
} else if (asset.mime === 'text/css') {
|
||||||
const link = document.createElement('link');
|
const link = document.createElement('link');
|
||||||
link.rel = 'stylesheet';
|
link.rel = 'stylesheet';
|
||||||
link.href = `${appBaseUrl}/${asset.path}`;
|
link.href = `${appBaseUrl}/css/${asset.path}`;
|
||||||
pluginAssetsContainer.appendChild(link);
|
pluginAssetsContainer.appendChild(link);
|
||||||
}
|
}
|
||||||
}
|
}
|
BIN
packages/server/schema.sqlite
Normal file
BIN
packages/server/schema.sqlite
Normal file
Binary file not shown.
@@ -14,6 +14,9 @@ import routeHandler from './middleware/routeHandler';
|
|||||||
import notificationHandler from './middleware/notificationHandler';
|
import notificationHandler from './middleware/notificationHandler';
|
||||||
import ownerHandler from './middleware/ownerHandler';
|
import ownerHandler from './middleware/ownerHandler';
|
||||||
import setupAppContext from './utils/setupAppContext';
|
import setupAppContext from './utils/setupAppContext';
|
||||||
|
import { initializeJoplinUtils } from './utils/joplinUtils';
|
||||||
|
import startServices from './utils/startServices';
|
||||||
|
// import { createItemTree } from './utils/testing/testUtils';
|
||||||
|
|
||||||
const nodeEnvFile = require('node-env-file');
|
const nodeEnvFile = require('node-env-file');
|
||||||
const { shimInit } = require('@joplin/lib/shim-init-node.js');
|
const { shimInit } = require('@joplin/lib/shim-init-node.js');
|
||||||
@@ -126,12 +129,46 @@ async function main() {
|
|||||||
const appContext = app.context as AppContext;
|
const appContext = app.context as AppContext;
|
||||||
|
|
||||||
await setupAppContext(appContext, env, connectionCheck.connection, appLogger);
|
await setupAppContext(appContext, env, connectionCheck.connection, appLogger);
|
||||||
|
await initializeJoplinUtils(config(), appContext.models);
|
||||||
|
|
||||||
appLogger().info('Migrating database...');
|
appLogger().info('Migrating database...');
|
||||||
await migrateDb(appContext.db);
|
await migrateDb(appContext.db);
|
||||||
|
|
||||||
|
appLogger().info('Starting services...');
|
||||||
|
await startServices(appContext);
|
||||||
|
|
||||||
|
// if (env !== Env.Prod) {
|
||||||
|
// const done = await handleDebugCommands(argv, appContext.db, config());
|
||||||
|
// if (done) {
|
||||||
|
// appLogger().info('Debug command has been executed. Now starting server...');
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
appLogger().info(`Call this for testing: \`curl ${config().baseUrl}/api/ping\``);
|
appLogger().info(`Call this for testing: \`curl ${config().baseUrl}/api/ping\``);
|
||||||
|
|
||||||
|
// const tree: any = {
|
||||||
|
// '000000000000000000000000000000F1': {},
|
||||||
|
// '000000000000000000000000000000F2': {
|
||||||
|
// '00000000000000000000000000000001': null,
|
||||||
|
// '00000000000000000000000000000002': null,
|
||||||
|
// },
|
||||||
|
// '000000000000000000000000000000F3': {
|
||||||
|
// '00000000000000000000000000000003': null,
|
||||||
|
// '000000000000000000000000000000F4': {
|
||||||
|
// '00000000000000000000000000000004': null,
|
||||||
|
// '00000000000000000000000000000005': null,
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// '00000000000000000000000000000006': null,
|
||||||
|
// '00000000000000000000000000000007': null,
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const users = await appContext.models.user().all();
|
||||||
|
|
||||||
|
// const itemModel = appContext.models.item({ userId: users[0].id });
|
||||||
|
|
||||||
|
// await createItemTree(itemModel, '', tree);
|
||||||
|
|
||||||
app.listen(config().port);
|
app.listen(config().port);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,278 +0,0 @@
|
|||||||
import JoplinDatabase from '@joplin/lib/JoplinDatabase';
|
|
||||||
import Logger from '@joplin/lib/Logger';
|
|
||||||
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
|
|
||||||
import BaseItem from '@joplin/lib/models/BaseItem';
|
|
||||||
import Note from '@joplin/lib/models/Note';
|
|
||||||
import { File, Share, Uuid } from '../../db';
|
|
||||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
|
||||||
import { MarkupToHtml } from '@joplin/renderer';
|
|
||||||
import Setting from '@joplin/lib/models/Setting';
|
|
||||||
import Resource from '@joplin/lib/models/Resource';
|
|
||||||
import FileModel from '../../models/FileModel';
|
|
||||||
import { ErrorNotFound } from '../../utils/errors';
|
|
||||||
import BaseApplication from '../../services/BaseApplication';
|
|
||||||
import { formatDateTime } from '../../utils/time';
|
|
||||||
import { OptionsResourceModel } from '@joplin/renderer/MarkupToHtml';
|
|
||||||
const { DatabaseDriverNode } = require('@joplin/lib/database-driver-node.js');
|
|
||||||
const { themeStyle } = require('@joplin/lib/theme');
|
|
||||||
|
|
||||||
const logger = Logger.create('JoplinApp');
|
|
||||||
|
|
||||||
export interface FileViewerResponse {
|
|
||||||
body: any;
|
|
||||||
mime: string;
|
|
||||||
size: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ResourceInfo {
|
|
||||||
localState: any;
|
|
||||||
item: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LinkedItemInfo {
|
|
||||||
item: any;
|
|
||||||
file: File;
|
|
||||||
}
|
|
||||||
|
|
||||||
type LinkedItemInfos = Record<Uuid, LinkedItemInfo>;
|
|
||||||
|
|
||||||
type ResourceInfos = Record<Uuid, ResourceInfo>;
|
|
||||||
|
|
||||||
export default class Application extends BaseApplication {
|
|
||||||
|
|
||||||
// Although we don't use the database to store data, we still need to setup
|
|
||||||
// so that its schema can be accessed. This is needed for example by
|
|
||||||
// Note.unserialize to know what fields are valid for a note, and to format
|
|
||||||
// the field values correctly.
|
|
||||||
private db_: JoplinDatabase;
|
|
||||||
|
|
||||||
private pluginAssetRootDir_: string;
|
|
||||||
|
|
||||||
public async initialize() {
|
|
||||||
this.mustache.prefersDarkEnabled = false;
|
|
||||||
this.pluginAssetRootDir_ = require('path').resolve(__dirname, '../../..', 'node_modules/@joplin/renderer/assets');
|
|
||||||
|
|
||||||
const filePath = `${this.config.tempDir}/joplin.sqlite`;
|
|
||||||
|
|
||||||
this.db_ = new JoplinDatabase(new DatabaseDriverNode());
|
|
||||||
this.db_.setLogger(logger as Logger);
|
|
||||||
await this.db_.open({ name: filePath });
|
|
||||||
|
|
||||||
BaseModel.setDb(this.db_);
|
|
||||||
|
|
||||||
// Only load the classes that will be needed to render the notes and
|
|
||||||
// resources.
|
|
||||||
BaseItem.loadClass('Note', Note);
|
|
||||||
BaseItem.loadClass('Resource', Resource);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async localFileFromUrl(url: string): Promise<string> {
|
|
||||||
const pluginAssetPrefix = 'apps/joplin/pluginAssets/';
|
|
||||||
|
|
||||||
if (url.indexOf(pluginAssetPrefix) === 0) {
|
|
||||||
return `${this.pluginAssetRootDir_}/${url.substr(pluginAssetPrefix.length)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private itemIdFilename(itemId: string): string {
|
|
||||||
return `${itemId}.md`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async itemMetadataFile(parentId: Uuid, itemId: string): Promise<File | null> {
|
|
||||||
const file = await this.models.file().fileByName(parentId, this.itemIdFilename(itemId), { skipPermissionCheck: true });
|
|
||||||
if (!file) {
|
|
||||||
// We don't throw an error because it can happen if the note
|
|
||||||
// contains an invalid link to a resource or note.
|
|
||||||
logger.error(`Could not find item with ID "${itemId}" on parent "${parentId}"`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return this.models.file().loadWithContent(file.id, { skipPermissionCheck: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
private async unserializeItem(file: File): Promise<any> {
|
|
||||||
const content = file.content.toString();
|
|
||||||
return BaseItem.unserialize(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async resourceInfos(linkedItemInfos: LinkedItemInfos): Promise<ResourceInfos> {
|
|
||||||
const output: Record<string, any> = {};
|
|
||||||
|
|
||||||
for (const itemId of Object.keys(linkedItemInfos)) {
|
|
||||||
const info = linkedItemInfos[itemId];
|
|
||||||
|
|
||||||
if (info.item.type_ !== ModelType.Resource) continue;
|
|
||||||
|
|
||||||
output[info.item.id] = {
|
|
||||||
item: info.item,
|
|
||||||
localState: {
|
|
||||||
fetch_status: Resource.FETCH_STATUS_DONE,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async noteLinkedItemInfos(noteFileParentId: string, note: NoteEntity): Promise<LinkedItemInfos> {
|
|
||||||
const itemIds = await Note.linkedItemIds(note.body);
|
|
||||||
const output: LinkedItemInfos = {};
|
|
||||||
|
|
||||||
for (const itemId of itemIds) {
|
|
||||||
const itemFile = await this.itemMetadataFile(noteFileParentId, itemId);
|
|
||||||
if (!itemFile) continue;
|
|
||||||
|
|
||||||
output[itemId] = {
|
|
||||||
item: await this.unserializeItem(itemFile),
|
|
||||||
file: itemFile,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async resourceDir(fileModel: FileModel, parentId: Uuid): Promise<File> {
|
|
||||||
const parent = await fileModel.load(parentId);
|
|
||||||
const parentFullPath = await fileModel.itemFullPath(parent);
|
|
||||||
const dirPath = fileModel.resolve(parentFullPath, '.resource');
|
|
||||||
return fileModel.pathToFile(dirPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async itemFile(fileModel: FileModel, parentId: Uuid, itemType: ModelType, itemId: string): Promise<File> {
|
|
||||||
let output: File = null;
|
|
||||||
|
|
||||||
if (itemType === ModelType.Resource) {
|
|
||||||
const resourceDir = await this.resourceDir(fileModel, parentId);
|
|
||||||
output = await fileModel.fileByName(resourceDir.id, itemId);
|
|
||||||
} else if (itemType === ModelType.Note) {
|
|
||||||
output = await fileModel.fileByName(parentId, this.itemIdFilename(itemId));
|
|
||||||
} else {
|
|
||||||
throw new Error(`Unsupported type: ${itemType}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return fileModel.loadWithContent(output.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async renderResource(file: File): Promise<FileViewerResponse> {
|
|
||||||
return {
|
|
||||||
body: file.content,
|
|
||||||
mime: file.mime_type,
|
|
||||||
size: file.size,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async renderNote(share: Share, note: NoteEntity, resourceInfos: ResourceInfos, linkedItemInfos: LinkedItemInfos): Promise<FileViewerResponse> {
|
|
||||||
const markupToHtml = new MarkupToHtml({
|
|
||||||
ResourceModel: Resource as OptionsResourceModel,
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderOptions: any = {
|
|
||||||
resources: resourceInfos,
|
|
||||||
|
|
||||||
itemIdToUrl: (itemId: Uuid) => {
|
|
||||||
if (!linkedItemInfos[itemId]) return '#';
|
|
||||||
|
|
||||||
const item = linkedItemInfos[itemId].item;
|
|
||||||
if (!item) throw new Error(`No such item in this note: ${itemId}`);
|
|
||||||
|
|
||||||
if (item.type_ === ModelType.Note) {
|
|
||||||
return '#';
|
|
||||||
} else if (item.type_ === ModelType.Resource) {
|
|
||||||
return `${this.models.share().shareUrl(share.id)}?resource_id=${item.id}&t=${item.updated_time}`;
|
|
||||||
} else {
|
|
||||||
throw new Error(`Unsupported item type: ${item.type_}`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Switch-off the media players because there's no option to toggle
|
|
||||||
// them on and off.
|
|
||||||
audioPlayerEnabled: false,
|
|
||||||
videoPlayerEnabled: false,
|
|
||||||
pdfViewerEnabled: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await markupToHtml.render(note.markup_language, note.body, themeStyle(Setting.THEME_LIGHT), renderOptions);
|
|
||||||
|
|
||||||
const bodyHtml = await this.mustache.renderView({
|
|
||||||
cssFiles: ['note'],
|
|
||||||
jsFiles: ['note'],
|
|
||||||
name: 'note',
|
|
||||||
path: 'note',
|
|
||||||
content: {
|
|
||||||
note: {
|
|
||||||
...note,
|
|
||||||
bodyHtml: result.html,
|
|
||||||
updatedDateTime: formatDateTime(note.updated_time),
|
|
||||||
},
|
|
||||||
cssStrings: result.cssStrings.join('\n'),
|
|
||||||
assetsJs: `
|
|
||||||
const joplinNoteViewer = {
|
|
||||||
pluginAssets: ${JSON.stringify(result.pluginAssets)},
|
|
||||||
appBaseUrl: ${JSON.stringify(this.appBaseUrl)},
|
|
||||||
};
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
body: bodyHtml,
|
|
||||||
mime: 'text/html',
|
|
||||||
size: bodyHtml.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async renderFile(file: File, share: Share, query: Record<string, any>): Promise<FileViewerResponse> {
|
|
||||||
const fileModel = this.models.file({ userId: file.owner_id });
|
|
||||||
|
|
||||||
const rootNote: NoteEntity = await this.unserializeItem(file);
|
|
||||||
const linkedItemInfos = await this.noteLinkedItemInfos(file.parent_id, rootNote);
|
|
||||||
const resourceInfos = await this.resourceInfos(linkedItemInfos);
|
|
||||||
|
|
||||||
const fileToRender = {
|
|
||||||
file: file,
|
|
||||||
itemId: rootNote.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (query.resource_id) {
|
|
||||||
fileToRender.file = await this.itemFile(fileModel, file.parent_id, ModelType.Resource, query.resource_id);
|
|
||||||
fileToRender.itemId = query.resource_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No longer supported - need to decide what to do about note links.
|
|
||||||
|
|
||||||
// if (query.note_id) {
|
|
||||||
// fileToRender.file = await this.itemFile(fileModel, file.parent_id, ModelType.Note, query.note_id);
|
|
||||||
// fileToRender.itemId = query.note_id;
|
|
||||||
// }
|
|
||||||
|
|
||||||
if (fileToRender.file !== file && !linkedItemInfos[fileToRender.itemId]) {
|
|
||||||
throw new ErrorNotFound(`Item "${fileToRender.itemId}" does not belong to this note`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const itemToRender = fileToRender.file === file ? rootNote : linkedItemInfos[fileToRender.itemId].item;
|
|
||||||
const itemType: ModelType = itemToRender.type_;
|
|
||||||
|
|
||||||
if (itemType === ModelType.Resource) {
|
|
||||||
return this.renderResource(fileToRender.file);
|
|
||||||
} else if (itemType === ModelType.Note) {
|
|
||||||
return this.renderNote(share, itemToRender, resourceInfos, linkedItemInfos);
|
|
||||||
} else {
|
|
||||||
throw new Error(`Cannot render item with type "${itemType}"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async isItemFile(file: File): Promise<boolean> {
|
|
||||||
if (file.mime_type !== 'text/markdown') return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.unserializeItem(file);
|
|
||||||
} catch (error) {
|
|
||||||
// No need to log - it means it's not a note file
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@@ -67,7 +67,7 @@ function baseUrlFromEnv(env: any, appPort: number): string {
|
|||||||
|
|
||||||
let config_: Config = null;
|
let config_: Config = null;
|
||||||
|
|
||||||
export function initConfig(env: EnvVariables) {
|
export function initConfig(env: EnvVariables, overrides: any = null) {
|
||||||
runningInDocker_ = !!env.RUNNING_IN_DOCKER;
|
runningInDocker_ = !!env.RUNNING_IN_DOCKER;
|
||||||
|
|
||||||
const rootDir = pathUtils.dirname(__dirname);
|
const rootDir = pathUtils.dirname(__dirname);
|
||||||
@@ -83,6 +83,7 @@ export function initConfig(env: EnvVariables) {
|
|||||||
database: databaseConfigFromEnv(runningInDocker_, env),
|
database: databaseConfigFromEnv(runningInDocker_, env),
|
||||||
port: appPort,
|
port: appPort,
|
||||||
baseUrl: baseUrlFromEnv(env, appPort),
|
baseUrl: baseUrlFromEnv(env, appPort),
|
||||||
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import * as Knex from 'knex';
|
import { knex, Knex } from 'knex';
|
||||||
import { DatabaseConfig } from './utils/types';
|
import { DatabaseConfig } from './utils/types';
|
||||||
import * as pathUtils from 'path';
|
import * as pathUtils from 'path';
|
||||||
import time from '@joplin/lib/time';
|
import time from '@joplin/lib/time';
|
||||||
@@ -98,7 +98,7 @@ export async function waitForConnection(dbConfig: DatabaseConfig): Promise<Conne
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function connectDb(dbConfig: DatabaseConfig): Promise<DbConnection> {
|
export async function connectDb(dbConfig: DatabaseConfig): Promise<DbConnection> {
|
||||||
return require('knex')(makeKnexConfig(dbConfig));
|
return knex(makeKnexConfig(dbConfig));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function disconnectDb(db: DbConnection) {
|
export async function disconnectDb(db: DbConnection) {
|
||||||
@@ -148,7 +148,19 @@ function isNoSuchTableError(error: any): boolean {
|
|||||||
if (error.code === '42P01') return true;
|
if (error.code === '42P01') return true;
|
||||||
|
|
||||||
// Sqlite3 error
|
// Sqlite3 error
|
||||||
if (error.message && error.message.includes('no such table: knex_migrations')) return true;
|
if (error.message && error.message.includes('SQLITE_ERROR: no such table:')) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUniqueConstraintError(error: any): boolean {
|
||||||
|
if (error) {
|
||||||
|
// Postgres error: 23505: unique_violation
|
||||||
|
if (error.code === '23505') return true;
|
||||||
|
|
||||||
|
// Sqlite3 error
|
||||||
|
if (error.code === 'SQLITE_CONSTRAINT' && error.message.includes('UNIQUE constraint')) return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -201,8 +213,9 @@ export enum NotificationLevel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export enum ItemType {
|
export enum ItemType {
|
||||||
File = 1,
|
Item = 1,
|
||||||
User,
|
UserItem = 2,
|
||||||
|
User,
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ChangeType {
|
export enum ChangeType {
|
||||||
@@ -211,6 +224,11 @@ export enum ChangeType {
|
|||||||
Delete = 3,
|
Delete = 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum FileContentType {
|
||||||
|
Any = 1,
|
||||||
|
JoplinItem = 2,
|
||||||
|
}
|
||||||
|
|
||||||
export function changeTypeToString(t: ChangeType): string {
|
export function changeTypeToString(t: ChangeType): string {
|
||||||
if (t === ChangeType.Create) return 'create';
|
if (t === ChangeType.Create) return 'create';
|
||||||
if (t === ChangeType.Update) return 'update';
|
if (t === ChangeType.Update) return 'update';
|
||||||
@@ -221,6 +239,13 @@ export function changeTypeToString(t: ChangeType): string {
|
|||||||
export enum ShareType {
|
export enum ShareType {
|
||||||
Link = 1, // When a note is shared via a public link
|
Link = 1, // When a note is shared via a public link
|
||||||
App = 2, // When a note is shared with another user on the same server instance
|
App = 2, // When a note is shared with another user on the same server instance
|
||||||
|
JoplinRootFolder = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ShareUserStatus {
|
||||||
|
Waiting = 0,
|
||||||
|
Accepted = 1,
|
||||||
|
Rejected = 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WithDates {
|
export interface WithDates {
|
||||||
@@ -229,7 +254,7 @@ export interface WithDates {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface WithUuid {
|
export interface WithUuid {
|
||||||
id?: string;
|
id?: Uuid;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DatabaseTableColumn {
|
interface DatabaseTableColumn {
|
||||||
@@ -258,33 +283,21 @@ export interface Session extends WithDates, WithUuid {
|
|||||||
auth_code?: string;
|
auth_code?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Permission extends WithDates, WithUuid {
|
export interface File {
|
||||||
user_id?: Uuid;
|
id?: Uuid;
|
||||||
item_type?: ItemType;
|
|
||||||
item_id?: Uuid;
|
|
||||||
can_read?: number;
|
|
||||||
can_write?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface File extends WithDates, WithUuid {
|
|
||||||
owner_id?: Uuid;
|
owner_id?: Uuid;
|
||||||
name?: string;
|
name?: string;
|
||||||
content?: Buffer;
|
content?: any;
|
||||||
mime_type?: string;
|
mime_type?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
is_directory?: number;
|
is_directory?: number;
|
||||||
is_root?: number;
|
is_root?: number;
|
||||||
parent_id?: Uuid;
|
parent_id?: Uuid;
|
||||||
}
|
updated_time?: string;
|
||||||
|
created_time?: string;
|
||||||
export interface Change extends WithDates, WithUuid {
|
source_file_id?: Uuid;
|
||||||
counter?: number;
|
content_type?: number;
|
||||||
owner_id?: Uuid;
|
content_id?: Uuid;
|
||||||
item_type?: ItemType;
|
|
||||||
parent_id?: Uuid;
|
|
||||||
item_id?: Uuid;
|
|
||||||
item_name?: string;
|
|
||||||
type?: ChangeType;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiClient extends WithDates, WithUuid {
|
export interface ApiClient extends WithDates, WithUuid {
|
||||||
@@ -301,10 +314,59 @@ export interface Notification extends WithDates, WithUuid {
|
|||||||
canBeDismissed?: number;
|
canBeDismissed?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ShareUser extends WithDates, WithUuid {
|
||||||
|
share_id?: Uuid;
|
||||||
|
user_id?: Uuid;
|
||||||
|
status?: ShareUserStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Item extends WithDates, WithUuid {
|
||||||
|
name?: string;
|
||||||
|
mime_type?: string;
|
||||||
|
content?: Buffer;
|
||||||
|
content_size?: number;
|
||||||
|
jop_id?: Uuid;
|
||||||
|
jop_parent_id?: Uuid;
|
||||||
|
jop_share_id?: Uuid;
|
||||||
|
jop_type?: number;
|
||||||
|
jop_encryption_applied?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserItem extends WithDates {
|
||||||
|
id?: number;
|
||||||
|
user_id?: Uuid;
|
||||||
|
item_id?: Uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ItemResource {
|
||||||
|
id?: number;
|
||||||
|
item_id?: Uuid;
|
||||||
|
resource_id?: Uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyValue {
|
||||||
|
id?: number;
|
||||||
|
key?: string;
|
||||||
|
type?: number;
|
||||||
|
value?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Share extends WithDates, WithUuid {
|
export interface Share extends WithDates, WithUuid {
|
||||||
owner_id?: Uuid;
|
owner_id?: Uuid;
|
||||||
file_id?: Uuid;
|
item_id?: Uuid;
|
||||||
type?: ShareType;
|
type?: ShareType;
|
||||||
|
folder_id?: Uuid;
|
||||||
|
note_id?: Uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Change extends WithDates, WithUuid {
|
||||||
|
counter?: number;
|
||||||
|
item_type?: ItemType;
|
||||||
|
item_id?: Uuid;
|
||||||
|
item_name?: string;
|
||||||
|
type?: ChangeType;
|
||||||
|
previous_item?: string;
|
||||||
|
user_id?: Uuid;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const databaseSchema: DatabaseTables = {
|
export const databaseSchema: DatabaseTables = {
|
||||||
@@ -324,16 +386,6 @@ export const databaseSchema: DatabaseTables = {
|
|||||||
updated_time: { type: 'string' },
|
updated_time: { type: 'string' },
|
||||||
created_time: { type: 'string' },
|
created_time: { type: 'string' },
|
||||||
},
|
},
|
||||||
permissions: {
|
|
||||||
id: { type: 'string' },
|
|
||||||
user_id: { type: 'string' },
|
|
||||||
item_type: { type: 'number' },
|
|
||||||
item_id: { type: 'string' },
|
|
||||||
can_read: { type: 'number' },
|
|
||||||
can_write: { type: 'number' },
|
|
||||||
updated_time: { type: 'string' },
|
|
||||||
created_time: { type: 'string' },
|
|
||||||
},
|
|
||||||
files: {
|
files: {
|
||||||
id: { type: 'string' },
|
id: { type: 'string' },
|
||||||
owner_id: { type: 'string' },
|
owner_id: { type: 'string' },
|
||||||
@@ -346,18 +398,9 @@ export const databaseSchema: DatabaseTables = {
|
|||||||
parent_id: { type: 'string' },
|
parent_id: { type: 'string' },
|
||||||
updated_time: { type: 'string' },
|
updated_time: { type: 'string' },
|
||||||
created_time: { type: 'string' },
|
created_time: { type: 'string' },
|
||||||
},
|
source_file_id: { type: 'string' },
|
||||||
changes: {
|
content_type: { type: 'number' },
|
||||||
counter: { type: 'number' },
|
content_id: { type: 'string' },
|
||||||
id: { type: 'string' },
|
|
||||||
owner_id: { type: 'string' },
|
|
||||||
item_type: { type: 'number' },
|
|
||||||
parent_id: { type: 'string' },
|
|
||||||
item_id: { type: 'string' },
|
|
||||||
item_name: { type: 'string' },
|
|
||||||
type: { type: 'number' },
|
|
||||||
updated_time: { type: 'string' },
|
|
||||||
created_time: { type: 'string' },
|
|
||||||
},
|
},
|
||||||
api_clients: {
|
api_clients: {
|
||||||
id: { type: 'string' },
|
id: { type: 'string' },
|
||||||
@@ -377,13 +420,67 @@ export const databaseSchema: DatabaseTables = {
|
|||||||
updated_time: { type: 'string' },
|
updated_time: { type: 'string' },
|
||||||
created_time: { type: 'string' },
|
created_time: { type: 'string' },
|
||||||
},
|
},
|
||||||
shares: {
|
share_users: {
|
||||||
id: { type: 'string' },
|
id: { type: 'string' },
|
||||||
owner_id: { type: 'string' },
|
share_id: { type: 'string' },
|
||||||
file_id: { type: 'string' },
|
user_id: { type: 'string' },
|
||||||
type: { type: 'number' },
|
status: { type: 'number' },
|
||||||
updated_time: { type: 'string' },
|
updated_time: { type: 'string' },
|
||||||
created_time: { type: 'string' },
|
created_time: { type: 'string' },
|
||||||
},
|
},
|
||||||
|
items: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
name: { type: 'string' },
|
||||||
|
mime_type: { type: 'string' },
|
||||||
|
updated_time: { type: 'string' },
|
||||||
|
created_time: { type: 'string' },
|
||||||
|
content: { type: 'any' },
|
||||||
|
content_size: { type: 'number' },
|
||||||
|
jop_id: { type: 'string' },
|
||||||
|
jop_parent_id: { type: 'string' },
|
||||||
|
jop_share_id: { type: 'string' },
|
||||||
|
jop_type: { type: 'number' },
|
||||||
|
jop_encryption_applied: { type: 'number' },
|
||||||
|
},
|
||||||
|
user_items: {
|
||||||
|
id: { type: 'number' },
|
||||||
|
user_id: { type: 'string' },
|
||||||
|
item_id: { type: 'string' },
|
||||||
|
updated_time: { type: 'string' },
|
||||||
|
created_time: { type: 'string' },
|
||||||
|
},
|
||||||
|
item_resources: {
|
||||||
|
id: { type: 'number' },
|
||||||
|
item_id: { type: 'string' },
|
||||||
|
resource_id: { type: 'string' },
|
||||||
|
},
|
||||||
|
key_values: {
|
||||||
|
id: { type: 'number' },
|
||||||
|
key: { type: 'string' },
|
||||||
|
type: { type: 'number' },
|
||||||
|
value: { type: 'string' },
|
||||||
|
},
|
||||||
|
shares: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
owner_id: { type: 'string' },
|
||||||
|
item_id: { type: 'string' },
|
||||||
|
type: { type: 'number' },
|
||||||
|
updated_time: { type: 'string' },
|
||||||
|
created_time: { type: 'string' },
|
||||||
|
folder_id: { type: 'string' },
|
||||||
|
note_id: { type: 'string' },
|
||||||
|
},
|
||||||
|
changes: {
|
||||||
|
counter: { type: 'number' },
|
||||||
|
id: { type: 'string' },
|
||||||
|
item_type: { type: 'number' },
|
||||||
|
item_id: { type: 'string' },
|
||||||
|
item_name: { type: 'string' },
|
||||||
|
type: { type: 'number' },
|
||||||
|
updated_time: { type: 'string' },
|
||||||
|
created_time: { type: 'string' },
|
||||||
|
previous_item: { type: 'string' },
|
||||||
|
user_id: { type: 'string' },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
// AUTO-GENERATED-TYPES
|
// AUTO-GENERATED-TYPES
|
||||||
|
@@ -17,9 +17,9 @@ describe('notificationHandler', function() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should check admin password', async function() {
|
test('should check admin password', async function() {
|
||||||
const { user, session } = await createUserAndSession(1, true);
|
const { session } = await createUserAndSession(1, true);
|
||||||
|
|
||||||
const admin = await models().user({ userId: user.id }).save({
|
const admin = await models().user().save({
|
||||||
email: defaultAdminEmail,
|
email: defaultAdminEmail,
|
||||||
password: defaultAdminPassword,
|
password: defaultAdminPassword,
|
||||||
is_admin: 1,
|
is_admin: 1,
|
||||||
@@ -38,7 +38,7 @@ describe('notificationHandler', function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
await models().user({ userId: admin.id }).save({
|
await models().user().save({
|
||||||
id: admin.id,
|
id: admin.id,
|
||||||
password: 'changed!',
|
password: 'changed!',
|
||||||
});
|
});
|
||||||
|
@@ -12,20 +12,22 @@ async function handleChangeAdminPasswordNotification(ctx: AppContext) {
|
|||||||
if (!ctx.owner.is_admin) return;
|
if (!ctx.owner.is_admin) return;
|
||||||
|
|
||||||
const defaultAdmin = await ctx.models.user().login(defaultAdminEmail, defaultAdminPassword);
|
const defaultAdmin = await ctx.models.user().login(defaultAdminEmail, defaultAdminPassword);
|
||||||
const notificationModel = ctx.models.notification({ userId: ctx.owner.id });
|
const notificationModel = ctx.models.notification();
|
||||||
|
|
||||||
if (defaultAdmin) {
|
if (defaultAdmin) {
|
||||||
await notificationModel.add(
|
await notificationModel.add(
|
||||||
|
ctx.owner.id,
|
||||||
'change_admin_password',
|
'change_admin_password',
|
||||||
NotificationLevel.Important,
|
NotificationLevel.Important,
|
||||||
_('The default admin password is insecure and has not been changed! [Change it now](%s)', await ctx.models.user().profileUrl())
|
_('The default admin password is insecure and has not been changed! [Change it now](%s)', await ctx.models.user().profileUrl())
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await notificationModel.markAsRead('change_admin_password');
|
await notificationModel.markAsRead(ctx.owner.id, 'change_admin_password');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config().database.client === 'sqlite3' && ctx.env === 'prod') {
|
if (config().database.client === 'sqlite3' && ctx.env === 'prod') {
|
||||||
await notificationModel.add(
|
await notificationModel.add(
|
||||||
|
ctx.owner.id,
|
||||||
'using_sqlite_in_prod',
|
'using_sqlite_in_prod',
|
||||||
NotificationLevel.Important,
|
NotificationLevel.Important,
|
||||||
'The server is currently using SQLite3 as a database. It is not recommended in production as it is slow and can cause locking issues. Please see the README for information on how to change it.'
|
'The server is currently using SQLite3 as a database. It is not recommended in production as it is slow and can cause locking issues. Please see the README for information on how to change it.'
|
||||||
@@ -36,10 +38,11 @@ async function handleChangeAdminPasswordNotification(ctx: AppContext) {
|
|||||||
async function handleSqliteInProdNotification(ctx: AppContext) {
|
async function handleSqliteInProdNotification(ctx: AppContext) {
|
||||||
if (!ctx.owner.is_admin) return;
|
if (!ctx.owner.is_admin) return;
|
||||||
|
|
||||||
const notificationModel = ctx.models.notification({ userId: ctx.owner.id });
|
const notificationModel = ctx.models.notification();
|
||||||
|
|
||||||
if (config().database.client === 'sqlite3' && ctx.env === 'prod') {
|
if (config().database.client === 'sqlite3' && ctx.env === 'prod') {
|
||||||
await notificationModel.add(
|
await notificationModel.add(
|
||||||
|
ctx.owner.id,
|
||||||
'using_sqlite_in_prod',
|
'using_sqlite_in_prod',
|
||||||
NotificationLevel.Important,
|
NotificationLevel.Important,
|
||||||
'The server is currently using SQLite3 as a database. It is not recommended in production as it is slow and can cause locking issues. Please see the README for information on how to change it.'
|
'The server is currently using SQLite3 as a database. It is not recommended in production as it is slow and can cause locking issues. Please see the README for information on how to change it.'
|
||||||
@@ -50,7 +53,7 @@ async function handleSqliteInProdNotification(ctx: AppContext) {
|
|||||||
async function makeNotificationViews(ctx: AppContext): Promise<NotificationView[]> {
|
async function makeNotificationViews(ctx: AppContext): Promise<NotificationView[]> {
|
||||||
const markdownIt = new MarkdownIt();
|
const markdownIt = new MarkdownIt();
|
||||||
|
|
||||||
const notificationModel = ctx.models.notification({ userId: ctx.owner.id });
|
const notificationModel = ctx.models.notification();
|
||||||
const notifications = await notificationModel.allUnreadByUserId(ctx.owner.id);
|
const notifications = await notificationModel.allUnreadByUserId(ctx.owner.id);
|
||||||
const views: NotificationView[] = [];
|
const views: NotificationView[] = [];
|
||||||
for (const n of notifications) {
|
for (const n of notifications) {
|
||||||
|
@@ -1,7 +1,5 @@
|
|||||||
import routes from '../routes/routes';
|
import { routeResponseFormat, Response, RouteResponseFormat, execRequest } from '../utils/routeUtils';
|
||||||
import { ErrorForbidden, ErrorNotFound } from '../utils/errors';
|
import { AppContext, Env } from '../utils/types';
|
||||||
import { routeResponseFormat, findMatchingRoute, Response, RouteResponseFormat, MatchedRoute } from '../utils/routeUtils';
|
|
||||||
import { AppContext, Env, HttpMethod } from '../utils/types';
|
|
||||||
import MustacheService, { isView, View } from '../services/MustacheService';
|
import MustacheService, { isView, View } from '../services/MustacheService';
|
||||||
import config from '../config';
|
import config from '../config';
|
||||||
|
|
||||||
@@ -16,38 +14,21 @@ function mustache(): MustacheService {
|
|||||||
export default async function(ctx: AppContext) {
|
export default async function(ctx: AppContext) {
|
||||||
ctx.appLogger().info(`${ctx.request.method} ${ctx.path}`);
|
ctx.appLogger().info(`${ctx.request.method} ${ctx.path}`);
|
||||||
|
|
||||||
const match: MatchedRoute = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const match = findMatchingRoute(ctx.path, routes);
|
const responseObject = await execRequest(ctx.routes, ctx);
|
||||||
|
|
||||||
if (match) {
|
if (responseObject instanceof Response) {
|
||||||
let responseObject = null;
|
ctx.response = responseObject.response;
|
||||||
|
} else if (isView(responseObject)) {
|
||||||
const routeHandler = match.route.findEndPoint(ctx.request.method as HttpMethod, match.subPath.schema);
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = await mustache().renderView(responseObject, {
|
||||||
// This is a generic catch-all for all private end points - if we
|
notifications: ctx.notifications || [],
|
||||||
// couldn't get a valid session, we exit now. Individual end points
|
hasNotifications: !!ctx.notifications && !!ctx.notifications.length,
|
||||||
// might have additional permission checks depending on the action.
|
owner: ctx.owner,
|
||||||
if (!match.route.public && !ctx.owner) throw new ErrorForbidden();
|
});
|
||||||
|
|
||||||
responseObject = await routeHandler(match.subPath, ctx);
|
|
||||||
|
|
||||||
if (responseObject instanceof Response) {
|
|
||||||
ctx.response = responseObject.response;
|
|
||||||
} else if (isView(responseObject)) {
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = await mustache().renderView(responseObject, {
|
|
||||||
notifications: ctx.notifications || [],
|
|
||||||
hasNotifications: !!ctx.notifications && !!ctx.notifications.length,
|
|
||||||
owner: ctx.owner,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = [undefined, null].includes(responseObject) ? '' : responseObject;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
throw new ErrorNotFound();
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = [undefined, null].includes(responseObject) ? '' : responseObject;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.httpCode >= 400 && error.httpCode < 500) {
|
if (error.httpCode >= 400 && error.httpCode < 500) {
|
||||||
@@ -56,9 +37,12 @@ export default async function(ctx: AppContext) {
|
|||||||
ctx.appLogger().error(error);
|
ctx.appLogger().error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Uncomment this when getting HTML blobs as errors while running tests.
|
||||||
|
// console.error(error);
|
||||||
|
|
||||||
ctx.response.status = error.httpCode ? error.httpCode : 500;
|
ctx.response.status = error.httpCode ? error.httpCode : 500;
|
||||||
|
|
||||||
const responseFormat = routeResponseFormat(match, ctx);
|
const responseFormat = routeResponseFormat(ctx);
|
||||||
|
|
||||||
if (responseFormat === RouteResponseFormat.Html) {
|
if (responseFormat === RouteResponseFormat.Html) {
|
||||||
ctx.response.set('Content-Type', 'text/html');
|
ctx.response.set('Content-Type', 'text/html');
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import * as Knex from 'knex';
|
import { Knex } from 'knex';
|
||||||
import { DbConnection, defaultAdminEmail, defaultAdminPassword } from '../db';
|
import { DbConnection, defaultAdminEmail, defaultAdminPassword } from '../db';
|
||||||
import { hashPassword } from '../utils/auth';
|
import { hashPassword } from '../utils/auth';
|
||||||
import uuidgen from '../utils/uuidgen';
|
import uuidgen from '../utils/uuidgen';
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import * as Knex from 'knex';
|
import { Knex } from 'knex';
|
||||||
import { DbConnection } from '../db';
|
import { DbConnection } from '../db';
|
||||||
|
|
||||||
export async function up(db: DbConnection): Promise<any> {
|
export async function up(db: DbConnection): Promise<any> {
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import * as Knex from 'knex';
|
import { Knex } from 'knex';
|
||||||
import { DbConnection } from '../db';
|
import { DbConnection } from '../db';
|
||||||
|
|
||||||
export async function up(db: DbConnection): Promise<any> {
|
export async function up(db: DbConnection): Promise<any> {
|
||||||
|
45
packages/server/src/migrations/20210201143859_app_share.ts
Normal file
45
packages/server/src/migrations/20210201143859_app_share.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
import { DbConnection } from '../db';
|
||||||
|
|
||||||
|
export async function up(db: DbConnection): Promise<any> {
|
||||||
|
await db.schema.createTable('share_users', function(table: Knex.CreateTableBuilder) {
|
||||||
|
table.string('id', 32).unique().primary().notNullable();
|
||||||
|
table.string('share_id', 32).notNullable();
|
||||||
|
table.string('user_id', 32).notNullable();
|
||||||
|
table.integer('status').defaultTo(0).notNullable();
|
||||||
|
table.bigInteger('updated_time').notNullable();
|
||||||
|
table.bigInteger('created_time').notNullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.schema.alterTable('share_users', function(table: Knex.CreateTableBuilder) {
|
||||||
|
table.unique(['share_id', 'user_id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.schema.alterTable('files', function(table: Knex.CreateTableBuilder) {
|
||||||
|
table.string('source_file_id', 32).defaultTo('').notNullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.schema.alterTable('files', function(table: Knex.CreateTableBuilder) {
|
||||||
|
table.index(['owner_id']);
|
||||||
|
table.index(['source_file_id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.schema.alterTable('changes', function(table: Knex.CreateTableBuilder) {
|
||||||
|
table.index(['item_id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.schema.dropTable('shares');
|
||||||
|
|
||||||
|
await db.schema.createTable('shares', function(table: Knex.CreateTableBuilder) {
|
||||||
|
table.string('id', 32).unique().primary().notNullable();
|
||||||
|
table.string('owner_id', 32).notNullable();
|
||||||
|
table.string('item_id', 32).notNullable();
|
||||||
|
table.integer('type').notNullable();
|
||||||
|
table.bigInteger('updated_time').notNullable();
|
||||||
|
table.bigInteger('created_time').notNullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: DbConnection): Promise<any> {
|
||||||
|
await db.schema.dropTable('share_users');
|
||||||
|
}
|
@@ -0,0 +1,25 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
import { DbConnection } from '../db';
|
||||||
|
|
||||||
|
export async function up(db: DbConnection): Promise<any> {
|
||||||
|
await db.schema.createTable('joplin_file_contents', function(table: Knex.CreateTableBuilder) {
|
||||||
|
table.string('id', 32).unique().primary().notNullable();
|
||||||
|
table.string('owner_id', 32).notNullable();
|
||||||
|
table.string('item_id', 32).notNullable();
|
||||||
|
table.string('parent_id', 32).defaultTo('').notNullable();
|
||||||
|
table.integer('type', 2).notNullable();
|
||||||
|
table.bigInteger('updated_time').notNullable();
|
||||||
|
table.bigInteger('created_time').notNullable();
|
||||||
|
table.integer('encryption_applied', 1).notNullable();
|
||||||
|
table.binary('content').defaultTo('').notNullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.schema.alterTable('files', function(table: Knex.CreateTableBuilder) {
|
||||||
|
table.integer('content_type', 2).defaultTo(1).notNullable();
|
||||||
|
table.string('content_id', 32).defaultTo('').notNullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: DbConnection): Promise<any> {
|
||||||
|
await db.schema.dropTable('joplin_file_contents');
|
||||||
|
}
|
@@ -0,0 +1,13 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
import { DbConnection } from '../db';
|
||||||
|
|
||||||
|
export async function up(db: DbConnection): Promise<any> {
|
||||||
|
await db.schema.alterTable('shares', function(table: Knex.CreateTableBuilder) {
|
||||||
|
table.string('folder_id', 32).defaultTo('').notNullable();
|
||||||
|
table.integer('is_auto', 1).defaultTo(0).notNullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: DbConnection): Promise<any> {
|
||||||
|
await db.schema.dropTable('joplin_items');
|
||||||
|
}
|
116
packages/server/src/migrations/20210412110640_item_refactor.ts
Normal file
116
packages/server/src/migrations/20210412110640_item_refactor.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
import { DbConnection } from '../db';
|
||||||
|
|
||||||
|
// parent_id: ${'parent_id' in note ? note.parent_id : defaultFolderId}
|
||||||
|
// created_time: 2020-10-15T10:34:16.044Z
|
||||||
|
// updated_time: 2021-01-28T23:10:30.054Z
|
||||||
|
// is_conflict: 0
|
||||||
|
// latitude: 0.00000000
|
||||||
|
// longitude: 0.00000000
|
||||||
|
// altitude: 0.0000
|
||||||
|
// author:
|
||||||
|
// source_url:
|
||||||
|
// is_todo: 1
|
||||||
|
// todo_due: 1602760405000
|
||||||
|
// todo_completed: 0
|
||||||
|
// source: joplindev-desktop
|
||||||
|
// source_application: net.cozic.joplindev-desktop
|
||||||
|
// application_data:
|
||||||
|
// order: 0
|
||||||
|
// user_created_time: 2020-10-15T10:34:16.044Z
|
||||||
|
// user_updated_time: 2020-10-19T17:21:03.394Z
|
||||||
|
// encryption_cipher_text:
|
||||||
|
// encryption_applied: 0
|
||||||
|
// markup_language: 1
|
||||||
|
// is_shared: 1
|
||||||
|
// type_: 1`;
|
||||||
|
|
||||||
|
export async function up(db: DbConnection): Promise<any> {
|
||||||
|
await db.schema.createTable('items', function(table: Knex.CreateTableBuilder) {
|
||||||
|
table.string('id', 32).unique().primary().notNullable();
|
||||||
|
table.text('name').notNullable();
|
||||||
|
table.string('mime_type', 128).defaultTo('application/octet-stream').notNullable();
|
||||||
|
table.bigInteger('updated_time').notNullable();
|
||||||
|
table.bigInteger('created_time').notNullable();
|
||||||
|
table.binary('content').defaultTo('').notNullable();
|
||||||
|
table.integer('content_size').defaultTo(0).notNullable();
|
||||||
|
table.string('jop_id', 32).defaultTo('').notNullable();
|
||||||
|
table.string('jop_parent_id', 32).defaultTo('').notNullable();
|
||||||
|
table.string('jop_share_id', 32).defaultTo('').notNullable();
|
||||||
|
table.integer('jop_type', 2).defaultTo(0).notNullable();
|
||||||
|
table.integer('jop_encryption_applied', 1).defaultTo(0).notNullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.schema.alterTable('items', function(table: Knex.CreateTableBuilder) {
|
||||||
|
table.index('name');
|
||||||
|
table.index('jop_id');
|
||||||
|
table.index('jop_parent_id');
|
||||||
|
table.index('jop_type');
|
||||||
|
table.index('jop_share_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.schema.createTable('user_items', function(table: Knex.CreateTableBuilder) {
|
||||||
|
table.increments('id').unique().primary().notNullable();
|
||||||
|
table.string('user_id', 32).notNullable();
|
||||||
|
table.string('item_id', 32).notNullable();
|
||||||
|
table.bigInteger('updated_time').notNullable();
|
||||||
|
table.bigInteger('created_time').notNullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.schema.alterTable('user_items', function(table: Knex.CreateTableBuilder) {
|
||||||
|
table.unique(['user_id', 'item_id']);
|
||||||
|
table.index('user_id');
|
||||||
|
table.index('item_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.schema.createTable('item_resources', function(table: Knex.CreateTableBuilder) {
|
||||||
|
table.increments('id').unique().primary().notNullable();
|
||||||
|
table.string('item_id', 32).notNullable();
|
||||||
|
table.string('resource_id', 32).notNullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.schema.alterTable('item_resources', function(table: Knex.CreateTableBuilder) {
|
||||||
|
table.unique(['item_id', 'resource_id']);
|
||||||
|
table.index(['item_id', 'resource_id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.schema.createTable('key_values', function(table: Knex.CreateTableBuilder) {
|
||||||
|
table.increments('id').unique().primary().notNullable();
|
||||||
|
table.text('key').notNullable();
|
||||||
|
table.integer('type').notNullable();
|
||||||
|
table.text('value').notNullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.schema.alterTable('key_values', function(table: Knex.CreateTableBuilder) {
|
||||||
|
table.index(['key']);
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.schema.alterTable('shares', function(table: Knex.CreateTableBuilder) {
|
||||||
|
table.dropColumn('is_auto');
|
||||||
|
table.string('note_id', 32).defaultTo('').notNullable();
|
||||||
|
table.index(['note_id']);
|
||||||
|
table.index(['folder_id']);
|
||||||
|
table.index(['item_id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.schema.alterTable('changes', function(table: Knex.CreateTableBuilder) {
|
||||||
|
table.text('previous_item').defaultTo('').notNullable();
|
||||||
|
table.string('user_id', 32).defaultTo('').notNullable();
|
||||||
|
|
||||||
|
table.dropColumn('owner_id');
|
||||||
|
table.dropColumn('parent_id');
|
||||||
|
|
||||||
|
table.index('user_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Previous changes aren't relevant anymore since they relate to a "files"
|
||||||
|
// table that is no longer used.
|
||||||
|
await db('changes').truncate();
|
||||||
|
|
||||||
|
await db.schema.dropTable('permissions');
|
||||||
|
await db.schema.dropTable('joplin_file_contents');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: DbConnection): Promise<any> {
|
||||||
|
await db.schema.dropTable('items');
|
||||||
|
}
|
@@ -1,22 +1,25 @@
|
|||||||
import { WithDates, WithUuid, databaseSchema, DbConnection, ItemType, ChangeType } from '../db';
|
import { WithDates, WithUuid, databaseSchema, DbConnection, ItemType, Uuid, User } from '../db';
|
||||||
import TransactionHandler from '../utils/TransactionHandler';
|
import TransactionHandler from '../utils/TransactionHandler';
|
||||||
import uuidgen from '../utils/uuidgen';
|
import uuidgen from '../utils/uuidgen';
|
||||||
import { ErrorUnprocessableEntity, ErrorBadRequest } from '../utils/errors';
|
import { ErrorUnprocessableEntity, ErrorBadRequest } from '../utils/errors';
|
||||||
import { Models } from './factory';
|
import { Models } from './factory';
|
||||||
|
import * as EventEmitter from 'events';
|
||||||
export interface ModelOptions {
|
|
||||||
userId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SaveOptions {
|
export interface SaveOptions {
|
||||||
isNew?: boolean;
|
isNew?: boolean;
|
||||||
skipValidation?: boolean;
|
skipValidation?: boolean;
|
||||||
validationRules?: any;
|
validationRules?: any;
|
||||||
trackChanges?: boolean;
|
previousItem?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoadOptions {
|
||||||
|
fields?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeleteOptions {
|
export interface DeleteOptions {
|
||||||
validationRules?: any;
|
validationRules?: any;
|
||||||
|
allowNoOp?: boolean;
|
||||||
|
deletedItemUserIds?: Record<Uuid, Uuid[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ValidateOptions {
|
export interface ValidateOptions {
|
||||||
@@ -24,24 +27,29 @@ export interface ValidateOptions {
|
|||||||
rules?: any;
|
rules?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum AclAction {
|
||||||
|
Create = 1,
|
||||||
|
Read = 2,
|
||||||
|
Update = 3,
|
||||||
|
Delete = 4,
|
||||||
|
List = 5,
|
||||||
|
}
|
||||||
|
|
||||||
export default abstract class BaseModel<T> {
|
export default abstract class BaseModel<T> {
|
||||||
|
|
||||||
private options_: ModelOptions = null;
|
|
||||||
private defaultFields_: string[] = [];
|
private defaultFields_: string[] = [];
|
||||||
private db_: DbConnection;
|
private db_: DbConnection;
|
||||||
private transactionHandler_: TransactionHandler;
|
private transactionHandler_: TransactionHandler;
|
||||||
private modelFactory_: Function;
|
private modelFactory_: Function;
|
||||||
private baseUrl_: string;
|
private baseUrl_: string;
|
||||||
|
private static eventEmitter_: EventEmitter = null;
|
||||||
|
|
||||||
public constructor(db: DbConnection, modelFactory: Function, baseUrl: string, options: ModelOptions = null) {
|
public constructor(db: DbConnection, modelFactory: Function, baseUrl: string) {
|
||||||
this.db_ = db;
|
this.db_ = db;
|
||||||
this.modelFactory_ = modelFactory;
|
this.modelFactory_ = modelFactory;
|
||||||
this.baseUrl_ = baseUrl;
|
this.baseUrl_ = baseUrl;
|
||||||
this.options_ = Object.assign({}, options);
|
|
||||||
|
|
||||||
this.transactionHandler_ = new TransactionHandler(db);
|
this.transactionHandler_ = new TransactionHandler(db);
|
||||||
|
|
||||||
if ('userId' in this.options && !this.options.userId) throw new Error('If userId is set, it cannot be null');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// When a model create an instance of another model, the active
|
// When a model create an instance of another model, the active
|
||||||
@@ -55,14 +63,6 @@ export default abstract class BaseModel<T> {
|
|||||||
return this.baseUrl_;
|
return this.baseUrl_;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected get options(): ModelOptions {
|
|
||||||
return this.options_;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected get userId(): string {
|
|
||||||
return this.options.userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected get db(): DbConnection {
|
protected get db(): DbConnection {
|
||||||
if (this.transactionHandler_.activeTransaction) return this.transactionHandler_.activeTransaction;
|
if (this.transactionHandler_.activeTransaction) return this.transactionHandler_.activeTransaction;
|
||||||
return this.db_;
|
return this.db_;
|
||||||
@@ -75,6 +75,34 @@ export default abstract class BaseModel<T> {
|
|||||||
return this.defaultFields_.slice();
|
return this.defaultFields_.slice();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static get eventEmitter(): EventEmitter {
|
||||||
|
if (!this.eventEmitter_) {
|
||||||
|
this.eventEmitter_ = new EventEmitter();
|
||||||
|
}
|
||||||
|
return this.eventEmitter_;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async checkIfAllowed(_user: User, _action: AclAction, _resource: T = null): Promise<void> {
|
||||||
|
throw new Error('Must be overriden');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected selectFields(options: LoadOptions, defaultFields: string[] = null, mainTable: string = ''): string[] {
|
||||||
|
let output: string[] = [];
|
||||||
|
if (options && options.fields) {
|
||||||
|
output = options.fields;
|
||||||
|
} else if (defaultFields) {
|
||||||
|
output = defaultFields;
|
||||||
|
} else {
|
||||||
|
output = this.defaultFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mainTable) {
|
||||||
|
output = output.map(f => `${mainTable}.${f}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
protected get tableName(): string {
|
protected get tableName(): string {
|
||||||
throw new Error('Not implemented');
|
throw new Error('Not implemented');
|
||||||
}
|
}
|
||||||
@@ -83,15 +111,11 @@ export default abstract class BaseModel<T> {
|
|||||||
throw new Error('Not implemented');
|
throw new Error('Not implemented');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected get trackChanges(): boolean {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected hasUuid(): boolean {
|
protected hasUuid(): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected hasDateProperties(): boolean {
|
protected autoTimestampEnabled(): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +145,7 @@ export default abstract class BaseModel<T> {
|
|||||||
//
|
//
|
||||||
// The `name` argument is only for debugging, so that any stuck transaction
|
// The `name` argument is only for debugging, so that any stuck transaction
|
||||||
// can be more easily identified.
|
// can be more easily identified.
|
||||||
protected async withTransaction(fn: Function, name: string = null): Promise<void> {
|
protected async withTransaction<T>(fn: Function, name: string = null): Promise<T> {
|
||||||
const debugTransaction = false;
|
const debugTransaction = false;
|
||||||
|
|
||||||
const debugTimerId = debugTransaction ? setTimeout(() => {
|
const debugTimerId = debugTransaction ? setTimeout(() => {
|
||||||
@@ -132,8 +156,10 @@ export default abstract class BaseModel<T> {
|
|||||||
|
|
||||||
if (debugTransaction) console.info('START', name, txIndex);
|
if (debugTransaction) console.info('START', name, txIndex);
|
||||||
|
|
||||||
|
let output: T = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fn();
|
output = await fn();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await this.transactionHandler_.rollback(txIndex);
|
await this.transactionHandler_.rollback(txIndex);
|
||||||
|
|
||||||
@@ -151,10 +177,12 @@ export default abstract class BaseModel<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.transactionHandler_.commit(txIndex);
|
await this.transactionHandler_.commit(txIndex);
|
||||||
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async all(): Promise<T[]> {
|
public async all(options: LoadOptions = {}): Promise<T[]> {
|
||||||
return this.db(this.tableName).select(...this.defaultFields);
|
const rows: any[] = await this.db(this.tableName).select(this.selectFields(options));
|
||||||
|
return rows as T[];
|
||||||
}
|
}
|
||||||
|
|
||||||
public fromApiInput(object: T): T {
|
public fromApiInput(object: T): T {
|
||||||
@@ -170,10 +198,18 @@ export default abstract class BaseModel<T> {
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
public toApiOutput(object: any): any {
|
protected objectToApiOutput(object: T): T {
|
||||||
return { ...object };
|
return { ...object };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public toApiOutput(object: T | T[]): T | T[] {
|
||||||
|
if (Array.isArray(object)) {
|
||||||
|
return object.map(f => this.objectToApiOutput(f));
|
||||||
|
} else {
|
||||||
|
return this.objectToApiOutput(object);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected async validate(object: T, options: ValidateOptions = {}): Promise<T> {
|
protected async validate(object: T, options: ValidateOptions = {}): Promise<T> {
|
||||||
if (!options.isNew && !(object as WithUuid).id) throw new ErrorUnprocessableEntity('id is missing');
|
if (!options.isNew && !(object as WithUuid).id) throw new ErrorUnprocessableEntity('id is missing');
|
||||||
return object;
|
return object;
|
||||||
@@ -182,31 +218,10 @@ export default abstract class BaseModel<T> {
|
|||||||
protected async isNew(object: T, options: SaveOptions): Promise<boolean> {
|
protected async isNew(object: T, options: SaveOptions): Promise<boolean> {
|
||||||
if (options.isNew === false) return false;
|
if (options.isNew === false) return false;
|
||||||
if (options.isNew === true) return true;
|
if (options.isNew === true) return true;
|
||||||
|
if ('id' in object && !(object as WithUuid).id) throw new Error('ID cannot be undefined or null');
|
||||||
return !(object as WithUuid).id;
|
return !(object as WithUuid).id;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleChangeTracking(options: SaveOptions, item: T, changeType: ChangeType): Promise<void> {
|
|
||||||
const trackChanges = this.trackChanges && options.trackChanges !== false;
|
|
||||||
if (!trackChanges) return;
|
|
||||||
|
|
||||||
let parentId = null;
|
|
||||||
if (this.hasParentId) {
|
|
||||||
if (!('parent_id' in item)) {
|
|
||||||
const temp: any = await this.db(this.tableName).select(['parent_id']).where('id', '=', (item as WithUuid).id).first();
|
|
||||||
parentId = temp.parent_id;
|
|
||||||
} else {
|
|
||||||
parentId = (item as any).parent_id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanity check - shouldn't happen
|
|
||||||
// Parent ID can be an empty string for root folders, but it shouldn't be null or undefined
|
|
||||||
if (this.hasParentId && !parentId && parentId !== '') throw new Error(`Could not find parent ID for item: ${(item as WithUuid).id}`);
|
|
||||||
|
|
||||||
const changeModel = this.models().change({ userId: this.userId });
|
|
||||||
await changeModel.add(this.itemType, parentId, (item as WithUuid).id, (item as any).name || '', changeType);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async save(object: T, options: SaveOptions = {}): Promise<T> {
|
public async save(object: T, options: SaveOptions = {}): Promise<T> {
|
||||||
if (!object) throw new Error('Object cannot be empty');
|
if (!object) throw new Error('Object cannot be empty');
|
||||||
|
|
||||||
@@ -214,11 +229,11 @@ export default abstract class BaseModel<T> {
|
|||||||
|
|
||||||
const isNew = await this.isNew(object, options);
|
const isNew = await this.isNew(object, options);
|
||||||
|
|
||||||
if (isNew && !(toSave as WithUuid).id) {
|
if (this.hasUuid() && isNew && !(toSave as WithUuid).id) {
|
||||||
(toSave as WithUuid).id = uuidgen();
|
(toSave as WithUuid).id = uuidgen();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.hasDateProperties()) {
|
if (this.autoTimestampEnabled()) {
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
(toSave as WithDates).created_time = timestamp;
|
(toSave as WithDates).created_time = timestamp;
|
||||||
@@ -231,7 +246,6 @@ export default abstract class BaseModel<T> {
|
|||||||
await this.withTransaction(async () => {
|
await this.withTransaction(async () => {
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
await this.db(this.tableName).insert(toSave);
|
await this.db(this.tableName).insert(toSave);
|
||||||
await this.handleChangeTracking(options, toSave, ChangeType.Create);
|
|
||||||
} else {
|
} else {
|
||||||
const objectId: string = (toSave as WithUuid).id;
|
const objectId: string = (toSave as WithUuid).id;
|
||||||
if (!objectId) throw new Error('Missing "id" property');
|
if (!objectId) throw new Error('Missing "id" property');
|
||||||
@@ -239,8 +253,6 @@ export default abstract class BaseModel<T> {
|
|||||||
const updatedCount: number = await this.db(this.tableName).update(toSave).where({ id: objectId });
|
const updatedCount: number = await this.db(this.tableName).update(toSave).where({ id: objectId });
|
||||||
(toSave as WithUuid).id = objectId;
|
(toSave as WithUuid).id = objectId;
|
||||||
|
|
||||||
await this.handleChangeTracking(options, toSave, ChangeType.Update);
|
|
||||||
|
|
||||||
// Sanity check:
|
// Sanity check:
|
||||||
if (updatedCount !== 1) throw new ErrorBadRequest(`one row should have been updated, but ${updatedCount} row(s) were updated`);
|
if (updatedCount !== 1) throw new ErrorBadRequest(`one row should have been updated, but ${updatedCount} row(s) were updated`);
|
||||||
}
|
}
|
||||||
@@ -249,31 +261,24 @@ export default abstract class BaseModel<T> {
|
|||||||
return toSave;
|
return toSave;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async loadByIds(ids: string[]): Promise<T[]> {
|
public async loadByIds(ids: string[], options: LoadOptions = {}): Promise<T[]> {
|
||||||
if (!ids.length) return [];
|
if (!ids.length) return [];
|
||||||
return this.db(this.tableName).select(this.defaultFields).whereIn('id', ids);
|
return this.db(this.tableName).select(options.fields || this.defaultFields).whereIn('id', ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async load(id: string): Promise<T> {
|
public async load(id: string, options: LoadOptions = {}): Promise<T> {
|
||||||
if (!id) throw new Error('id cannot be empty');
|
if (!id) throw new Error('id cannot be empty');
|
||||||
|
|
||||||
return this.db(this.tableName).select(this.defaultFields).where({ id: id }).first();
|
return this.db(this.tableName).select(options.fields || this.defaultFields).where({ id: id }).first();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async delete(id: string | string[]): Promise<void> {
|
public async delete(id: string | string[], options: DeleteOptions = {}): Promise<void> {
|
||||||
if (!id) throw new Error('id cannot be empty');
|
if (!id) throw new Error('id cannot be empty');
|
||||||
|
|
||||||
const ids = typeof id === 'string' ? [id] : id;
|
const ids = typeof id === 'string' ? [id] : id;
|
||||||
|
|
||||||
if (!ids.length) throw new Error('no id provided');
|
if (!ids.length) throw new Error('no id provided');
|
||||||
|
|
||||||
const trackChanges = this.trackChanges;
|
|
||||||
|
|
||||||
let itemsWithParentIds: T[] = null;
|
|
||||||
if (trackChanges) {
|
|
||||||
itemsWithParentIds = await this.db(this.tableName).select(['id', 'parent_id', 'name']).whereIn('id', ids);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.withTransaction(async () => {
|
await this.withTransaction(async () => {
|
||||||
const query = this.db(this.tableName).where({ id: ids[0] });
|
const query = this.db(this.tableName).where({ id: ids[0] });
|
||||||
for (let i = 1; i < ids.length; i++) {
|
for (let i = 1; i < ids.length; i++) {
|
||||||
@@ -281,11 +286,7 @@ export default abstract class BaseModel<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deletedCount = await query.del();
|
const deletedCount = await query.del();
|
||||||
if (deletedCount !== ids.length) throw new Error(`${ids.length} row(s) should have been deleted by ${deletedCount} row(s) were deleted`);
|
if (!options.allowNoOp && deletedCount !== ids.length) throw new Error(`${ids.length} row(s) should have been deleted but ${deletedCount} row(s) were deleted`);
|
||||||
|
|
||||||
if (trackChanges) {
|
|
||||||
for (const item of itemsWithParentIds) await this.handleChangeTracking({}, item, ChangeType.Delete);
|
|
||||||
}
|
|
||||||
}, 'BaseModel::delete');
|
}, 'BaseModel::delete');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,13 +1,11 @@
|
|||||||
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, expectThrow } from '../utils/testing/testUtils';
|
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, expectThrow, createFolder } from '../utils/testing/testUtils';
|
||||||
import { ChangeType, File } from '../db';
|
import { ChangeType, Item, Uuid } from '../db';
|
||||||
import FileModel from './FileModel';
|
|
||||||
import { msleep } from '../utils/time';
|
import { msleep } from '../utils/time';
|
||||||
import { ChangePagination } from './ChangeModel';
|
import { ChangePagination } from './ChangeModel';
|
||||||
|
|
||||||
async function makeTestFile(fileModel: FileModel): Promise<File> {
|
async function makeTestItem(userId: Uuid, num: number): Promise<Item> {
|
||||||
return fileModel.save({
|
return models().item().saveForUser(userId, {
|
||||||
name: 'test',
|
name: `0000000000000000000000000000000${num}.md`,
|
||||||
parent_id: await fileModel.userRootFileId(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,95 +23,111 @@ describe('ChangeModel', function() {
|
|||||||
await beforeEachDb();
|
await beforeEachDb();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should track changes - create', async function() {
|
test('should track changes - create only', async function() {
|
||||||
const { user } = await createUserAndSession(1, true);
|
const { session, user } = await createUserAndSession(1, true);
|
||||||
const fileModel = models().file({ userId: user.id });
|
const changeModel = models().change();
|
||||||
const changeModel = models().change({ userId: user.id });
|
|
||||||
|
|
||||||
const file1 = await makeTestFile(fileModel);
|
const item1 = await createFolder(session.id, { title: 'folder' });
|
||||||
const dirId = await fileModel.userRootFileId();
|
|
||||||
|
|
||||||
{
|
{
|
||||||
const changes = (await changeModel.byDirectoryId(dirId, { limit: 20 })).items;
|
const changes = (await changeModel.allForUser(user.id)).items;
|
||||||
expect(changes.length).toBe(1);
|
expect(changes.length).toBe(1);
|
||||||
expect(changes[0].item.id).toBe(file1.id);
|
expect(changes[0].item_id).toBe(item1.id);
|
||||||
expect(changes[0].type).toBe(ChangeType.Create);
|
expect(changes[0].type).toBe(ChangeType.Create);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should track changes - create, then update', async function() {
|
test('should track changes - create, then update', async function() {
|
||||||
const { user } = await createUserAndSession(1, true);
|
const { user } = await createUserAndSession(1, true);
|
||||||
const fileModel = models().file({ userId: user.id });
|
const itemModel = models().item();
|
||||||
const changeModel = models().change({ userId: user.id });
|
const changeModel = models().change();
|
||||||
|
|
||||||
let i = 1;
|
await msleep(1); const item1 = await makeTestItem(user.id, 1); // [1] CREATE 1
|
||||||
await msleep(1); const file1 = await makeTestFile(fileModel); // CREATE 1
|
await msleep(1); await itemModel.saveForUser(user.id, { id: item1.id, name: '0000000000000000000000000000001A.md' }); // [2] UPDATE 1a
|
||||||
await msleep(1); await fileModel.save({ id: file1.id, name: `test_mod${i++}` }); // UPDATE 1
|
await msleep(1); await itemModel.saveForUser(user.id, { id: item1.id, name: '0000000000000000000000000000001B.md' }); // [3] UPDATE 1b
|
||||||
await msleep(1); await fileModel.save({ id: file1.id, name: `test_mod${i++}` }); // UPDATE 1
|
await msleep(1); const item2 = await makeTestItem(user.id, 2); // [4] CREATE 2
|
||||||
await msleep(1); const file2 = await makeTestFile(fileModel); // CREATE 2
|
await msleep(1); await itemModel.saveForUser(user.id, { id: item2.id, name: '0000000000000000000000000000002A.md' }); // [5] UPDATE 2a
|
||||||
await msleep(1); await fileModel.save({ id: file2.id, name: `test_mod${i++}` }); // UPDATE 2
|
await msleep(1); await itemModel.delete(item1.id); // [6] DELETE 1
|
||||||
await msleep(1); await fileModel.delete(file1.id); // DELETE 1
|
await msleep(1); await itemModel.saveForUser(user.id, { id: item2.id, name: '0000000000000000000000000000002B.md' }); // [7] UPDATE 2b
|
||||||
await msleep(1); await fileModel.save({ id: file2.id, name: `test_mod${i++}` }); // UPDATE 2
|
await msleep(1); const item3 = await makeTestItem(user.id, 3); // [8] CREATE 3
|
||||||
await msleep(1); const file3 = await makeTestFile(fileModel); // CREATE 3
|
|
||||||
|
|
||||||
const dirId = await fileModel.userRootFileId();
|
// Check that the 8 changes were created
|
||||||
|
const allUncompressedChanges = await changeModel.all();
|
||||||
|
expect(allUncompressedChanges.length).toBe(8);
|
||||||
|
|
||||||
{
|
{
|
||||||
const changes = (await changeModel.byDirectoryId(dirId, { limit: 20 })).items;
|
// When we get all the changes, we only get CREATE 2 and CREATE 3.
|
||||||
|
// We don't get CREATE 1 because item 1 has been deleted. And we
|
||||||
|
// also don't get any UPDATE event since they've been compressed
|
||||||
|
// down to the CREATE events.
|
||||||
|
const changes = (await changeModel.allForUser(user.id)).items;
|
||||||
expect(changes.length).toBe(2);
|
expect(changes.length).toBe(2);
|
||||||
expect(changes[0].item.id).toBe(file2.id);
|
expect(changes[0].item_id).toBe(item2.id);
|
||||||
expect(changes[0].type).toBe(ChangeType.Create);
|
expect(changes[0].type).toBe(ChangeType.Create);
|
||||||
expect(changes[1].item.id).toBe(file3.id);
|
expect(changes[1].item_id).toBe(item3.id);
|
||||||
expect(changes[1].type).toBe(ChangeType.Create);
|
expect(changes[1].type).toBe(ChangeType.Create);
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
const pagination: ChangePagination = { limit: 5 };
|
const pagination: ChangePagination = { limit: 3 };
|
||||||
|
|
||||||
// In this page, the "create" change for file1 will not appear
|
// Internally, when we request the first three changes, we get back:
|
||||||
// because this file has been deleted. The "delete" change will
|
//
|
||||||
// however appear in the second page.
|
// - CREATE 1
|
||||||
const page1 = (await changeModel.byDirectoryId(dirId, pagination));
|
// - CREATE 2
|
||||||
|
// - UPDATE 2a
|
||||||
|
//
|
||||||
|
// We don't get back UPDATE 1a and 1b because the associated item
|
||||||
|
// has been deleted.
|
||||||
|
//
|
||||||
|
// Unlike CREATE events, which come from "user_items" and are
|
||||||
|
// associated with a user, UPDATE events comes from "items" and are
|
||||||
|
// not associated with any specific user. Only if the user has a
|
||||||
|
// corresponding user_item do they get UPDATE events. But in this
|
||||||
|
// case, since the item has been deleted, there's no longer
|
||||||
|
// "user_items" objects.
|
||||||
|
//
|
||||||
|
// Then CREATE 1 is removed since item 1 has been deleted and UPDATE
|
||||||
|
// 2a is compressed down to CREATE 2.
|
||||||
|
const page1 = (await changeModel.allForUser(user.id, pagination));
|
||||||
let changes = page1.items;
|
let changes = page1.items;
|
||||||
expect(changes.length).toBe(1);
|
expect(changes.length).toBe(1);
|
||||||
expect(page1.has_more).toBe(true);
|
expect(page1.has_more).toBe(true);
|
||||||
expect(changes[0].item.id).toBe(file2.id);
|
expect(changes[0].item_id).toBe(item2.id);
|
||||||
expect(changes[0].type).toBe(ChangeType.Create);
|
expect(changes[0].type).toBe(ChangeType.Create);
|
||||||
|
|
||||||
const page2 = (await changeModel.byDirectoryId(dirId, { ...pagination, cursor: page1.cursor }));
|
// In the second page, we get all the expected events since nothing
|
||||||
|
// has been compressed.
|
||||||
|
const page2 = (await changeModel.allForUser(user.id, { ...pagination, cursor: page1.cursor }));
|
||||||
changes = page2.items;
|
changes = page2.items;
|
||||||
expect(changes.length).toBe(3);
|
expect(changes.length).toBe(3);
|
||||||
expect(page2.has_more).toBe(false);
|
// Although there are no more changes, it's not possible to know
|
||||||
expect(changes[0].item.id).toBe(file1.id);
|
// that without running the next query
|
||||||
|
expect(page2.has_more).toBe(true);
|
||||||
|
expect(changes[0].item_id).toBe(item1.id);
|
||||||
expect(changes[0].type).toBe(ChangeType.Delete);
|
expect(changes[0].type).toBe(ChangeType.Delete);
|
||||||
expect(changes[1].item.id).toBe(file2.id);
|
expect(changes[1].item_id).toBe(item2.id);
|
||||||
expect(changes[1].type).toBe(ChangeType.Update);
|
expect(changes[1].type).toBe(ChangeType.Update);
|
||||||
expect(changes[2].item.id).toBe(file3.id);
|
expect(changes[2].item_id).toBe(item3.id);
|
||||||
expect(changes[2].type).toBe(ChangeType.Create);
|
expect(changes[2].type).toBe(ChangeType.Create);
|
||||||
|
|
||||||
|
// Check that we indeed reached the end of the feed.
|
||||||
|
const page3 = (await changeModel.allForUser(user.id, { ...pagination, cursor: page2.cursor }));
|
||||||
|
expect(page3.items.length).toBe(0);
|
||||||
|
expect(page3.has_more).toBe(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should throw an error if cursor is invalid', async function() {
|
test('should throw an error if cursor is invalid', async function() {
|
||||||
const { user } = await createUserAndSession(1, true);
|
const { user } = await createUserAndSession(1, true);
|
||||||
const fileModel = models().file({ userId: user.id });
|
const itemModel = models().item();
|
||||||
const changeModel = models().change({ userId: user.id });
|
const changeModel = models().change();
|
||||||
|
|
||||||
const dirId = await fileModel.userRootFileId();
|
|
||||||
|
|
||||||
let i = 1;
|
let i = 1;
|
||||||
await msleep(1); const file1 = await makeTestFile(fileModel); // CREATE 1
|
await msleep(1); const item1 = await makeTestItem(user.id, 1); // CREATE 1
|
||||||
await msleep(1); await fileModel.save({ id: file1.id, name: `test_mod${i++}` }); // UPDATE 1
|
await msleep(1); await itemModel.saveForUser(user.id, { id: item1.id, name: `test_mod${i++}` }); // UPDATE 1
|
||||||
|
|
||||||
await expectThrow(async () => changeModel.byDirectoryId(dirId, { limit: 1, cursor: 'invalid' }), 'resyncRequired');
|
await expectThrow(async () => changeModel.allForUser(user.id, { limit: 1, cursor: 'invalid' }), 'resyncRequired');
|
||||||
});
|
|
||||||
|
|
||||||
test('should throw an error if trying to do get changes for a file', async function() {
|
|
||||||
const { user } = await createUserAndSession(1, true);
|
|
||||||
const fileModel = models().file({ userId: user.id });
|
|
||||||
const changeModel = models().change({ userId: user.id });
|
|
||||||
const file1 = await makeTestFile(fileModel);
|
|
||||||
|
|
||||||
await expectThrow(async () => changeModel.byDirectoryId(file1.id));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@@ -1,16 +1,17 @@
|
|||||||
import { Change, ChangeType, File, ItemType, Uuid } from '../db';
|
import { Change, ChangeType, Item, Uuid } from '../db';
|
||||||
import { ErrorResyncRequired, ErrorUnprocessableEntity } from '../utils/errors';
|
import { md5 } from '../utils/crypto';
|
||||||
import BaseModel from './BaseModel';
|
import { ErrorResyncRequired } from '../utils/errors';
|
||||||
import { paginateDbQuery, PaginatedResults, Pagination } from './utils/pagination';
|
import BaseModel, { SaveOptions } from './BaseModel';
|
||||||
|
import { PaginatedResults } from './utils/pagination';
|
||||||
|
|
||||||
export interface ChangeWithItem {
|
export interface ChangeWithItem {
|
||||||
item: File;
|
item: Item;
|
||||||
updated_time: number;
|
updated_time: number;
|
||||||
type: ChangeType;
|
type: ChangeType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedChanges extends PaginatedResults {
|
export interface PaginatedChanges extends PaginatedResults {
|
||||||
items: ChangeWithItem[];
|
items: Change[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChangePagination {
|
export interface ChangePagination {
|
||||||
@@ -18,6 +19,13 @@ export interface ChangePagination {
|
|||||||
cursor?: string;
|
cursor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChangePreviousItem {
|
||||||
|
name: string;
|
||||||
|
jop_parent_id: string;
|
||||||
|
jop_resource_ids: string[];
|
||||||
|
jop_share_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function defaultChangePagination(): ChangePagination {
|
export function defaultChangePagination(): ChangePagination {
|
||||||
return {
|
return {
|
||||||
limit: 100,
|
limit: 100,
|
||||||
@@ -25,6 +33,10 @@ export function defaultChangePagination(): ChangePagination {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AllForUserOptions {
|
||||||
|
compressChanges?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export default class ChangeModel extends BaseModel<Change> {
|
export default class ChangeModel extends BaseModel<Change> {
|
||||||
|
|
||||||
public get tableName(): string {
|
public get tableName(): string {
|
||||||
@@ -32,44 +44,39 @@ export default class ChangeModel extends BaseModel<Change> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected hasUuid(): boolean {
|
protected hasUuid(): boolean {
|
||||||
return false;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async add(itemType: ItemType, parentId: Uuid, itemId: Uuid, itemName: string, changeType: ChangeType): Promise<Change> {
|
public serializePreviousItem(item: ChangePreviousItem): string {
|
||||||
const change: Change = {
|
return JSON.stringify(item);
|
||||||
item_type: itemType,
|
|
||||||
parent_id: parentId || '',
|
|
||||||
item_id: itemId,
|
|
||||||
item_name: itemName,
|
|
||||||
type: changeType,
|
|
||||||
owner_id: this.userId,
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.save(change) as Change;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async countByUser(userId: string): Promise<number> {
|
public unserializePreviousItem(item: string): ChangePreviousItem {
|
||||||
const r: any = await this.db(this.tableName).where('owner_id', userId).count('id', { as: 'total' }).first();
|
if (!item) return null;
|
||||||
return r.total;
|
return JSON.parse(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
public changeUrl(): string {
|
public changeUrl(): string {
|
||||||
return `${this.baseUrl}/changes`;
|
return `${this.baseUrl}/changes`;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async allWithPagination(pagination: Pagination): Promise<PaginatedChanges> {
|
public async allFromId(id: string): Promise<Change[]> {
|
||||||
const results = await paginateDbQuery(this.db(this.tableName).select(...this.defaultFields).where('owner_id', '=', this.userId), pagination);
|
const startChange: Change = id ? await this.load(id) : null;
|
||||||
const changeWithItems = await this.loadChangeItems(results.items);
|
const query = this.db(this.tableName).select(...this.defaultFields);
|
||||||
return {
|
if (startChange) void query.where('counter', '>', startChange.counter);
|
||||||
...results,
|
void query.limit(1000);
|
||||||
items: changeWithItems,
|
let results = await query;
|
||||||
page_count: Math.ceil(await this.countByUser(this.userId) / pagination.limit),
|
results = await this.removeDeletedItems(results);
|
||||||
};
|
results = await this.compressChanges(results);
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: doesn't currently support checking for changes recursively but this
|
public async allForUser(userId: Uuid, pagination: ChangePagination = null, options: AllForUserOptions = null): Promise<PaginatedChanges> {
|
||||||
// is not needed for Joplin synchronisation.
|
options = {
|
||||||
public async byDirectoryId(dirId: string, pagination: ChangePagination = null): Promise<PaginatedChanges> {
|
compressChanges: true,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
|
||||||
pagination = {
|
pagination = {
|
||||||
...defaultChangePagination(),
|
...defaultChangePagination(),
|
||||||
...pagination,
|
...pagination,
|
||||||
@@ -82,37 +89,49 @@ export default class ChangeModel extends BaseModel<Change> {
|
|||||||
if (!changeAtCursor) throw new ErrorResyncRequired();
|
if (!changeAtCursor) throw new ErrorResyncRequired();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the directory object to check that it exists and that we have
|
// When need to get:
|
||||||
// the right permissions (loading will check permissions)
|
//
|
||||||
const fileModel = this.models().file({ userId: this.userId });
|
// - All the CREATE and DELETE changes associated with the user
|
||||||
const directory = await fileModel.load(dirId);
|
// - All the UPDATE changes that applies to items associated with the
|
||||||
if (!directory.is_directory) throw new ErrorUnprocessableEntity(`Item with id "${dirId}" is not a directory.`);
|
// user.
|
||||||
|
//
|
||||||
|
// UPDATE changes do not have the user_id set because they are specific
|
||||||
|
// to the item, not to a particular user.
|
||||||
|
|
||||||
// Rather than query the changes, then use JS to compress them, it might
|
const query = this
|
||||||
// be possible to do both in one query.
|
.db('changes')
|
||||||
// https://stackoverflow.com/questions/65348794
|
|
||||||
const query = this.db(this.tableName)
|
|
||||||
.select([
|
.select([
|
||||||
'counter',
|
|
||||||
'id',
|
'id',
|
||||||
'item_id',
|
'item_id',
|
||||||
'item_name',
|
'item_name',
|
||||||
'type',
|
'type',
|
||||||
|
'updated_time',
|
||||||
])
|
])
|
||||||
.where('parent_id', dirId)
|
.where(function() {
|
||||||
.orderBy('counter', 'asc')
|
void this.whereRaw('((type = ? OR type = ?) AND user_id = ?)', [ChangeType.Create, ChangeType.Delete, userId])
|
||||||
.limit(pagination.limit);
|
// Need to use a RAW query here because Knex has a "not a
|
||||||
|
// bug" bug that makes it go into infinite loop in some
|
||||||
|
// contexts, possibly only when running inside Jest (didn't
|
||||||
|
// test outside).
|
||||||
|
// https://github.com/knex/knex/issues/1851
|
||||||
|
.orWhereRaw('type = ? AND item_id IN (SELECT item_id FROM user_items WHERE user_id = ?)', [ChangeType.Update, userId]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// If a cursor was provided, apply it to both queries.
|
||||||
if (changeAtCursor) {
|
if (changeAtCursor) {
|
||||||
void query.where('counter', '>', changeAtCursor.counter);
|
void query.where('counter', '>', changeAtCursor.counter);
|
||||||
}
|
}
|
||||||
|
|
||||||
const changes: Change[] = await query;
|
void query
|
||||||
const compressedChanges = this.compressChanges(changes);
|
.orderBy('counter', 'asc')
|
||||||
const changeWithItems = await this.loadChangeItems(compressedChanges);
|
.limit(pagination.limit) as any[];
|
||||||
|
|
||||||
|
const changes = await query;
|
||||||
|
|
||||||
|
const finalChanges = options.compressChanges ? await this.removeDeletedItems(this.compressChanges(changes)) : changes;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: changeWithItems,
|
items: finalChanges,
|
||||||
// If we have changes, we return the ID of the latest changes from which delta sync can resume.
|
// If we have changes, we return the ID of the latest changes from which delta sync can resume.
|
||||||
// If there's no change, we return the previous cursor.
|
// If there's no change, we return the previous cursor.
|
||||||
cursor: changes.length ? changes[changes.length - 1].id : pagination.cursor,
|
cursor: changes.length ? changes[changes.length - 1].id : pagination.cursor,
|
||||||
@@ -120,15 +139,19 @@ export default class ChangeModel extends BaseModel<Change> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadChangeItems(changes: Change[]): Promise<ChangeWithItem[]> {
|
private async removeDeletedItems(changes: Change[]): Promise<Change[]> {
|
||||||
const itemIds = changes.map(c => c.item_id);
|
const itemIds = changes.map(c => c.item_id);
|
||||||
const fileModel = this.models().file({ userId: this.userId });
|
|
||||||
const items: File[] = await fileModel.loadByIds(itemIds);
|
|
||||||
|
|
||||||
const output: ChangeWithItem[] = [];
|
// We skip permission check here because, when an item is shared, we need
|
||||||
|
// to fetch files that don't belong to the current user. This check
|
||||||
|
// would not be needed anyway because the change items are generated in
|
||||||
|
// a context where permissions have already been checked.
|
||||||
|
const items: Item[] = await this.db('items').select('id').whereIn('items.id', itemIds);
|
||||||
|
|
||||||
|
const output: Change[] = [];
|
||||||
|
|
||||||
for (const change of changes) {
|
for (const change of changes) {
|
||||||
let item = items.find(f => f.id === change.item_id);
|
const item = items.find(f => f.id === change.item_id);
|
||||||
|
|
||||||
// If the item associated with this change has been deleted, we have
|
// If the item associated with this change has been deleted, we have
|
||||||
// two cases:
|
// two cases:
|
||||||
@@ -136,63 +159,78 @@ export default class ChangeModel extends BaseModel<Change> {
|
|||||||
// - If it's anything else, skip it. The "delete" change will be
|
// - If it's anything else, skip it. The "delete" change will be
|
||||||
// sent on one of the next pages.
|
// sent on one of the next pages.
|
||||||
|
|
||||||
if (!item) {
|
if (!item && change.type !== ChangeType.Delete) {
|
||||||
if (change.type === ChangeType.Delete) {
|
continue;
|
||||||
item = {
|
|
||||||
id: change.item_id,
|
|
||||||
name: change.item_name,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
output.push({
|
output.push(change);
|
||||||
type: change.type,
|
|
||||||
updated_time: change.updated_time,
|
|
||||||
item: item,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compresses the changes so that, for example, multiple updates on the same
|
||||||
|
// item are reduced down to one, because calling code usually only needs to
|
||||||
|
// know that the item has changed at least once. The reduction is basically:
|
||||||
|
//
|
||||||
|
// create - update => create
|
||||||
|
// create - delete => NOOP
|
||||||
|
// update - update => update
|
||||||
|
// update - delete => delete
|
||||||
|
//
|
||||||
|
// There's one exception for changes that include a "previous_item". This is
|
||||||
|
// used to save specific properties about the previous state of the item,
|
||||||
|
// such as "jop_parent_id" or "name", which is used by the share mechanism
|
||||||
|
// to know if an item has been moved from one folder to another. In that
|
||||||
|
// case, we need to know about each individual change, so they are not
|
||||||
|
// compressed.
|
||||||
private compressChanges(changes: Change[]): Change[] {
|
private compressChanges(changes: Change[]): Change[] {
|
||||||
const itemChanges: Record<Uuid, Change> = {};
|
const itemChanges: Record<Uuid, Change> = {};
|
||||||
|
|
||||||
|
const uniqueUpdateChanges: Record<Uuid, Record<string, Change>> = {};
|
||||||
|
|
||||||
for (const change of changes) {
|
for (const change of changes) {
|
||||||
const previous = itemChanges[change.item_id];
|
const itemId = change.item_id;
|
||||||
|
const previous = itemChanges[itemId];
|
||||||
|
|
||||||
|
if (change.type === ChangeType.Update) {
|
||||||
|
const key = md5(itemId + change.previous_item);
|
||||||
|
if (!uniqueUpdateChanges[itemId]) uniqueUpdateChanges[itemId] = {};
|
||||||
|
uniqueUpdateChanges[itemId][key] = change;
|
||||||
|
}
|
||||||
|
|
||||||
if (previous) {
|
if (previous) {
|
||||||
// create - update => create
|
|
||||||
// create - delete => NOOP
|
|
||||||
// update - update => update
|
|
||||||
// update - delete => delete
|
|
||||||
|
|
||||||
if (previous.type === ChangeType.Create && change.type === ChangeType.Update) {
|
if (previous.type === ChangeType.Create && change.type === ChangeType.Update) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (previous.type === ChangeType.Create && change.type === ChangeType.Delete) {
|
if (previous.type === ChangeType.Create && change.type === ChangeType.Delete) {
|
||||||
delete itemChanges[change.item_id];
|
delete itemChanges[itemId];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (previous.type === ChangeType.Update && change.type === ChangeType.Update) {
|
if (previous.type === ChangeType.Update && change.type === ChangeType.Update) {
|
||||||
itemChanges[change.item_id] = change;
|
itemChanges[itemId] = change;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (previous.type === ChangeType.Update && change.type === ChangeType.Delete) {
|
if (previous.type === ChangeType.Update && change.type === ChangeType.Delete) {
|
||||||
itemChanges[change.item_id] = change;
|
itemChanges[itemId] = change;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
itemChanges[change.item_id] = change;
|
itemChanges[itemId] = change;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const output = [];
|
const output: Change[] = [];
|
||||||
|
|
||||||
for (const itemId in itemChanges) {
|
for (const itemId in itemChanges) {
|
||||||
output.push(itemChanges[itemId]);
|
const change = itemChanges[itemId];
|
||||||
|
if (change.type === ChangeType.Update) {
|
||||||
|
for (const key of Object.keys(uniqueUpdateChanges[itemId])) {
|
||||||
|
output.push(uniqueUpdateChanges[itemId][key]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
output.push(change);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
output.sort((a: Change, b: Change) => a.counter < b.counter ? -1 : +1);
|
output.sort((a: Change, b: Change) => a.counter < b.counter ? -1 : +1);
|
||||||
@@ -200,4 +238,10 @@ export default class ChangeModel extends BaseModel<Change> {
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async save(change: Change, options: SaveOptions = {}): Promise<Change> {
|
||||||
|
const savedChange = await super.save(change, options);
|
||||||
|
ChangeModel.eventEmitter.emit('saved');
|
||||||
|
return savedChange;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -1,93 +0,0 @@
|
|||||||
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, createFileTree } from '../utils/testing/testUtils';
|
|
||||||
import { File } from '../db';
|
|
||||||
|
|
||||||
describe('FileModel', function() {
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
await beforeAllDb('FileModel');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await afterAllTests();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await beforeEachDb();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should compute item full path', async function() {
|
|
||||||
const { user } = await createUserAndSession(1, true);
|
|
||||||
const fileModel = models().file({ userId: user.id });
|
|
||||||
const rootId = await fileModel.userRootFileId();
|
|
||||||
|
|
||||||
const tree: any = {
|
|
||||||
folder1: {},
|
|
||||||
folder2: {
|
|
||||||
file2_1: null,
|
|
||||||
file2_2: null,
|
|
||||||
},
|
|
||||||
folder3: {
|
|
||||||
file3_1: null,
|
|
||||||
},
|
|
||||||
file1: null,
|
|
||||||
file2: null,
|
|
||||||
file3: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
await createFileTree(fileModel, rootId, tree);
|
|
||||||
|
|
||||||
const testCases = Object.keys(tree)
|
|
||||||
.concat(Object.keys(tree.folder2))
|
|
||||||
.concat(Object.keys(tree.folder3));
|
|
||||||
|
|
||||||
for (const t of testCases) {
|
|
||||||
const file: File = await fileModel.loadByName(t);
|
|
||||||
const path = await fileModel.itemFullPath(file);
|
|
||||||
const fileBackId: string = await fileModel.pathToFileId(path);
|
|
||||||
expect(file.id).toBe(fileBackId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootPath = await fileModel.itemFullPath(await fileModel.userRootFile());
|
|
||||||
expect(rootPath).toBe('root');
|
|
||||||
const fileBackId: string = await fileModel.pathToFileId(rootPath);
|
|
||||||
expect(fileBackId).toBe(rootId);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should resolve file paths', async function() {
|
|
||||||
const testCases = [
|
|
||||||
[
|
|
||||||
['root', '.resource', 'test'],
|
|
||||||
'root:/.resource/test:',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
['root:/.resource:', 'test'],
|
|
||||||
'root:/.resource/test:',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
['root:/.resource:', ''],
|
|
||||||
'root:/.resource:',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
['root:/.resource:'],
|
|
||||||
'root:/.resource:',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
['root:/.resource:'],
|
|
||||||
'root:/.resource:',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
['root'],
|
|
||||||
'root',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
const fileModel = models().file();
|
|
||||||
|
|
||||||
for (const t of testCases) {
|
|
||||||
const [input, expected] = t;
|
|
||||||
const actual = fileModel.resolve(...input);
|
|
||||||
expect(actual).toBe(expected);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
@@ -1,498 +0,0 @@
|
|||||||
import BaseModel, { ValidateOptions, SaveOptions, DeleteOptions } from './BaseModel';
|
|
||||||
import { File, ItemType, databaseSchema, Uuid } from '../db';
|
|
||||||
import { ErrorForbidden, ErrorUnprocessableEntity, ErrorNotFound, ErrorBadRequest, ErrorConflict } from '../utils/errors';
|
|
||||||
import uuidgen from '../utils/uuidgen';
|
|
||||||
import { splitItemPath, filePathInfo } from '../utils/routeUtils';
|
|
||||||
import { paginateDbQuery, PaginatedResults, Pagination } from './utils/pagination';
|
|
||||||
import { setQueryParameters } from '../utils/urlUtils';
|
|
||||||
|
|
||||||
const mimeUtils = require('@joplin/lib/mime-utils.js').mime;
|
|
||||||
|
|
||||||
const nodeEnv = process.env.NODE_ENV || 'development';
|
|
||||||
|
|
||||||
const removeTrailingColonsRegex = /^(:|)(.*?)(:|)$/;
|
|
||||||
|
|
||||||
export interface PaginatedFiles extends PaginatedResults {
|
|
||||||
items: File[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PathToFileOptions {
|
|
||||||
mustExist?: boolean;
|
|
||||||
returnFullEntity?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoadOptions {
|
|
||||||
skipPermissionCheck?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class FileModel extends BaseModel<File> {
|
|
||||||
|
|
||||||
private readonly reservedCharacters = ['/', '\\', '*', '<', '>', '?', ':', '|', '#', '%'];
|
|
||||||
|
|
||||||
protected get tableName(): string {
|
|
||||||
return 'files';
|
|
||||||
}
|
|
||||||
|
|
||||||
protected get trackChanges(): boolean {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected get itemType(): ItemType {
|
|
||||||
return ItemType.File;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected get hasParentId(): boolean {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async userRootFile(): Promise<File> {
|
|
||||||
const file: File = await this.db<File>(this.tableName).select(...this.defaultFields).from(this.tableName).where({
|
|
||||||
'owner_id': this.userId,
|
|
||||||
'is_root': 1,
|
|
||||||
}).first();
|
|
||||||
if (file) await this.checkCanReadPermissions(file);
|
|
||||||
return file;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async userRootFileId(): Promise<string> {
|
|
||||||
const r = await this.userRootFile();
|
|
||||||
return r ? r.id : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
private isSpecialDir(dirname: string): boolean {
|
|
||||||
return dirname === 'root';
|
|
||||||
}
|
|
||||||
|
|
||||||
private async specialDirId(dirname: string): Promise<string> {
|
|
||||||
if (dirname === 'root') return this.userRootFileId();
|
|
||||||
return null; // Not a special dir
|
|
||||||
}
|
|
||||||
|
|
||||||
public async itemFullPaths(items: File[]): Promise<Record<string, string>> {
|
|
||||||
const output: Record<string, string> = {};
|
|
||||||
|
|
||||||
const itemCache: Record<string, File> = {};
|
|
||||||
|
|
||||||
await this.withTransaction(async () => {
|
|
||||||
for (const item of items) {
|
|
||||||
const segments: string[] = [];
|
|
||||||
let current: File = item;
|
|
||||||
|
|
||||||
while (current) {
|
|
||||||
if (current.is_root) break;
|
|
||||||
segments.splice(0, 0, current.name);
|
|
||||||
|
|
||||||
if (current.parent_id) {
|
|
||||||
const id = current.parent_id;
|
|
||||||
current = itemCache[id] ? itemCache[id] : await this.load(id);
|
|
||||||
itemCache[id] = current;
|
|
||||||
} else {
|
|
||||||
current = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
output[item.id] = segments.length ? (`root:/${segments.join('/')}:`) : 'root';
|
|
||||||
}
|
|
||||||
}, 'FileModel::itemFullPaths');
|
|
||||||
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async itemDisplayPath(item: File, loadOptions: LoadOptions = {}): Promise<string> {
|
|
||||||
const path = await this.itemFullPath(item, loadOptions);
|
|
||||||
return this.removeTrailingColons(path.replace(/root:\//, ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async itemFullPath(item: File, loadOptions: LoadOptions = {}): Promise<string> {
|
|
||||||
const segments: string[] = [];
|
|
||||||
while (item) {
|
|
||||||
if (item.is_root) break;
|
|
||||||
segments.splice(0, 0, item.name);
|
|
||||||
item = item.parent_id ? await this.load(item.parent_id, loadOptions) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return segments.length ? (`root:/${segments.join('/')}:`) : 'root';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove first and last colon from a path element
|
|
||||||
private removeTrailingColons(path: string): string {
|
|
||||||
return path.replace(removeTrailingColonsRegex, '$2');
|
|
||||||
}
|
|
||||||
|
|
||||||
public resolve(...paths: string[]): string {
|
|
||||||
if (!paths.length) throw new Error('Path is empty');
|
|
||||||
|
|
||||||
let pathElements: string[] = [];
|
|
||||||
|
|
||||||
for (const p of paths) {
|
|
||||||
pathElements = pathElements.concat(p.split('/').map(s => this.removeTrailingColons(s)));
|
|
||||||
}
|
|
||||||
|
|
||||||
pathElements = pathElements.filter(p => !!p);
|
|
||||||
|
|
||||||
if (!this.isSpecialDir(pathElements[0])) throw new Error(`Path must start with a special dir: ${pathElements.join('/')}`);
|
|
||||||
|
|
||||||
if (pathElements.length === 1) return pathElements[0];
|
|
||||||
|
|
||||||
// If the output is just "root", we return that only. Otherwise we build the path, eg. `root:/.resource/file:'`
|
|
||||||
const specialDir = pathElements.splice(0, 1)[0];
|
|
||||||
|
|
||||||
return `${specialDir}:/${pathElements.join('/')}:`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same as `pathToFile` but returns the ID only.
|
|
||||||
public async pathToFileId(idOrPath: string, options: PathToFileOptions = {}): Promise<Uuid> {
|
|
||||||
const file = await this.pathToFile(idOrPath, {
|
|
||||||
...options,
|
|
||||||
returnFullEntity: false,
|
|
||||||
});
|
|
||||||
return file ? file.id : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Converts an ID such as "Ps2YtQ8Udi4eCYm1A5bLFDGhHCWWCR43" or a path such
|
|
||||||
// as "root:/path/to/file.txt:" to the actual file.
|
|
||||||
public async pathToFile(idOrPath: string, options: PathToFileOptions = {}): Promise<File> {
|
|
||||||
if (!idOrPath) throw new Error('ID cannot be null');
|
|
||||||
|
|
||||||
options = {
|
|
||||||
mustExist: true,
|
|
||||||
returnFullEntity: true,
|
|
||||||
...options,
|
|
||||||
};
|
|
||||||
|
|
||||||
const specialDirId = await this.specialDirId(idOrPath);
|
|
||||||
|
|
||||||
if (specialDirId) {
|
|
||||||
return options.returnFullEntity ? this.load(specialDirId) : { id: specialDirId };
|
|
||||||
} else if (idOrPath.indexOf(':') < 0) {
|
|
||||||
if (options.mustExist) {
|
|
||||||
const file = await this.load(idOrPath);
|
|
||||||
if (!file) throw new ErrorNotFound(`file not found: ${idOrPath}`);
|
|
||||||
return options.returnFullEntity ? file : { id: file.id };
|
|
||||||
} else {
|
|
||||||
return options.returnFullEntity ? this.load(idOrPath) : { id: idOrPath };
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// When this input is a path, there can be two cases:
|
|
||||||
// - A path to an existing file - in which case we return the file
|
|
||||||
// - A path to a file that needs to be created - in which case we
|
|
||||||
// return a file with all the relevant properties populated. This
|
|
||||||
// file might then be created by the caller.
|
|
||||||
// The caller can check file.id to see if it's a new or existing file.
|
|
||||||
// In both cases the directories before the filename must exist.
|
|
||||||
|
|
||||||
const fileInfo = filePathInfo(idOrPath);
|
|
||||||
const parentFiles = await this.pathToFiles(fileInfo.dirname);
|
|
||||||
const parentId = parentFiles[parentFiles.length - 1].id;
|
|
||||||
|
|
||||||
if (!fileInfo.basename) {
|
|
||||||
const specialDirId = await this.specialDirId(fileInfo.dirname);
|
|
||||||
// The path simply refers to a special directory. Can happen
|
|
||||||
// for example with a path like `root:/:` (which is the same
|
|
||||||
// as just `root`).
|
|
||||||
if (specialDirId) return { id: specialDirId };
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is an existing file
|
|
||||||
const existingFile = await this.fileByName(parentId, fileInfo.basename);
|
|
||||||
if (existingFile) return options.returnFullEntity ? existingFile : { id: existingFile.id };
|
|
||||||
|
|
||||||
if (options.mustExist) throw new ErrorNotFound(`file not found: ${idOrPath}`);
|
|
||||||
|
|
||||||
// This is a potentially new file
|
|
||||||
return {
|
|
||||||
name: fileInfo.basename,
|
|
||||||
parent_id: parentId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected get defaultFields(): string[] {
|
|
||||||
return Object.keys(databaseSchema[this.tableName]).filter(f => f !== 'content');
|
|
||||||
}
|
|
||||||
|
|
||||||
public async fileByName(parentId: string, name: string, options: LoadOptions = {}): Promise<File> {
|
|
||||||
const file = await this.db<File>(this.tableName).select(...this.defaultFields).where({
|
|
||||||
parent_id: parentId,
|
|
||||||
name: name,
|
|
||||||
}).first();
|
|
||||||
|
|
||||||
if (!file) return null;
|
|
||||||
|
|
||||||
if (!options.skipPermissionCheck) await this.checkCanReadPermissions(file);
|
|
||||||
|
|
||||||
return file;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async validate(object: File, options: ValidateOptions = {}): Promise<File> {
|
|
||||||
const file: File = object;
|
|
||||||
|
|
||||||
const mustBeFile = options.rules.mustBeFile === true;
|
|
||||||
|
|
||||||
if (options.isNew) {
|
|
||||||
if (!file.is_root && !file.name) throw new ErrorUnprocessableEntity('name cannot be empty');
|
|
||||||
if (file.is_directory && mustBeFile) throw new ErrorUnprocessableEntity('item must not be a directory');
|
|
||||||
} else {
|
|
||||||
if ('name' in file && !file.name) throw new ErrorUnprocessableEntity('name cannot be empty');
|
|
||||||
if ('is_directory' in file) throw new ErrorUnprocessableEntity('cannot turn a file into a directory or vice-versa');
|
|
||||||
|
|
||||||
if (mustBeFile && !('is_directory' in file)) {
|
|
||||||
const existingFile = await this.load(file.id);
|
|
||||||
if (existingFile.is_directory) throw new ErrorUnprocessableEntity('item must not be a directory');
|
|
||||||
} else {
|
|
||||||
if (file.is_directory) throw new ErrorUnprocessableEntity('item must not be a directory');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let parentId = file.parent_id;
|
|
||||||
if (!parentId) parentId = await this.userRootFileId();
|
|
||||||
|
|
||||||
if ('parent_id' in file && !file.is_root) {
|
|
||||||
const invalidParentError = function(extraInfo: string) {
|
|
||||||
let msg = `Invalid parent ID or no permission to write to it: ${parentId}`;
|
|
||||||
if (nodeEnv !== 'production') msg += ` (${extraInfo})`;
|
|
||||||
return new ErrorForbidden(msg);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!parentId) throw invalidParentError('No parent ID');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parentFile: File = await this.load(parentId);
|
|
||||||
if (!parentFile) throw invalidParentError('Cannot load parent file');
|
|
||||||
if (!parentFile.is_directory) throw invalidParentError('Specified parent is not a directory');
|
|
||||||
await this.checkCanWritePermission(parentFile);
|
|
||||||
} catch (error) {
|
|
||||||
if (error.message.indexOf('Invalid parent') === 0) throw error;
|
|
||||||
throw invalidParentError(`Unknown: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('name' in file && !file.is_root) {
|
|
||||||
const existingFile = await this.fileByName(parentId, file.name);
|
|
||||||
if (existingFile && options.isNew) throw new ErrorConflict(`Already a file with name "${file.name}"`);
|
|
||||||
if (existingFile && file.id === existingFile.id) throw new ErrorConflict(`Already a file with name "${file.name}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('name' in file) {
|
|
||||||
if (this.includesReservedCharacter(file.name)) throw new ErrorUnprocessableEntity(`File name may not contain any of these characters: ${this.reservedCharacters.join('')}: ${file.name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return file;
|
|
||||||
}
|
|
||||||
|
|
||||||
public fromApiInput(object: File): File {
|
|
||||||
const file: File = {};
|
|
||||||
|
|
||||||
if ('id' in object) file.id = object.id;
|
|
||||||
if ('name' in object) file.name = object.name;
|
|
||||||
if ('parent_id' in object) file.parent_id = object.parent_id;
|
|
||||||
if ('mime_type' in object) file.mime_type = object.mime_type;
|
|
||||||
if ('is_directory' in object) file.is_directory = object.is_directory;
|
|
||||||
|
|
||||||
return file;
|
|
||||||
}
|
|
||||||
|
|
||||||
public toApiOutput(object: any): any {
|
|
||||||
if (Array.isArray(object)) {
|
|
||||||
return object.map(f => this.toApiOutput(f));
|
|
||||||
} else {
|
|
||||||
const output: File = { ...object };
|
|
||||||
delete output.content;
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async createRootFile(): Promise<File> {
|
|
||||||
const existingRootFile = await this.userRootFile();
|
|
||||||
if (existingRootFile) throw new Error(`User ${this.userId} has already a root file`);
|
|
||||||
|
|
||||||
const id = uuidgen();
|
|
||||||
|
|
||||||
return this.save({
|
|
||||||
id: id,
|
|
||||||
is_directory: 1,
|
|
||||||
is_root: 1,
|
|
||||||
name: id, // Name must be unique so we set it to the ID
|
|
||||||
}, {
|
|
||||||
isNew: true,
|
|
||||||
trackChanges: false, // Root file always exist and never changes so we don't want any change event being generated
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async checkCanReadOrWritePermissions(methodName: 'canRead' | 'canWrite', file: File | File[]): Promise<void> {
|
|
||||||
const files = Array.isArray(file) ? file : [file];
|
|
||||||
|
|
||||||
if (!files.length || !files[0]) throw new ErrorNotFound();
|
|
||||||
|
|
||||||
const fileIds = files.map(f => f.id);
|
|
||||||
|
|
||||||
const permissionModel = this.models().permission();
|
|
||||||
const permissionGrantedMap = await permissionModel[methodName](fileIds, this.userId);
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
if (file.owner_id === this.userId) permissionGrantedMap[file.id] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const fileId in permissionGrantedMap) {
|
|
||||||
if (!permissionGrantedMap[fileId]) throw new ErrorForbidden(`No read access to: ${fileId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async checkCanReadPermissions(file: File | File[]): Promise<void> {
|
|
||||||
await this.checkCanReadOrWritePermissions('canRead', file);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async checkCanWritePermission(file: File): Promise<void> {
|
|
||||||
await this.checkCanReadOrWritePermissions('canWrite', file);
|
|
||||||
}
|
|
||||||
|
|
||||||
private includesReservedCharacter(path: string): boolean {
|
|
||||||
return this.reservedCharacters.some(c => path.indexOf(c) >= 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async fileUrl(idOrPath: string, query: any = null): Promise<string> {
|
|
||||||
const file: File = await this.pathToFile(idOrPath);
|
|
||||||
const contentSuffix = !file.is_directory ? '/content' : '';
|
|
||||||
return setQueryParameters(`${this.baseUrl}/files/${await this.itemFullPath(file)}${contentSuffix}`, query);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async pathToFiles(path: string, mustExist: boolean = true): Promise<File[]> {
|
|
||||||
const filenames = splitItemPath(path);
|
|
||||||
const output: File[] = [];
|
|
||||||
let parent: File = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < filenames.length; i++) {
|
|
||||||
const filename = filenames[i];
|
|
||||||
let file: File = null;
|
|
||||||
if (i === 0) {
|
|
||||||
// For now we only support "root" as a root component, but potentially it could
|
|
||||||
// be any special directory like "documents", "pictures", etc.
|
|
||||||
if (filename !== 'root') throw new ErrorBadRequest(`unknown path root component: ${filename}`);
|
|
||||||
file = await this.userRootFile();
|
|
||||||
} else {
|
|
||||||
file = await this.fileByName(parent.id, filename);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!file && mustExist) throw new ErrorNotFound(`file not found: "${filename}" on parent "${parent ? parent.name : ''}"`);
|
|
||||||
|
|
||||||
output.push(file);
|
|
||||||
parent = { ...file };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!output.length && mustExist) throw new ErrorBadRequest(`path without a base directory: ${path}`);
|
|
||||||
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mostly makes sense for testing/debugging because the filename would
|
|
||||||
// have to globally unique, which is not a requirement.
|
|
||||||
public async loadByName(name: string, options: LoadOptions = {}): Promise<File> {
|
|
||||||
const file: File = await this.db(this.tableName)
|
|
||||||
.select(this.defaultFields)
|
|
||||||
.where({ name: name })
|
|
||||||
.andWhere({ owner_id: this.userId })
|
|
||||||
.first();
|
|
||||||
if (!file) throw new ErrorNotFound(`No such file: ${name}`);
|
|
||||||
if (!options.skipPermissionCheck) await this.checkCanReadPermissions(file);
|
|
||||||
return file;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async loadWithContent(id: string, options: LoadOptions = {}): Promise<any> {
|
|
||||||
const file: File = await this.db<File>(this.tableName).select('*').where({ id: id }).first();
|
|
||||||
if (!file) return null;
|
|
||||||
if (!options.skipPermissionCheck) await this.checkCanReadPermissions(file);
|
|
||||||
return file;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async loadByIds(ids: string[], options: LoadOptions = {}): Promise<File[]> {
|
|
||||||
const files: File[] = await super.loadByIds(ids);
|
|
||||||
if (!files.length) return [];
|
|
||||||
if (!options.skipPermissionCheck) await this.checkCanReadPermissions(files);
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async load(id: string, options: LoadOptions = {}): Promise<File> {
|
|
||||||
const file: File = await super.load(id);
|
|
||||||
if (!file) return null;
|
|
||||||
if (!options.skipPermissionCheck) await this.checkCanReadPermissions(file);
|
|
||||||
return file;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async save(object: File, options: SaveOptions = {}): Promise<File> {
|
|
||||||
const isNew = await this.isNew(object, options);
|
|
||||||
|
|
||||||
const file: File = { ... object };
|
|
||||||
|
|
||||||
if ('content' in file) file.size = file.content ? file.content.byteLength : 0;
|
|
||||||
|
|
||||||
if (isNew) {
|
|
||||||
if (!file.parent_id && !file.is_root) file.parent_id = await this.userRootFileId();
|
|
||||||
|
|
||||||
// Even if there's no content, set the mime type based on the extension
|
|
||||||
if (!file.is_directory) file.mime_type = mimeUtils.fromFilename(file.name);
|
|
||||||
|
|
||||||
// Make sure it's not NULL, which is not allowed
|
|
||||||
if (!file.mime_type) file.mime_type = '';
|
|
||||||
|
|
||||||
file.owner_id = this.userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return super.save(file, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async childrenCount(id: string): Promise<number> {
|
|
||||||
const parent = await this.load(id);
|
|
||||||
await this.checkCanReadPermissions(parent);
|
|
||||||
const r: any = await this.db(this.tableName).where('parent_id', id).count('id', { as: 'total' }).first();
|
|
||||||
return r.total;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async childrens(id: string, pagination: Pagination): Promise<PaginatedFiles> {
|
|
||||||
const parent = await this.load(id);
|
|
||||||
await this.checkCanReadPermissions(parent);
|
|
||||||
return paginateDbQuery(this.db(this.tableName).select(...this.defaultFields).where('parent_id', id), pagination);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async childrenIds(id: string): Promise<string[]> {
|
|
||||||
const output = await this.db(this.tableName).select('id').where('parent_id', id);
|
|
||||||
return output.map(r => r.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async deleteChildren(id: string): Promise<void> {
|
|
||||||
const file: File = await this.load(id);
|
|
||||||
if (!file) return;
|
|
||||||
await this.checkCanWritePermission(file);
|
|
||||||
if (!file.is_directory) throw new ErrorBadRequest(`Not a directory: ${id}`);
|
|
||||||
|
|
||||||
await this.withTransaction(async () => {
|
|
||||||
const childrenIds = await this.childrenIds(file.id);
|
|
||||||
for (const childId of childrenIds) {
|
|
||||||
await this.delete(childId);
|
|
||||||
}
|
|
||||||
}, 'FileModel::deleteChildren');
|
|
||||||
}
|
|
||||||
|
|
||||||
public async delete(id: string, options: DeleteOptions = {}): Promise<void> {
|
|
||||||
const file: File = await this.load(id);
|
|
||||||
if (!file) return;
|
|
||||||
await this.checkCanWritePermission(file);
|
|
||||||
|
|
||||||
const canDeleteRoot = !!options.validationRules && !!options.validationRules.canDeleteRoot;
|
|
||||||
|
|
||||||
if (id === await this.userRootFileId() && !canDeleteRoot) throw new ErrorForbidden('the root directory may not be deleted');
|
|
||||||
|
|
||||||
await this.withTransaction(async () => {
|
|
||||||
await this.models().permission().deleteByFileId(id);
|
|
||||||
|
|
||||||
if (file.is_directory) {
|
|
||||||
const childrenIds = await this.childrenIds(file.id);
|
|
||||||
for (const childId of childrenIds) {
|
|
||||||
await this.delete(childId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await super.delete(id);
|
|
||||||
}, 'FileModel::delete');
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
133
packages/server/src/models/ItemModel.test.ts
Normal file
133
packages/server/src/models/ItemModel.test.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, createItem, createItemTree, createResource, createNote, createFolder } from '../utils/testing/testUtils';
|
||||||
|
import { shareFolderWithUser } from '../utils/testing/shareApiUtils';
|
||||||
|
import { resourceBlobPath } from '../utils/joplinUtils';
|
||||||
|
|
||||||
|
describe('ItemModel', function() {
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await beforeAllDb('ItemModel');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await afterAllTests();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await beforeEachDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should find exclusively owned items 1', async function() {
|
||||||
|
const { user: user1 } = await createUserAndSession(1, true);
|
||||||
|
const { session: session2, user: user2 } = await createUserAndSession(2);
|
||||||
|
|
||||||
|
const tree: any = {
|
||||||
|
'000000000000000000000000000000F1': {
|
||||||
|
'00000000000000000000000000000001': null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await createItemTree(user1.id, '', tree);
|
||||||
|
await createItem(session2.id, 'root:/test.txt:', 'testing');
|
||||||
|
|
||||||
|
{
|
||||||
|
const itemIds = await models().item().exclusivelyOwnedItemIds(user1.id);
|
||||||
|
expect(itemIds.length).toBe(2);
|
||||||
|
|
||||||
|
const item1 = await models().item().load(itemIds[0]);
|
||||||
|
const item2 = await models().item().load(itemIds[1]);
|
||||||
|
|
||||||
|
expect([item1.jop_id, item2.jop_id].sort()).toEqual(['000000000000000000000000000000F1', '00000000000000000000000000000001'].sort());
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const itemIds = await models().item().exclusivelyOwnedItemIds(user2.id);
|
||||||
|
expect(itemIds.length).toBe(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should find exclusively owned items 2', async function() {
|
||||||
|
const { session: session1, user: user1 } = await createUserAndSession(1, true);
|
||||||
|
const { session: session2, user: user2 } = await createUserAndSession(2);
|
||||||
|
|
||||||
|
await shareFolderWithUser(session1.id, session2.id, '000000000000000000000000000000F1', {
|
||||||
|
'000000000000000000000000000000F1': {
|
||||||
|
'00000000000000000000000000000001': null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await createFolder(session2.id, { id: '000000000000000000000000000000F2' });
|
||||||
|
|
||||||
|
{
|
||||||
|
const itemIds = await models().item().exclusivelyOwnedItemIds(user1.id);
|
||||||
|
expect(itemIds.length).toBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const itemIds = await models().item().exclusivelyOwnedItemIds(user2.id);
|
||||||
|
expect(itemIds.length).toBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
await models().user().delete(user2.id);
|
||||||
|
|
||||||
|
{
|
||||||
|
const itemIds = await models().item().exclusivelyOwnedItemIds(user1.id);
|
||||||
|
expect(itemIds.length).toBe(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should find all items within a shared folder', async function() {
|
||||||
|
const { user: user1, session: session1 } = await createUserAndSession(1);
|
||||||
|
const { session: session2 } = await createUserAndSession(2);
|
||||||
|
|
||||||
|
const resourceItem1 = await createResource(session1.id, { id: '000000000000000000000000000000E1' }, 'testing1');
|
||||||
|
const resourceItem2 = await createResource(session1.id, { id: '000000000000000000000000000000E2' }, 'testing2');
|
||||||
|
|
||||||
|
const { share } = await shareFolderWithUser(session1.id, session2.id, '000000000000000000000000000000F1', [
|
||||||
|
{
|
||||||
|
id: '000000000000000000000000000000F1',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: '00000000000000000000000000000001',
|
||||||
|
title: 'note test 1',
|
||||||
|
body: `[testing 1](:/${resourceItem1.jop_id}) [testing 2](:/${resourceItem2.jop_id})`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '00000000000000000000000000000002',
|
||||||
|
title: 'note test 2',
|
||||||
|
body: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '000000000000000000000000000000F2',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await createNote(session2.id, { id: '00000000000000000000000000000003', parent_id: '000000000000000000000000000000F1' });
|
||||||
|
|
||||||
|
{
|
||||||
|
const shareUserIds = await models().share().allShareUserIds(share);
|
||||||
|
const children = await models().item().sharedFolderChildrenItems(shareUserIds, '000000000000000000000000000000F1');
|
||||||
|
|
||||||
|
expect(children.filter(c => !!c.jop_id).map(c => c.jop_id).sort()).toEqual([
|
||||||
|
'00000000000000000000000000000001',
|
||||||
|
'00000000000000000000000000000002',
|
||||||
|
'00000000000000000000000000000003',
|
||||||
|
'000000000000000000000000000000E1',
|
||||||
|
'000000000000000000000000000000E2',
|
||||||
|
].sort());
|
||||||
|
|
||||||
|
expect(children.filter(c => !c.jop_id).map(c => c.name).sort()).toEqual([
|
||||||
|
resourceBlobPath('000000000000000000000000000000E1'),
|
||||||
|
resourceBlobPath('000000000000000000000000000000E2'),
|
||||||
|
].sort());
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const children = await models().item().sharedFolderChildrenItems([user1.id], '000000000000000000000000000000F2');
|
||||||
|
expect(children.map(c => c.jop_id).sort()).toEqual([].sort());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
556
packages/server/src/models/ItemModel.ts
Normal file
556
packages/server/src/models/ItemModel.ts
Normal file
@@ -0,0 +1,556 @@
|
|||||||
|
import BaseModel, { SaveOptions, LoadOptions, DeleteOptions, ValidateOptions, AclAction } from './BaseModel';
|
||||||
|
import { ItemType, databaseSchema, Uuid, Item, ShareType, Share, ChangeType, User } from '../db';
|
||||||
|
import { defaultPagination, paginateDbQuery, PaginatedResults, Pagination } from './utils/pagination';
|
||||||
|
import { isJoplinItemName, isJoplinResourceBlobPath, linkedResourceIds, serializeJoplinItem, unserializeJoplinItem } from '../utils/joplinUtils';
|
||||||
|
import { ModelType } from '@joplin/lib/BaseModel';
|
||||||
|
import { ApiError, ErrorForbidden, ErrorNotFound, ErrorUnprocessableEntity } from '../utils/errors';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
import { ChangePreviousItem } from './ChangeModel';
|
||||||
|
|
||||||
|
const mimeUtils = require('@joplin/lib/mime-utils.js').mime;
|
||||||
|
|
||||||
|
// Converts "root:/myfile.txt:" to "myfile.txt"
|
||||||
|
const extractNameRegex = /^root:\/(.*):$/;
|
||||||
|
|
||||||
|
export interface PaginatedItems extends PaginatedResults {
|
||||||
|
items: Item[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedRootInfo {
|
||||||
|
item: Item;
|
||||||
|
share: Share;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ItemSaveOption extends SaveOptions {
|
||||||
|
shareId?: Uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class ItemModel extends BaseModel<Item> {
|
||||||
|
|
||||||
|
protected get tableName(): string {
|
||||||
|
return 'items';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected get itemType(): ItemType {
|
||||||
|
return ItemType.Item;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected get hasParentId(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected get defaultFields(): string[] {
|
||||||
|
return Object.keys(databaseSchema[this.tableName]).filter(f => f !== 'content');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async checkIfAllowed(user: User, action: AclAction, resource: Item = null): Promise<void> {
|
||||||
|
if (action === AclAction.Create) {
|
||||||
|
if (!(await this.models().shareUser().isShareParticipant(resource.jop_share_id, user.id))) throw new ErrorForbidden('user has no access to this share');
|
||||||
|
}
|
||||||
|
|
||||||
|
// if (action === AclAction.Delete) {
|
||||||
|
// const share = await this.models().share().byItemId(resource.id);
|
||||||
|
// if (share && share.type === ShareType.JoplinRootFolder) {
|
||||||
|
// if (user.id !== share.owner_id) throw new ErrorForbidden('only the owner of the shared notebook can delete it');
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
public fromApiInput(item: Item): Item {
|
||||||
|
const output: Item = {};
|
||||||
|
|
||||||
|
if ('id' in item) item.id = output.id;
|
||||||
|
if ('name' in item) item.name = output.name;
|
||||||
|
if ('mime_type' in item) item.mime_type = output.mime_type;
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected objectToApiOutput(object: Item): Item {
|
||||||
|
const output: Item = {};
|
||||||
|
const propNames = ['id', 'name', 'updated_time', 'created_time'];
|
||||||
|
for (const k of Object.keys(object)) {
|
||||||
|
if (propNames.includes(k)) (output as any)[k] = (object as any)[k];
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async userHasItem(userId: Uuid, itemId: Uuid): Promise<boolean> {
|
||||||
|
const r = await this
|
||||||
|
.db('user_items')
|
||||||
|
.select('user_items.id')
|
||||||
|
.where('user_items.user_id', '=', userId)
|
||||||
|
.where('user_items.item_id', '=', itemId)
|
||||||
|
.first();
|
||||||
|
return !!r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove first and last colon from a path element
|
||||||
|
public pathToName(path: string): string {
|
||||||
|
if (path === 'root') return '';
|
||||||
|
return path.replace(extractNameRegex, '$1');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async byShareId(shareId: Uuid, options: LoadOptions = {}): Promise<Item[]> {
|
||||||
|
return this
|
||||||
|
.db('items')
|
||||||
|
.select(this.selectFields(options, null, 'items'))
|
||||||
|
.where('jop_share_id', '=', shareId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async loadByJopIds(userId: Uuid | Uuid[], jopIds: string[], options: LoadOptions = {}): Promise<Item[]> {
|
||||||
|
if (!jopIds.length) return [];
|
||||||
|
|
||||||
|
const userIds = Array.isArray(userId) ? userId : [userId];
|
||||||
|
if (!userIds.length) return [];
|
||||||
|
|
||||||
|
return this
|
||||||
|
.db('user_items')
|
||||||
|
.leftJoin('items', 'items.id', 'user_items.item_id')
|
||||||
|
.distinct(this.selectFields(options, null, 'items'))
|
||||||
|
.whereIn('user_items.user_id', userIds)
|
||||||
|
.whereIn('jop_id', jopIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async loadByJopId(userId: Uuid, jopId: string, options: LoadOptions = {}): Promise<Item> {
|
||||||
|
const items = await this.loadByJopIds(userId, [jopId], options);
|
||||||
|
return items.length ? items[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async loadByNames(userId: Uuid | Uuid[], names: string[], options: LoadOptions = {}): Promise<Item[]> {
|
||||||
|
if (!names.length) return [];
|
||||||
|
|
||||||
|
const userIds = Array.isArray(userId) ? userId : [userId];
|
||||||
|
|
||||||
|
return this
|
||||||
|
.db('user_items')
|
||||||
|
.leftJoin('items', 'items.id', 'user_items.item_id')
|
||||||
|
.distinct(this.selectFields(options, null, 'items'))
|
||||||
|
.whereIn('user_items.user_id', userIds)
|
||||||
|
.whereIn('name', names);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async loadByName(userId: Uuid, name: string, options: LoadOptions = {}): Promise<Item> {
|
||||||
|
const items = await this.loadByNames(userId, [name], options);
|
||||||
|
return items.length ? items[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async loadWithContent(id: Uuid, options: LoadOptions = {}): Promise<Item> {
|
||||||
|
return this
|
||||||
|
.db('user_items')
|
||||||
|
.leftJoin('items', 'items.id', 'user_items.item_id')
|
||||||
|
.select(this.selectFields(options, ['*'], 'items'))
|
||||||
|
.where('items.id', '=', id)
|
||||||
|
.first();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async loadAsSerializedJoplinItem(id: Uuid): Promise<string> {
|
||||||
|
return serializeJoplinItem(await this.loadAsJoplinItem(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async serializedContent(item: Item | Uuid): Promise<Buffer> {
|
||||||
|
item = typeof item === 'string' ? await this.loadWithContent(item) : item;
|
||||||
|
|
||||||
|
if (item.jop_type > 0) {
|
||||||
|
return Buffer.from(await serializeJoplinItem(this.itemToJoplinItem(item)));
|
||||||
|
} else {
|
||||||
|
return item.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sharedFolderChildrenItems(shareUserIds: Uuid[], folderId: string, includeResources: boolean = true): Promise<Item[]> {
|
||||||
|
if (!shareUserIds.length) throw new Error('User IDs must be specified');
|
||||||
|
|
||||||
|
let output: Item[] = [];
|
||||||
|
|
||||||
|
const folderAndNotes: Item[] = await this
|
||||||
|
.db('user_items')
|
||||||
|
.leftJoin('items', 'items.id', 'user_items.item_id')
|
||||||
|
.distinct('items.id', 'items.jop_id', 'items.jop_type')
|
||||||
|
.where('items.jop_parent_id', '=', folderId)
|
||||||
|
.whereIn('user_items.user_id', shareUserIds)
|
||||||
|
.whereIn('jop_type', [ModelType.Folder, ModelType.Note]);
|
||||||
|
|
||||||
|
for (const item of folderAndNotes) {
|
||||||
|
output.push(item);
|
||||||
|
|
||||||
|
if (item.jop_type === ModelType.Folder) {
|
||||||
|
const children = await this.sharedFolderChildrenItems(shareUserIds, item.jop_id, false);
|
||||||
|
output = output.concat(children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeResources) {
|
||||||
|
const noteItemIds = output.filter(i => i.jop_type === ModelType.Note).map(i => i.id);
|
||||||
|
|
||||||
|
const itemResourceIds = await this.models().itemResource().byItemIds(noteItemIds);
|
||||||
|
|
||||||
|
for (const itemId in itemResourceIds) {
|
||||||
|
// TODO: should be resources with that path, that belong to any of the share users
|
||||||
|
const resourceItems = await this.models().item().loadByJopIds(shareUserIds, itemResourceIds[itemId]);
|
||||||
|
|
||||||
|
for (const resourceItem of resourceItems) {
|
||||||
|
output.push({
|
||||||
|
id: resourceItem.id,
|
||||||
|
jop_id: resourceItem.jop_id,
|
||||||
|
jop_type: ModelType.Resource,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let allResourceIds: string[] = [];
|
||||||
|
for (const itemId in itemResourceIds) {
|
||||||
|
allResourceIds = allResourceIds.concat(itemResourceIds[itemId]);
|
||||||
|
}
|
||||||
|
// TODO: should be resources with that path, that belong to any of the share users
|
||||||
|
const blobItems = await this.models().itemResource().blobItemsByResourceIds(shareUserIds, allResourceIds);
|
||||||
|
for (const blobItem of blobItems) {
|
||||||
|
output.push({
|
||||||
|
id: blobItem.id,
|
||||||
|
name: blobItem.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
// private async folderChildrenItems(userId: Uuid, folderId: string): Promise<Item[]> {
|
||||||
|
// let output: Item[] = [];
|
||||||
|
|
||||||
|
// const rows: Item[] = await this
|
||||||
|
// .db('user_items')
|
||||||
|
// .leftJoin('items', 'items.id', 'user_items.item_id')
|
||||||
|
// .select('items.id', 'items.jop_id', 'items.jop_type')
|
||||||
|
// .where('items.jop_parent_id', '=', folderId)
|
||||||
|
// .where('user_items.user_id', '=', userId);
|
||||||
|
|
||||||
|
// for (const row of rows) {
|
||||||
|
// output.push(row);
|
||||||
|
|
||||||
|
// if (row.jop_type === ModelType.Folder) {
|
||||||
|
// const children = await this.folderChildrenItems(userId, row.jop_id);
|
||||||
|
// output = output.concat(children);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return output;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// public async shareJoplinFolderAndContent(shareId: Uuid, fromUserId: Uuid, toUserId: Uuid, folderId: string) {
|
||||||
|
// const folderItem = await this.loadByJopId(fromUserId, folderId, { fields: ['id'] });
|
||||||
|
// if (!folderItem) throw new ErrorNotFound(`Could not find folder "${folderId}" for share "${shareId}"`);
|
||||||
|
|
||||||
|
// const items = [folderItem].concat(await this.folderChildrenItems(fromUserId, folderId));
|
||||||
|
|
||||||
|
// const alreadySharedItemIds: string[] = await this
|
||||||
|
// .db('user_items')
|
||||||
|
// .pluck('item_id')
|
||||||
|
// .whereIn('item_id', items.map(i => i.id))
|
||||||
|
// .where('user_id', '=', toUserId)
|
||||||
|
// // .where('share_id', '!=', '');
|
||||||
|
|
||||||
|
// await this.withTransaction(async () => {
|
||||||
|
// for (const item of items) {
|
||||||
|
// if (alreadySharedItemIds.includes(item.id)) continue;
|
||||||
|
// await this.models().userItem().add(toUserId, item.id, shareId);
|
||||||
|
|
||||||
|
// if (item.jop_type === ModelType.Note) {
|
||||||
|
// const resourceIds = await this.models().itemResource().byItemId(item.id);
|
||||||
|
// await this.models().share().updateResourceShareStatus(true, shareId, fromUserId, toUserId, resourceIds);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
public itemToJoplinItem(itemRow: Item): any {
|
||||||
|
if (itemRow.jop_type <= 0) throw new Error(`Not a Joplin item: ${itemRow.id}`);
|
||||||
|
if (!itemRow.content) throw new Error('Item content is missing');
|
||||||
|
const item = JSON.parse(itemRow.content.toString());
|
||||||
|
|
||||||
|
item.id = itemRow.jop_id;
|
||||||
|
item.parent_id = itemRow.jop_parent_id;
|
||||||
|
item.share_id = itemRow.jop_share_id;
|
||||||
|
item.type_ = itemRow.jop_type;
|
||||||
|
item.encryption_applied = itemRow.jop_encryption_applied;
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async loadAsJoplinItem(id: Uuid): Promise<any> {
|
||||||
|
const raw = await this.loadWithContent(id);
|
||||||
|
return this.itemToJoplinItem(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async saveFromRawContent(userId: Uuid, name: string, buffer: Buffer, options: ItemSaveOption = null): Promise<Item> {
|
||||||
|
options = options || {};
|
||||||
|
|
||||||
|
const existingItem = await this.loadByName(userId, name);
|
||||||
|
|
||||||
|
const isJoplinItem = isJoplinItemName(name);
|
||||||
|
let isNote = false;
|
||||||
|
|
||||||
|
const item: Item = {
|
||||||
|
name,
|
||||||
|
};
|
||||||
|
|
||||||
|
let resourceIds: string[] = [];
|
||||||
|
|
||||||
|
if (isJoplinItem) {
|
||||||
|
const joplinItem = await unserializeJoplinItem(buffer.toString());
|
||||||
|
isNote = joplinItem.type_ === ModelType.Note;
|
||||||
|
resourceIds = isNote ? linkedResourceIds(joplinItem.body) : [];
|
||||||
|
|
||||||
|
item.jop_id = joplinItem.id;
|
||||||
|
item.jop_parent_id = joplinItem.parent_id || '';
|
||||||
|
item.jop_type = joplinItem.type_;
|
||||||
|
item.jop_encryption_applied = joplinItem.encryption_applied || 0;
|
||||||
|
item.jop_share_id = joplinItem.share_id || '';
|
||||||
|
|
||||||
|
delete joplinItem.id;
|
||||||
|
delete joplinItem.parent_id;
|
||||||
|
delete joplinItem.share_id;
|
||||||
|
delete joplinItem.type_;
|
||||||
|
delete joplinItem.encryption_applied;
|
||||||
|
|
||||||
|
item.content = Buffer.from(JSON.stringify(joplinItem));
|
||||||
|
} else {
|
||||||
|
item.content = buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingItem) item.id = existingItem.id;
|
||||||
|
|
||||||
|
if (options.shareId) item.jop_share_id = options.shareId;
|
||||||
|
|
||||||
|
return this.withTransaction<Item>(async () => {
|
||||||
|
const savedItem = await this.saveForUser(userId, item);
|
||||||
|
|
||||||
|
if (isNote) {
|
||||||
|
await this.models().itemResource().deleteByItemId(savedItem.id);
|
||||||
|
await this.models().itemResource().addResourceIds(savedItem.id, resourceIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedItem;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async validate(item: Item, options: ValidateOptions = {}): Promise<Item> {
|
||||||
|
if (options.isNew) {
|
||||||
|
if (!item.name) throw new ErrorUnprocessableEntity('name cannot be empty');
|
||||||
|
} else {
|
||||||
|
if ('name' in item && !item.name) throw new ErrorUnprocessableEntity('name cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.validate(item, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private childrenQuery(userId: Uuid, pathQuery: string = '', options: LoadOptions = {}): Knex.QueryBuilder {
|
||||||
|
const query = this
|
||||||
|
.db('user_items')
|
||||||
|
.leftJoin('items', 'user_items.item_id', 'items.id')
|
||||||
|
.select(this.selectFields(options, ['id', 'name', 'updated_time'], 'items'))
|
||||||
|
.where('user_items.user_id', '=', userId);
|
||||||
|
|
||||||
|
if (pathQuery) {
|
||||||
|
// We support /* as a prefix only. Anywhere else would have
|
||||||
|
// performance issue or requires a revert index.
|
||||||
|
const sqlLike = pathQuery.replace(/\/\*$/g, '/%');
|
||||||
|
void query.where('name', 'like', sqlLike);
|
||||||
|
}
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
public itemUrl(): string {
|
||||||
|
return `${this.baseUrl}/items`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public itemContentUrl(itemId: Uuid): string {
|
||||||
|
return `${this.baseUrl}/items/${itemId}/content`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async children(userId: Uuid, pathQuery: string = '', pagination: Pagination = null, options: LoadOptions = {}): Promise<PaginatedItems> {
|
||||||
|
pagination = pagination || defaultPagination();
|
||||||
|
const query = this.childrenQuery(userId, pathQuery, options);
|
||||||
|
return paginateDbQuery(query, pagination, 'items');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async childrenCount(userId: Uuid, pathQuery: string = ''): Promise<number> {
|
||||||
|
const query = this.childrenQuery(userId, pathQuery);
|
||||||
|
return query.count();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async joplinItemPath(jopId: string): Promise<Item[]> {
|
||||||
|
// Use Recursive Common Table Expression to find path to given item
|
||||||
|
// https://www.sqlite.org/lang_with.html#recursivecte
|
||||||
|
|
||||||
|
// with recursive paths(id, jop_id, jop_parent_id) as (
|
||||||
|
// select id, jop_id, jop_parent_id from items where jop_id = '000000000000000000000000000000F1'
|
||||||
|
// union
|
||||||
|
// select items.id, items.jop_id, items.jop_parent_id
|
||||||
|
// from items join paths where items.jop_id = paths.jop_parent_id
|
||||||
|
// )
|
||||||
|
// select id, jop_id, jop_parent_id from paths;
|
||||||
|
|
||||||
|
return this.db.withRecursive('paths', (qb: Knex.QueryBuilder) => {
|
||||||
|
void qb.select('id', 'jop_id', 'jop_parent_id')
|
||||||
|
.from('items')
|
||||||
|
.where('jop_id', '=', jopId)
|
||||||
|
.whereIn('jop_type', [ModelType.Note, ModelType.Folder])
|
||||||
|
|
||||||
|
.union((qb: Knex.QueryBuilder) => {
|
||||||
|
void qb
|
||||||
|
.select('items.id', 'items.jop_id', 'items.jop_parent_id')
|
||||||
|
.from('items')
|
||||||
|
.join('paths', 'items.jop_id', 'paths.jop_parent_id')
|
||||||
|
.whereIn('jop_type', [ModelType.Note, ModelType.Folder]);
|
||||||
|
});
|
||||||
|
}).select('id', 'jop_id', 'jop_parent_id').from('paths');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the note or folder is within a shared folder, this function returns
|
||||||
|
// that shared folder. It returns null otherwise.
|
||||||
|
public async joplinItemSharedRootInfo(jopId: string): Promise<SharedRootInfo | null> {
|
||||||
|
const path = await this.joplinItemPath(jopId);
|
||||||
|
if (!path.length) throw new ApiError(`Cannot retrieve path for item: ${jopId}`, null, 'noPathForItem');
|
||||||
|
const rootFolderItem = path[path.length - 1];
|
||||||
|
const share = await this.models().share().itemShare(ShareType.JoplinRootFolder, rootFolderItem.id);
|
||||||
|
if (!share) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
item: await this.load(rootFolderItem.id),
|
||||||
|
share,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async allForDebug(): Promise<any[]> {
|
||||||
|
const items = await this.all({ fields: ['*'] });
|
||||||
|
return items.map(i => {
|
||||||
|
if (!i.content) return i;
|
||||||
|
i.content = i.content.toString() as any;
|
||||||
|
return i;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public shouldRecordChange(itemName: string): boolean {
|
||||||
|
if (isJoplinItemName(itemName)) return true;
|
||||||
|
if (isJoplinResourceBlobPath(itemName)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isRootSharedFolder(item: Item): boolean {
|
||||||
|
return item.jop_type === ModelType.Folder && item.jop_parent_id === '' && !!item.jop_share_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the item IDs that are owned only by the given user. In other
|
||||||
|
// words, the items that are not shared with anyone else. Such items
|
||||||
|
// can be safely deleted when the user is deleted.
|
||||||
|
public async exclusivelyOwnedItemIds(userId: Uuid): Promise<Uuid[]> {
|
||||||
|
const query = this
|
||||||
|
.db('items')
|
||||||
|
.select(this.db.raw('items.id, count(user_items.item_id) as user_item_count'))
|
||||||
|
.leftJoin('user_items', 'user_items.item_id', 'items.id')
|
||||||
|
.whereIn('items.id', this.db('user_items').select('user_items.item_id').where('user_id', '=', userId))
|
||||||
|
.groupBy('items.id');
|
||||||
|
|
||||||
|
const rows: any[] = await query;
|
||||||
|
return rows.filter(r => r.user_item_count === 1).map(r => r.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteExclusivelyOwnedItems(userId: Uuid) {
|
||||||
|
const itemIds = await this.exclusivelyOwnedItemIds(userId);
|
||||||
|
await this.delete(itemIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteAll(userId: Uuid): Promise<void> {
|
||||||
|
while (true) {
|
||||||
|
const page = await this.children(userId, '', { ...defaultPagination(), limit: 1000 });
|
||||||
|
await this.delete(page.items.map(c => c.id));
|
||||||
|
if (!page.has_more) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async delete(id: string | string[], options: DeleteOptions = {}): Promise<void> {
|
||||||
|
const ids = typeof id === 'string' ? [id] : id;
|
||||||
|
if (!ids.length) return;
|
||||||
|
|
||||||
|
const shares = await this.models().share().byItemIds(ids);
|
||||||
|
|
||||||
|
await this.withTransaction(async () => {
|
||||||
|
await this.models().share().delete(shares.map(s => s.id));
|
||||||
|
await this.models().userItem().deleteByItemIds(ids);
|
||||||
|
await this.models().itemResource().deleteByItemIds(ids);
|
||||||
|
|
||||||
|
await super.delete(ids, options);
|
||||||
|
}, 'ItemModel::delete');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteForUser(userId: Uuid, item: Item): Promise<void> {
|
||||||
|
if (this.isRootSharedFolder(item)) {
|
||||||
|
const share = await this.models().share().byItemId(item.id);
|
||||||
|
if (!share) throw new ErrorNotFound(`Cannot find share associated with item ${item.id}`);
|
||||||
|
const userShare = await this.models().shareUser().byShareAndUserId(share.id, userId);
|
||||||
|
if (!userShare) return;
|
||||||
|
await this.models().shareUser().delete(userShare.id);
|
||||||
|
} else {
|
||||||
|
await this.delete(item.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async saveForUser(userId: Uuid, item: Item, options: SaveOptions = {}): Promise<Item> {
|
||||||
|
if (!userId) throw new Error('userId is required');
|
||||||
|
|
||||||
|
item = { ... item };
|
||||||
|
const isNew = await this.isNew(item, options);
|
||||||
|
|
||||||
|
if (item.content) {
|
||||||
|
item.content_size = item.content.byteLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
let previousItem: ChangePreviousItem = null;
|
||||||
|
|
||||||
|
if (isNew) {
|
||||||
|
if (!item.mime_type) item.mime_type = mimeUtils.fromFilename(item.name) || '';
|
||||||
|
} else {
|
||||||
|
const beforeSaveItem = (await this.load(item.id, { fields: ['name', 'jop_type', 'jop_parent_id', 'jop_share_id'] }));
|
||||||
|
const resourceIds = beforeSaveItem.jop_type === ModelType.Note ? await this.models().itemResource().byItemId(item.id) : [];
|
||||||
|
|
||||||
|
previousItem = {
|
||||||
|
jop_parent_id: beforeSaveItem.jop_parent_id,
|
||||||
|
name: beforeSaveItem.name,
|
||||||
|
jop_resource_ids: resourceIds,
|
||||||
|
jop_share_id: beforeSaveItem.jop_share_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.withTransaction(async () => {
|
||||||
|
item = await super.save(item, options);
|
||||||
|
|
||||||
|
if (isNew) await this.models().userItem().add(userId, item.id);
|
||||||
|
|
||||||
|
// We only record updates. This because Create and Update events are
|
||||||
|
// per user, whenever a user_item is created or deleted.
|
||||||
|
const changeItemName = item.name || previousItem.name;
|
||||||
|
|
||||||
|
if (!isNew && this.shouldRecordChange(changeItemName)) {
|
||||||
|
await this.models().change().save({
|
||||||
|
item_type: this.itemType,
|
||||||
|
item_id: item.id,
|
||||||
|
item_name: changeItemName,
|
||||||
|
type: isNew ? ChangeType.Create : ChangeType.Update,
|
||||||
|
previous_item: previousItem ? this.models().change().serializePreviousItem(previousItem) : '',
|
||||||
|
user_id: userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async save(_item: Item, _options: SaveOptions = {}): Promise<Item> {
|
||||||
|
throw new Error('Use saveForUser()');
|
||||||
|
// return this.saveForUser('', item, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user