1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-08-24 20:19:10 +02:00

Compare commits

...

163 Commits

Author SHA1 Message Date
Laurent Cozic
047a5c0acf fix paths 2021-05-13 18:48:19 +02:00
Laurent Cozic
e0379464fc Merge branch 'dev' into server_app_share_3 2021-05-13 18:29:46 +02:00
Laurent Cozic
756e646615 remove apps 2021-05-13 18:18:53 +02:00
Laurent Cozic
d68c0bf5de remove apps 2021-05-13 18:16:29 +02:00
Laurent Cozic
fd57ec10a9 fixed tests 2021-05-13 14:26:52 +02:00
Laurent Cozic
052ef1617f fix share 2021-05-13 12:03:17 +02:00
Laurent Cozic
f776b68594 change log 2021-05-13 11:07:36 +02:00
Laurent Cozic
0d19f64734 remove state from Folder 2021-05-12 18:29:46 +02:00
Laurent Cozic
fb5b73c582 share 2021-05-12 18:14:21 +02:00
Laurent Cozic
e735dba5f5 unshre 2021-05-12 15:10:24 +02:00
Laurent Cozic
7f3bab278f share 2021-05-11 17:30:14 +02:00
Laurent Cozic
61ae86c11c delete user share 2021-05-11 16:59:04 +02:00
Laurent Cozic
8d7ecff4ed restore deleted file 2021-05-11 15:16:34 +02:00
Laurent Cozic
2a3f542cb9 fix mobile 2021-05-11 12:35:14 +02:00
Laurent Cozic
f2860ed011 share link 2021-05-11 11:24:27 +02:00
Laurent Cozic
4b8a429252 ui 2021-05-10 20:16:49 +02:00
Laurent Cozic
1721be3072 ui 2021-05-10 20:09:55 +02:00
Laurent Cozic
3c00e96501 clean up 2021-05-10 19:34:22 +02:00
Laurent Cozic
814b4d49d4 updaet shares 2021-05-10 18:57:07 +02:00
Laurent Cozic
9974107941 fix share ID logic 2021-05-10 18:43:30 +02:00
Laurent Cozic
5164e20f7c Merge branch 'dev' into server_app_share_3 2021-05-10 12:55:35 +02:00
Laurent Cozic
e9362c92f6 share folder 2021-05-10 12:50:22 +02:00
Laurent Cozic
9a0ea2aa47 fix tests 2021-05-08 23:25:47 +02:00
Laurent Cozic
0117088f7f fix test 2021-05-08 20:45:18 +02:00
Laurent Cozic
e96ee8f13e fix test 2021-05-08 18:46:34 +02:00
Laurent Cozic
e404af0812 set share_id on client 2021-05-07 20:17:53 +02:00
Laurent Cozic
3f964fc8af share 2021-05-07 16:05:36 +02:00
Laurent Cozic
134c36922d apply note share_id to resources 2021-05-07 15:15:11 +02:00
Laurent Cozic
1686ae77f6 share 2021-05-07 12:18:44 +02:00
Laurent Cozic
063fa8e92e fix tests 2021-05-07 11:20:50 +02:00
Laurent Cozic
88abebe044 tweaks 2021-05-07 10:47:35 +02:00
Laurent Cozic
c4f073cb77 trying another approach 2021-05-06 21:13:01 +02:00
Laurent Cozic
2d8eced79d clean up 2021-05-06 15:14:41 +02:00
Laurent Cozic
8176c78fb1 clean up 2021-05-06 14:29:52 +02:00
Laurent Cozic
97e7313de4 clean, up 2021-05-06 14:27:09 +02:00
Laurent Cozic
b7289f0acb share 2021-05-06 13:24:44 +02:00
Laurent Cozic
0c6222ad76 tests* 2021-05-05 22:10:48 +02:00
Laurent Cozic
14d6336521 fix tests 2021-05-05 22:06:42 +02:00
Laurent Cozic
91c0372f16 trying another approach to update shares 2021-05-05 21:54:10 +02:00
Laurent Cozic
b999222fe3 tweak 2021-05-05 17:11:02 +02:00
Laurent Cozic
53f63f3e86 tweak 2021-05-05 16:40:24 +02:00
Laurent Cozic
3ad36d4a56 Added way to get all shares of a user 2021-05-05 14:19:22 +02:00
Laurent Cozic
1fb90dbbce various tweaks 2021-05-05 14:01:12 +02:00
Laurent Cozic
2201a8865d fixed sharing notification 2021-05-03 19:22:20 +02:00
Laurent Cozic
17e80ef48f share 2021-05-03 19:07:26 +02:00
Laurent Cozic
7ee8efb67f fix migrations 2021-05-03 15:38:39 +02:00
Laurent Cozic
320fcc1c3e fix migrations 2021-05-03 15:16:00 +02:00
Laurent Cozic
127fc3cb9a delete file on startup 2021-05-03 15:04:34 +02:00
Laurent Cozic
dd4ddd5d36 Merge branch 'dev' into server_app_share_3 2021-05-03 12:58:35 +02:00
Laurent Cozic
fa6491bf78 Desktop: Add synchronization tools to clear local sync state or data 2021-05-03 12:55:38 +02:00
Laurent Cozic
610631596d Merge branch 'dev' into server_app_share_3 2021-05-01 16:11:13 +02:00
Laurent Cozic
71cc77b30c share 2021-05-01 16:07:58 +02:00
Laurent Cozic
765bbf5295 share 2021-05-01 16:07:36 +02:00
Laurent Cozic
e9d3864022 more permission checks 2021-04-30 18:48:57 +02:00
Laurent Cozic
631c2af353 permission checks 2021-04-30 11:41:36 +02:00
Laurent Cozic
b24f4d2a1d comment out sync sharing test 2021-04-29 19:16:12 +02:00
Laurent Cozic
b6028a7014 fixed resource sharing logic and ShareService race condition 2021-04-29 19:14:53 +02:00
Laurent Cozic
166ff3866c fixed resource sharing logic 2021-04-29 18:23:17 +02:00
Laurent Cozic
af13ed6b95 refactor resource sharing 2021-04-29 17:59:36 +02:00
Laurent Cozic
b8d5a8b025 script to test sharing 2021-04-28 19:06:14 +02:00
Laurent Cozic
425a17e777 fixed sharing 2021-04-28 18:43:17 +02:00
Laurent Cozic
8d5c538e18 Fixed sharing 2021-04-28 17:14:08 +02:00
Laurent Cozic
1c2d4306f9 ts 2021-04-28 12:00:39 +02:00
Laurent Cozic
99270bc1ea share 2021-04-28 11:56:00 +02:00
Laurent Cozic
6130fcc5a8 share service test 2021-04-26 16:35:34 +02:00
Laurent Cozic
614359c267 share service mainteannce 2021-04-26 11:54:07 +02:00
Laurent Cozic
f438fcb50d linter 2021-04-25 13:44:49 +02:00
Laurent Cozic
bff42b8a82 full sharing workflow 2021-04-25 12:49:32 +02:00
Laurent Cozic
c5a9208594 invitation status 2021-04-25 12:27:33 +02:00
Laurent Cozic
a43be838b5 added debug tols 2021-04-25 12:05:00 +02:00
Laurent Cozic
9ae31583af Merge branch 'dev' into server_app_share_3 2021-04-25 11:04:52 +02:00
Laurent Cozic
0fa141881b Merge branch 'dev' into server_app_share_3 2021-04-25 10:45:17 +02:00
Laurent Cozic
6152f19712 ui 2021-04-23 19:58:57 +02:00
Laurent Cozic
2ade4c7951 handle share_users 2021-04-23 18:28:22 +02:00
Laurent Cozic
85c887d004 ui 2021-04-23 16:40:07 +02:00
Laurent Cozic
47d49935bf Retrieve share info 2021-04-23 15:32:38 +02:00
Laurent Cozic
14d1d8ca72 fixed sharing 2021-04-23 09:58:09 +02:00
Laurent Cozic
e903b17a3f Share folder GUI 2021-04-22 12:38:47 +02:00
Laurent Cozic
10138260d9 fixed tests on CI 2021-04-21 23:37:33 +02:00
Laurent Cozic
6ddef009aa remove changes.parent_id 2021-04-21 22:22:34 +02:00
Laurent Cozic
425d8acb2d delete resource link when deleting item 2021-04-21 22:19:01 +02:00
Laurent Cozic
11b00ccb70 add support for sharing resoruces 2021-04-21 22:16:25 +02:00
Laurent Cozic
c9288e117f clean up 2021-04-21 16:54:59 +02:00
Laurent Cozic
da2bc801a6 share resources 2021-04-20 22:20:47 +02:00
Laurent Cozic
95a9dc0de4 wat 2021-04-20 18:41:56 +02:00
Laurent Cozic
f437b8ee60 fixed issue when shared item is moved then changed twice 2021-04-20 12:21:09 +02:00
Laurent Cozic
3e82fdab04 refactor dialog component 2021-04-20 09:59:17 +02:00
Laurent Cozic
9555875b53 update license 2021-04-19 20:01:49 +02:00
Laurent Cozic
9c9d30e73f fixed sharing 2021-04-19 20:01:38 +02:00
Laurent Cozic
9bb920a98a fixed sync and sharing link 2021-04-19 18:12:07 +02:00
Laurent Cozic
8e8ad3193c add indexes 2021-04-19 17:39:10 +02:00
Laurent Cozic
5eb4193451 fix 2021-04-19 17:31:03 +02:00
Laurent Cozic
2d373f0c31 remove share.folder_id 2021-04-19 17:21:09 +02:00
Laurent Cozic
e78d3cfd3d remove share.is_auto 2021-04-19 17:15:49 +02:00
Laurent Cozic
21b4ce97dc userShare ACL 2021-04-19 17:14:15 +02:00
Laurent Cozic
2e9192d10a fix sharing logic 2021-04-19 17:02:25 +02:00
Laurent Cozic
5820261577 clean up validation 2021-04-19 15:19:07 +02:00
Laurent Cozic
d86a4f7437 clean up validation 2021-04-19 15:07:52 +02:00
Laurent Cozic
13d77e44cf Remove item.owner_id 2021-04-19 15:04:24 +02:00
Laurent Cozic
5895c8364f linter 2021-04-19 12:45:55 +02:00
Laurent Cozic
88183a4660 making userId dependency explicit 2021-04-19 12:44:47 +02:00
Laurent Cozic
c6a1aaee72 making userId dependency explicit 2021-04-19 12:34:58 +02:00
Laurent Cozic
4f8539fceb making userId dependency explicit 2021-04-19 12:33:58 +02:00
Laurent Cozic
6a05349412 Added support for ACL 2021-04-19 12:24:02 +02:00
Laurent Cozic
81e71e08be making userId dependency explicit 2021-04-19 09:39:10 +02:00
Laurent Cozic
50bdbc165a making userId dependency explicit 2021-04-19 09:29:08 +02:00
Laurent Cozic
9261d9c36b Upgrade Knex to latest version 2021-04-19 09:18:15 +02:00
Laurent Cozic
a19549573d Moved item change tracking out of base class 2021-04-19 01:46:54 +02:00
Laurent Cozic
e4435f52fc making userId dependency explicit 2021-04-19 01:18:33 +02:00
Laurent Cozic
090518bb76 making userId dependency explicit 2021-04-18 22:30:23 +02:00
Laurent Cozic
856f45507c making userId dependency explicit 2021-04-18 22:21:13 +02:00
Laurent Cozic
5dc4551d60 making userId dependency explicit 2021-04-18 22:06:13 +02:00
Laurent Cozic
a3f59691e5 making userId dependency explicit 2021-04-18 22:01:35 +02:00
Laurent Cozic
e0aa8bd7fb making userId dependency explicit 2021-04-18 21:58:56 +02:00
Laurent Cozic
9333b37147 making userId dependency explicit 2021-04-18 21:53:03 +02:00
Laurent Cozic
bcbcf583c1 remove duplicate code 2021-04-18 20:17:23 +02:00
Laurent Cozic
189aef7f91 remove permissions 2021-04-18 20:05:24 +02:00
Laurent Cozic
1286716007 fixing sync 2021-04-18 19:27:31 +02:00
Laurent Cozic
003f60aff1 clean up 2021-04-18 11:16:04 +02:00
Laurent Cozic
9373aec3cf clean up 2021-04-18 11:06:54 +02:00
Laurent Cozic
e89836ff9d finished fixing tests 2021-04-18 01:18:20 +02:00
Laurent Cozic
c7996f66ee fix more tests 2021-04-17 21:07:19 +02:00
Laurent Cozic
b4bac8ff31 fixed change tracking logic 2021-04-17 20:41:17 +02:00
Laurent Cozic
c7c8631333 Fix sharing note by link 2021-04-16 22:18:00 +02:00
Laurent Cozic
48cda86f54 fixed delta sync 2021-04-16 12:37:00 +02:00
Laurent Cozic
fe54b383ca share folder 2021-04-16 12:07:53 +02:00
Laurent Cozic
e904f9d90a Handle case where shared item is moved between folder 2021-04-15 20:29:22 +02:00
Laurent Cozic
41a3f9c052 handle user share 2021-04-15 12:28:48 +02:00
Laurent Cozic
c40b4eab17 share tests 2021-04-14 18:00:25 +02:00
Laurent Cozic
5a186146ab started refactoring change system 2021-04-13 19:16:42 +02:00
Laurent Cozic
d389886394 Refactor to support native Joplin items 2021-04-13 18:17:09 +02:00
Laurent Cozic
09930bc8a6 Merge branch 'dev' into server_app_share_3 2021-04-12 11:05:49 +02:00
Laurent Cozic
67dcd41bf7 Merge branch 'dev' into server_app_share_3 2021-04-11 11:24:25 +02:00
Laurent Cozic
f2bf1375bf test 2021-04-11 11:08:31 +02:00
Laurent Cozic
7bb00e1338 clean up 2021-04-08 18:32:35 +02:00
Laurent Cozic
8562909a4f share folder 2021-04-08 18:27:57 +02:00
Laurent Cozic
48a499f741 share new note 2021-04-05 12:10:45 +02:00
Laurent Cozic
4702976ceb User share tests 2021-04-02 11:39:57 +02:00
Laurent Cozic
2006288108 Merge branch 'dev' into server_app_share_3 2021-03-30 10:22:47 +02:00
Laurent Cozic
8446693e91 fix 2021-03-29 10:33:41 +02:00
Laurent Cozic
b3579d70e9 Merge branch 'dev' into server_app_share_3 2021-03-26 17:56:42 +01:00
Laurent Cozic
73ce9b2443 fix tests 2021-03-26 17:55:30 +01:00
Laurent Cozic
15f5b90211 test 2021-03-26 14:31:48 +01:00
Laurent Cozic
0011b570aa second attempt 2021-03-24 12:13:55 +01:00
Laurent Cozic
aeb3c4a98d trying 2021-02-05 16:17:49 +00:00
Laurent Cozic
58a464d040 allow custom app routes 2021-02-05 12:12:53 +00:00
Laurent Cozic
8e13ccb665 fixed updated time handling 2021-02-05 10:34:12 +00:00
Laurent Cozic
6dd14ff04b clean up 2021-02-04 22:13:00 +00:00
Laurent Cozic
2022b5bc48 add indexes 2021-02-04 22:06:50 +00:00
Laurent Cozic
7ade9b2948 third user test 2021-02-04 21:57:58 +00:00
Laurent Cozic
4157dad9f1 prevent share from being shared again 2021-02-04 21:26:07 +00:00
Laurent Cozic
a088061de9 Fxied tests 2021-02-04 21:13:00 +00:00
Laurent Cozic
439d29387f no need for delta state 2021-02-04 17:35:36 +00:00
Laurent Cozic
2f15e4db59 tests 2021-02-04 17:06:40 +00:00
Laurent Cozic
0b37e99132 clean up 2021-02-04 16:46:01 +00:00
Laurent Cozic
6d41787a29 clean up 2021-02-04 15:54:21 +00:00
Laurent Cozic
28fc0374c5 testes 2021-02-04 12:03:50 +00:00
Laurent Cozic
726ee4a574 update 2021-02-04 10:11:21 +00:00
Laurent Cozic
25e32226ef Check linked file content 2021-02-03 12:05:40 +00:00
Laurent Cozic
9efdbf9854 refactor 2021-02-02 22:35:17 +00:00
Laurent Cozic
09c95f10f4 share 2021-02-02 18:20:19 +00:00
Laurent Cozic
a6453af3e5 Merge branch 'dev' into server_app_share 2021-02-02 11:06:50 +00:00
Laurent Cozic
b8c8178b26 update 2021-02-01 17:34:17 +00:00
165 changed files with 8803 additions and 3072 deletions

View File

@@ -18,6 +18,7 @@ packages/turndown-plugin-gfm/
node_modules/
packages/lib/lib/lib.js
packages/lib/locales/index.js
packages/lib/services/database/types.ts
packages/app-cli/build
packages/app-cli/build/
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.js
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.js
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.js
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.js
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.js
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.js
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.js
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.js
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.js
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.js
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.js
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.js
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.js
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.js
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.js
packages/lib/JoplinServerApi.js.map
@@ -877,6 +908,9 @@ packages/lib/commands/synchronize.js.map
packages/lib/database.d.ts
packages/lib/database.js
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.js
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.js
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.js
packages/lib/services/spellChecker/SpellCheckerService.js.map

39
.gitignore vendored
View File

@@ -99,6 +99,9 @@ packages/app-cli/tests/Synchronizer.tools.js.map
packages/app-cli/tests/dateTimeFormats.d.ts
packages/app-cli/tests/dateTimeFormats.js
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.js
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.js
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.js
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.js
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.js
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.js
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.js
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.js
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.js
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.js
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.js
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.js
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.js
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.js
packages/lib/JoplinServerApi.js.map
@@ -864,6 +894,9 @@ packages/lib/commands/synchronize.js.map
packages/lib/database.d.ts
packages/lib/database.js
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.js
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.js
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.js
packages/lib/services/spellChecker/SpellCheckerService.js.map

12
LICENSE
View File

@@ -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
Copyright (c) 2016-2020 Laurent Cozic

View File

@@ -35,7 +35,6 @@ module.exports = {
'<rootDir>/build/',
'<rootDir>/tests/test-utils.js',
'<rootDir>/tests/test-utils-synchronizer.js',
'<rootDir>/tests/file_api_driver.js',
'<rootDir>/tests/tmp/',
'<rootDir>/tests/test data/',
],

View File

@@ -9,6 +9,7 @@ import Resource from '@joplin/lib/models/Resource';
import ResourceFetcher from '@joplin/lib/services/ResourceFetcher';
import MasterKey from '@joplin/lib/models/MasterKey';
import BaseItem from '@joplin/lib/models/BaseItem';
import { createFolderTree } from './test-utils';
let insideBeforeEach = false;
@@ -361,13 +362,26 @@ describe('Synchronizer.e2ee', function() {
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);
await loadEncryptionMasterKey();
const folder1 = await Folder.save({ title: 'folder1' });
const note1 = await Note.save({ title: 'un', parent_id: folder1.id });
let note2 = await Note.save({ title: 'deux', parent_id: folder1.id });
await createFolderTree('', [
{
title: 'folder1',
children: [
{
title: 'un',
},
{
title: 'deux',
},
],
},
]);
const note1 = await Note.loadByTitle('un');
let note2 = await Note.loadByTitle('deux');
await synchronizerStart();
await switchClient(2);
@@ -400,4 +414,61 @@ describe('Synchronizer.e2ee', function() {
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();
}
}));
});

View 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

View File

@@ -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() {
beforeEach(async (done) => {
await setupDatabaseAndSynchronizer(1);
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);
it('should skip', (async () => {
expect(true).toBe(true);
}));
});
// 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());
// }));
// });

View 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);
}));
});

View File

@@ -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
// });

View 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);
// });
});

View File

@@ -1,5 +1,5 @@
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 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 }));
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());
}
}));
});

View File

@@ -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>;
}

View File

@@ -53,6 +53,7 @@ import ResourceFetcher from '@joplin/lib/services/ResourceFetcher';
const WebDavApi = require('@joplin/lib/WebDavApi');
const DropboxApi = require('@joplin/lib/DropboxApi');
import JoplinServerApi from '@joplin/lib/JoplinServerApi';
import { FolderEntity } from '@joplin/lib/services/database/types';
const { loadKeychainServiceAndSettings } = require('@joplin/lib/services/SettingUtils');
const md5 = require('md5');
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);
}
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) {
if (id === null) id = currentClient_;
return `${dataDir}/export`;
@@ -385,7 +409,7 @@ async function setupDatabaseAndSynchronizer(id: number, options: any = null) {
if (!synchronizers_[id]) {
const SyncTargetClass = SyncTargetRegistry.classById(syncTargetId_);
const syncTarget = new SyncTargetClass(db(id));
await initFileApi(suiteName_);
await initFileApi();
syncTarget.setFileApi(fileApi());
syncTarget.setLogger(logger);
synchronizers_[id] = await syncTarget.synchronizer();
@@ -484,7 +508,13 @@ async function loadEncryptionMasterKey(id: number = null, useExisting = false) {
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;
let fileApi = null;
@@ -524,9 +554,7 @@ async function initFileApi(suiteName: string) {
// OneDrive app directory, and it's not clear how to get that
// working.
if (!process.argv.includes('--runInBand')) {
throw new Error('OneDrive tests must be run sequentially, with the --runInBand arg. eg `npm test -- --runInBand`');
}
mustRunInBand();
const { parameters, setEnvOverride } = require('@joplin/lib/parameters.js');
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 });
fileApi = new FileApi('', new FileApiDriverAmazonS3(api, amazonS3Creds.bucket));
} else if (syncTargetId_ == SyncTargetRegistry.nameToId('joplinServer')) {
mustRunInBand();
// 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
// read/write from multiple processes at the same time.
@@ -558,7 +588,8 @@ async function initFileApi(suiteName: string) {
username: () => 'admin@localhost',
password: () => 'admin',
});
fileApi = new FileApi(`Apps/Joplin-${suiteName}`, new FileApiDriverJoplinServer(api));
fileApi = new FileApi('', new FileApiDriverJoplinServer(api));
}
fileApi.setLogger(logger);

View File

@@ -7,4 +7,5 @@ gui/note-viewer/pluginAssets/
pluginAssets/
gui/note-viewer/fonts/
gui/note-viewer/lib.js
gui/NoteEditor/NoteBody/TinyMCE/supportedLocales.js
gui/NoteEditor/NoteBody/TinyMCE/supportedLocales.js
runForSharingCommands-*

View File

@@ -13,6 +13,7 @@ import Logger, { TargetType } from '@joplin/lib/Logger';
import Setting from '@joplin/lib/models/Setting';
import actionApi from '@joplin/lib/services/rest/actionApi.desktop';
import BaseApplication from '@joplin/lib/BaseApplication';
import DebugService from '@joplin/lib/debug/DebugService';
import { _, setLocale } from '@joplin/lib/locale';
import SpellCheckerService from '@joplin/lib/services/spellChecker/SpellCheckerService';
import SpellCheckerServiceDriverNative from './services/spellChecker/SpellCheckerServiceDriverNative';
@@ -58,6 +59,7 @@ const commands = [
require('./gui/MainScreen/commands/openTag'),
require('./gui/MainScreen/commands/print'),
require('./gui/MainScreen/commands/renameFolder'),
require('./gui/MainScreen/commands/showShareFolderDialog'),
require('./gui/MainScreen/commands/renameTag'),
require('./gui/MainScreen/commands/search'),
require('./gui/MainScreen/commands/selectTemplate'),
@@ -100,6 +102,7 @@ const globalCommands = [
];
import editorCommandDeclarations from './gui/NoteEditor/commands/editorCommandDeclarations';
import ShareService from '@joplin/lib/services/share/ShareService';
const pluginClasses = [
require('./plugins/GotoAnything').default,
@@ -730,6 +733,8 @@ class Application extends BaseApplication {
bridge().window().show();
}
void ShareService.instance().maintenance();
ResourceService.runInBackground();
if (Setting.value('env') === 'dev') {
@@ -764,15 +769,16 @@ class Application extends BaseApplication {
RevisionService.instance().runInBackground();
// Make it available to the console window - useful to call revisionService.collectRevisions()
(window as any).joplin = () => {
return {
if (Setting.value('env') === 'dev') {
(window as any).joplin = {
revisionService: RevisionService.instance(),
migrationService: MigrationService.instance(),
decryptionWorker: DecryptionWorker.instance(),
commandService: CommandService.instance(),
bridge: bridge(),
debug: new DebugService(reg.db()),
};
};
}
bridge().addEventListener('nativeThemeUpdated', this.bridge_nativeThemeUpdated);

View 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>
);
}

View File

@@ -2,7 +2,26 @@ const React = require('react');
const { _ } = require('@joplin/lib/locale');
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 okButton_click = () => {
@@ -13,7 +32,11 @@ function DialogButtonRow(props) {
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) {
okButton_click();
} else if (event.keyCode === 27) {
@@ -23,6 +46,16 @@ function DialogButtonRow(props) {
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) {
buttonComps.push(
<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>;
}
module.exports = DialogButtonRow;

View 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>
);
}

View File

@@ -29,12 +29,16 @@ import { themeStyle } from '@joplin/lib/theme';
import validateLayout from '../ResizableLayout/utils/validateLayout';
import iterateItems from '../ResizableLayout/utils/iterateItems';
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 { PromptDialog } = require('../PromptDialog.min.js');
const NotePropertiesDialog = require('../NotePropertiesDialog.min.js');
const PluginManager = require('@joplin/lib/services/PluginManager');
import EncryptionService from '@joplin/lib/services/EncryptionService';
const ipcRenderer = require('electron').ipcRenderer;
interface LayerModalState {
@@ -63,15 +67,22 @@ interface Props {
settingEditorCodeView: boolean;
pluginsLegacy: any;
startupPluginsLoaded: boolean;
shareInvitations: ShareInvitation[];
isSafeMode: boolean;
}
interface ShareFolderDialogOptions {
folderId: string;
visible: boolean;
}
interface State {
promptOptions: any;
modalLayer: LayerModalState;
notePropertiesDialogOptions: any;
noteContentPropertiesDialogOptions: any;
shareNoteDialogOptions: any;
shareFolderDialogOptions: ShareFolderDialogOptions;
}
const StyledUserWebviewDialogContainer = styled.div`
@@ -105,6 +116,7 @@ const commands = [
require('./commands/newTodo'),
require('./commands/print'),
require('./commands/renameFolder'),
require('./commands/showShareFolderDialog'),
require('./commands/renameTag'),
require('./commands/search'),
require('./commands/selectTemplate'),
@@ -144,6 +156,10 @@ class MainScreenComponent extends React.Component<Props, State> {
notePropertiesDialogOptions: {},
noteContentPropertiesDialogOptions: {},
shareNoteDialogOptions: {},
shareFolderDialogOptions: {
visible: false,
folderId: '',
},
};
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.noteContentPropertiesDialog_close = this.noteContentPropertiesDialog_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_renderItem = this.resizableLayout_renderItem.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;
}
private showShareInvitationNotification(props: Props): boolean {
return !!props.shareInvitations.find(i => i.status === 0);
}
private buildLayout(plugins: PluginStates): LayoutItem {
const rootLayoutSize = this.rootLayoutSize();
@@ -268,10 +289,14 @@ class MainScreenComponent extends React.Component<Props, State> {
this.setState({ noteContentPropertiesDialogOptions: {} });
}
shareNoteDialog_close() {
private shareNoteDialog_close() {
this.setState({ shareNoteDialogOptions: {} });
}
private shareFolderDialog_close() {
this.setState({ shareFolderDialogOptions: { visible: false, folderId: '' } });
}
updateMainLayout(layout: LayoutItem) {
this.props.dispatch({
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) {
const toSave = saveLayout(this.props.mainLayout);
Setting.setValue('ui.layout', toSave);
@@ -496,6 +528,12 @@ class MainScreenComponent extends React.Component<Props, State> {
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;
if (this.props.isSafeMode) {
@@ -516,15 +554,6 @@ class MainScreenComponent extends React.Component<Props, State> {
</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.hasDisabledEncryptionItems) {
msg = (
<span>
@@ -534,15 +563,6 @@ class MainScreenComponent extends React.Component<Props, State> {
</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>
);
} else if (this.props.showNeedUpgradingMasterKeyMessage) {
msg = (
<span>
@@ -561,6 +581,40 @@ class MainScreenComponent extends React.Component<Props, State> {
</a>
</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 (
@@ -572,7 +626,7 @@ class MainScreenComponent extends React.Component<Props, State> {
messageBoxVisible(props: Props = null) {
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() {
@@ -739,6 +793,7 @@ class MainScreenComponent extends React.Component<Props, State> {
const notePropertiesDialogOptions = this.state.notePropertiesDialogOptions;
const noteContentPropertiesDialogOptions = this.state.noteContentPropertiesDialogOptions;
const shareNoteDialogOptions = this.state.shareNoteDialogOptions;
const shareFolderDialogOptions = this.state.shareFolderDialogOptions;
const layoutComp = this.props.mainLayout ? (
<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}/>}
{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} />}
{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} />
@@ -794,6 +850,7 @@ const mapStateToProps = (state: AppState) => {
layoutMoveMode: state.layoutMoveMode,
mainLayout: state.mainLayout,
startupPluginsLoaded: state.startupPluginsLoaded,
shareInvitations: state.shareService.shareInvitations,
isSafeMode: state.settings.isSafeMode,
};
};

View File

@@ -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',
};
};

View File

@@ -1,8 +1,8 @@
import * as React from 'react';
import { useState, useEffect } from 'react';
import { _ } from '@joplin/lib/locale';
import DialogButtonRow from './DialogButtonRow';
const { themeStyle } = require('@joplin/lib/theme');
const DialogButtonRow = require('./DialogButtonRow.min');
const Countable = require('countable');
import markupLanguageUtils from '../utils/markupLanguageUtils';

View File

@@ -2,7 +2,7 @@ const React = require('react');
const { _ } = require('@joplin/lib/locale');
const { themeStyle } = require('@joplin/lib/theme');
const time = require('@joplin/lib/time').default;
const DialogButtonRow = require('./DialogButtonRow.min');
const DialogButtonRow = require('./DialogButtonRow').default;
const Datetime = require('react-datetime');
const Note = require('@joplin/lib/models/Note').default;
const formatcoords = require('formatcoords');

View 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);

View File

@@ -4,12 +4,12 @@ import JoplinServerApi from '@joplin/lib/JoplinServerApi';
import { _, _n } from '@joplin/lib/locale';
import Note from '@joplin/lib/models/Note';
import Setting from '@joplin/lib/models/Setting';
import BaseItem from '@joplin/lib/models/BaseItem';
import SyncTargetJoplinServer from '@joplin/lib/SyncTargetJoplinServer';
const { themeStyle, buildStyle } = require('@joplin/lib/theme');
const DialogButtonRow = require('./DialogButtonRow.min');
import DialogButtonRow from './DialogButtonRow';
import { themeStyle, buildStyle } from '@joplin/lib/theme';
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');
interface ShareNoteDialogProps {
@@ -83,26 +83,19 @@ export default function ShareNoteDialog(props: ShareNoteDialogProps) {
void fetchNotes();
}, [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 = () => {
props.onClose();
};
const copyLinksToClipboard = (api: JoplinServerApi, shares: SharesMap) => {
const copyLinksToClipboard = (shares: SharesMap) => {
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'));
};
const shareLinkButton_click = async () => {
const service = ShareService.instance();
let hasSynced = false;
let tryToSync = false;
while (true) {
@@ -116,29 +109,20 @@ export default function ShareNoteDialog(props: ShareNoteDialogProps) {
setSharesState('creating');
const api = await joplinServerApi();
const newShares = Object.assign({}, shares);
let sharedStatusChanged = false;
for (const note of notes) {
const fullPath = (await fileApi()).fullPath(BaseItem.systemPath(note.id));
const share = await api.shareFile(fullPath);
const share = await service.shareNote(note.id);
newShares[note.id] = share;
const changed = await BaseItem.updateShareStatus(note, true);
if (changed) sharedStatusChanged = true;
}
setShares(newShares);
if (sharedStatusChanged) {
setSharesState('synchronizing');
await reg.waitForSyncFinishedThenSync();
setSharesState('creating');
}
setSharesState('synchronizing');
await reg.waitForSyncFinishedThenSync();
setSharesState('creating');
copyLinksToClipboard(api, newShares);
copyLinksToClipboard(newShares);
setSharesState('created');
} 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>;
}
function renderBetaWarningMessage() {
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>;
}
const rootStyle = Object.assign({}, theme.dialogBox);
rootStyle.width = '50%';
return (
<div style={theme.dialogModalLayer}>
<div style={rootStyle}>
<div style={theme.dialogTitle}>{_('Share Notes')}</div>
function renderContent() {
return (
<div>
<DialogTitle title={_('Share 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>
<div style={theme.textStyle}>{statusMessage(sharesState)}</div>
{renderEncryptionWarningMessage()}
{renderBetaWarningMessage()}
<DialogButtonRow themeId={props.themeId} onClick={buttonRow_click} okButtonShow={false} cancelButtonLabel={_('Close')}/>
</div>
</div>
);
}
return (
<Dialog renderContent={renderContent}/>
);
}

View File

@@ -1,5 +1,5 @@
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 CommandService from '@joplin/lib/services/CommandService';
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 { AppState } from '../../app';
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 Folder from '@joplin/lib/models/Folder';
import Note from '@joplin/lib/models/Note';
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 bridge = require('electron').remote.require('./bridge').default;
const Menu = bridge().Menu;
@@ -26,6 +29,8 @@ const MenuItem = bridge().MenuItem;
const { substrWithEllipsis } = require('@joplin/lib/string-utils');
const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids');
const logger = Logger.create('Sidebar');
interface Props {
themeId: number;
dispatch: Function;
@@ -70,10 +75,12 @@ function ExpandLink(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 shareIcon = shareId && !parentId ? <StyledShareIcon className="fas fa-share-alt"></StyledShareIcon> : null;
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}>
<ExpandLink themeId={props.themeId} hasChildren={hasChildren} folderId={folderId} onClick={onFolderToggleClick_} isExpanded={isExpanded}/>
@@ -83,6 +90,7 @@ function FolderItem(props: any) {
isConflictFolder={folderId === Folder.conflictFolderId()}
href="#"
selected={selected}
shareId={shareId}
data-id={folderId}
data-type={BaseModel.TYPE_FOLDER}
onContextMenu={itemContextMenu}
@@ -92,7 +100,7 @@ function FolderItem(props: any) {
}}
onDoubleClick={onFolderToggleClick_}
>
{folderTitle} {noteCountComp}
{folderTitle} {shareIcon} {noteCountComp}
</StyledListItemAnchor>
</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
// since there's no such thing as a root note.
if (dt.types.indexOf('text/x-jop-note-ids') >= 0) {
event.preventDefault();
try {
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'));
for (let i = 0; i < noteIds.length; i++) {
await Note.moveToFolder(noteIds[i], folderId);
}
} else if (dt.types.indexOf('text/x-jop-folder-ids') >= 0) {
event.preventDefault();
const folderIds = JSON.parse(dt.getData('text/x-jop-folder-ids'));
for (let i = 0; i < folderIds.length; i++) {
await Folder.moveToFolder(folderIds[i], folderId);
const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids'));
for (let i = 0; i < noteIds.length; i++) {
await Note.moveToFolder(noteIds[i], folderId);
}
} else if (dt.types.indexOf('text/x-jop-folder-ids') >= 0) {
event.preventDefault();
const folderIds = JSON.parse(dt.getData('text/x-jop-folder-ids'));
for (let i = 0; i < folderIds.length; i++) {
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'));
if (!itemId || !itemType) throw new Error('No data on element');
const state: AppState = store().getState();
let deleteMessage = '';
let buttonLabel = _('Remove');
let deleteButtonLabel = _('Remove');
if (itemType === BaseModel.TYPE_FOLDER) {
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));
buttonLabel = _('Delete');
deleteButtonLabel = _('Delete');
} else if (itemType === BaseModel.TYPE_TAG) {
const tag = await Tag.load(itemId);
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(
new MenuItem({
label: buttonLabel,
label: deleteButtonLabel,
click: async () => {
const ok = bridge().showConfirmMessageBox(deleteMessage, {
buttons: [buttonLabel, _('Cancel')],
buttons: [deleteButtonLabel, _('Cancel')],
defaultId: 1,
});
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(
new MenuItem({
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 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.
if (isExpanded) {
@@ -418,6 +441,8 @@ class SidebarComponent extends React.Component<Props, State> {
itemContextMenu={this.itemContextMenu}
folderItem_click={this.folderItem_click}
onFolderToggleClick_={this.onFolderToggleClick_}
shareId={folder.share_id}
parentId={folder.parent_id}
/>;
}

View File

@@ -63,6 +63,7 @@ export const StyledListItem = styled.div`
function listItemTextColor(props: any) {
if (props.isConflictFolder) return props.theme.colorError2;
if (props.isSpecialItem) return props.theme.colorFaded2;
if (props.shareId) return props.theme.colorWarn2;
return props.theme.color2;
}
@@ -72,7 +73,7 @@ export const StyledListItemAnchor = styled.a`
text-decoration: none;
color: ${(props: any) => listItemTextColor(props)};
cursor: default;
opacity: ${(props: any) => props.selected ? 1 : 0.8};
opacity: ${(props: any) => props.selected || props.shareId ? 1 : 0.8};
white-space: nowrap;
display: flex;
flex: 1;
@@ -81,6 +82,10 @@ export const StyledListItemAnchor = styled.a`
height: 100%;
`;
export const StyledShareIcon = styled.i`
margin-left: 8px;
`;
export const StyledExpandLink = styled.a`
color: ${(props: any) => props.theme.color2};
cursor: default;

View 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;

View 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;

View File

@@ -3199,7 +3199,7 @@
},
"ini": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz",
"resolved": false,
"integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==",
"dev": true,
"optional": true
@@ -7659,7 +7659,7 @@
},
"ini": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz",
"resolved": false,
"integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==",
"dev": true,
"optional": true

View 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"

View File

@@ -4,12 +4,12 @@
// one.
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';
export default function stateToWhenClauseContext(state: AppState) {
export default function stateToWhenClauseContext(state: AppState, options: WhenClauseContextOptions = null) {
return {
...libStateToWhenClauseContext(state),
...libStateToWhenClauseContext(state, options),
// UI elements
markdownEditorVisible: !!state.settings['editor.codeView'],

View File

@@ -372,7 +372,7 @@ class SideMenuContentComponent extends Component {
items.push(this.renderSidebarButton('folder_header', _('Notebooks'), 'md-folder'));
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;
items = items.concat(folderItems);
}

View File

@@ -36,7 +36,7 @@ const SafeAreaView = require('./components/SafeAreaView');
const { connect, Provider } = require('react-redux');
const { BackButtonService } = require('./services/back-button.js');
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 { shimInit } = require('./utils/shim-init-react.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 { clearSharedFilesCache } from './utils/ShareUtils';
import setIgnoreTlsErrors from './utils/TlsUtils';
import ShareService from '@joplin/lib/services/share/ShareService';
let storeDispatch = function(_action: any) {};
@@ -525,6 +526,7 @@ async function initialize(dispatch: Function) {
EncryptionService.instance().setLogger(mainLogger);
// eslint-disable-next-line require-atomic-updates
BaseItem.encryptionService_ = EncryptionService.instance();
BaseItem.shareService_ = ShareService.instance();
DecryptionWorker.instance().dispatch = dispatch;
DecryptionWorker.instance().setLogger(mainLogger);
DecryptionWorker.instance().setKvStore(KvStore.instance());
@@ -536,6 +538,8 @@ async function initialize(dispatch: Function) {
// / E2EE SETUP
// ----------------------------------------------------------------
await ShareService.instance().initialize(store);
reg.logger().info('Loading folders...');
await FoldersScreenUtils.refreshFolders();

View File

@@ -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> {
if (Platform.OS === 'android') {

View File

@@ -2,14 +2,14 @@ import Setting from './models/Setting';
import Logger, { TargetType, LoggerWrapper } from './Logger';
import shim from './shim';
import BaseService from './services/BaseService';
import reducer from './reducer';
import reducer, { setStore } from './reducer';
import KeychainServiceDriver from './services/keychain/KeychainServiceDriver.node';
import { _, setLocale } from './locale';
import KvStore from './services/KvStore';
import SyncTargetJoplinServer from './SyncTargetJoplinServer';
import SyncTargetOneDrive from './SyncTargetOneDrive';
const { createStore, applyMiddleware } = require('redux');
import { createStore, applyMiddleware, Store } from 'redux';
const { defaultState, stateUtils } = require('./reducer');
import JoplinDatabase from './JoplinDatabase';
const { FoldersScreenUtils } = require('./folders-screen-utils.js');
@@ -26,7 +26,7 @@ import BaseSyncTarget from './BaseSyncTarget';
const reduxSharedMiddleware = require('./components/shared/reduxSharedMiddleware');
const os = require('os');
const fs = require('fs-extra');
const JoplinError = require('./JoplinError');
import JoplinError from './JoplinError';
const EventEmitter = require('events');
const syswidecas = require('./vendor/syswide-cas');
const SyncTargetRegistry = require('./SyncTargetRegistry.js');
@@ -44,6 +44,7 @@ import ResourceService from './services/ResourceService';
import DecryptionWorker from './services/DecryptionWorker';
const { loadKeychainServiceAndSettings } = require('./services/SettingUtils');
import MigrationService from './services/MigrationService';
import ShareService from './services/share/ShareService';
import handleSyncStartupOperation from './services/synchronizer/utils/handleSyncStartupOperation';
const { toSystemSlashes } = require('./path-utils');
const { setAutoFreeze } = require('immer');
@@ -67,7 +68,7 @@ export default class BaseApplication {
// state and UI out of sync.
private currentFolder_: any = null;
protected store_: any = null;
protected store_: Store<any> = null;
constructor() {
this.eventEmitter_ = new EventEmitter();
@@ -602,13 +603,15 @@ export default class BaseApplication {
}
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;
FoldersScreenUtils.dispatch = this.store().dispatch;
// reg.dispatch = this.store().dispatch;
BaseSyncTarget.dispatch = this.store().dispatch;
DecryptionWorker.instance().dispatch = this.store().dispatch;
ResourceFetcher.instance().dispatch = this.store().dispatch;
ShareService.instance().initialize(this.store());
}
deinitRedux() {
@@ -793,6 +796,7 @@ export default class BaseApplication {
EncryptionService.instance().setLogger(globalLogger);
BaseItem.encryptionService_ = EncryptionService.instance();
BaseItem.shareService_ = ShareService.instance();
DecryptionWorker.instance().setLogger(globalLogger);
DecryptionWorker.instance().setEncryptionService(EncryptionService.instance());
DecryptionWorker.instance().setKvStore(KvStore.instance());

View File

@@ -3,6 +3,7 @@ import Synchronizer from './Synchronizer';
import EncryptionService from './services/EncryptionService';
import shim from './shim';
import ResourceService from './services/ResourceService';
import ShareService from './services/share/ShareService';
export default class BaseSyncTarget {
@@ -113,6 +114,7 @@ export default class BaseSyncTarget {
this.synchronizer_.setLogger(this.logger());
this.synchronizer_.setEncryptionService(EncryptionService.instance());
this.synchronizer_.setResourceService(ResourceService.instance());
this.synchronizer_.setShareService(ShareService.instance());
this.synchronizer_.dispatch = BaseSyncTarget.dispatch;
this.initState_ = 'ready';
return this.synchronizer_;

View File

@@ -1,6 +1,6 @@
const Logger = require('./Logger').default;
const shim = require('./shim').default;
const JoplinError = require('./JoplinError');
const JoplinError = require('./JoplinError').default;
const time = require('./time').default;
const EventDispatcher = require('./EventDispatcher');

View File

@@ -138,20 +138,20 @@ export default class JoplinDatabase extends Database {
private tableFieldNames_: Record<string, string[]> = {};
private tableDescriptions_: any;
constructor(driver: any) {
public constructor(driver: any) {
super(driver);
}
initialized() {
public initialized() {
return this.initialized_;
}
async open(options: any) {
public async open(options: any) {
await super.open(options);
return this.initialize();
}
tableFieldNames(tableName: string) {
public tableFieldNames(tableName: string) {
if (this.tableFieldNames_[tableName]) return this.tableFieldNames_[tableName].slice();
const tf = this.tableFields(tableName);
@@ -164,7 +164,7 @@ export default class JoplinDatabase extends Database {
return output.slice();
}
tableFields(tableName: string, options: any = null) {
public tableFields(tableName: string, options: any = null) {
if (options === null) options = {};
if (!this.tableFields_) throw new Error('Fields have not been loaded yet');
@@ -180,7 +180,7 @@ export default class JoplinDatabase extends Database {
return output;
}
async clearForTesting() {
public async clearForTesting() {
const tableNames = [
'notes',
'folders',
@@ -220,7 +220,7 @@ export default class JoplinDatabase extends Database {
await this.transactionExecBatch(queries);
}
createDefaultRow(tableName: string) {
public createDefaultRow(tableName: string) {
const row: any = {};
const fields = this.tableFields(tableName);
for (let i = 0; i < fields.length; i++) {
@@ -230,7 +230,7 @@ export default class JoplinDatabase extends Database {
return row;
}
fieldByName(tableName: string, fieldName: string) {
public fieldByName(tableName: string, fieldName: string) {
const fields = this.tableFields(tableName);
for (const field of fields) {
if (field.name === fieldName) return field;
@@ -238,11 +238,11 @@ export default class JoplinDatabase extends Database {
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;
}
fieldDescription(tableName: string, fieldName: string) {
public fieldDescription(tableName: string, fieldName: string) {
const sp = sprintf;
if (!this.tableDescriptions_) {
@@ -278,7 +278,7 @@ export default class JoplinDatabase extends Database {
return d && d[fieldName] ? d[fieldName] : '';
}
refreshTableFields(newVersion: number) {
public refreshTableFields(newVersion: number) {
this.logger().info('Initializing tables...');
const queries: SqlQuery[] = [];
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();
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:
//
// 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.
// 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);
@@ -870,6 +870,12 @@ export default class JoplinDatabase extends Database {
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] };
queries.push(updateVersionQuery);
@@ -906,7 +912,7 @@ export default class JoplinDatabase extends Database {
return latestVersion;
}
async ftsEnabled() {
public async ftsEnabled() {
try {
await this.selectOne('SELECT count(*) FROM notes_fts');
} catch (error) {
@@ -919,7 +925,7 @@ export default class JoplinDatabase extends Database {
return true;
}
async fuzzySearchEnabled() {
public async fuzzySearchEnabled() {
try {
await this.selectOne('SELECT count(*) FROM notes_spellfix');
} catch (error) {
@@ -930,11 +936,11 @@ export default class JoplinDatabase extends Database {
return true;
}
version() {
public version() {
return this.version_;
}
async initialize() {
public async initialize() {
this.logger().info('Checking for database schema update...');
let versionRow = null;

View File

@@ -1,8 +1,13 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
class JoplinError extends Error {
constructor(message, code = null) {
super(message);
this.code = code;
}
constructor(message, code = null, details = null) {
super(message);
this.code = null;
this.details = '';
this.code = code;
this.details = details;
}
}
module.exports = JoplinError;
exports.default = JoplinError;
//# sourceMappingURL=JoplinError.js.map

View 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;
}
}

View File

@@ -1,13 +1,15 @@
import shim from './shim';
import { _ } from './locale';
const { rtrimSlashes } = require('./path-utils.js');
const JoplinError = require('./JoplinError');
import JoplinError from './JoplinError';
import { Env } from './models/Setting';
const { stringify } = require('query-string');
interface Options {
baseUrl(): string;
username(): string;
password(): string;
env?: Env;
}
enum ExecOptionsResponseFormat {
@@ -27,22 +29,30 @@ interface ExecOptions {
source?: string;
}
interface Session {
id: string;
user_id: string;
}
export default class JoplinServerApi {
private options_: Options;
private session_: any;
private session_: Session;
private debugRequests_: boolean = false;
public constructor(options: Options) {
this.options_ = options;
if (options.env === Env.Dev) {
this.debugRequests_ = true;
}
}
private baseUrl() {
public baseUrl() {
return rtrimSlashes(this.options_.baseUrl());
}
private async session() {
// TODO: handle invalid session
if (this.session_) return this.session_;
this.session_ = await this.exec('POST', 'api/sessions', null, {
@@ -58,11 +68,8 @@ export default class JoplinServerApi {
return session ? session.id : '';
}
public async shareFile(pathOrId: string) {
return this.exec('POST', 'api/shares', null, {
file_id: pathOrId,
type: 1, // ShareType.Link
});
public get userId(): string {
return this.session_ ? this.session_.user_id : '';
}
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);
}
public shareUrl(share: any): string {
return `${this.baseUrl()}/shares/${share.id}`;
}
private requestToCurl_(url: string, options: any) {
const output = [];
output.push('curl');
@@ -85,8 +88,11 @@ export default class JoplinServerApi {
output.push(`${'-H ' + '"'}${n}: ${options.headers[n]}"`);
}
}
if (options.body) output.push(`${'--data ' + '\''}${JSON.stringify(options.body)}'`);
output.push(url);
if (options.body) {
const serialized = typeof options.body !== 'string' ? JSON.stringify(options.body) : options.body;
output.push(`${'--data ' + '\''}${serialized}'`);
}
output.push(`'${url}'`);
return output.join(' ');
}
@@ -150,14 +156,17 @@ export default class JoplinServerApi {
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
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
// 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);
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;

View File

@@ -10,7 +10,6 @@ interface FileApiOptions {
path(): string;
username(): string;
password(): string;
directory(): string;
}
export default class SyncTargetJoplinServer extends BaseSyncTarget {
@@ -28,7 +27,7 @@ export default class SyncTargetJoplinServer extends BaseSyncTarget {
}
public static label() {
return _('Joplin Server');
return `${_('Joplin Server')} (Beta)`;
}
public async isAuthenticated() {
@@ -44,11 +43,12 @@ export default class SyncTargetJoplinServer extends BaseSyncTarget {
baseUrl: () => options.path(),
username: () => options.username(),
password: () => options.password(),
env: Setting.value('env'),
};
const api = new JoplinServerApi(apiOptions);
const driver = new FileApiDriverJoplinServer(api);
const fileApi = new FileApi(options.directory, driver);
const fileApi = new FileApi('', driver);
fileApi.setSyncTargetId(this.id());
await fileApi.initialize();
return fileApi;
@@ -64,8 +64,10 @@ export default class SyncTargetJoplinServer extends BaseSyncTarget {
const fileApi = await SyncTargetJoplinServer.newFileApi_(options);
fileApi.requestRepeatCount_ = 0;
const result = await fileApi.stat('');
if (!result) throw new Error(`Sync directory not found: "${options.directory()}" on server "${options.path()}"`);
await fileApi.put('testing.txt', 'testing');
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;
} catch (error) {
output.errorMessage = error.message;
@@ -80,7 +82,6 @@ export default class SyncTargetJoplinServer extends BaseSyncTarget {
path: () => Setting.value('sync.9.path'),
username: () => Setting.value('sync.9.username'),
password: () => Setting.value('sync.9.password'),
directory: () => Setting.value('sync.9.directory'),
});
fileApi.setLogger(this.logger());

View File

@@ -5,7 +5,6 @@ import shim from './shim';
import MigrationHandler from './services/synchronizer/MigrationHandler';
import eventManager from './eventManager';
import { _ } from './locale';
import BaseItem from './models/BaseItem';
import Folder from './models/Folder';
import Note from './models/Note';
@@ -14,12 +13,12 @@ import ItemChange from './models/ItemChange';
import ResourceLocalState from './models/ResourceLocalState';
import MasterKey from './models/MasterKey';
import BaseModel from './BaseModel';
const { sprintf } = require('sprintf-js');
import time from './time';
import ResourceService from './services/ResourceService';
import EncryptionService from './services/EncryptionService';
import NoteResource from './models/NoteResource';
const JoplinError = require('./JoplinError');
import JoplinError from './JoplinError';
import ShareService from './services/share/ShareService';
const { sprintf } = require('sprintf-js');
const TaskQueue = require('./TaskQueue');
const { Dirnames } = require('./services/synchronizer/utils/types');
@@ -45,6 +44,7 @@ export default class Synchronizer {
private encryptionService_: EncryptionService = null;
private resourceService_: ResourceService = null;
private syncTargetIsLocked_: boolean = false;
private shareService_: ShareService = null;
// Debug flags are used to test certain hard-to-test conditions
// 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;
}
public setShareService(v: ShareService) {
this.shareService_ = v;
}
public setEncryptionService(v: any) {
this.encryptionService_ = v;
}
@@ -351,12 +355,15 @@ export default class Synchronizer {
if (this.resourceService()) {
this.logger().info('Indexing resources...');
await this.resourceService().indexNoteResources();
await NoteResource.applySharedStatusToLinkedResources();
}
} catch (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 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.`);
}
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) {
if (error && ['rejectedByTarget', 'fileNotFound'].indexOf(error.code) >= 0) {
await handleCannotSyncItem(ItemClass, syncTargetId, local, error.message);
@@ -924,6 +931,16 @@ export default class Synchronizer {
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.logSyncOperation('finished', null, null, `Synchronisation finished [${synchronizationId}]`);

View File

@@ -1,7 +1,7 @@
const Logger = require('./Logger').default;
const shim = require('./shim').default;
const parseXmlString = require('xml2js').parseString;
const JoplinError = require('./JoplinError');
const JoplinError = require('./JoplinError').default;
const URL = require('url-parse');
const { rtrimSlashes } = require('./path-utils');
const base64 = require('base-64');

View File

@@ -4,7 +4,7 @@ import shim from './shim';
const Mutex = require('async-mutex').Mutex;
type SqlParams = Record<string, any>;
type SqlParams = any[];
export interface SqlQuery {
sql: string;

View 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');
}
}

View File

@@ -1,7 +1,7 @@
const { basicDelta } = require('./file-api');
const { basename } = require('./path-utils');
const shim = require('./shim').default;
const JoplinError = require('./JoplinError');
const JoplinError = require('./JoplinError').default;
const { Buffer } = require('buffer');
const S3_MAX_DELETES = 1000;

View File

@@ -1,6 +1,6 @@
const time = require('./time').default;
const shim = require('./shim').default;
const JoplinError = require('./JoplinError');
const JoplinError = require('./JoplinError').default;
class FileApiDriverDropbox {
constructor(api) {

View File

@@ -1,5 +1,5 @@
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
// "root:/path/to/file:" when doing the API call.
@@ -34,38 +34,35 @@ export default class FileApiDriverJoplinServer {
return 3;
}
private metadataToStat_(md: any, path: string, isDeleted: boolean = false) {
private metadataToStat_(md: any, path: string, isDeleted: boolean = false, rootPath: string) {
const output = {
path: path,
path: rootPath ? path.substr(rootPath.length + 1) : path,
updated_time: md.updated_time,
isDir: !!md.is_directory,
isDir: false, // !!md.is_directory,
isDeleted: isDeleted,
};
// TODO - HANDLE DELETED
// if (md['.tag'] === 'deleted') output.isDeleted = true;
return output;
}
private metadataToStats_(mds: any[]) {
private metadataToStats_(mds: any[], rootPath: string) {
const output = [];
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;
}
// 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) {
return `api/files/root:/${trimSlashes(p)}:`;
return `api/items/root:/${trimSlashes(p)}:`;
}
public async stat(path: string) {
try {
const response = await this.api().exec('GET', this.apiFilePath_(path));
return this.metadataToStat_(response, path);
return this.metadataToStat_(response, path, false, '');
} catch (error) {
if (error.code === 404) return null;
throw error;
@@ -80,9 +77,13 @@ export default class FileApiDriverJoplinServer {
try {
const query = cursor ? { cursor } : {};
const response = await this.api().exec('GET', `${this.apiFilePath_(path)}/delta`, query);
const stats = response.items.map((item: any) => {
return this.metadataToStat_(item.item, item.item.name, item.type === 3);
});
const stats = response.items
.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 = {
items: stats,
@@ -108,15 +109,22 @@ export default class FileApiDriverJoplinServer {
...options,
};
let isUsingWildcard = false;
let searchPath = path;
if (searchPath) {
searchPath += '/*';
isUsingWildcard = true;
}
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 = {};
if (results.cursor) newContext.cursor = results.cursor;
return {
items: this.metadataToStats_(results.items),
items: this.metadataToStats_(results.items, isUsingWildcard ? path : ''),
hasMore: results.has_more,
context: newContext,
} as any;
@@ -134,32 +142,13 @@ export default class FileApiDriverJoplinServer {
}
}
private parentPath_(path: string) {
return dirname(path);
}
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 mkdir(_path: string) {
// This is a no-op because all items technically are at the root, but
// they can have names such as ".resources/xxxxxxxxxx'
}
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',
}, options);
}
@@ -174,6 +163,5 @@ export default class FileApiDriverJoplinServer {
public async clearRoot(path: string) {
await this.delete(path);
await this.mkdir(path);
}
}

View File

@@ -1,6 +1,6 @@
const { basicDelta } = require('./file-api');
const { rtrimSlashes, ltrimSlashes } = require('./path-utils');
const JoplinError = require('./JoplinError');
const JoplinError = require('./JoplinError').default;
class FileApiDriverWebDav {
constructor(api) {

View File

@@ -4,7 +4,7 @@ import BaseItem from './models/BaseItem';
import time from './time';
const { isHidden } = require('./path-utils');
const JoplinError = require('./JoplinError');
import JoplinError from './JoplinError';
const ArrayUtils = require('./ArrayUtils');
const { sprintf } = require('sprintf-js');
const Mutex = require('async-mutex').Mutex;
@@ -14,6 +14,10 @@ const logger = Logger.create('FileApi');
function requestCanBeRepeated(error: any) {
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.
if (errorCode === 'rejectedByTarget') return false;

View File

@@ -8,10 +8,19 @@ import { _ } from '../locale';
import Database from '../database';
import ItemChange from './ItemChange';
import ShareService from '../services/share/ShareService';
const JoplinError = require('../JoplinError.js');
const { sprintf } = require('sprintf-js');
const moment = require('moment');
export interface BaseItemEntity {
id?: string;
encryption_applied?: boolean;
is_shared?: number;
share_id?: string;
type_?: ModelType;
}
export interface ItemsThatNeedDecryptionResult {
hasMore: boolean;
items: any[];
@@ -21,6 +30,7 @@ export default class BaseItem extends BaseModel {
public static encryptionService_: any = null;
public static revisionService_: any = null;
public static shareService_: ShareService = null;
// Also update:
// - itemsThatNeedSync()
@@ -382,14 +392,19 @@ export default class BaseItem extends BaseModel {
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 shownKeys = ItemClass.fieldNames();
shownKeys.push('type_');
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
if (item.encryption_applied) throw new JoplinError('Item is encrypted but encryption is currently disabled', 'cannotSyncEncrypted');
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
// 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 = {};
for (let i = 0; i < keepKeys.length; i++) {
const n = keepKeys[i];
if (!item.hasOwnProperty(n)) continue;
reducedItem[n] = item[n];
reducedItem[n] = (item as any)[n];
}
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.is_shared === !!isShared) return false;
const ItemClass = this.getClassByItemType(item.type_);

View File

@@ -2,10 +2,11 @@ import { FolderEntity } from '../services/database/types';
import BaseModel from '../BaseModel';
import time from '../time';
import { _ } from '../locale';
import Note from './Note';
import Database from '../database';
import BaseItem from './BaseItem';
import Resource from './Resource';
import { isRootSharedFolder } from '../services/share/reducer';
const { substrWithEllipsis } = require('../string-utils.js');
interface FolderEntityWithChildren extends FolderEntity {
@@ -75,8 +76,10 @@ export default class Folder extends BaseItem {
}
static async delete(folderId: string, options: any = null) {
if (!options) options = {};
if (!('deleteChildren' in options)) options.deleteChildren = true;
options = {
deleteChildren: true,
...options,
};
const folder = await Folder.load(folderId);
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) {
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) {
if (folderId === targetFolderId) return false;
const folder = await Folder.load(folderId);
if (isRootSharedFolder(folder)) return false;
const conflictFolderId = Folder.conflictFolderId();
if (folderId == conflictFolderId || targetFolderId == conflictFolderId) return false;

View File

@@ -111,7 +111,7 @@ export default class Note extends BaseItem {
return BaseModel.TYPE_NOTE;
}
static linkedItemIds(body: string): string[] {
public static linkedItemIds(body: string): string[] {
if (!body || body.length <= 32) return [];
const links = urlUtils.extractResourceUrls(body);

View File

@@ -36,6 +36,40 @@ export default class NoteResource extends BaseModel {
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[]> {
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);

View File

@@ -11,7 +11,7 @@ const pathUtils = require('../path-utils');
const { mime } = require('../mime-utils.js');
const { filename, safeFilename } = require('../path-utils');
const { FsDriverDummy } = require('../fs-driver-dummy.js');
const JoplinError = require('../JoplinError');
import JoplinError from '../JoplinError';
export default class Resource extends BaseItem {
@@ -48,7 +48,7 @@ export default class Resource extends BaseItem {
}
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() {

View File

@@ -3,7 +3,7 @@ import { RevisionEntity } from '../services/database/types';
import BaseItem from './BaseItem';
const DiffMatchPatch = require('diff-match-patch');
const ArrayUtils = require('../ArrayUtils.js');
const JoplinError = require('../JoplinError');
import JoplinError from '../JoplinError';
const { sprintf } = require('sprintf-js');
const dmp = new DiffMatchPatch();

View File

@@ -1,6 +1,5 @@
import shim from '../shim';
import { _, supportedLocalesToLanguages, defaultLocale } from '../locale';
import { ltrimSlashes } from '../path-utils';
import eventManager from '../eventManager';
import BaseModel from '../BaseModel';
import Database from '../database';
@@ -88,6 +87,31 @@ export enum SyncStartupOperation {
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 {
[key: string]: SettingSection;
}
@@ -151,8 +175,8 @@ class Setting extends BaseModel {
// Contains constants that are set by the application and
// cannot be modified by the user:
public static constants_: any = {
env: 'SET_ME',
public static constants_: Constants = {
env: Env.Undefined,
isDemo: false,
appName: 'joplin',
appId: 'SET_ME', // Each app should set this identifier
@@ -452,20 +476,20 @@ class Setting extends BaseModel {
description: () => emptyDirWarning,
storage: SettingStorage.File,
},
'sync.9.directory': {
value: 'Apps/Joplin',
type: SettingItemType.String,
section: 'sync',
show: (settings: any) => {
return settings['sync.target'] == SyncTargetRegistry.nameToId('joplinServer');
},
filter: value => {
return value ? ltrimSlashes(rtrimSlashes(value)) : '';
},
public: true,
label: () => _('Joplin Server Directory'),
storage: SettingStorage.File,
},
// 'sync.9.directory': {
// value: 'Apps/Joplin',
// type: SettingItemType.String,
// section: 'sync',
// show: (settings: any) => {
// return settings['sync.target'] == SyncTargetRegistry.nameToId('joplinServer');
// },
// filter: value => {
// return value ? ltrimSlashes(rtrimSlashes(value)) : '';
// },
// public: true,
// label: () => _('Joplin Server Directory'),
// storage: SettingStorage.File,
// },
'sync.9.username': {
value: '',
type: SettingItemType.String,
@@ -474,7 +498,7 @@ class Setting extends BaseModel {
return settings['sync.target'] == SyncTargetRegistry.nameToId('joplinServer');
},
public: true,
label: () => _('Joplin Server username'),
label: () => _('Joplin Server email'),
storage: SettingStorage.File,
},
'sync.9.password': {
@@ -843,6 +867,12 @@ class Setting extends BaseModel {
public: false,
},
'sync.userId': {
value: '',
type: SettingItemType.String,
public: false,
},
// 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 },
@@ -1383,7 +1413,7 @@ class Setting extends BaseModel {
static setConstant(key: string, value: any) {
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) {
@@ -1546,7 +1576,7 @@ class Setting extends BaseModel {
}
if (key in this.constants_) {
const v = this.constants_[key];
const v = (this.constants_ as any)[key];
const output = typeof v === 'function' ? v() : v;
if (output == 'SET_ME') throw new Error(`SET_ME constant has not been set: ${key}`);
return output;

View File

@@ -1,8 +1,10 @@
import produce, { Draft } from 'immer';
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 Folder from './models/Folder';
import BaseModel from './BaseModel';
import { Store } from 'redux';
const ArrayUtils = require('./ArrayUtils.js');
const { ALL_NOTES_FILTER_ID } = require('./reserved-ids');
const { createSelectorCreator, defaultMemoize } = require('reselect');
@@ -16,6 +18,12 @@ additionalReducers.push({
reducer: pluginServiceReducer,
});
additionalReducers.push({
stateRootKey: shareServiceStateRootKey,
defaultState: shareServiceDefaultState,
reducer: shareServiceReducer,
});
interface StateLastSelectedNotesIds {
Folder: any;
Tag: any;
@@ -86,6 +94,7 @@ export interface State {
// Extra reducer keys go here:
pluginService: PluginServiceState;
shareService: ShareServiceState;
}
export const defaultState: State = {
@@ -153,12 +162,23 @@ export const defaultState: State = {
hasEncryptedItems: false,
pluginService: pluginServiceDefaultState,
shareService: shareServiceDefaultState,
};
for (const additionalReducer of additionalReducers) {
(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;
const derivedStateCache_: any = {};

View File

@@ -99,8 +99,8 @@ class Registry {
}
if (Setting.value('env') === 'dev' && delay !== 0) {
this.logger().info('Schedule sync DISABLED!!!');
return;
// this.logger().info('Schedule sync DISABLED!!!');
// return;
}
this.logger().debug('Scheduling sync operation...', delay);

View File

@@ -6,7 +6,7 @@ import MasterKey from '../models/MasterKey';
import BaseItem from '../models/BaseItem';
const { padLeft } = require('../string-utils.js');
const JoplinError = require('../JoplinError');
import JoplinError from '../JoplinError';
function hexPad(s: string, length: number) {
return padLeft(s, length, '0');

View File

@@ -3,7 +3,11 @@ import ItemChange from '../models/ItemChange';
export default class ItemChangeUtils {
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);
await ItemChange.deleteOldChanges(lowestChangeId);

View File

@@ -1,12 +1,49 @@
import { State, stateUtils } from '../../reducer';
import BaseModel from '../../BaseModel';
import Folder from '../../models/Folder';
import MarkupToHtml from '@joplin/renderer/MarkupToHtml';
import { isRootSharedFolder, isSharedFolderOwner } from '../share/reducer';
import { FolderEntity, NoteEntity } from '../database/types';
export default function stateToWhenClauseContext(state: State) {
const noteId = state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null;
const note = noteId ? BaseModel.byId(state.notes, noteId) : null;
export interface WhenClauseContextOptions {
commandFolderId?: string;
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 {
// Application state
@@ -17,7 +54,7 @@ export default function stateToWhenClauseContext(state: State) {
inConflictFolder: state.selectedFolderId === Folder.conflictFolderId(),
// Note selection
oneNoteSelected: !!note,
oneNoteSelected: !!selectedNote,
someNotesSelected: state.selectedNoteIds.length > 0,
multipleNotesSelected: state.selectedNoteIds.length > 1,
noNotesSelected: !state.selectedNoteIds.length,
@@ -30,9 +67,13 @@ export default function stateToWhenClauseContext(state: State) {
oneFolderSelected: !!state.selectedFolderId,
// Current note properties
noteIsTodo: note ? !!note.is_todo : false,
noteTodoCompleted: note ? !!note.todo_completed : false,
noteIsMarkdown: note ? note.markup_language === MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN : false,
noteIsHtml: note ? note.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML : false,
noteIsTodo: selectedNote ? !!selectedNote.is_todo : false,
noteTodoCompleted: selectedNote ? !!selectedNote.todo_completed : false,
noteIsMarkdown: selectedNote ? selectedNote.markup_language === MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN : 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,
};
}

View File

@@ -1,227 +1,235 @@
// 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 {
'id'?: number | null;
'note_id'?: string;
'trigger_time'?: number;
'type_'?: number;
"id"?: number | null
"note_id"?: string
"trigger_time"?: number
"type_"?: number
}
export interface DeletedItemEntity {
'id'?: number | null;
'item_type'?: number;
'item_id'?: string;
'deleted_time'?: number;
'sync_target'?: number;
'type_'?: number;
"id"?: number | null
"item_type"?: number
"item_id"?: string
"deleted_time"?: number
"sync_target"?: number
"type_"?: number
}
export interface FolderEntity {
'id'?: string | null;
'title'?: string;
'created_time'?: number;
'updated_time'?: number;
'user_created_time'?: number;
'user_updated_time'?: number;
'encryption_cipher_text'?: string;
'encryption_applied'?: number;
'parent_id'?: string;
'is_shared'?: number;
'type_'?: number;
"id"?: string | null
"title"?: string
"created_time"?: number
"updated_time"?: number
"user_created_time"?: number
"user_updated_time"?: number
"encryption_cipher_text"?: string
"encryption_applied"?: number
"parent_id"?: string
"is_shared"?: number
"is_linked_folder"?: number
"source_folder_owner_id"?: string
"share_id"?: string
"type_"?: number
}
export interface ItemChangeEntity {
'id'?: number | null;
'item_type'?: number;
'item_id'?: string;
'type'?: number;
'created_time'?: number;
'source'?: number;
'before_change_item'?: string;
'type_'?: number;
"id"?: number | null
"item_type"?: number
"item_id"?: string
"type"?: number
"created_time"?: number
"source"?: number
"before_change_item"?: string
"type_"?: number
}
export interface KeyValueEntity {
'id'?: number | null;
'key'?: string;
'value'?: string;
'type'?: number;
'updated_time'?: number;
'type_'?: number;
"id"?: number | null
"key"?: string
"value"?: string
"type"?: number
"updated_time"?: number
"type_"?: number
}
export interface MasterKeyEntity {
'id'?: string | null;
'created_time'?: number;
'updated_time'?: number;
'source_application'?: string;
'encryption_method'?: number;
'checksum'?: string;
'content'?: string;
'type_'?: number;
"id"?: string | null
"created_time"?: number
"updated_time"?: number
"source_application"?: string
"encryption_method"?: number
"checksum"?: string
"content"?: string
"type_"?: number
}
export interface MigrationEntity {
'id'?: number | null;
'number'?: number;
'updated_time'?: number;
'created_time'?: number;
'type_'?: number;
"id"?: number | null
"number"?: number
"updated_time"?: number
"created_time"?: number
"type_"?: number
}
export interface NoteResourceEntity {
'id'?: number | null;
'note_id'?: string;
'resource_id'?: string;
'is_associated'?: number;
'last_seen_time'?: number;
'type_'?: number;
"id"?: number | null
"note_id"?: string
"resource_id"?: string
"is_associated"?: number
"last_seen_time"?: number
"type_"?: number
}
export interface NoteTagEntity {
'id'?: string | null;
'note_id'?: string;
'tag_id'?: string;
'created_time'?: number;
'updated_time'?: number;
'user_created_time'?: number;
'user_updated_time'?: number;
'encryption_cipher_text'?: string;
'encryption_applied'?: number;
'is_shared'?: number;
'type_'?: number;
"id"?: string | null
"note_id"?: string
"tag_id"?: string
"created_time"?: number
"updated_time"?: number
"user_created_time"?: number
"user_updated_time"?: number
"encryption_cipher_text"?: string
"encryption_applied"?: number
"is_shared"?: number
"type_"?: number
}
export interface NoteEntity {
'id'?: string | null;
'parent_id'?: string;
'title'?: string;
'body'?: string;
'created_time'?: number;
'updated_time'?: number;
'is_conflict'?: number;
'latitude'?: number;
'longitude'?: number;
'altitude'?: number;
'author'?: string;
'source_url'?: string;
'is_todo'?: number;
'todo_due'?: number;
'todo_completed'?: number;
'source'?: string;
'source_application'?: string;
'application_data'?: string;
'order'?: number;
'user_created_time'?: number;
'user_updated_time'?: number;
'encryption_cipher_text'?: string;
'encryption_applied'?: number;
'markup_language'?: number;
'is_shared'?: number;
'type_'?: number;
"id"?: string | null
"parent_id"?: string
"title"?: string
"body"?: string
"created_time"?: number
"updated_time"?: number
"is_conflict"?: number
"latitude"?: number
"longitude"?: number
"altitude"?: number
"author"?: string
"source_url"?: string
"is_todo"?: number
"todo_due"?: number
"todo_completed"?: number
"source"?: string
"source_application"?: string
"application_data"?: string
"order"?: number
"user_created_time"?: number
"user_updated_time"?: number
"encryption_cipher_text"?: string
"encryption_applied"?: number
"markup_language"?: number
"is_shared"?: number
"share_id"?: string
"type_"?: number
}
export interface NotesNormalizedEntity {
'id'?: string;
'title'?: string;
'body'?: string;
'user_created_time'?: number;
'user_updated_time'?: number;
'is_todo'?: number;
'todo_completed'?: number;
'parent_id'?: string;
'latitude'?: number;
'longitude'?: number;
'altitude'?: number;
'source_url'?: string;
'type_'?: number;
"id"?: string
"title"?: string
"body"?: string
"user_created_time"?: number
"user_updated_time"?: number
"is_todo"?: number
"todo_completed"?: number
"parent_id"?: string
"latitude"?: number
"longitude"?: number
"altitude"?: number
"source_url"?: string
"todo_due"?: number
"type_"?: number
}
export interface ResourceLocalStateEntity {
'id'?: number | null;
'resource_id'?: string;
'fetch_status'?: number;
'fetch_error'?: string;
'type_'?: number;
"id"?: number | null
"resource_id"?: string
"fetch_status"?: number
"fetch_error"?: string
"type_"?: number
}
export interface ResourceEntity {
'id'?: string | null;
'title'?: string;
'mime'?: string;
'filename'?: string;
'created_time'?: number;
'updated_time'?: number;
'user_created_time'?: number;
'user_updated_time'?: number;
'file_extension'?: string;
'encryption_cipher_text'?: string;
'encryption_applied'?: number;
'encryption_blob_encrypted'?: number;
'size'?: number;
'is_shared'?: number;
'type_'?: number;
"id"?: string | null
"title"?: string
"mime"?: string
"filename"?: string
"created_time"?: number
"updated_time"?: number
"user_created_time"?: number
"user_updated_time"?: number
"file_extension"?: string
"encryption_cipher_text"?: string
"encryption_applied"?: number
"encryption_blob_encrypted"?: number
"size"?: number
"is_shared"?: number
"share_id"?: string
"type_"?: number
}
export interface ResourcesToDownloadEntity {
'id'?: number | null;
'resource_id'?: string;
'updated_time'?: number;
'created_time'?: number;
'type_'?: number;
"id"?: number | null
"resource_id"?: string
"updated_time"?: number
"created_time"?: number
"type_"?: number
}
export interface RevisionEntity {
'id'?: string | null;
'parent_id'?: string;
'item_type'?: number;
'item_id'?: string;
'item_updated_time'?: number;
'title_diff'?: string;
'body_diff'?: string;
'metadata_diff'?: string;
'encryption_cipher_text'?: string;
'encryption_applied'?: number;
'updated_time'?: number;
'created_time'?: number;
'type_'?: number;
"id"?: string | null
"parent_id"?: string
"item_type"?: number
"item_id"?: string
"item_updated_time"?: number
"title_diff"?: string
"body_diff"?: string
"metadata_diff"?: string
"encryption_cipher_text"?: string
"encryption_applied"?: number
"updated_time"?: number
"created_time"?: number
"type_"?: number
}
export interface SettingEntity {
'key'?: string | null;
'value'?: string | null;
'type_'?: number;
"key"?: string | null
"value"?: string | null
"type_"?: number
}
export interface SyncItemEntity {
'id'?: number | null;
'sync_target'?: number;
'sync_time'?: number;
'item_type'?: number;
'item_id'?: string;
'sync_disabled'?: number;
'sync_disabled_reason'?: string;
'force_sync'?: number;
'item_location'?: number;
'type_'?: number;
"id"?: number | null
"sync_target"?: number
"sync_time"?: number
"item_type"?: number
"item_id"?: string
"sync_disabled"?: number
"sync_disabled_reason"?: string
"force_sync"?: number
"item_location"?: number
"type_"?: number
}
export interface TableFieldEntity {
'id'?: number | null;
'table_name'?: string;
'field_name'?: string;
'field_type'?: number;
'field_default'?: string | null;
'type_'?: number;
"id"?: number | null
"table_name"?: string
"field_name"?: string
"field_type"?: number
"field_default"?: string | null
"type_"?: number
}
export interface TagEntity {
'id'?: string | null;
'title'?: string;
'created_time'?: number;
'updated_time'?: number;
'user_created_time'?: number;
'user_updated_time'?: number;
'encryption_cipher_text'?: string;
'encryption_applied'?: number;
'is_shared'?: number;
'parent_id'?: string;
'type_'?: number;
"id"?: string | null
"title"?: string
"created_time"?: number
"updated_time"?: number
"user_created_time"?: number
"user_updated_time"?: number
"encryption_cipher_text"?: string
"encryption_applied"?: number
"is_shared"?: number
"parent_id"?: string
"type_"?: number
}
export interface TagsWithNoteCountEntity {
'id'?: string | null;
'title'?: string | null;
'created_time'?: number | null;
'updated_time'?: number | null;
'note_count'?: any | null;
'type_'?: number;
"id"?: string | null
"title"?: string | null
"created_time"?: number | null
"updated_time"?: number | null
"note_count"?: any | null
"type_"?: number
}
export interface VersionEntity {
'version'?: number;
'table_fields_version'?: number;
'type_'?: number;
"version"?: number
"table_fields_version"?: number
"type_"?: number
}

View File

@@ -18,11 +18,17 @@ export default class KeychainService extends BaseService {
this.driver = driver;
}
// This is to programatically disable the keychain service, regardless whether keychain
// is supported or not in the system (In other word, this might "enabled" but nothing
// will be saved to the keychain if there isn't one).
// This is to programatically disable the keychain service, whether keychain
// is supported or not in the system (In other word, this be might "enabled"
// but nothing will be saved to the keychain if there isn't one).
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) {

View 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);
}
}
}

View 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;

View File

@@ -1,7 +1,7 @@
import { Dirnames } from './utils/types';
import shim from '../../shim';
const JoplinError = require('../../JoplinError');
import JoplinError from '../../JoplinError';
import time from '../../time';
const { fileExtension, filename } = require('../../path-utils');

View File

@@ -15,7 +15,7 @@ const migrations = [
import Setting from '../../models/Setting';
const { sprintf } = require('sprintf-js');
const JoplinError = require('../../JoplinError');
import JoplinError from '../../JoplinError';
interface SyncTargetInfo {
version: number;

View File

@@ -24,6 +24,7 @@ const theme: Theme = {
color2: '#ffffff',
selectedColor2: '#013F74',
colorError2: '#ff6c6c',
colorWarn2: '#ffcb81',
// Color scheme "3" is used for the config screens for example/
// It's dark text over gray background.

View File

@@ -8,7 +8,7 @@ const theme: Theme = {
// content. It's basically dark gray text on white background
backgroundColor: '#ffffff',
backgroundColorTransparent: 'rgba(255,255,255,0.9)',
oddBackgroundColor: '#dddddd',
oddBackgroundColor: '#eeeeee',
color: '#32373F', // For regular text
colorError: 'red',
colorWarn: '#9A5B00',
@@ -24,6 +24,7 @@ const theme: Theme = {
color2: '#ffffff',
selectedColor2: '#131313',
colorError2: '#ff6c6c',
colorWarn2: '#ffcb81',
// Color scheme "3" is used for the config screens for example/
// It's dark text over gray background.

View File

@@ -26,6 +26,7 @@ export interface Theme {
color2: string;
selectedColor2: string;
colorError2: string;
colorWarn2: string;
// Color scheme "3" is used for the config screens for example/
// It's dark text over gray background.

7
packages/server/LICENSE Normal file
View 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.

File diff suppressed because it is too large Load Diff

View File

@@ -5,10 +5,12 @@
"scripts": {
"start-dev": "nodemon --config nodemon.json dist/app.js --env dev",
"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",
"test": "jest",
"test": "jest --verbose=false",
"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"
},
"dependencies": {
@@ -22,7 +24,7 @@
"formidable": "^1.2.2",
"fs-extra": "^8.1.0",
"html-entities": "^1.3.1",
"knex": "^0.19.4",
"knex": "0.95.4",
"koa": "^2.8.1",
"markdown-it": "^12.0.4",
"mustache": "^3.1.0",

View File

@@ -10,12 +10,12 @@ function addPluginAssets(appBaseUrl, assets) {
if (asset.mime === 'application/javascript') {
const script = document.createElement('script');
script.src = `${appBaseUrl}/${asset.path}`;
script.src = `${appBaseUrl}/js/${asset.path}`;
pluginAssetsContainer.appendChild(script);
} else if (asset.mime === 'text/css') {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = `${appBaseUrl}/${asset.path}`;
link.href = `${appBaseUrl}/css/${asset.path}`;
pluginAssetsContainer.appendChild(link);
}
}

Binary file not shown.

View File

@@ -14,6 +14,9 @@ import routeHandler from './middleware/routeHandler';
import notificationHandler from './middleware/notificationHandler';
import ownerHandler from './middleware/ownerHandler';
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 { shimInit } = require('@joplin/lib/shim-init-node.js');
@@ -126,12 +129,46 @@ async function main() {
const appContext = app.context as AppContext;
await setupAppContext(appContext, env, connectionCheck.connection, appLogger);
await initializeJoplinUtils(config(), appContext.models);
appLogger().info('Migrating database...');
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\``);
// 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);
}
}

View File

@@ -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;
}
}

View File

@@ -67,7 +67,7 @@ function baseUrlFromEnv(env: any, appPort: number): string {
let config_: Config = null;
export function initConfig(env: EnvVariables) {
export function initConfig(env: EnvVariables, overrides: any = null) {
runningInDocker_ = !!env.RUNNING_IN_DOCKER;
const rootDir = pathUtils.dirname(__dirname);
@@ -83,6 +83,7 @@ export function initConfig(env: EnvVariables) {
database: databaseConfigFromEnv(runningInDocker_, env),
port: appPort,
baseUrl: baseUrlFromEnv(env, appPort),
...overrides,
};
}

View File

@@ -1,4 +1,4 @@
import * as Knex from 'knex';
import { knex, Knex } from 'knex';
import { DatabaseConfig } from './utils/types';
import * as pathUtils from 'path';
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> {
return require('knex')(makeKnexConfig(dbConfig));
return knex(makeKnexConfig(dbConfig));
}
export async function disconnectDb(db: DbConnection) {
@@ -148,7 +148,19 @@ function isNoSuchTableError(error: any): boolean {
if (error.code === '42P01') return true;
// 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;
@@ -201,8 +213,9 @@ export enum NotificationLevel {
}
export enum ItemType {
File = 1,
User,
Item = 1,
UserItem = 2,
User,
}
export enum ChangeType {
@@ -211,6 +224,11 @@ export enum ChangeType {
Delete = 3,
}
export enum FileContentType {
Any = 1,
JoplinItem = 2,
}
export function changeTypeToString(t: ChangeType): string {
if (t === ChangeType.Create) return 'create';
if (t === ChangeType.Update) return 'update';
@@ -221,6 +239,13 @@ export function changeTypeToString(t: ChangeType): string {
export enum ShareType {
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
JoplinRootFolder = 3,
}
export enum ShareUserStatus {
Waiting = 0,
Accepted = 1,
Rejected = 2,
}
export interface WithDates {
@@ -229,7 +254,7 @@ export interface WithDates {
}
export interface WithUuid {
id?: string;
id?: Uuid;
}
interface DatabaseTableColumn {
@@ -258,33 +283,21 @@ export interface Session extends WithDates, WithUuid {
auth_code?: string;
}
export interface Permission extends WithDates, WithUuid {
user_id?: Uuid;
item_type?: ItemType;
item_id?: Uuid;
can_read?: number;
can_write?: number;
}
export interface File extends WithDates, WithUuid {
export interface File {
id?: Uuid;
owner_id?: Uuid;
name?: string;
content?: Buffer;
content?: any;
mime_type?: string;
size?: number;
is_directory?: number;
is_root?: number;
parent_id?: Uuid;
}
export interface Change extends WithDates, WithUuid {
counter?: number;
owner_id?: Uuid;
item_type?: ItemType;
parent_id?: Uuid;
item_id?: Uuid;
item_name?: string;
type?: ChangeType;
updated_time?: string;
created_time?: string;
source_file_id?: Uuid;
content_type?: number;
content_id?: Uuid;
}
export interface ApiClient extends WithDates, WithUuid {
@@ -301,10 +314,59 @@ export interface Notification extends WithDates, WithUuid {
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 {
owner_id?: Uuid;
file_id?: Uuid;
item_id?: Uuid;
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 = {
@@ -324,16 +386,6 @@ export const databaseSchema: DatabaseTables = {
updated_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: {
id: { type: 'string' },
owner_id: { type: 'string' },
@@ -346,18 +398,9 @@ export const databaseSchema: DatabaseTables = {
parent_id: { type: 'string' },
updated_time: { type: 'string' },
created_time: { type: 'string' },
},
changes: {
counter: { type: 'number' },
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' },
source_file_id: { type: 'string' },
content_type: { type: 'number' },
content_id: { type: 'string' },
},
api_clients: {
id: { type: 'string' },
@@ -377,13 +420,67 @@ export const databaseSchema: DatabaseTables = {
updated_time: { type: 'string' },
created_time: { type: 'string' },
},
shares: {
share_users: {
id: { type: 'string' },
owner_id: { type: 'string' },
file_id: { type: 'string' },
type: { type: 'number' },
share_id: { type: 'string' },
user_id: { type: 'string' },
status: { type: 'number' },
updated_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

View File

@@ -17,9 +17,9 @@ describe('notificationHandler', 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,
password: defaultAdminPassword,
is_admin: 1,
@@ -38,7 +38,7 @@ describe('notificationHandler', function() {
}
{
await models().user({ userId: admin.id }).save({
await models().user().save({
id: admin.id,
password: 'changed!',
});

View File

@@ -12,20 +12,22 @@ async function handleChangeAdminPasswordNotification(ctx: AppContext) {
if (!ctx.owner.is_admin) return;
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) {
await notificationModel.add(
ctx.owner.id,
'change_admin_password',
NotificationLevel.Important,
_('The default admin password is insecure and has not been changed! [Change it now](%s)', await ctx.models.user().profileUrl())
);
} else {
await notificationModel.markAsRead('change_admin_password');
await notificationModel.markAsRead(ctx.owner.id, 'change_admin_password');
}
if (config().database.client === 'sqlite3' && ctx.env === 'prod') {
await notificationModel.add(
ctx.owner.id,
'using_sqlite_in_prod',
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.'
@@ -36,10 +38,11 @@ async function handleChangeAdminPasswordNotification(ctx: AppContext) {
async function handleSqliteInProdNotification(ctx: AppContext) {
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') {
await notificationModel.add(
ctx.owner.id,
'using_sqlite_in_prod',
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.'
@@ -50,7 +53,7 @@ async function handleSqliteInProdNotification(ctx: AppContext) {
async function makeNotificationViews(ctx: AppContext): Promise<NotificationView[]> {
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 views: NotificationView[] = [];
for (const n of notifications) {

View File

@@ -1,7 +1,5 @@
import routes from '../routes/routes';
import { ErrorForbidden, ErrorNotFound } from '../utils/errors';
import { routeResponseFormat, findMatchingRoute, Response, RouteResponseFormat, MatchedRoute } from '../utils/routeUtils';
import { AppContext, Env, HttpMethod } from '../utils/types';
import { routeResponseFormat, Response, RouteResponseFormat, execRequest } from '../utils/routeUtils';
import { AppContext, Env } from '../utils/types';
import MustacheService, { isView, View } from '../services/MustacheService';
import config from '../config';
@@ -16,38 +14,21 @@ function mustache(): MustacheService {
export default async function(ctx: AppContext) {
ctx.appLogger().info(`${ctx.request.method} ${ctx.path}`);
const match: MatchedRoute = null;
try {
const match = findMatchingRoute(ctx.path, routes);
const responseObject = await execRequest(ctx.routes, ctx);
if (match) {
let responseObject = null;
const routeHandler = match.route.findEndPoint(ctx.request.method as HttpMethod, match.subPath.schema);
// This is a generic catch-all for all private end points - if we
// couldn't get a valid session, we exit now. Individual end points
// might have additional permission checks depending on the action.
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;
}
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 {
throw new ErrorNotFound();
ctx.response.status = 200;
ctx.response.body = [undefined, null].includes(responseObject) ? '' : responseObject;
}
} catch (error) {
if (error.httpCode >= 400 && error.httpCode < 500) {
@@ -56,9 +37,12 @@ export default async function(ctx: AppContext) {
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;
const responseFormat = routeResponseFormat(match, ctx);
const responseFormat = routeResponseFormat(ctx);
if (responseFormat === RouteResponseFormat.Html) {
ctx.response.set('Content-Type', 'text/html');

View File

@@ -1,4 +1,4 @@
import * as Knex from 'knex';
import { Knex } from 'knex';
import { DbConnection, defaultAdminEmail, defaultAdminPassword } from '../db';
import { hashPassword } from '../utils/auth';
import uuidgen from '../utils/uuidgen';

View File

@@ -1,4 +1,4 @@
import * as Knex from 'knex';
import { Knex } from 'knex';
import { DbConnection } from '../db';
export async function up(db: DbConnection): Promise<any> {

View File

@@ -1,4 +1,4 @@
import * as Knex from 'knex';
import { Knex } from 'knex';
import { DbConnection } from '../db';
export async function up(db: DbConnection): Promise<any> {

View 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');
}

View File

@@ -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');
}

View File

@@ -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');
}

View 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');
}

View File

@@ -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 uuidgen from '../utils/uuidgen';
import { ErrorUnprocessableEntity, ErrorBadRequest } from '../utils/errors';
import { Models } from './factory';
export interface ModelOptions {
userId?: string;
}
import * as EventEmitter from 'events';
export interface SaveOptions {
isNew?: boolean;
skipValidation?: boolean;
validationRules?: any;
trackChanges?: boolean;
previousItem?: any;
}
export interface LoadOptions {
fields?: string[];
}
export interface DeleteOptions {
validationRules?: any;
allowNoOp?: boolean;
deletedItemUserIds?: Record<Uuid, Uuid[]>;
}
export interface ValidateOptions {
@@ -24,24 +27,29 @@ export interface ValidateOptions {
rules?: any;
}
export enum AclAction {
Create = 1,
Read = 2,
Update = 3,
Delete = 4,
List = 5,
}
export default abstract class BaseModel<T> {
private options_: ModelOptions = null;
private defaultFields_: string[] = [];
private db_: DbConnection;
private transactionHandler_: TransactionHandler;
private modelFactory_: Function;
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.modelFactory_ = modelFactory;
this.baseUrl_ = baseUrl;
this.options_ = Object.assign({}, options);
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
@@ -55,14 +63,6 @@ export default abstract class BaseModel<T> {
return this.baseUrl_;
}
protected get options(): ModelOptions {
return this.options_;
}
protected get userId(): string {
return this.options.userId;
}
protected get db(): DbConnection {
if (this.transactionHandler_.activeTransaction) return this.transactionHandler_.activeTransaction;
return this.db_;
@@ -75,6 +75,34 @@ export default abstract class BaseModel<T> {
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 {
throw new Error('Not implemented');
}
@@ -83,15 +111,11 @@ export default abstract class BaseModel<T> {
throw new Error('Not implemented');
}
protected get trackChanges(): boolean {
return false;
}
protected hasUuid(): boolean {
return true;
}
protected hasDateProperties(): boolean {
protected autoTimestampEnabled(): boolean {
return true;
}
@@ -121,7 +145,7 @@ export default abstract class BaseModel<T> {
//
// The `name` argument is only for debugging, so that any stuck transaction
// 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 debugTimerId = debugTransaction ? setTimeout(() => {
@@ -132,8 +156,10 @@ export default abstract class BaseModel<T> {
if (debugTransaction) console.info('START', name, txIndex);
let output: T = null;
try {
await fn();
output = await fn();
} catch (error) {
await this.transactionHandler_.rollback(txIndex);
@@ -151,10 +177,12 @@ export default abstract class BaseModel<T> {
}
await this.transactionHandler_.commit(txIndex);
return output;
}
public async all(): Promise<T[]> {
return this.db(this.tableName).select(...this.defaultFields);
public async all(options: LoadOptions = {}): Promise<T[]> {
const rows: any[] = await this.db(this.tableName).select(this.selectFields(options));
return rows as T[];
}
public fromApiInput(object: T): T {
@@ -170,10 +198,18 @@ export default abstract class BaseModel<T> {
return output;
}
public toApiOutput(object: any): any {
protected objectToApiOutput(object: T): T {
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> {
if (!options.isNew && !(object as WithUuid).id) throw new ErrorUnprocessableEntity('id is missing');
return object;
@@ -182,31 +218,10 @@ export default abstract class BaseModel<T> {
protected async isNew(object: T, options: SaveOptions): Promise<boolean> {
if (options.isNew === false) return false;
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;
}
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> {
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);
if (isNew && !(toSave as WithUuid).id) {
if (this.hasUuid() && isNew && !(toSave as WithUuid).id) {
(toSave as WithUuid).id = uuidgen();
}
if (this.hasDateProperties()) {
if (this.autoTimestampEnabled()) {
const timestamp = Date.now();
if (isNew) {
(toSave as WithDates).created_time = timestamp;
@@ -231,7 +246,6 @@ export default abstract class BaseModel<T> {
await this.withTransaction(async () => {
if (isNew) {
await this.db(this.tableName).insert(toSave);
await this.handleChangeTracking(options, toSave, ChangeType.Create);
} else {
const objectId: string = (toSave as WithUuid).id;
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 });
(toSave as WithUuid).id = objectId;
await this.handleChangeTracking(options, toSave, ChangeType.Update);
// Sanity check:
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;
}
public async loadByIds(ids: string[]): Promise<T[]> {
public async loadByIds(ids: string[], options: LoadOptions = {}): Promise<T[]> {
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');
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');
const ids = typeof id === 'string' ? [id] : id;
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 () => {
const query = this.db(this.tableName).where({ id: ids[0] });
for (let i = 1; i < ids.length; i++) {
@@ -281,11 +286,7 @@ export default abstract class BaseModel<T> {
}
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 (trackChanges) {
for (const item of itemsWithParentIds) await this.handleChangeTracking({}, item, ChangeType.Delete);
}
if (!options.allowNoOp && deletedCount !== ids.length) throw new Error(`${ids.length} row(s) should have been deleted but ${deletedCount} row(s) were deleted`);
}, 'BaseModel::delete');
}

View File

@@ -1,13 +1,11 @@
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, expectThrow } from '../utils/testing/testUtils';
import { ChangeType, File } from '../db';
import FileModel from './FileModel';
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, expectThrow, createFolder } from '../utils/testing/testUtils';
import { ChangeType, Item, Uuid } from '../db';
import { msleep } from '../utils/time';
import { ChangePagination } from './ChangeModel';
async function makeTestFile(fileModel: FileModel): Promise<File> {
return fileModel.save({
name: 'test',
parent_id: await fileModel.userRootFileId(),
async function makeTestItem(userId: Uuid, num: number): Promise<Item> {
return models().item().saveForUser(userId, {
name: `0000000000000000000000000000000${num}.md`,
});
}
@@ -25,95 +23,111 @@ describe('ChangeModel', function() {
await beforeEachDb();
});
test('should track changes - create', async function() {
const { user } = await createUserAndSession(1, true);
const fileModel = models().file({ userId: user.id });
const changeModel = models().change({ userId: user.id });
test('should track changes - create only', async function() {
const { session, user } = await createUserAndSession(1, true);
const changeModel = models().change();
const file1 = await makeTestFile(fileModel);
const dirId = await fileModel.userRootFileId();
const item1 = await createFolder(session.id, { title: 'folder' });
{
const changes = (await changeModel.byDirectoryId(dirId, { limit: 20 })).items;
const changes = (await changeModel.allForUser(user.id)).items;
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);
}
});
test('should track changes - create, then update', async function() {
const { user } = await createUserAndSession(1, true);
const fileModel = models().file({ userId: user.id });
const changeModel = models().change({ userId: user.id });
const itemModel = models().item();
const changeModel = models().change();
let i = 1;
await msleep(1); const file1 = await makeTestFile(fileModel); // CREATE 1
await msleep(1); await fileModel.save({ id: file1.id, name: `test_mod${i++}` }); // UPDATE 1
await msleep(1); await fileModel.save({ id: file1.id, name: `test_mod${i++}` }); // UPDATE 1
await msleep(1); const file2 = await makeTestFile(fileModel); // CREATE 2
await msleep(1); await fileModel.save({ id: file2.id, name: `test_mod${i++}` }); // UPDATE 2
await msleep(1); await fileModel.delete(file1.id); // DELETE 1
await msleep(1); await fileModel.save({ id: file2.id, name: `test_mod${i++}` }); // UPDATE 2
await msleep(1); const file3 = await makeTestFile(fileModel); // CREATE 3
await msleep(1); const item1 = await makeTestItem(user.id, 1); // [1] CREATE 1
await msleep(1); await itemModel.saveForUser(user.id, { id: item1.id, name: '0000000000000000000000000000001A.md' }); // [2] UPDATE 1a
await msleep(1); await itemModel.saveForUser(user.id, { id: item1.id, name: '0000000000000000000000000000001B.md' }); // [3] UPDATE 1b
await msleep(1); const item2 = await makeTestItem(user.id, 2); // [4] CREATE 2
await msleep(1); await itemModel.saveForUser(user.id, { id: item2.id, name: '0000000000000000000000000000002A.md' }); // [5] UPDATE 2a
await msleep(1); await itemModel.delete(item1.id); // [6] DELETE 1
await msleep(1); await itemModel.saveForUser(user.id, { id: item2.id, name: '0000000000000000000000000000002B.md' }); // [7] UPDATE 2b
await msleep(1); const item3 = await makeTestItem(user.id, 3); // [8] 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[0].item.id).toBe(file2.id);
expect(changes[0].item_id).toBe(item2.id);
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);
}
{
const pagination: ChangePagination = { limit: 5 };
const pagination: ChangePagination = { limit: 3 };
// In this page, the "create" change for file1 will not appear
// because this file has been deleted. The "delete" change will
// however appear in the second page.
const page1 = (await changeModel.byDirectoryId(dirId, pagination));
// Internally, when we request the first three changes, we get back:
//
// - CREATE 1
// - 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;
expect(changes.length).toBe(1);
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);
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;
expect(changes.length).toBe(3);
expect(page2.has_more).toBe(false);
expect(changes[0].item.id).toBe(file1.id);
// Although there are no more changes, it's not possible to know
// 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[1].item.id).toBe(file2.id);
expect(changes[1].item_id).toBe(item2.id);
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);
// 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() {
const { user } = await createUserAndSession(1, true);
const fileModel = models().file({ userId: user.id });
const changeModel = models().change({ userId: user.id });
const dirId = await fileModel.userRootFileId();
const itemModel = models().item();
const changeModel = models().change();
let i = 1;
await msleep(1); const file1 = await makeTestFile(fileModel); // CREATE 1
await msleep(1); await fileModel.save({ id: file1.id, name: `test_mod${i++}` }); // UPDATE 1
await msleep(1); const item1 = await makeTestItem(user.id, 1); // CREATE 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');
});
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));
await expectThrow(async () => changeModel.allForUser(user.id, { limit: 1, cursor: 'invalid' }), 'resyncRequired');
});
});

View File

@@ -1,16 +1,17 @@
import { Change, ChangeType, File, ItemType, Uuid } from '../db';
import { ErrorResyncRequired, ErrorUnprocessableEntity } from '../utils/errors';
import BaseModel from './BaseModel';
import { paginateDbQuery, PaginatedResults, Pagination } from './utils/pagination';
import { Change, ChangeType, Item, Uuid } from '../db';
import { md5 } from '../utils/crypto';
import { ErrorResyncRequired } from '../utils/errors';
import BaseModel, { SaveOptions } from './BaseModel';
import { PaginatedResults } from './utils/pagination';
export interface ChangeWithItem {
item: File;
item: Item;
updated_time: number;
type: ChangeType;
}
export interface PaginatedChanges extends PaginatedResults {
items: ChangeWithItem[];
items: Change[];
}
export interface ChangePagination {
@@ -18,6 +19,13 @@ export interface ChangePagination {
cursor?: string;
}
export interface ChangePreviousItem {
name: string;
jop_parent_id: string;
jop_resource_ids: string[];
jop_share_id: string;
}
export function defaultChangePagination(): ChangePagination {
return {
limit: 100,
@@ -25,6 +33,10 @@ export function defaultChangePagination(): ChangePagination {
};
}
interface AllForUserOptions {
compressChanges?: boolean;
}
export default class ChangeModel extends BaseModel<Change> {
public get tableName(): string {
@@ -32,44 +44,39 @@ export default class ChangeModel extends BaseModel<Change> {
}
protected hasUuid(): boolean {
return false;
return true;
}
public async add(itemType: ItemType, parentId: Uuid, itemId: Uuid, itemName: string, changeType: ChangeType): Promise<Change> {
const change: Change = {
item_type: itemType,
parent_id: parentId || '',
item_id: itemId,
item_name: itemName,
type: changeType,
owner_id: this.userId,
};
return this.save(change) as Change;
public serializePreviousItem(item: ChangePreviousItem): string {
return JSON.stringify(item);
}
private async countByUser(userId: string): Promise<number> {
const r: any = await this.db(this.tableName).where('owner_id', userId).count('id', { as: 'total' }).first();
return r.total;
public unserializePreviousItem(item: string): ChangePreviousItem {
if (!item) return null;
return JSON.parse(item);
}
public changeUrl(): string {
return `${this.baseUrl}/changes`;
}
public async allWithPagination(pagination: Pagination): Promise<PaginatedChanges> {
const results = await paginateDbQuery(this.db(this.tableName).select(...this.defaultFields).where('owner_id', '=', this.userId), pagination);
const changeWithItems = await this.loadChangeItems(results.items);
return {
...results,
items: changeWithItems,
page_count: Math.ceil(await this.countByUser(this.userId) / pagination.limit),
};
public async allFromId(id: string): Promise<Change[]> {
const startChange: Change = id ? await this.load(id) : null;
const query = this.db(this.tableName).select(...this.defaultFields);
if (startChange) void query.where('counter', '>', startChange.counter);
void query.limit(1000);
let results = await query;
results = await this.removeDeletedItems(results);
results = await this.compressChanges(results);
return results;
}
// Note: doesn't currently support checking for changes recursively but this
// is not needed for Joplin synchronisation.
public async byDirectoryId(dirId: string, pagination: ChangePagination = null): Promise<PaginatedChanges> {
public async allForUser(userId: Uuid, pagination: ChangePagination = null, options: AllForUserOptions = null): Promise<PaginatedChanges> {
options = {
compressChanges: true,
...options,
};
pagination = {
...defaultChangePagination(),
...pagination,
@@ -82,37 +89,49 @@ export default class ChangeModel extends BaseModel<Change> {
if (!changeAtCursor) throw new ErrorResyncRequired();
}
// Load the directory object to check that it exists and that we have
// the right permissions (loading will check permissions)
const fileModel = this.models().file({ userId: this.userId });
const directory = await fileModel.load(dirId);
if (!directory.is_directory) throw new ErrorUnprocessableEntity(`Item with id "${dirId}" is not a directory.`);
// When need to get:
//
// - All the CREATE and DELETE changes associated with the user
// - All the UPDATE changes that applies to items associated with the
// 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
// be possible to do both in one query.
// https://stackoverflow.com/questions/65348794
const query = this.db(this.tableName)
const query = this
.db('changes')
.select([
'counter',
'id',
'item_id',
'item_name',
'type',
'updated_time',
])
.where('parent_id', dirId)
.orderBy('counter', 'asc')
.limit(pagination.limit);
.where(function() {
void this.whereRaw('((type = ? OR type = ?) AND user_id = ?)', [ChangeType.Create, ChangeType.Delete, userId])
// 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) {
void query.where('counter', '>', changeAtCursor.counter);
}
const changes: Change[] = await query;
const compressedChanges = this.compressChanges(changes);
const changeWithItems = await this.loadChangeItems(compressedChanges);
void query
.orderBy('counter', 'asc')
.limit(pagination.limit) as any[];
const changes = await query;
const finalChanges = options.compressChanges ? await this.removeDeletedItems(this.compressChanges(changes)) : changes;
return {
items: changeWithItems,
items: finalChanges,
// 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.
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 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) {
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
// 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
// sent on one of the next pages.
if (!item) {
if (change.type === ChangeType.Delete) {
item = {
id: change.item_id,
name: change.item_name,
};
} else {
continue;
}
if (!item && change.type !== ChangeType.Delete) {
continue;
}
output.push({
type: change.type,
updated_time: change.updated_time,
item: item,
});
output.push(change);
}
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[] {
const itemChanges: Record<Uuid, Change> = {};
const uniqueUpdateChanges: Record<Uuid, Record<string, Change>> = {};
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) {
// create - update => create
// create - delete => NOOP
// update - update => update
// update - delete => delete
if (previous.type === ChangeType.Create && change.type === ChangeType.Update) {
continue;
}
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) {
itemChanges[change.item_id] = change;
itemChanges[itemId] = change;
}
if (previous.type === ChangeType.Update && change.type === ChangeType.Delete) {
itemChanges[change.item_id] = change;
itemChanges[itemId] = change;
}
} else {
itemChanges[change.item_id] = change;
itemChanges[itemId] = change;
}
}
const output = [];
const output: Change[] = [];
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);
@@ -200,4 +238,10 @@ export default class ChangeModel extends BaseModel<Change> {
return output;
}
public async save(change: Change, options: SaveOptions = {}): Promise<Change> {
const savedChange = await super.save(change, options);
ChangeModel.eventEmitter.emit('saved');
return savedChange;
}
}

View File

@@ -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);
}
});
});

View File

@@ -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');
}
}

View 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());
}
});
});

View 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