1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-08-30 20:39:46 +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/ node_modules/
packages/lib/lib/lib.js packages/lib/lib/lib.js
packages/lib/locales/index.js packages/lib/locales/index.js
packages/lib/services/database/types.ts
packages/app-cli/build packages/app-cli/build
packages/app-cli/build/ packages/app-cli/build/
packages/app-cli/locales packages/app-cli/locales
@@ -112,6 +113,9 @@ packages/app-cli/tests/Synchronizer.tools.js.map
packages/app-cli/tests/dateTimeFormats.d.ts packages/app-cli/tests/dateTimeFormats.d.ts
packages/app-cli/tests/dateTimeFormats.js packages/app-cli/tests/dateTimeFormats.js
packages/app-cli/tests/dateTimeFormats.js.map packages/app-cli/tests/dateTimeFormats.js.map
packages/app-cli/tests/file-api-driver.d.ts
packages/app-cli/tests/file-api-driver.js
packages/app-cli/tests/file-api-driver.js.map
packages/app-cli/tests/fsDriver.d.ts packages/app-cli/tests/fsDriver.d.ts
packages/app-cli/tests/fsDriver.js packages/app-cli/tests/fsDriver.js
packages/app-cli/tests/fsDriver.js.map packages/app-cli/tests/fsDriver.js.map
@@ -121,6 +125,9 @@ packages/app-cli/tests/htmlUtils.js.map
packages/app-cli/tests/models_Folder.d.ts packages/app-cli/tests/models_Folder.d.ts
packages/app-cli/tests/models_Folder.js packages/app-cli/tests/models_Folder.js
packages/app-cli/tests/models_Folder.js.map packages/app-cli/tests/models_Folder.js.map
packages/app-cli/tests/models_Folder.sharing.d.ts
packages/app-cli/tests/models_Folder.sharing.js
packages/app-cli/tests/models_Folder.sharing.js.map
packages/app-cli/tests/models_Note.d.ts packages/app-cli/tests/models_Note.d.ts
packages/app-cli/tests/models_Note.js packages/app-cli/tests/models_Note.js
packages/app-cli/tests/models_Note.js.map packages/app-cli/tests/models_Note.js.map
@@ -250,6 +257,15 @@ packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.js.ma
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.d.ts packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.d.ts
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.js packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.js.map packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.js.map
packages/app-desktop/gui/Dialog.d.ts
packages/app-desktop/gui/Dialog.js
packages/app-desktop/gui/Dialog.js.map
packages/app-desktop/gui/DialogButtonRow.d.ts
packages/app-desktop/gui/DialogButtonRow.js
packages/app-desktop/gui/DialogButtonRow.js.map
packages/app-desktop/gui/DialogTitle.d.ts
packages/app-desktop/gui/DialogTitle.js
packages/app-desktop/gui/DialogTitle.js.map
packages/app-desktop/gui/DropboxLoginScreen.d.ts packages/app-desktop/gui/DropboxLoginScreen.d.ts
packages/app-desktop/gui/DropboxLoginScreen.js packages/app-desktop/gui/DropboxLoginScreen.js
packages/app-desktop/gui/DropboxLoginScreen.js.map packages/app-desktop/gui/DropboxLoginScreen.js.map
@@ -337,6 +353,9 @@ packages/app-desktop/gui/MainScreen/commands/showNoteContentProperties.js.map
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.d.ts packages/app-desktop/gui/MainScreen/commands/showNoteProperties.d.ts
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.js packages/app-desktop/gui/MainScreen/commands/showNoteProperties.js
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.js.map packages/app-desktop/gui/MainScreen/commands/showNoteProperties.js.map
packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.d.ts
packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.js
packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.js.map
packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.d.ts packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.d.ts
packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.js packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.js
packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.js.map packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.js.map
@@ -589,6 +608,9 @@ packages/app-desktop/gui/Root_UpgradeSyncTarget.js.map
packages/app-desktop/gui/SearchBar/SearchBar.d.ts packages/app-desktop/gui/SearchBar/SearchBar.d.ts
packages/app-desktop/gui/SearchBar/SearchBar.js packages/app-desktop/gui/SearchBar/SearchBar.js
packages/app-desktop/gui/SearchBar/SearchBar.js.map packages/app-desktop/gui/SearchBar/SearchBar.js.map
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.d.ts
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js.map
packages/app-desktop/gui/ShareNoteDialog.d.ts packages/app-desktop/gui/ShareNoteDialog.d.ts
packages/app-desktop/gui/ShareNoteDialog.js packages/app-desktop/gui/ShareNoteDialog.js
packages/app-desktop/gui/ShareNoteDialog.js.map packages/app-desktop/gui/ShareNoteDialog.js.map
@@ -646,9 +668,15 @@ packages/app-desktop/gui/lib/ToggleButton/ToggleButton.js.map
packages/app-desktop/gui/menuCommandNames.d.ts packages/app-desktop/gui/menuCommandNames.d.ts
packages/app-desktop/gui/menuCommandNames.js packages/app-desktop/gui/menuCommandNames.js
packages/app-desktop/gui/menuCommandNames.js.map packages/app-desktop/gui/menuCommandNames.js.map
packages/app-desktop/gui/style/StyledFormLabel.d.ts
packages/app-desktop/gui/style/StyledFormLabel.js
packages/app-desktop/gui/style/StyledFormLabel.js.map
packages/app-desktop/gui/style/StyledInput.d.ts packages/app-desktop/gui/style/StyledInput.d.ts
packages/app-desktop/gui/style/StyledInput.js packages/app-desktop/gui/style/StyledInput.js
packages/app-desktop/gui/style/StyledInput.js.map packages/app-desktop/gui/style/StyledInput.js.map
packages/app-desktop/gui/style/StyledMessage.d.ts
packages/app-desktop/gui/style/StyledMessage.js
packages/app-desktop/gui/style/StyledMessage.js.map
packages/app-desktop/gui/style/StyledTextInput.d.ts packages/app-desktop/gui/style/StyledTextInput.d.ts
packages/app-desktop/gui/style/StyledTextInput.js packages/app-desktop/gui/style/StyledTextInput.js
packages/app-desktop/gui/style/StyledTextInput.js.map packages/app-desktop/gui/style/StyledTextInput.js.map
@@ -847,6 +875,9 @@ packages/lib/InMemoryCache.js.map
packages/lib/JoplinDatabase.d.ts packages/lib/JoplinDatabase.d.ts
packages/lib/JoplinDatabase.js packages/lib/JoplinDatabase.js
packages/lib/JoplinDatabase.js.map packages/lib/JoplinDatabase.js.map
packages/lib/JoplinError.d.ts
packages/lib/JoplinError.js
packages/lib/JoplinError.js.map
packages/lib/JoplinServerApi.d.ts packages/lib/JoplinServerApi.d.ts
packages/lib/JoplinServerApi.js packages/lib/JoplinServerApi.js
packages/lib/JoplinServerApi.js.map packages/lib/JoplinServerApi.js.map
@@ -877,6 +908,9 @@ packages/lib/commands/synchronize.js.map
packages/lib/database.d.ts packages/lib/database.d.ts
packages/lib/database.js packages/lib/database.js
packages/lib/database.js.map packages/lib/database.js.map
packages/lib/debug/DebugService.d.ts
packages/lib/debug/DebugService.js
packages/lib/debug/DebugService.js.map
packages/lib/dummy.test.d.ts packages/lib/dummy.test.d.ts
packages/lib/dummy.test.js packages/lib/dummy.test.js
packages/lib/dummy.test.js.map packages/lib/dummy.test.js.map
@@ -1333,6 +1367,12 @@ packages/lib/services/searchengine/filterParser.js.map
packages/lib/services/searchengine/queryBuilder.d.ts packages/lib/services/searchengine/queryBuilder.d.ts
packages/lib/services/searchengine/queryBuilder.js packages/lib/services/searchengine/queryBuilder.js
packages/lib/services/searchengine/queryBuilder.js.map packages/lib/services/searchengine/queryBuilder.js.map
packages/lib/services/share/ShareService.d.ts
packages/lib/services/share/ShareService.js
packages/lib/services/share/ShareService.js.map
packages/lib/services/share/reducer.d.ts
packages/lib/services/share/reducer.js
packages/lib/services/share/reducer.js.map
packages/lib/services/spellChecker/SpellCheckerService.d.ts packages/lib/services/spellChecker/SpellCheckerService.d.ts
packages/lib/services/spellChecker/SpellCheckerService.js packages/lib/services/spellChecker/SpellCheckerService.js
packages/lib/services/spellChecker/SpellCheckerService.js.map packages/lib/services/spellChecker/SpellCheckerService.js.map

39
.gitignore vendored
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.d.ts
packages/app-cli/tests/dateTimeFormats.js packages/app-cli/tests/dateTimeFormats.js
packages/app-cli/tests/dateTimeFormats.js.map packages/app-cli/tests/dateTimeFormats.js.map
packages/app-cli/tests/file-api-driver.d.ts
packages/app-cli/tests/file-api-driver.js
packages/app-cli/tests/file-api-driver.js.map
packages/app-cli/tests/fsDriver.d.ts packages/app-cli/tests/fsDriver.d.ts
packages/app-cli/tests/fsDriver.js packages/app-cli/tests/fsDriver.js
packages/app-cli/tests/fsDriver.js.map packages/app-cli/tests/fsDriver.js.map
@@ -108,6 +111,9 @@ packages/app-cli/tests/htmlUtils.js.map
packages/app-cli/tests/models_Folder.d.ts packages/app-cli/tests/models_Folder.d.ts
packages/app-cli/tests/models_Folder.js packages/app-cli/tests/models_Folder.js
packages/app-cli/tests/models_Folder.js.map packages/app-cli/tests/models_Folder.js.map
packages/app-cli/tests/models_Folder.sharing.d.ts
packages/app-cli/tests/models_Folder.sharing.js
packages/app-cli/tests/models_Folder.sharing.js.map
packages/app-cli/tests/models_Note.d.ts packages/app-cli/tests/models_Note.d.ts
packages/app-cli/tests/models_Note.js packages/app-cli/tests/models_Note.js
packages/app-cli/tests/models_Note.js.map packages/app-cli/tests/models_Note.js.map
@@ -237,6 +243,15 @@ packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.js.ma
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.d.ts packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.d.ts
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.js packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.js.map packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.js.map
packages/app-desktop/gui/Dialog.d.ts
packages/app-desktop/gui/Dialog.js
packages/app-desktop/gui/Dialog.js.map
packages/app-desktop/gui/DialogButtonRow.d.ts
packages/app-desktop/gui/DialogButtonRow.js
packages/app-desktop/gui/DialogButtonRow.js.map
packages/app-desktop/gui/DialogTitle.d.ts
packages/app-desktop/gui/DialogTitle.js
packages/app-desktop/gui/DialogTitle.js.map
packages/app-desktop/gui/DropboxLoginScreen.d.ts packages/app-desktop/gui/DropboxLoginScreen.d.ts
packages/app-desktop/gui/DropboxLoginScreen.js packages/app-desktop/gui/DropboxLoginScreen.js
packages/app-desktop/gui/DropboxLoginScreen.js.map packages/app-desktop/gui/DropboxLoginScreen.js.map
@@ -324,6 +339,9 @@ packages/app-desktop/gui/MainScreen/commands/showNoteContentProperties.js.map
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.d.ts packages/app-desktop/gui/MainScreen/commands/showNoteProperties.d.ts
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.js packages/app-desktop/gui/MainScreen/commands/showNoteProperties.js
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.js.map packages/app-desktop/gui/MainScreen/commands/showNoteProperties.js.map
packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.d.ts
packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.js
packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.js.map
packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.d.ts packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.d.ts
packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.js packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.js
packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.js.map packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.js.map
@@ -576,6 +594,9 @@ packages/app-desktop/gui/Root_UpgradeSyncTarget.js.map
packages/app-desktop/gui/SearchBar/SearchBar.d.ts packages/app-desktop/gui/SearchBar/SearchBar.d.ts
packages/app-desktop/gui/SearchBar/SearchBar.js packages/app-desktop/gui/SearchBar/SearchBar.js
packages/app-desktop/gui/SearchBar/SearchBar.js.map packages/app-desktop/gui/SearchBar/SearchBar.js.map
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.d.ts
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js.map
packages/app-desktop/gui/ShareNoteDialog.d.ts packages/app-desktop/gui/ShareNoteDialog.d.ts
packages/app-desktop/gui/ShareNoteDialog.js packages/app-desktop/gui/ShareNoteDialog.js
packages/app-desktop/gui/ShareNoteDialog.js.map packages/app-desktop/gui/ShareNoteDialog.js.map
@@ -633,9 +654,15 @@ packages/app-desktop/gui/lib/ToggleButton/ToggleButton.js.map
packages/app-desktop/gui/menuCommandNames.d.ts packages/app-desktop/gui/menuCommandNames.d.ts
packages/app-desktop/gui/menuCommandNames.js packages/app-desktop/gui/menuCommandNames.js
packages/app-desktop/gui/menuCommandNames.js.map packages/app-desktop/gui/menuCommandNames.js.map
packages/app-desktop/gui/style/StyledFormLabel.d.ts
packages/app-desktop/gui/style/StyledFormLabel.js
packages/app-desktop/gui/style/StyledFormLabel.js.map
packages/app-desktop/gui/style/StyledInput.d.ts packages/app-desktop/gui/style/StyledInput.d.ts
packages/app-desktop/gui/style/StyledInput.js packages/app-desktop/gui/style/StyledInput.js
packages/app-desktop/gui/style/StyledInput.js.map packages/app-desktop/gui/style/StyledInput.js.map
packages/app-desktop/gui/style/StyledMessage.d.ts
packages/app-desktop/gui/style/StyledMessage.js
packages/app-desktop/gui/style/StyledMessage.js.map
packages/app-desktop/gui/style/StyledTextInput.d.ts packages/app-desktop/gui/style/StyledTextInput.d.ts
packages/app-desktop/gui/style/StyledTextInput.js packages/app-desktop/gui/style/StyledTextInput.js
packages/app-desktop/gui/style/StyledTextInput.js.map packages/app-desktop/gui/style/StyledTextInput.js.map
@@ -834,6 +861,9 @@ packages/lib/InMemoryCache.js.map
packages/lib/JoplinDatabase.d.ts packages/lib/JoplinDatabase.d.ts
packages/lib/JoplinDatabase.js packages/lib/JoplinDatabase.js
packages/lib/JoplinDatabase.js.map packages/lib/JoplinDatabase.js.map
packages/lib/JoplinError.d.ts
packages/lib/JoplinError.js
packages/lib/JoplinError.js.map
packages/lib/JoplinServerApi.d.ts packages/lib/JoplinServerApi.d.ts
packages/lib/JoplinServerApi.js packages/lib/JoplinServerApi.js
packages/lib/JoplinServerApi.js.map packages/lib/JoplinServerApi.js.map
@@ -864,6 +894,9 @@ packages/lib/commands/synchronize.js.map
packages/lib/database.d.ts packages/lib/database.d.ts
packages/lib/database.js packages/lib/database.js
packages/lib/database.js.map packages/lib/database.js.map
packages/lib/debug/DebugService.d.ts
packages/lib/debug/DebugService.js
packages/lib/debug/DebugService.js.map
packages/lib/dummy.test.d.ts packages/lib/dummy.test.d.ts
packages/lib/dummy.test.js packages/lib/dummy.test.js
packages/lib/dummy.test.js.map packages/lib/dummy.test.js.map
@@ -1320,6 +1353,12 @@ packages/lib/services/searchengine/filterParser.js.map
packages/lib/services/searchengine/queryBuilder.d.ts packages/lib/services/searchengine/queryBuilder.d.ts
packages/lib/services/searchengine/queryBuilder.js packages/lib/services/searchengine/queryBuilder.js
packages/lib/services/searchengine/queryBuilder.js.map packages/lib/services/searchengine/queryBuilder.js.map
packages/lib/services/share/ShareService.d.ts
packages/lib/services/share/ShareService.js
packages/lib/services/share/ShareService.js.map
packages/lib/services/share/reducer.d.ts
packages/lib/services/share/reducer.js
packages/lib/services/share/reducer.js.map
packages/lib/services/spellChecker/SpellCheckerService.d.ts packages/lib/services/spellChecker/SpellCheckerService.d.ts
packages/lib/services/spellChecker/SpellCheckerService.js packages/lib/services/spellChecker/SpellCheckerService.js
packages/lib/services/spellChecker/SpellCheckerService.js.map packages/lib/services/spellChecker/SpellCheckerService.js.map

12
LICENSE
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 MIT License
Copyright (c) 2016-2020 Laurent Cozic Copyright (c) 2016-2020 Laurent Cozic

View File

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

View File

@@ -9,6 +9,7 @@ import Resource from '@joplin/lib/models/Resource';
import ResourceFetcher from '@joplin/lib/services/ResourceFetcher'; import ResourceFetcher from '@joplin/lib/services/ResourceFetcher';
import MasterKey from '@joplin/lib/models/MasterKey'; import MasterKey from '@joplin/lib/models/MasterKey';
import BaseItem from '@joplin/lib/models/BaseItem'; import BaseItem from '@joplin/lib/models/BaseItem';
import { createFolderTree } from './test-utils';
let insideBeforeEach = false; let insideBeforeEach = false;
@@ -361,13 +362,26 @@ describe('Synchronizer.e2ee', function() {
expect((await decryptionWorker().decryptionDisabledItems()).length).toBe(0); expect((await decryptionWorker().decryptionDisabledItems()).length).toBe(0);
})); }));
it('should not encrypt notes that are shared', (async () => { it('should not encrypt notes that are shared by link', (async () => {
Setting.setValue('encryption.enabled', true); Setting.setValue('encryption.enabled', true);
await loadEncryptionMasterKey(); await loadEncryptionMasterKey();
const folder1 = await Folder.save({ title: 'folder1' }); await createFolderTree('', [
const note1 = await Note.save({ title: 'un', parent_id: folder1.id }); {
let note2 = await Note.save({ title: 'deux', parent_id: folder1.id }); title: 'folder1',
children: [
{
title: 'un',
},
{
title: 'deux',
},
],
},
]);
const note1 = await Note.loadByTitle('un');
let note2 = await Note.loadByTitle('deux');
await synchronizerStart(); await synchronizerStart();
await switchClient(2); await switchClient(2);
@@ -400,4 +414,61 @@ describe('Synchronizer.e2ee', function() {
expect(note1_2.title).toBe(''); expect(note1_2.title).toBe('');
})); }));
it('should not encrypt items that are shared by folder', (async () => {
Setting.setValue('encryption.enabled', true);
await loadEncryptionMasterKey();
const folder1 = await createFolderTree('', [
{
title: 'folder1',
children: [
{
title: 'note1',
},
],
},
{
title: 'folder2',
children: [
{
title: 'note2',
},
],
},
]);
await synchronizerStart();
await switchClient(2);
await synchronizerStart();
await switchClient(1);
// Simulate that the folder has been shared
await Folder.save({ id: folder1.id, share_id: 'abcd' });
await synchronizerStart();
await switchClient(2);
await synchronizerStart();
// The shared items should be decrypted
{
const folder1 = await Folder.loadByTitle('folder1');
const note1 = await Note.loadByTitle('note1');
expect(folder1.title).toBe('folder1');
expect(note1.title).toBe('note1');
}
// The non-shared items should be encrypted
{
const folder2 = await Folder.loadByTitle('folder2');
const note2 = await Note.loadByTitle('note2');
expect(folder2).toBeFalsy();
expect(note2).toBeFalsy();
}
}));
}); });

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() { describe('Synchronizer.sharing', function() {
beforeEach(async (done) => { it('should skip', (async () => {
await setupDatabaseAndSynchronizer(1); expect(true).toBe(true);
await setupDatabaseAndSynchronizer(2);
await switchClient(1);
done();
});
afterAll(async () => {
await afterAllCleanUp();
});
it('should mark link resources as shared before syncing', (async () => {
let note1 = await Note.save({ title: 'note1' });
note1 = await shim.attachFileToNote(note1, `${__dirname}/../tests/support/photo.jpg`);
const resourceId1 = (await Note.linkedResourceIds(note1.body))[0];
const note2 = await Note.save({ title: 'note2' });
await shim.attachFileToNote(note2, `${__dirname}/../tests/support/photo.jpg`);
expect((await Resource.sharedResourceIds()).length).toBe(0);
await BaseItem.updateShareStatus(note1, true);
await synchronizerStart();
const sharedResourceIds = await Resource.sharedResourceIds();
expect(sharedResourceIds.length).toBe(1);
expect(sharedResourceIds[0]).toBe(resourceId1);
})); }));
}); });
// import { afterAllCleanUp, synchronizerStart, setupDatabaseAndSynchronizer, switchClient, joplinServerApi } from './test-utils';
// import Note from '@joplin/lib/models/Note';
// import BaseItem from '@joplin/lib/models/BaseItem';
// import shim from '@joplin/lib/shim';
// import Resource from '@joplin/lib/models/Resource';
// import Folder from '@joplin/lib/models/Folder';
// describe('Synchronizer.sharing', function() {
// beforeEach(async (done) => {
// await setupDatabaseAndSynchronizer(1);
// await switchClient(1);
// done();
// });
// afterAll(async () => {
// await afterAllCleanUp();
// });
// it('should mark link resources as shared before syncing', (async () => {
// let note1 = await Note.save({ title: 'note1' });
// note1 = await shim.attachFileToNote(note1, `${__dirname}/../tests/support/photo.jpg`);
// const resourceId1 = (await Note.linkedResourceIds(note1.body))[0];
// const note2 = await Note.save({ title: 'note2' });
// await shim.attachFileToNote(note2, `${__dirname}/../tests/support/photo.jpg`);
// expect((await Resource.sharedResourceIds()).length).toBe(0);
// await BaseItem.updateShareStatus(note1, true);
// await synchronizerStart();
// const sharedResourceIds = await Resource.sharedResourceIds();
// expect(sharedResourceIds.length).toBe(1);
// expect(sharedResourceIds[0]).toBe(resourceId1);
// }));
// it('should share items', (async () => {
// await setupDatabaseAndSynchronizer(1, { userEmail: 'user1@example.com' });
// await switchClient(1);
// const api = joplinServerApi();
// await api.exec('POST', 'api/debug', null, { action: 'createTestUsers' });
// await api.clearSession();
// const folder1 = await Folder.save({ title: 'folder1' });
// await Note.save({ title: 'note1', parent_id: folder1.id });
// await synchronizerStart();
// await setupDatabaseAndSynchronizer(2, { userEmail: 'user2@example.com' });
// await switchClient(2);
// await synchronizerStart();
// await switchClient(1);
// console.info(await Note.all());
// }));
// });

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 { FolderEntity } from '@joplin/lib/services/database/types';
import { createNTestNotes, setupDatabaseAndSynchronizer, sleep, switchClient, checkThrowAsync } from './test-utils'; import { createNTestNotes, setupDatabaseAndSynchronizer, sleep, switchClient, checkThrowAsync, createFolderTree } from './test-utils';
import Folder from '@joplin/lib/models/Folder'; import Folder from '@joplin/lib/models/Folder';
import Note from '@joplin/lib/models/Note'; import Note from '@joplin/lib/models/Note';
@@ -225,4 +225,61 @@ describe('models_Folder', function() {
const hasThrown = await checkThrowAsync(() => Folder.save({ id: f1.id, parent_id: f1.id }, { userSideValidation: true })); const hasThrown = await checkThrowAsync(() => Folder.save({ id: f1.id, parent_id: f1.id }, { userSideValidation: true }));
expect(hasThrown).toBe(true); expect(hasThrown).toBe(true);
})); }));
it('should get all the children of a folder', (async () => {
const folder = await createFolderTree('', [
{
title: 'folder 1',
children: [
{
title: 'note 1',
},
{
title: 'note 2',
},
{
title: 'folder 2',
children: [
{
title: 'note 3',
},
],
},
{
title: 'folder 3',
children: [],
},
],
},
{
title: 'folder 4',
children: [
{
title: 'folder 5',
children: [],
},
],
},
]);
const folder2 = await Folder.loadByTitle('folder 2');
const folder3 = await Folder.loadByTitle('folder 3');
const folder4 = await Folder.loadByTitle('folder 4');
const folder5 = await Folder.loadByTitle('folder 5');
{
const children = await Folder.allChildrenFolders(folder.id);
expect(children.map(c => c.id).sort()).toEqual([folder2.id, folder3.id].sort());
}
{
const children = await Folder.allChildrenFolders(folder4.id);
expect(children.map(c => c.id).sort()).toEqual([folder5.id].sort());
}
{
const children = await Folder.allChildrenFolders(folder5.id);
expect(children.map(c => c.id).sort()).toEqual([].sort());
}
}));
}); });

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 WebDavApi = require('@joplin/lib/WebDavApi');
const DropboxApi = require('@joplin/lib/DropboxApi'); const DropboxApi = require('@joplin/lib/DropboxApi');
import JoplinServerApi from '@joplin/lib/JoplinServerApi'; import JoplinServerApi from '@joplin/lib/JoplinServerApi';
import { FolderEntity } from '@joplin/lib/services/database/types';
const { loadKeychainServiceAndSettings } = require('@joplin/lib/services/SettingUtils'); const { loadKeychainServiceAndSettings } = require('@joplin/lib/services/SettingUtils');
const md5 = require('md5'); const md5 = require('md5');
const S3 = require('aws-sdk/clients/s3'); const S3 = require('aws-sdk/clients/s3');
@@ -346,6 +347,29 @@ async function setupDatabase(id: number = null, options: any = null) {
await loadKeychainServiceAndSettings(options.keychainEnabled ? KeychainServiceDriver : KeychainServiceDriverDummy); await loadKeychainServiceAndSettings(options.keychainEnabled ? KeychainServiceDriver : KeychainServiceDriverDummy);
} }
export async function createFolderTree(parentId: string, tree: any[], num: number = 0): Promise<FolderEntity> {
let rootFolder: FolderEntity = null;
for (const item of tree) {
const isFolder = !!item.children;
num++;
const data = { ...item };
delete data.children;
if (isFolder) {
const folder = await Folder.save({ title: `Folder ${num}`, parent_id: parentId, ...data });
if (!rootFolder) rootFolder = folder;
if (item.children.length) await createFolderTree(folder.id, item.children, num);
} else {
await Note.save({ title: `Note ${num}`, parent_id: parentId, ...data });
}
}
return rootFolder;
}
function exportDir(id: number = null) { function exportDir(id: number = null) {
if (id === null) id = currentClient_; if (id === null) id = currentClient_;
return `${dataDir}/export`; return `${dataDir}/export`;
@@ -385,7 +409,7 @@ async function setupDatabaseAndSynchronizer(id: number, options: any = null) {
if (!synchronizers_[id]) { if (!synchronizers_[id]) {
const SyncTargetClass = SyncTargetRegistry.classById(syncTargetId_); const SyncTargetClass = SyncTargetRegistry.classById(syncTargetId_);
const syncTarget = new SyncTargetClass(db(id)); const syncTarget = new SyncTargetClass(db(id));
await initFileApi(suiteName_); await initFileApi();
syncTarget.setFileApi(fileApi()); syncTarget.setFileApi(fileApi());
syncTarget.setLogger(logger); syncTarget.setLogger(logger);
synchronizers_[id] = await syncTarget.synchronizer(); synchronizers_[id] = await syncTarget.synchronizer();
@@ -484,7 +508,13 @@ async function loadEncryptionMasterKey(id: number = null, useExisting = false) {
return masterKey; return masterKey;
} }
async function initFileApi(suiteName: string) { function mustRunInBand() {
if (!process.argv.includes('--runInBand')) {
throw new Error('Tests must be run sequentially for this sync target, with the --runInBand arg. eg `npm test -- --runInBand`');
}
}
async function initFileApi() {
if (fileApis_[syncTargetId_]) return; if (fileApis_[syncTargetId_]) return;
let fileApi = null; let fileApi = null;
@@ -524,9 +554,7 @@ async function initFileApi(suiteName: string) {
// OneDrive app directory, and it's not clear how to get that // OneDrive app directory, and it's not clear how to get that
// working. // working.
if (!process.argv.includes('--runInBand')) { mustRunInBand();
throw new Error('OneDrive tests must be run sequentially, with the --runInBand arg. eg `npm test -- --runInBand`');
}
const { parameters, setEnvOverride } = require('@joplin/lib/parameters.js'); const { parameters, setEnvOverride } = require('@joplin/lib/parameters.js');
Setting.setConstant('env', 'dev'); Setting.setConstant('env', 'dev');
@@ -550,6 +578,8 @@ async function initFileApi(suiteName: string) {
const api = new S3({ accessKeyId: amazonS3Creds.accessKeyId, secretAccessKey: amazonS3Creds.secretAccessKey, s3UseArnRegion: true }); const api = new S3({ accessKeyId: amazonS3Creds.accessKeyId, secretAccessKey: amazonS3Creds.secretAccessKey, s3UseArnRegion: true });
fileApi = new FileApi('', new FileApiDriverAmazonS3(api, amazonS3Creds.bucket)); fileApi = new FileApi('', new FileApiDriverAmazonS3(api, amazonS3Creds.bucket));
} else if (syncTargetId_ == SyncTargetRegistry.nameToId('joplinServer')) { } else if (syncTargetId_ == SyncTargetRegistry.nameToId('joplinServer')) {
mustRunInBand();
// Note that to test the API in parallel mode, you need to use Postgres // Note that to test the API in parallel mode, you need to use Postgres
// as database, as the SQLite database is not reliable when being // as database, as the SQLite database is not reliable when being
// read/write from multiple processes at the same time. // read/write from multiple processes at the same time.
@@ -558,7 +588,8 @@ async function initFileApi(suiteName: string) {
username: () => 'admin@localhost', username: () => 'admin@localhost',
password: () => 'admin', password: () => 'admin',
}); });
fileApi = new FileApi(`Apps/Joplin-${suiteName}`, new FileApiDriverJoplinServer(api));
fileApi = new FileApi('', new FileApiDriverJoplinServer(api));
} }
fileApi.setLogger(logger); fileApi.setLogger(logger);

View File

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

View File

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

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 { _ } = require('@joplin/lib/locale');
const { themeStyle } = require('@joplin/lib/theme'); const { themeStyle } = require('@joplin/lib/theme');
function DialogButtonRow(props) { export interface ButtonSpec {
name: string;
label: string;
}
export interface ClickEvent {
buttonName: string;
}
interface Props {
themeId: number;
onClick?: (event: ClickEvent)=> void;
okButtonShow?: boolean;
cancelButtonShow?: boolean;
cancelButtonLabel?: string;
okButtonRef?: any;
customButtons?: ButtonSpec[];
}
export default function DialogButtonRow(props: Props) {
const theme = themeStyle(props.themeId); const theme = themeStyle(props.themeId);
const okButton_click = () => { const okButton_click = () => {
@@ -13,7 +32,11 @@ function DialogButtonRow(props) {
if (props.onClick) props.onClick({ buttonName: 'cancel' }); if (props.onClick) props.onClick({ buttonName: 'cancel' });
}; };
const onKeyDown = (event) => { const customButton_click = (event: ClickEvent) => {
if (props.onClick) props.onClick(event);
};
const onKeyDown = (event: any) => {
if (event.keyCode === 13) { if (event.keyCode === 13) {
okButton_click(); okButton_click();
} else if (event.keyCode === 27) { } else if (event.keyCode === 27) {
@@ -23,6 +46,16 @@ function DialogButtonRow(props) {
const buttonComps = []; const buttonComps = [];
if (props.customButtons) {
for (const b of props.customButtons) {
buttonComps.push(
<button key={b.name} style={theme.buttonStyle} onClick={() => customButton_click({ buttonName: b.name })} onKeyDown={onKeyDown}>
{b.label}
</button>
);
}
}
if (props.okButtonShow !== false) { if (props.okButtonShow !== false) {
buttonComps.push( buttonComps.push(
<button key="ok" style={theme.buttonStyle} onClick={okButton_click} ref={props.okButtonRef} onKeyDown={onKeyDown}> <button key="ok" style={theme.buttonStyle} onClick={okButton_click} ref={props.okButtonRef} onKeyDown={onKeyDown}>
@@ -41,5 +74,3 @@ function DialogButtonRow(props) {
return <div style={{ textAlign: 'right', marginTop: 10 }}>{buttonComps}</div>; return <div style={{ textAlign: 'right', marginTop: 10 }}>{buttonComps}</div>;
} }
module.exports = DialogButtonRow;

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 validateLayout from '../ResizableLayout/utils/validateLayout';
import iterateItems from '../ResizableLayout/utils/iterateItems'; import iterateItems from '../ResizableLayout/utils/iterateItems';
import removeItem from '../ResizableLayout/utils/removeItem'; import removeItem from '../ResizableLayout/utils/removeItem';
import EncryptionService from '@joplin/lib/services/EncryptionService';
import ShareFolderDialog from '../ShareFolderDialog/ShareFolderDialog';
import { ShareInvitation } from '@joplin/lib/services/share/reducer';
import ShareService from '@joplin/lib/services/share/ShareService';
import { reg } from '@joplin/lib/registry';
const { connect } = require('react-redux'); const { connect } = require('react-redux');
const { PromptDialog } = require('../PromptDialog.min.js'); const { PromptDialog } = require('../PromptDialog.min.js');
const NotePropertiesDialog = require('../NotePropertiesDialog.min.js'); const NotePropertiesDialog = require('../NotePropertiesDialog.min.js');
const PluginManager = require('@joplin/lib/services/PluginManager'); const PluginManager = require('@joplin/lib/services/PluginManager');
import EncryptionService from '@joplin/lib/services/EncryptionService';
const ipcRenderer = require('electron').ipcRenderer; const ipcRenderer = require('electron').ipcRenderer;
interface LayerModalState { interface LayerModalState {
@@ -63,15 +67,22 @@ interface Props {
settingEditorCodeView: boolean; settingEditorCodeView: boolean;
pluginsLegacy: any; pluginsLegacy: any;
startupPluginsLoaded: boolean; startupPluginsLoaded: boolean;
shareInvitations: ShareInvitation[];
isSafeMode: boolean; isSafeMode: boolean;
} }
interface ShareFolderDialogOptions {
folderId: string;
visible: boolean;
}
interface State { interface State {
promptOptions: any; promptOptions: any;
modalLayer: LayerModalState; modalLayer: LayerModalState;
notePropertiesDialogOptions: any; notePropertiesDialogOptions: any;
noteContentPropertiesDialogOptions: any; noteContentPropertiesDialogOptions: any;
shareNoteDialogOptions: any; shareNoteDialogOptions: any;
shareFolderDialogOptions: ShareFolderDialogOptions;
} }
const StyledUserWebviewDialogContainer = styled.div` const StyledUserWebviewDialogContainer = styled.div`
@@ -105,6 +116,7 @@ const commands = [
require('./commands/newTodo'), require('./commands/newTodo'),
require('./commands/print'), require('./commands/print'),
require('./commands/renameFolder'), require('./commands/renameFolder'),
require('./commands/showShareFolderDialog'),
require('./commands/renameTag'), require('./commands/renameTag'),
require('./commands/search'), require('./commands/search'),
require('./commands/selectTemplate'), require('./commands/selectTemplate'),
@@ -144,6 +156,10 @@ class MainScreenComponent extends React.Component<Props, State> {
notePropertiesDialogOptions: {}, notePropertiesDialogOptions: {},
noteContentPropertiesDialogOptions: {}, noteContentPropertiesDialogOptions: {},
shareNoteDialogOptions: {}, shareNoteDialogOptions: {},
shareFolderDialogOptions: {
visible: false,
folderId: '',
},
}; };
this.updateMainLayout(this.buildLayout(props.plugins)); this.updateMainLayout(this.buildLayout(props.plugins));
@@ -155,6 +171,7 @@ class MainScreenComponent extends React.Component<Props, State> {
this.notePropertiesDialog_close = this.notePropertiesDialog_close.bind(this); this.notePropertiesDialog_close = this.notePropertiesDialog_close.bind(this);
this.noteContentPropertiesDialog_close = this.noteContentPropertiesDialog_close.bind(this); this.noteContentPropertiesDialog_close = this.noteContentPropertiesDialog_close.bind(this);
this.shareNoteDialog_close = this.shareNoteDialog_close.bind(this); this.shareNoteDialog_close = this.shareNoteDialog_close.bind(this);
this.shareFolderDialog_close = this.shareFolderDialog_close.bind(this);
this.resizableLayout_resize = this.resizableLayout_resize.bind(this); this.resizableLayout_resize = this.resizableLayout_resize.bind(this);
this.resizableLayout_renderItem = this.resizableLayout_renderItem.bind(this); this.resizableLayout_renderItem = this.resizableLayout_renderItem.bind(this);
this.resizableLayout_moveButtonClick = this.resizableLayout_moveButtonClick.bind(this); this.resizableLayout_moveButtonClick = this.resizableLayout_moveButtonClick.bind(this);
@@ -204,6 +221,10 @@ class MainScreenComponent extends React.Component<Props, State> {
return newLayout !== layout ? validateLayout(newLayout) : layout; return newLayout !== layout ? validateLayout(newLayout) : layout;
} }
private showShareInvitationNotification(props: Props): boolean {
return !!props.shareInvitations.find(i => i.status === 0);
}
private buildLayout(plugins: PluginStates): LayoutItem { private buildLayout(plugins: PluginStates): LayoutItem {
const rootLayoutSize = this.rootLayoutSize(); const rootLayoutSize = this.rootLayoutSize();
@@ -268,10 +289,14 @@ class MainScreenComponent extends React.Component<Props, State> {
this.setState({ noteContentPropertiesDialogOptions: {} }); this.setState({ noteContentPropertiesDialogOptions: {} });
} }
shareNoteDialog_close() { private shareNoteDialog_close() {
this.setState({ shareNoteDialogOptions: {} }); this.setState({ shareNoteDialogOptions: {} });
} }
private shareFolderDialog_close() {
this.setState({ shareFolderDialogOptions: { visible: false, folderId: '' } });
}
updateMainLayout(layout: LayoutItem) { updateMainLayout(layout: LayoutItem) {
this.props.dispatch({ this.props.dispatch({
type: 'MAIN_LAYOUT_SET', type: 'MAIN_LAYOUT_SET',
@@ -321,6 +346,13 @@ class MainScreenComponent extends React.Component<Props, State> {
}); });
} }
if (this.state.shareFolderDialogOptions !== prevState.shareFolderDialogOptions) {
this.props.dispatch({
type: this.state.shareFolderDialogOptions && this.state.shareFolderDialogOptions.visible ? 'VISIBLE_DIALOGS_ADD' : 'VISIBLE_DIALOGS_REMOVE',
name: 'shareFolder',
});
}
if (this.props.mainLayout !== prevProps.mainLayout) { if (this.props.mainLayout !== prevProps.mainLayout) {
const toSave = saveLayout(this.props.mainLayout); const toSave = saveLayout(this.props.mainLayout);
Setting.setValue('ui.layout', toSave); Setting.setValue('ui.layout', toSave);
@@ -496,6 +528,12 @@ class MainScreenComponent extends React.Component<Props, State> {
bridge().restart(); bridge().restart();
}; };
const onInvitationRespond = async (shareUserId: string, accept: boolean) => {
await ShareService.instance().respondInvitation(shareUserId, accept);
await ShareService.instance().refreshShareInvitations();
void reg.scheduleSync(1000);
};
let msg = null; let msg = null;
if (this.props.isSafeMode) { if (this.props.isSafeMode) {
@@ -516,15 +554,6 @@ class MainScreenComponent extends React.Component<Props, State> {
</a> </a>
</span> </span>
); );
} else if (this.props.hasDisabledSyncItems) {
msg = (
<span>
{_('Some items cannot be synchronised.')}{' '}
<a href="#" onClick={() => onViewStatusScreen()}>
{_('View them now')}
</a>
</span>
);
} else if (this.props.hasDisabledEncryptionItems) { } else if (this.props.hasDisabledEncryptionItems) {
msg = ( msg = (
<span> <span>
@@ -534,15 +563,6 @@ class MainScreenComponent extends React.Component<Props, State> {
</a> </a>
</span> </span>
); );
} else if (this.props.showMissingMasterKeyMessage) {
msg = (
<span>
{_('One or more master keys need a password.')}{' '}
<a href="#" onClick={() => onViewEncryptionConfigScreen()}>
{_('Set the password')}
</a>
</span>
);
} else if (this.props.showNeedUpgradingMasterKeyMessage) { } else if (this.props.showNeedUpgradingMasterKeyMessage) {
msg = ( msg = (
<span> <span>
@@ -561,6 +581,40 @@ class MainScreenComponent extends React.Component<Props, State> {
</a> </a>
</span> </span>
); );
} else if (this.showShareInvitationNotification(this.props)) {
const invitation = this.props.shareInvitations[0];
const sharer = invitation.share.user;
msg = (
<span>
{_('%s (%s) would like to share a notebook with you.', sharer.full_name, sharer.email)}{' '}
<a href="#" onClick={() => onInvitationRespond(invitation.id, true)}>
{_('Accept')}
</a>
{' / '}
<a href="#" onClick={() => onInvitationRespond(invitation.id,true)}>
{_('Reject')}
</a>
</span>
);
} else if (this.props.hasDisabledSyncItems) {
msg = (
<span>
{_('Some items cannot be synchronised.')}{' '}
<a href="#" onClick={() => onViewStatusScreen()}>
{_('View them now')}
</a>
</span>
);
} else if (this.props.showMissingMasterKeyMessage) {
msg = (
<span>
{_('One or more master keys need a password.')}{' '}
<a href="#" onClick={() => onViewEncryptionConfigScreen()}>
{_('Set the password')}
</a>
</span>
);
} }
return ( return (
@@ -572,7 +626,7 @@ class MainScreenComponent extends React.Component<Props, State> {
messageBoxVisible(props: Props = null) { messageBoxVisible(props: Props = null) {
if (!props) props = this.props; if (!props) props = this.props;
return props.hasDisabledSyncItems || props.showMissingMasterKeyMessage || props.showNeedUpgradingMasterKeyMessage || props.showShouldReencryptMessage || props.hasDisabledEncryptionItems || this.props.shouldUpgradeSyncTarget || props.isSafeMode; return props.hasDisabledSyncItems || props.showMissingMasterKeyMessage || props.showNeedUpgradingMasterKeyMessage || props.showShouldReencryptMessage || props.hasDisabledEncryptionItems || this.props.shouldUpgradeSyncTarget || props.isSafeMode || this.showShareInvitationNotification(props);
} }
registerCommands() { registerCommands() {
@@ -739,6 +793,7 @@ class MainScreenComponent extends React.Component<Props, State> {
const notePropertiesDialogOptions = this.state.notePropertiesDialogOptions; const notePropertiesDialogOptions = this.state.notePropertiesDialogOptions;
const noteContentPropertiesDialogOptions = this.state.noteContentPropertiesDialogOptions; const noteContentPropertiesDialogOptions = this.state.noteContentPropertiesDialogOptions;
const shareNoteDialogOptions = this.state.shareNoteDialogOptions; const shareNoteDialogOptions = this.state.shareNoteDialogOptions;
const shareFolderDialogOptions = this.state.shareFolderDialogOptions;
const layoutComp = this.props.mainLayout ? ( const layoutComp = this.props.mainLayout ? (
<ResizableLayout <ResizableLayout
@@ -759,6 +814,7 @@ class MainScreenComponent extends React.Component<Props, State> {
{noteContentPropertiesDialogOptions.visible && <NoteContentPropertiesDialog markupLanguage={noteContentPropertiesDialogOptions.markupLanguage} themeId={this.props.themeId} onClose={this.noteContentPropertiesDialog_close} text={noteContentPropertiesDialogOptions.text}/>} {noteContentPropertiesDialogOptions.visible && <NoteContentPropertiesDialog markupLanguage={noteContentPropertiesDialogOptions.markupLanguage} themeId={this.props.themeId} onClose={this.noteContentPropertiesDialog_close} text={noteContentPropertiesDialogOptions.text}/>}
{notePropertiesDialogOptions.visible && <NotePropertiesDialog themeId={this.props.themeId} noteId={notePropertiesDialogOptions.noteId} onClose={this.notePropertiesDialog_close} onRevisionLinkClick={notePropertiesDialogOptions.onRevisionLinkClick} />} {notePropertiesDialogOptions.visible && <NotePropertiesDialog themeId={this.props.themeId} noteId={notePropertiesDialogOptions.noteId} onClose={this.notePropertiesDialog_close} onRevisionLinkClick={notePropertiesDialogOptions.onRevisionLinkClick} />}
{shareNoteDialogOptions.visible && <ShareNoteDialog themeId={this.props.themeId} noteIds={shareNoteDialogOptions.noteIds} onClose={this.shareNoteDialog_close} />} {shareNoteDialogOptions.visible && <ShareNoteDialog themeId={this.props.themeId} noteIds={shareNoteDialogOptions.noteIds} onClose={this.shareNoteDialog_close} />}
{shareFolderDialogOptions.visible && <ShareFolderDialog themeId={this.props.themeId} folderId={shareFolderDialogOptions.folderId} onClose={this.shareFolderDialog_close} />}
<PromptDialog autocomplete={promptOptions && 'autocomplete' in promptOptions ? promptOptions.autocomplete : null} defaultValue={promptOptions && promptOptions.value ? promptOptions.value : ''} themeId={this.props.themeId} style={styles.prompt} onClose={this.promptOnClose_} label={promptOptions ? promptOptions.label : ''} description={promptOptions ? promptOptions.description : null} visible={!!this.state.promptOptions} buttons={promptOptions && 'buttons' in promptOptions ? promptOptions.buttons : null} inputType={promptOptions && 'inputType' in promptOptions ? promptOptions.inputType : null} /> <PromptDialog autocomplete={promptOptions && 'autocomplete' in promptOptions ? promptOptions.autocomplete : null} defaultValue={promptOptions && promptOptions.value ? promptOptions.value : ''} themeId={this.props.themeId} style={styles.prompt} onClose={this.promptOnClose_} label={promptOptions ? promptOptions.label : ''} description={promptOptions ? promptOptions.description : null} visible={!!this.state.promptOptions} buttons={promptOptions && 'buttons' in promptOptions ? promptOptions.buttons : null} inputType={promptOptions && 'inputType' in promptOptions ? promptOptions.inputType : null} />
@@ -794,6 +850,7 @@ const mapStateToProps = (state: AppState) => {
layoutMoveMode: state.layoutMoveMode, layoutMoveMode: state.layoutMoveMode,
mainLayout: state.mainLayout, mainLayout: state.mainLayout,
startupPluginsLoaded: state.startupPluginsLoaded, startupPluginsLoaded: state.startupPluginsLoaded,
shareInvitations: state.shareService.shareInvitations,
isSafeMode: state.settings.isSafeMode, isSafeMode: state.settings.isSafeMode,
}; };
}; };

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 * as React from 'react';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { _ } from '@joplin/lib/locale'; import { _ } from '@joplin/lib/locale';
import DialogButtonRow from './DialogButtonRow';
const { themeStyle } = require('@joplin/lib/theme'); const { themeStyle } = require('@joplin/lib/theme');
const DialogButtonRow = require('./DialogButtonRow.min');
const Countable = require('countable'); const Countable = require('countable');
import markupLanguageUtils from '../utils/markupLanguageUtils'; import markupLanguageUtils from '../utils/markupLanguageUtils';

View File

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

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 { _, _n } from '@joplin/lib/locale';
import Note from '@joplin/lib/models/Note'; import Note from '@joplin/lib/models/Note';
import Setting from '@joplin/lib/models/Setting'; import Setting from '@joplin/lib/models/Setting';
import BaseItem from '@joplin/lib/models/BaseItem'; import DialogButtonRow from './DialogButtonRow';
import SyncTargetJoplinServer from '@joplin/lib/SyncTargetJoplinServer'; import { themeStyle, buildStyle } from '@joplin/lib/theme';
const { themeStyle, buildStyle } = require('@joplin/lib/theme');
const DialogButtonRow = require('./DialogButtonRow.min');
import { reg } from '@joplin/lib/registry'; import { reg } from '@joplin/lib/registry';
import Dialog from './Dialog';
import DialogTitle from './DialogTitle';
import ShareService from '@joplin/lib/services/share/ShareService';
const { clipboard } = require('electron'); const { clipboard } = require('electron');
interface ShareNoteDialogProps { interface ShareNoteDialogProps {
@@ -83,26 +83,19 @@ export default function ShareNoteDialog(props: ShareNoteDialogProps) {
void fetchNotes(); void fetchNotes();
}, [props.noteIds]); }, [props.noteIds]);
const fileApi = async () => {
const syncTarget = reg.syncTarget() as SyncTargetJoplinServer;
return syncTarget.fileApi();
};
const joplinServerApi = async (): Promise<JoplinServerApi> => {
return (await fileApi()).driver().api();
};
const buttonRow_click = () => { const buttonRow_click = () => {
props.onClose(); props.onClose();
}; };
const copyLinksToClipboard = (api: JoplinServerApi, shares: SharesMap) => { const copyLinksToClipboard = (shares: SharesMap) => {
const links = []; const links = [];
for (const n in shares) links.push(api.shareUrl(shares[n])); for (const n in shares) links.push(ShareService.instance().shareUrl(shares[n]));
clipboard.writeText(links.join('\n')); clipboard.writeText(links.join('\n'));
}; };
const shareLinkButton_click = async () => { const shareLinkButton_click = async () => {
const service = ShareService.instance();
let hasSynced = false; let hasSynced = false;
let tryToSync = false; let tryToSync = false;
while (true) { while (true) {
@@ -116,29 +109,20 @@ export default function ShareNoteDialog(props: ShareNoteDialogProps) {
setSharesState('creating'); setSharesState('creating');
const api = await joplinServerApi();
const newShares = Object.assign({}, shares); const newShares = Object.assign({}, shares);
let sharedStatusChanged = false;
for (const note of notes) { for (const note of notes) {
const fullPath = (await fileApi()).fullPath(BaseItem.systemPath(note.id)); const share = await service.shareNote(note.id);
const share = await api.shareFile(fullPath);
newShares[note.id] = share; newShares[note.id] = share;
const changed = await BaseItem.updateShareStatus(note, true);
if (changed) sharedStatusChanged = true;
} }
setShares(newShares); setShares(newShares);
if (sharedStatusChanged) { setSharesState('synchronizing');
setSharesState('synchronizing'); await reg.waitForSyncFinishedThenSync();
await reg.waitForSyncFinishedThenSync(); setSharesState('creating');
setSharesState('creating');
}
copyLinksToClipboard(api, newShares); copyLinksToClipboard(newShares);
setSharesState('created'); setSharesState('created');
} catch (error) { } catch (error) {
@@ -202,24 +186,20 @@ export default function ShareNoteDialog(props: ShareNoteDialogProps) {
return <div style={theme.textStyle}>{_('Note: When a note is shared, it will no longer be encrypted on the server.')}<hr/></div>; return <div style={theme.textStyle}>{_('Note: When a note is shared, it will no longer be encrypted on the server.')}<hr/></div>;
} }
function renderBetaWarningMessage() { function renderContent() {
return <div style={theme.textStyle}>{'Sharing notes via Joplin Server is a Beta feature and the API might change later on. What it means is that if you share a note, the link might become invalid after an upgrade, and you will have to share it again.'}</div>; return (
} <div>
<DialogTitle title={_('Share Notes')}/>
const rootStyle = Object.assign({}, theme.dialogBox);
rootStyle.width = '50%';
return (
<div style={theme.dialogModalLayer}>
<div style={rootStyle}>
<div style={theme.dialogTitle}>{_('Share Notes')}</div>
{renderNoteList(notes)} {renderNoteList(notes)}
<button disabled={['creating', 'synchronizing'].indexOf(sharesState) >= 0} style={styles.copyShareLinkButton} onClick={shareLinkButton_click}>{_n('Copy Shareable Link', 'Copy Shareable Links', noteCount)}</button> <button disabled={['creating', 'synchronizing'].indexOf(sharesState) >= 0} style={styles.copyShareLinkButton} onClick={shareLinkButton_click}>{_n('Copy Shareable Link', 'Copy Shareable Links', noteCount)}</button>
<div style={theme.textStyle}>{statusMessage(sharesState)}</div> <div style={theme.textStyle}>{statusMessage(sharesState)}</div>
{renderEncryptionWarningMessage()} {renderEncryptionWarningMessage()}
{renderBetaWarningMessage()}
<DialogButtonRow themeId={props.themeId} onClick={buttonRow_click} okButtonShow={false} cancelButtonLabel={_('Close')}/> <DialogButtonRow themeId={props.themeId} onClick={buttonRow_click} okButtonShow={false} cancelButtonLabel={_('Close')}/>
</div> </div>
</div> );
}
return (
<Dialog renderContent={renderContent}/>
); );
} }

View File

@@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { StyledRoot, StyledAddButton, StyledHeader, StyledHeaderIcon, StyledAllNotesIcon, StyledHeaderLabel, StyledListItem, StyledListItemAnchor, StyledExpandLink, StyledNoteCount, StyledSyncReportText, StyledSyncReport, StyledSynchronizeButton } from './styles'; import { StyledRoot, StyledAddButton, StyledShareIcon, StyledHeader, StyledHeaderIcon, StyledAllNotesIcon, StyledHeaderLabel, StyledListItem, StyledListItemAnchor, StyledExpandLink, StyledNoteCount, StyledSyncReportText, StyledSyncReport, StyledSynchronizeButton } from './styles';
import { ButtonLevel } from '../Button/Button'; import { ButtonLevel } from '../Button/Button';
import CommandService from '@joplin/lib/services/CommandService'; import CommandService from '@joplin/lib/services/CommandService';
import InteropService from '@joplin/lib/services/interop/InteropService'; import InteropService from '@joplin/lib/services/interop/InteropService';
@@ -12,13 +12,16 @@ import { PluginStates, utils as pluginUtils } from '@joplin/lib/services/plugins
import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types'; import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types';
import { AppState } from '../../app'; import { AppState } from '../../app';
import { ModelType } from '@joplin/lib/BaseModel'; import { ModelType } from '@joplin/lib/BaseModel';
const { connect } = require('react-redux');
const shared = require('@joplin/lib/components/shared/side-menu-shared.js');
import BaseModel from '@joplin/lib/BaseModel'; import BaseModel from '@joplin/lib/BaseModel';
import Folder from '@joplin/lib/models/Folder'; import Folder from '@joplin/lib/models/Folder';
import Note from '@joplin/lib/models/Note'; import Note from '@joplin/lib/models/Note';
import Tag from '@joplin/lib/models/Tag'; import Tag from '@joplin/lib/models/Tag';
import Logger from '@joplin/lib/Logger';
import { FolderEntity } from '@joplin/lib/services/database/types';
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
import { store } from '@joplin/lib/reducer';
const { connect } = require('react-redux');
const shared = require('@joplin/lib/components/shared/side-menu-shared.js');
const { themeStyle } = require('@joplin/lib/theme'); const { themeStyle } = require('@joplin/lib/theme');
const bridge = require('electron').remote.require('./bridge').default; const bridge = require('electron').remote.require('./bridge').default;
const Menu = bridge().Menu; const Menu = bridge().Menu;
@@ -26,6 +29,8 @@ const MenuItem = bridge().MenuItem;
const { substrWithEllipsis } = require('@joplin/lib/string-utils'); const { substrWithEllipsis } = require('@joplin/lib/string-utils');
const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids'); const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids');
const logger = Logger.create('Sidebar');
interface Props { interface Props {
themeId: number; themeId: number;
dispatch: Function; dispatch: Function;
@@ -70,10 +75,12 @@ function ExpandLink(props: any) {
} }
function FolderItem(props: any) { function FolderItem(props: any) {
const { hasChildren, isExpanded, depth, selected, folderId, folderTitle, anchorRef, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_ } = props; const { hasChildren, isExpanded, parentId, depth, selected, folderId, folderTitle, anchorRef, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_, shareId } = props;
const noteCountComp = noteCount ? <StyledNoteCount>{noteCount}</StyledNoteCount> : null; const noteCountComp = noteCount ? <StyledNoteCount>{noteCount}</StyledNoteCount> : null;
const shareIcon = shareId && !parentId ? <StyledShareIcon className="fas fa-share-alt"></StyledShareIcon> : null;
return ( return (
<StyledListItem depth={depth} selected={selected} className={`list-item-container list-item-depth-${depth}`} onDragStart={onFolderDragStart_} onDragOver={onFolderDragOver_} onDrop={onFolderDrop_} draggable={true} data-folder-id={folderId}> <StyledListItem depth={depth} selected={selected} className={`list-item-container list-item-depth-${depth}`} onDragStart={onFolderDragStart_} onDragOver={onFolderDragOver_} onDrop={onFolderDrop_} draggable={true} data-folder-id={folderId}>
<ExpandLink themeId={props.themeId} hasChildren={hasChildren} folderId={folderId} onClick={onFolderToggleClick_} isExpanded={isExpanded}/> <ExpandLink themeId={props.themeId} hasChildren={hasChildren} folderId={folderId} onClick={onFolderToggleClick_} isExpanded={isExpanded}/>
@@ -83,6 +90,7 @@ function FolderItem(props: any) {
isConflictFolder={folderId === Folder.conflictFolderId()} isConflictFolder={folderId === Folder.conflictFolderId()}
href="#" href="#"
selected={selected} selected={selected}
shareId={shareId}
data-id={folderId} data-id={folderId}
data-type={BaseModel.TYPE_FOLDER} data-type={BaseModel.TYPE_FOLDER}
onContextMenu={itemContextMenu} onContextMenu={itemContextMenu}
@@ -92,7 +100,7 @@ function FolderItem(props: any) {
}} }}
onDoubleClick={onFolderToggleClick_} onDoubleClick={onFolderToggleClick_}
> >
{folderTitle} {noteCountComp} {folderTitle} {shareIcon} {noteCountComp}
</StyledListItemAnchor> </StyledListItemAnchor>
</StyledListItem> </StyledListItem>
); );
@@ -158,22 +166,27 @@ class SidebarComponent extends React.Component<Props, State> {
// to put the dropped folder at the root. But for notes, folderId needs to always be defined // to put the dropped folder at the root. But for notes, folderId needs to always be defined
// since there's no such thing as a root note. // since there's no such thing as a root note.
if (dt.types.indexOf('text/x-jop-note-ids') >= 0) { try {
event.preventDefault(); if (dt.types.indexOf('text/x-jop-note-ids') >= 0) {
event.preventDefault();
if (!folderId) return; if (!folderId) return;
const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids')); const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids'));
for (let i = 0; i < noteIds.length; i++) { for (let i = 0; i < noteIds.length; i++) {
await Note.moveToFolder(noteIds[i], folderId); await Note.moveToFolder(noteIds[i], folderId);
} }
} else if (dt.types.indexOf('text/x-jop-folder-ids') >= 0) { } else if (dt.types.indexOf('text/x-jop-folder-ids') >= 0) {
event.preventDefault(); event.preventDefault();
const folderIds = JSON.parse(dt.getData('text/x-jop-folder-ids')); const folderIds = JSON.parse(dt.getData('text/x-jop-folder-ids'));
for (let i = 0; i < folderIds.length; i++) { for (let i = 0; i < folderIds.length; i++) {
await Folder.moveToFolder(folderIds[i], folderId); await Folder.moveToFolder(folderIds[i], folderId);
}
} }
} catch (error) {
logger.error(error);
alert(error.message);
} }
} }
@@ -222,12 +235,14 @@ class SidebarComponent extends React.Component<Props, State> {
const itemType = Number(event.currentTarget.getAttribute('data-type')); const itemType = Number(event.currentTarget.getAttribute('data-type'));
if (!itemId || !itemType) throw new Error('No data on element'); if (!itemId || !itemType) throw new Error('No data on element');
const state: AppState = store().getState();
let deleteMessage = ''; let deleteMessage = '';
let buttonLabel = _('Remove'); let deleteButtonLabel = _('Remove');
if (itemType === BaseModel.TYPE_FOLDER) { if (itemType === BaseModel.TYPE_FOLDER) {
const folder = await Folder.load(itemId); const folder = await Folder.load(itemId);
deleteMessage = _('Delete notebook "%s"?\n\nAll notes and sub-notebooks within this notebook will also be deleted.', substrWithEllipsis(folder.title, 0, 32)); deleteMessage = _('Delete notebook "%s"?\n\nAll notes and sub-notebooks within this notebook will also be deleted.', substrWithEllipsis(folder.title, 0, 32));
buttonLabel = _('Delete'); deleteButtonLabel = _('Delete');
} else if (itemType === BaseModel.TYPE_TAG) { } else if (itemType === BaseModel.TYPE_TAG) {
const tag = await Tag.load(itemId); const tag = await Tag.load(itemId);
deleteMessage = _('Remove tag "%s" from all notes?', substrWithEllipsis(tag.title, 0, 32)); deleteMessage = _('Remove tag "%s" from all notes?', substrWithEllipsis(tag.title, 0, 32));
@@ -250,10 +265,10 @@ class SidebarComponent extends React.Component<Props, State> {
menu.append( menu.append(
new MenuItem({ new MenuItem({
label: buttonLabel, label: deleteButtonLabel,
click: async () => { click: async () => {
const ok = bridge().showConfirmMessageBox(deleteMessage, { const ok = bridge().showConfirmMessageBox(deleteMessage, {
buttons: [buttonLabel, _('Cancel')], buttons: [deleteButtonLabel, _('Cancel')],
defaultId: 1, defaultId: 1,
}); });
if (!ok) return; if (!ok) return;
@@ -294,6 +309,14 @@ class SidebarComponent extends React.Component<Props, State> {
); );
} }
// We don't display the "Share notebook" menu item for sub-notebooks
// that are within a shared notebook. If user wants to do this,
// they'd have to move the notebook out of the shared notebook
// first.
if (CommandService.instance().isEnabled('showShareFolderDialog', stateToWhenClauseContext(state, { commandFolderId: itemId }))) {
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('showShareFolderDialog', itemId)));
}
menu.append( menu.append(
new MenuItem({ new MenuItem({
label: _('Export'), label: _('Export'),
@@ -387,10 +410,10 @@ class SidebarComponent extends React.Component<Props, State> {
); );
} }
renderFolderItem(folder: any, selected: boolean, hasChildren: boolean, depth: number) { renderFolderItem(folder: FolderEntity, selected: boolean, hasChildren: boolean, depth: number) {
const anchorRef = this.anchorItemRef('folder', folder.id); const anchorRef = this.anchorItemRef('folder', folder.id);
const isExpanded = this.props.collapsedFolderIds.indexOf(folder.id) < 0; const isExpanded = this.props.collapsedFolderIds.indexOf(folder.id) < 0;
let noteCount = folder.note_count; let noteCount = (folder as any).note_count;
// Thunderbird count: Subtract children note_count from parent folder if it expanded. // Thunderbird count: Subtract children note_count from parent folder if it expanded.
if (isExpanded) { if (isExpanded) {
@@ -418,6 +441,8 @@ class SidebarComponent extends React.Component<Props, State> {
itemContextMenu={this.itemContextMenu} itemContextMenu={this.itemContextMenu}
folderItem_click={this.folderItem_click} folderItem_click={this.folderItem_click}
onFolderToggleClick_={this.onFolderToggleClick_} onFolderToggleClick_={this.onFolderToggleClick_}
shareId={folder.share_id}
parentId={folder.parent_id}
/>; />;
} }

View File

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

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

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. // one.
import { AppState } from '../../app'; import { AppState } from '../../app';
import libStateToWhenClauseContext from '@joplin/lib/services/commands/stateToWhenClauseContext'; import libStateToWhenClauseContext, { WhenClauseContextOptions } from '@joplin/lib/services/commands/stateToWhenClauseContext';
import layoutItemProp from '../../gui/ResizableLayout/utils/layoutItemProp'; import layoutItemProp from '../../gui/ResizableLayout/utils/layoutItemProp';
export default function stateToWhenClauseContext(state: AppState) { export default function stateToWhenClauseContext(state: AppState, options: WhenClauseContextOptions = null) {
return { return {
...libStateToWhenClauseContext(state), ...libStateToWhenClauseContext(state, options),
// UI elements // UI elements
markdownEditorVisible: !!state.settings['editor.codeView'], markdownEditorVisible: !!state.settings['editor.codeView'],

View File

@@ -372,7 +372,7 @@ class SideMenuContentComponent extends Component {
items.push(this.renderSidebarButton('folder_header', _('Notebooks'), 'md-folder')); items.push(this.renderSidebarButton('folder_header', _('Notebooks'), 'md-folder'));
if (this.props.folders.length) { if (this.props.folders.length) {
const result = shared.renderFolders(this.props, this.renderFolderItem); const result = shared.renderFolders(this.props, this.renderFolderItem, false);
const folderItems = result.items; const folderItems = result.items;
items = items.concat(folderItems); items = items.concat(folderItems);
} }

View File

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

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> { export default async function setIgnoreTlsErrors(ignore: boolean): Promise<boolean> {
if (Platform.OS === 'android') { if (Platform.OS === 'android') {

View File

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

View File

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

View File

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

View File

@@ -138,20 +138,20 @@ export default class JoplinDatabase extends Database {
private tableFieldNames_: Record<string, string[]> = {}; private tableFieldNames_: Record<string, string[]> = {};
private tableDescriptions_: any; private tableDescriptions_: any;
constructor(driver: any) { public constructor(driver: any) {
super(driver); super(driver);
} }
initialized() { public initialized() {
return this.initialized_; return this.initialized_;
} }
async open(options: any) { public async open(options: any) {
await super.open(options); await super.open(options);
return this.initialize(); return this.initialize();
} }
tableFieldNames(tableName: string) { public tableFieldNames(tableName: string) {
if (this.tableFieldNames_[tableName]) return this.tableFieldNames_[tableName].slice(); if (this.tableFieldNames_[tableName]) return this.tableFieldNames_[tableName].slice();
const tf = this.tableFields(tableName); const tf = this.tableFields(tableName);
@@ -164,7 +164,7 @@ export default class JoplinDatabase extends Database {
return output.slice(); return output.slice();
} }
tableFields(tableName: string, options: any = null) { public tableFields(tableName: string, options: any = null) {
if (options === null) options = {}; if (options === null) options = {};
if (!this.tableFields_) throw new Error('Fields have not been loaded yet'); if (!this.tableFields_) throw new Error('Fields have not been loaded yet');
@@ -180,7 +180,7 @@ export default class JoplinDatabase extends Database {
return output; return output;
} }
async clearForTesting() { public async clearForTesting() {
const tableNames = [ const tableNames = [
'notes', 'notes',
'folders', 'folders',
@@ -220,7 +220,7 @@ export default class JoplinDatabase extends Database {
await this.transactionExecBatch(queries); await this.transactionExecBatch(queries);
} }
createDefaultRow(tableName: string) { public createDefaultRow(tableName: string) {
const row: any = {}; const row: any = {};
const fields = this.tableFields(tableName); const fields = this.tableFields(tableName);
for (let i = 0; i < fields.length; i++) { for (let i = 0; i < fields.length; i++) {
@@ -230,7 +230,7 @@ export default class JoplinDatabase extends Database {
return row; return row;
} }
fieldByName(tableName: string, fieldName: string) { public fieldByName(tableName: string, fieldName: string) {
const fields = this.tableFields(tableName); const fields = this.tableFields(tableName);
for (const field of fields) { for (const field of fields) {
if (field.name === fieldName) return field; if (field.name === fieldName) return field;
@@ -238,11 +238,11 @@ export default class JoplinDatabase extends Database {
throw new Error(`No such field: ${tableName}: ${fieldName}`); throw new Error(`No such field: ${tableName}: ${fieldName}`);
} }
fieldDefaultValue(tableName: string, fieldName: string) { public fieldDefaultValue(tableName: string, fieldName: string) {
return this.fieldByName(tableName, fieldName).default; return this.fieldByName(tableName, fieldName).default;
} }
fieldDescription(tableName: string, fieldName: string) { public fieldDescription(tableName: string, fieldName: string) {
const sp = sprintf; const sp = sprintf;
if (!this.tableDescriptions_) { if (!this.tableDescriptions_) {
@@ -278,7 +278,7 @@ export default class JoplinDatabase extends Database {
return d && d[fieldName] ? d[fieldName] : ''; return d && d[fieldName] ? d[fieldName] : '';
} }
refreshTableFields(newVersion: number) { public refreshTableFields(newVersion: number) {
this.logger().info('Initializing tables...'); this.logger().info('Initializing tables...');
const queries: SqlQuery[] = []; const queries: SqlQuery[] = [];
queries.push(this.wrapQuery('DELETE FROM table_fields')); queries.push(this.wrapQuery('DELETE FROM table_fields'));
@@ -323,12 +323,12 @@ export default class JoplinDatabase extends Database {
}); });
} }
addMigrationFile(num: number) { public addMigrationFile(num: number) {
const timestamp = Date.now(); const timestamp = Date.now();
return { sql: 'INSERT INTO migrations (number, created_time, updated_time) VALUES (?, ?, ?)', params: [num, timestamp, timestamp] }; return { sql: 'INSERT INTO migrations (number, created_time, updated_time) VALUES (?, ?, ?)', params: [num, timestamp, timestamp] };
} }
async upgradeDatabase(fromVersion: number) { public async upgradeDatabase(fromVersion: number) {
// INSTRUCTIONS TO UPGRADE THE DATABASE: // INSTRUCTIONS TO UPGRADE THE DATABASE:
// //
// 1. Add the new version number to the existingDatabaseVersions array // 1. Add the new version number to the existingDatabaseVersions array
@@ -343,7 +343,7 @@ export default class JoplinDatabase extends Database {
// must be set in the synchronizer too. // must be set in the synchronizer too.
// Note: v16 and v17 don't do anything. They were used to debug an issue. // Note: v16 and v17 don't do anything. They were used to debug an issue.
const existingDatabaseVersions = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35]; const existingDatabaseVersions = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37];
let currentVersionIndex = existingDatabaseVersions.indexOf(fromVersion); let currentVersionIndex = existingDatabaseVersions.indexOf(fromVersion);
@@ -870,6 +870,12 @@ export default class JoplinDatabase extends Database {
queries.push(this.addMigrationFile(35)); queries.push(this.addMigrationFile(35));
} }
if (targetVersion == 36) {
queries.push('ALTER TABLE folders ADD COLUMN share_id TEXT NOT NULL DEFAULT ""');
queries.push('ALTER TABLE notes ADD COLUMN share_id TEXT NOT NULL DEFAULT ""');
queries.push('ALTER TABLE resources ADD COLUMN share_id TEXT NOT NULL DEFAULT ""');
}
const updateVersionQuery = { sql: 'UPDATE version SET version = ?', params: [targetVersion] }; const updateVersionQuery = { sql: 'UPDATE version SET version = ?', params: [targetVersion] };
queries.push(updateVersionQuery); queries.push(updateVersionQuery);
@@ -906,7 +912,7 @@ export default class JoplinDatabase extends Database {
return latestVersion; return latestVersion;
} }
async ftsEnabled() { public async ftsEnabled() {
try { try {
await this.selectOne('SELECT count(*) FROM notes_fts'); await this.selectOne('SELECT count(*) FROM notes_fts');
} catch (error) { } catch (error) {
@@ -919,7 +925,7 @@ export default class JoplinDatabase extends Database {
return true; return true;
} }
async fuzzySearchEnabled() { public async fuzzySearchEnabled() {
try { try {
await this.selectOne('SELECT count(*) FROM notes_spellfix'); await this.selectOne('SELECT count(*) FROM notes_spellfix');
} catch (error) { } catch (error) {
@@ -930,11 +936,11 @@ export default class JoplinDatabase extends Database {
return true; return true;
} }
version() { public version() {
return this.version_; return this.version_;
} }
async initialize() { public async initialize() {
this.logger().info('Checking for database schema update...'); this.logger().info('Checking for database schema update...');
let versionRow = null; let versionRow = null;

View File

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

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 shim from './shim';
import { _ } from './locale'; import { _ } from './locale';
const { rtrimSlashes } = require('./path-utils.js'); const { rtrimSlashes } = require('./path-utils.js');
const JoplinError = require('./JoplinError'); import JoplinError from './JoplinError';
import { Env } from './models/Setting';
const { stringify } = require('query-string'); const { stringify } = require('query-string');
interface Options { interface Options {
baseUrl(): string; baseUrl(): string;
username(): string; username(): string;
password(): string; password(): string;
env?: Env;
} }
enum ExecOptionsResponseFormat { enum ExecOptionsResponseFormat {
@@ -27,22 +29,30 @@ interface ExecOptions {
source?: string; source?: string;
} }
interface Session {
id: string;
user_id: string;
}
export default class JoplinServerApi { export default class JoplinServerApi {
private options_: Options; private options_: Options;
private session_: any; private session_: Session;
private debugRequests_: boolean = false; private debugRequests_: boolean = false;
public constructor(options: Options) { public constructor(options: Options) {
this.options_ = options; this.options_ = options;
if (options.env === Env.Dev) {
this.debugRequests_ = true;
}
} }
private baseUrl() { public baseUrl() {
return rtrimSlashes(this.options_.baseUrl()); return rtrimSlashes(this.options_.baseUrl());
} }
private async session() { private async session() {
// TODO: handle invalid session
if (this.session_) return this.session_; if (this.session_) return this.session_;
this.session_ = await this.exec('POST', 'api/sessions', null, { this.session_ = await this.exec('POST', 'api/sessions', null, {
@@ -58,11 +68,8 @@ export default class JoplinServerApi {
return session ? session.id : ''; return session ? session.id : '';
} }
public async shareFile(pathOrId: string) { public get userId(): string {
return this.exec('POST', 'api/shares', null, { return this.session_ ? this.session_.user_id : '';
file_id: pathOrId,
type: 1, // ShareType.Link
});
} }
public static connectionErrorMessage(error: any) { public static connectionErrorMessage(error: any) {
@@ -70,10 +77,6 @@ export default class JoplinServerApi {
return _('Could not connect to Joplin Server. Please check the Synchronisation options in the config screen. Full error was:\n\n%s', msg); return _('Could not connect to Joplin Server. Please check the Synchronisation options in the config screen. Full error was:\n\n%s', msg);
} }
public shareUrl(share: any): string {
return `${this.baseUrl()}/shares/${share.id}`;
}
private requestToCurl_(url: string, options: any) { private requestToCurl_(url: string, options: any) {
const output = []; const output = [];
output.push('curl'); output.push('curl');
@@ -85,8 +88,11 @@ export default class JoplinServerApi {
output.push(`${'-H ' + '"'}${n}: ${options.headers[n]}"`); output.push(`${'-H ' + '"'}${n}: ${options.headers[n]}"`);
} }
} }
if (options.body) output.push(`${'--data ' + '\''}${JSON.stringify(options.body)}'`); if (options.body) {
output.push(url); const serialized = typeof options.body !== 'string' ? JSON.stringify(options.body) : options.body;
output.push(`${'--data ' + '\''}${serialized}'`);
}
output.push(`'${url}'`);
return output.join(' '); return output.join(' ');
} }
@@ -150,14 +156,17 @@ export default class JoplinServerApi {
const responseText = await response.text(); const responseText = await response.text();
// console.info('Joplin API Response', responseText); if (this.debugRequests_) {
console.info('Joplin API Response', responseText);
}
// Creates an error object with as much data as possible as it will appear in the log, which will make debugging easier // Creates an error object with as much data as possible as it will appear in the log, which will make debugging easier
const newError = (message: string, code: number = 0) => { const newError = (message: string, code: number = 0) => {
// Gives a shorter response for error messages. Useful for cases where a full HTML page is accidentally loaded instead of // Gives a shorter response for error messages. Useful for cases where a full HTML page is accidentally loaded instead of
// JSON. That way the error message will still show there's a problem but without filling up the log or screen. // JSON. That way the error message will still show there's a problem but without filling up the log or screen.
const shortResponseText = (`${responseText}`).substr(0, 1024); const shortResponseText = (`${responseText}`).substr(0, 1024);
return new JoplinError(`${method} ${path}: ${message} (${code}): ${shortResponseText}`, code); // return new JoplinError(`${method} ${path}: ${message} (${code}): ${shortResponseText}`, code);
return new JoplinError(message, code, `${method} ${path}: ${message} (${code}): ${shortResponseText}`);
}; };
let responseJson_: any = null; let responseJson_: any = null;

View File

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

View File

@@ -5,7 +5,6 @@ import shim from './shim';
import MigrationHandler from './services/synchronizer/MigrationHandler'; import MigrationHandler from './services/synchronizer/MigrationHandler';
import eventManager from './eventManager'; import eventManager from './eventManager';
import { _ } from './locale'; import { _ } from './locale';
import BaseItem from './models/BaseItem'; import BaseItem from './models/BaseItem';
import Folder from './models/Folder'; import Folder from './models/Folder';
import Note from './models/Note'; import Note from './models/Note';
@@ -14,12 +13,12 @@ import ItemChange from './models/ItemChange';
import ResourceLocalState from './models/ResourceLocalState'; import ResourceLocalState from './models/ResourceLocalState';
import MasterKey from './models/MasterKey'; import MasterKey from './models/MasterKey';
import BaseModel from './BaseModel'; import BaseModel from './BaseModel';
const { sprintf } = require('sprintf-js');
import time from './time'; import time from './time';
import ResourceService from './services/ResourceService'; import ResourceService from './services/ResourceService';
import EncryptionService from './services/EncryptionService'; import EncryptionService from './services/EncryptionService';
import NoteResource from './models/NoteResource'; import JoplinError from './JoplinError';
const JoplinError = require('./JoplinError'); import ShareService from './services/share/ShareService';
const { sprintf } = require('sprintf-js');
const TaskQueue = require('./TaskQueue'); const TaskQueue = require('./TaskQueue');
const { Dirnames } = require('./services/synchronizer/utils/types'); const { Dirnames } = require('./services/synchronizer/utils/types');
@@ -45,6 +44,7 @@ export default class Synchronizer {
private encryptionService_: EncryptionService = null; private encryptionService_: EncryptionService = null;
private resourceService_: ResourceService = null; private resourceService_: ResourceService = null;
private syncTargetIsLocked_: boolean = false; private syncTargetIsLocked_: boolean = false;
private shareService_: ShareService = null;
// Debug flags are used to test certain hard-to-test conditions // Debug flags are used to test certain hard-to-test conditions
// such as cancelling in the middle of a loop. // such as cancelling in the middle of a loop.
@@ -108,6 +108,10 @@ export default class Synchronizer {
return this.appType_ === 'mobile' ? 100 * 1000 * 1000 : Infinity; return this.appType_ === 'mobile' ? 100 * 1000 * 1000 : Infinity;
} }
public setShareService(v: ShareService) {
this.shareService_ = v;
}
public setEncryptionService(v: any) { public setEncryptionService(v: any) {
this.encryptionService_ = v; this.encryptionService_ = v;
} }
@@ -351,12 +355,15 @@ export default class Synchronizer {
if (this.resourceService()) { if (this.resourceService()) {
this.logger().info('Indexing resources...'); this.logger().info('Indexing resources...');
await this.resourceService().indexNoteResources(); await this.resourceService().indexNoteResources();
await NoteResource.applySharedStatusToLinkedResources();
} }
} catch (error) { } catch (error) {
this.logger().error('Error indexing resources:', error); this.logger().error('Error indexing resources:', error);
} }
// Before synchronising make sure all share_id properties are set
// correctly so as to share/unshare the right items.
await Folder.updateAllShareIds();
let errorToThrow = null; let errorToThrow = null;
let syncLock = null; let syncLock = null;
@@ -505,7 +512,7 @@ export default class Synchronizer {
this.logger().warn(`Uploading a large resource (resourceId: ${local.id}, size:${local.size} bytes) which may tie up the sync process.`); this.logger().warn(`Uploading a large resource (resourceId: ${local.id}, size:${local.size} bytes) which may tie up the sync process.`);
} }
await this.apiCall('put', remoteContentPath, null, { path: localResourceContentPath, source: 'file' }); await this.apiCall('put', remoteContentPath, null, { path: localResourceContentPath, source: 'file', shareId: local.share_id });
} catch (error) { } catch (error) {
if (error && ['rejectedByTarget', 'fileNotFound'].indexOf(error.code) >= 0) { if (error && ['rejectedByTarget', 'fileNotFound'].indexOf(error.code) >= 0) {
await handleCannotSyncItem(ItemClass, syncTargetId, local, error.message); await handleCannotSyncItem(ItemClass, syncTargetId, local, error.message);
@@ -924,6 +931,16 @@ export default class Synchronizer {
this.cancelling_ = false; this.cancelling_ = false;
} }
// After syncing, we run the share service maintenance, which is going
// to fetch share invitations, if any.
if (this.shareService_) {
try {
await this.shareService_.maintenance();
} catch (error) {
this.logger().error('Could not run share service maintenance:', error);
}
}
this.progressReport_.completedTime = time.unixMs(); this.progressReport_.completedTime = time.unixMs();
this.logSyncOperation('finished', null, null, `Synchronisation finished [${synchronizationId}]`); this.logSyncOperation('finished', null, null, `Synchronisation finished [${synchronizationId}]`);

View File

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

View File

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

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 { basicDelta } = require('./file-api');
const { basename } = require('./path-utils'); const { basename } = require('./path-utils');
const shim = require('./shim').default; const shim = require('./shim').default;
const JoplinError = require('./JoplinError'); const JoplinError = require('./JoplinError').default;
const { Buffer } = require('buffer'); const { Buffer } = require('buffer');
const S3_MAX_DELETES = 1000; const S3_MAX_DELETES = 1000;

View File

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

View File

@@ -1,5 +1,5 @@
import JoplinServerApi from './JoplinServerApi'; import JoplinServerApi from './JoplinServerApi';
import { trimSlashes, dirname, basename } from './path-utils'; import { trimSlashes } from './path-utils';
// All input paths should be in the format: "path/to/file". This is converted to // All input paths should be in the format: "path/to/file". This is converted to
// "root:/path/to/file:" when doing the API call. // "root:/path/to/file:" when doing the API call.
@@ -34,38 +34,35 @@ export default class FileApiDriverJoplinServer {
return 3; return 3;
} }
private metadataToStat_(md: any, path: string, isDeleted: boolean = false) { private metadataToStat_(md: any, path: string, isDeleted: boolean = false, rootPath: string) {
const output = { const output = {
path: path, path: rootPath ? path.substr(rootPath.length + 1) : path,
updated_time: md.updated_time, updated_time: md.updated_time,
isDir: !!md.is_directory, isDir: false, // !!md.is_directory,
isDeleted: isDeleted, isDeleted: isDeleted,
}; };
// TODO - HANDLE DELETED
// if (md['.tag'] === 'deleted') output.isDeleted = true;
return output; return output;
} }
private metadataToStats_(mds: any[]) { private metadataToStats_(mds: any[], rootPath: string) {
const output = []; const output = [];
for (let i = 0; i < mds.length; i++) { for (let i = 0; i < mds.length; i++) {
output.push(this.metadataToStat_(mds[i], mds[i].name)); output.push(this.metadataToStat_(mds[i], mds[i].name, false, rootPath));
} }
return output; return output;
} }
// Transforms a path such as "Apps/Joplin/file.txt" to a complete a complete // Transforms a path such as "Apps/Joplin/file.txt" to a complete a complete
// API URL path: "api/files/root:/Apps/Joplin/file.txt:" // API URL path: "api/items/root:/Apps/Joplin/file.txt:"
private apiFilePath_(p: string) { private apiFilePath_(p: string) {
return `api/files/root:/${trimSlashes(p)}:`; return `api/items/root:/${trimSlashes(p)}:`;
} }
public async stat(path: string) { public async stat(path: string) {
try { try {
const response = await this.api().exec('GET', this.apiFilePath_(path)); const response = await this.api().exec('GET', this.apiFilePath_(path));
return this.metadataToStat_(response, path); return this.metadataToStat_(response, path, false, '');
} catch (error) { } catch (error) {
if (error.code === 404) return null; if (error.code === 404) return null;
throw error; throw error;
@@ -80,9 +77,13 @@ export default class FileApiDriverJoplinServer {
try { try {
const query = cursor ? { cursor } : {}; const query = cursor ? { cursor } : {};
const response = await this.api().exec('GET', `${this.apiFilePath_(path)}/delta`, query); const response = await this.api().exec('GET', `${this.apiFilePath_(path)}/delta`, query);
const stats = response.items.map((item: any) => { const stats = response.items
return this.metadataToStat_(item.item, item.item.name, item.type === 3); .filter((item: any) => {
}); return item.item_name.indexOf('locks/') !== 0 && item.item_name.indexOf('temp/') !== 0;
})
.map((item: any) => {
return this.metadataToStat_(item, item.item_name, item.type === 3, '');
});
const output = { const output = {
items: stats, items: stats,
@@ -108,15 +109,22 @@ export default class FileApiDriverJoplinServer {
...options, ...options,
}; };
let isUsingWildcard = false;
let searchPath = path;
if (searchPath) {
searchPath += '/*';
isUsingWildcard = true;
}
const query = options.context?.cursor ? { cursor: options.context.cursor } : null; const query = options.context?.cursor ? { cursor: options.context.cursor } : null;
const results = await this.api().exec('GET', `${this.apiFilePath_(path)}/children`, query); const results = await this.api().exec('GET', `${this.apiFilePath_(searchPath)}/children`, query);
const newContext: any = {}; const newContext: any = {};
if (results.cursor) newContext.cursor = results.cursor; if (results.cursor) newContext.cursor = results.cursor;
return { return {
items: this.metadataToStats_(results.items), items: this.metadataToStats_(results.items, isUsingWildcard ? path : ''),
hasMore: results.has_more, hasMore: results.has_more,
context: newContext, context: newContext,
} as any; } as any;
@@ -134,32 +142,13 @@ export default class FileApiDriverJoplinServer {
} }
} }
private parentPath_(path: string) { public async mkdir(_path: string) {
return dirname(path); // This is a no-op because all items technically are at the root, but
} // they can have names such as ".resources/xxxxxxxxxx'
private basename_(path: string) {
return basename(path);
}
public async mkdir(path: string) {
const parentPath = this.parentPath_(path);
const filename = this.basename_(path);
try {
const response = await this.api().exec('POST', `${this.apiFilePath_(parentPath)}/children`, null, {
name: filename,
is_directory: 1,
});
return response;
} catch (error) {
// 409 is OK - directory already exists
if (error.code !== 409) throw error;
}
} }
public async put(path: string, content: any, options: any = null) { public async put(path: string, content: any, options: any = null) {
return this.api().exec('PUT', `${this.apiFilePath_(path)}/content`, null, content, { return this.api().exec('PUT', `${this.apiFilePath_(path)}/content`, options && options.shareId ? { share_id: options.shareId } : null, content, {
'Content-Type': 'application/octet-stream', 'Content-Type': 'application/octet-stream',
}, options); }, options);
} }
@@ -174,6 +163,5 @@ export default class FileApiDriverJoplinServer {
public async clearRoot(path: string) { public async clearRoot(path: string) {
await this.delete(path); await this.delete(path);
await this.mkdir(path);
} }
} }

View File

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

View File

@@ -4,7 +4,7 @@ import BaseItem from './models/BaseItem';
import time from './time'; import time from './time';
const { isHidden } = require('./path-utils'); const { isHidden } = require('./path-utils');
const JoplinError = require('./JoplinError'); import JoplinError from './JoplinError';
const ArrayUtils = require('./ArrayUtils'); const ArrayUtils = require('./ArrayUtils');
const { sprintf } = require('sprintf-js'); const { sprintf } = require('sprintf-js');
const Mutex = require('async-mutex').Mutex; const Mutex = require('async-mutex').Mutex;
@@ -14,6 +14,10 @@ const logger = Logger.create('FileApi');
function requestCanBeRepeated(error: any) { function requestCanBeRepeated(error: any) {
const errorCode = typeof error === 'object' && error.code ? error.code : null; const errorCode = typeof error === 'object' && error.code ? error.code : null;
// Unauthorized error - means username or password is incorrect or other
// permission issue, which won't be fixed by repeating the request.
if (errorCode === 403) return false;
// The target is explicitely rejecting the item so repeating wouldn't make a difference. // The target is explicitely rejecting the item so repeating wouldn't make a difference.
if (errorCode === 'rejectedByTarget') return false; if (errorCode === 'rejectedByTarget') return false;

View File

@@ -8,10 +8,19 @@ import { _ } from '../locale';
import Database from '../database'; import Database from '../database';
import ItemChange from './ItemChange'; import ItemChange from './ItemChange';
import ShareService from '../services/share/ShareService';
const JoplinError = require('../JoplinError.js'); const JoplinError = require('../JoplinError.js');
const { sprintf } = require('sprintf-js'); const { sprintf } = require('sprintf-js');
const moment = require('moment'); const moment = require('moment');
export interface BaseItemEntity {
id?: string;
encryption_applied?: boolean;
is_shared?: number;
share_id?: string;
type_?: ModelType;
}
export interface ItemsThatNeedDecryptionResult { export interface ItemsThatNeedDecryptionResult {
hasMore: boolean; hasMore: boolean;
items: any[]; items: any[];
@@ -21,6 +30,7 @@ export default class BaseItem extends BaseModel {
public static encryptionService_: any = null; public static encryptionService_: any = null;
public static revisionService_: any = null; public static revisionService_: any = null;
public static shareService_: ShareService = null;
// Also update: // Also update:
// - itemsThatNeedSync() // - itemsThatNeedSync()
@@ -382,14 +392,19 @@ export default class BaseItem extends BaseModel {
return this.revisionService_; return this.revisionService_;
} }
static async serializeForSync(item: any) { protected static shareService() {
if (!this.shareService_) throw new Error('BaseItem.shareService_ is not set!!');
return this.shareService_;
}
public static async serializeForSync(item: BaseItemEntity) {
const ItemClass = this.itemClass(item); const ItemClass = this.itemClass(item);
const shownKeys = ItemClass.fieldNames(); const shownKeys = ItemClass.fieldNames();
shownKeys.push('type_'); shownKeys.push('type_');
const serialized = await ItemClass.serialize(item, shownKeys); const serialized = await ItemClass.serialize(item, shownKeys);
if (!Setting.value('encryption.enabled') || !ItemClass.encryptionSupported() || item.is_shared) { if (!Setting.value('encryption.enabled') || !ItemClass.encryptionSupported() || item.is_shared || item.share_id) {
// Normally not possible since itemsThatNeedSync should only return decrypted items // Normally not possible since itemsThatNeedSync should only return decrypted items
if (item.encryption_applied) throw new JoplinError('Item is encrypted but encryption is currently disabled', 'cannotSyncEncrypted'); if (item.encryption_applied) throw new JoplinError('Item is encrypted but encryption is currently disabled', 'cannotSyncEncrypted');
return serialized; return serialized;
@@ -415,13 +430,13 @@ export default class BaseItem extends BaseModel {
// List of keys that won't be encrypted - mostly foreign keys required to link items // List of keys that won't be encrypted - mostly foreign keys required to link items
// with each others and timestamp required for synchronisation. // with each others and timestamp required for synchronisation.
const keepKeys = ['id', 'note_id', 'tag_id', 'parent_id', 'updated_time', 'type_']; const keepKeys = ['id', 'note_id', 'tag_id', 'parent_id', 'share_id', 'updated_time', 'type_'];
const reducedItem: any = {}; const reducedItem: any = {};
for (let i = 0; i < keepKeys.length; i++) { for (let i = 0; i < keepKeys.length; i++) {
const n = keepKeys[i]; const n = keepKeys[i];
if (!item.hasOwnProperty(n)) continue; if (!item.hasOwnProperty(n)) continue;
reducedItem[n] = item[n]; reducedItem[n] = (item as any)[n];
} }
reducedItem.encryption_applied = 1; reducedItem.encryption_applied = 1;
@@ -792,7 +807,7 @@ export default class BaseItem extends BaseModel {
} }
} }
static async updateShareStatus(item: any, isShared: boolean) { static async updateShareStatus(item: BaseItemEntity, isShared: boolean) {
if (!item.id || !item.type_) throw new Error('Item must have an ID and a type'); if (!item.id || !item.type_) throw new Error('Item must have an ID and a type');
if (!!item.is_shared === !!isShared) return false; if (!!item.is_shared === !!isShared) return false;
const ItemClass = this.getClassByItemType(item.type_); const ItemClass = this.getClassByItemType(item.type_);

View File

@@ -2,10 +2,11 @@ import { FolderEntity } from '../services/database/types';
import BaseModel from '../BaseModel'; import BaseModel from '../BaseModel';
import time from '../time'; import time from '../time';
import { _ } from '../locale'; import { _ } from '../locale';
import Note from './Note'; import Note from './Note';
import Database from '../database'; import Database from '../database';
import BaseItem from './BaseItem'; import BaseItem from './BaseItem';
import Resource from './Resource';
import { isRootSharedFolder } from '../services/share/reducer';
const { substrWithEllipsis } = require('../string-utils.js'); const { substrWithEllipsis } = require('../string-utils.js');
interface FolderEntityWithChildren extends FolderEntity { interface FolderEntityWithChildren extends FolderEntity {
@@ -75,8 +76,10 @@ export default class Folder extends BaseItem {
} }
static async delete(folderId: string, options: any = null) { static async delete(folderId: string, options: any = null) {
if (!options) options = {}; options = {
if (!('deleteChildren' in options)) options.deleteChildren = true; deleteChildren: true,
...options,
};
const folder = await Folder.load(folderId); const folder = await Folder.load(folderId);
if (!folder) return; // noop if (!folder) return; // noop
@@ -256,6 +259,120 @@ export default class Folder extends BaseItem {
} }
} }
public static async allChildrenFolders(folderId: string): Promise<FolderEntity[]> {
const sql = `
WITH RECURSIVE
folders_cte(id, parent_id, share_id) AS (
SELECT id, parent_id, share_id
FROM folders
WHERE parent_id = ?
UNION ALL
SELECT folders.id, folders.parent_id, folders.share_id
FROM folders
INNER JOIN folders_cte AS folders_cte ON (folders.parent_id = folders_cte.id)
)
SELECT id, parent_id, share_id FROM folders_cte;
`;
return this.db().selectAll(sql, [folderId]);
}
private static async rootSharedFolders(): Promise<FolderEntity[]> {
return this.db().selectAll('SELECT id, share_id FROM folders WHERE parent_id = "" AND share_id != ""');
}
public static async updateFolderShareIds(): Promise<void> {
// Get all the sub-folders of the shared folders, and set the share_id
// property.
const rootFolders = await this.rootSharedFolders();
let sharedFolderIds: string[] = [];
for (const rootFolder of rootFolders) {
const children = await this.allChildrenFolders(rootFolder.id);
for (const child of children) {
if (child.share_id !== rootFolder.share_id) {
await this.save({
id: child.id,
share_id: rootFolder.share_id,
updated_time: Date.now(),
}, { autoTimestamp: false });
}
}
sharedFolderIds.push(rootFolder.id);
sharedFolderIds = sharedFolderIds.concat(children.map(c => c.id));
}
// Now that we've set the share ID on all the sub-folders of the shared
// folders, those that remain should not be shared anymore. For example,
// if they've been moved out of a shared folder.
// await this.unshareItems(ModelType.Folder, sharedFolderIds);
const sql = ['SELECT id FROM folders WHERE share_id != ""'];
if (sharedFolderIds.length) {
sql.push(` AND id NOT IN ("${sharedFolderIds.join('","')}")`);
}
const foldersToUnshare = await this.db().selectAll(sql.join(' '));
for (const item of foldersToUnshare) {
await this.save({
id: item.id,
share_id: '',
updated_time: Date.now(),
}, { autoTimestamp: false });
}
}
public static async updateNoteShareIds() {
// Find all the notes where the share_id is not the same as the
// parent share_id because we only need to update those.
const rows = await this.db().selectAll(`
SELECT notes.id, folders.share_id
FROM notes
LEFT JOIN folders ON notes.parent_id = folders.id
WHERE notes.share_id != folders.share_id
`);
for (const row of rows) {
await Note.save({
id: row.id,
share_id: row.share_id || '',
updated_time: Date.now(),
}, { autoTimestamp: false });
}
}
public static async updateResourceShareIds() {
// Find all resources where share_id is different from parent note
// share_id. Then update share_id on all these resources. Essentially it
// makes it match the resource share_id to the note share_id.
const rows = await this.db().selectAll(`
SELECT r.id, n.share_id, n.is_shared
FROM note_resources nr
LEFT JOIN resources r ON nr.resource_id = r.id
LEFT JOIN notes n ON nr.note_id = n.id
WHERE n.share_id != r.share_id
OR n.is_shared != r.is_shared
`);
for (const row of rows) {
await Resource.save({
id: row.id,
share_id: row.share_id || '',
is_shared: row.is_shared,
updated_time: Date.now(),
}, { autoTimestamp: false });
}
}
public static async updateAllShareIds() {
await this.updateFolderShareIds();
await this.updateNoteShareIds();
await this.updateResourceShareIds();
}
static async allAsTree(folders: FolderEntity[] = null, options: any = null) { static async allAsTree(folders: FolderEntity[] = null, options: any = null) {
const all = folders ? folders : await this.all(options); const all = folders ? folders : await this.all(options);
@@ -392,6 +509,9 @@ export default class Folder extends BaseItem {
static async canNestUnder(folderId: string, targetFolderId: string) { static async canNestUnder(folderId: string, targetFolderId: string) {
if (folderId === targetFolderId) return false; if (folderId === targetFolderId) return false;
const folder = await Folder.load(folderId);
if (isRootSharedFolder(folder)) return false;
const conflictFolderId = Folder.conflictFolderId(); const conflictFolderId = Folder.conflictFolderId();
if (folderId == conflictFolderId || targetFolderId == conflictFolderId) return false; if (folderId == conflictFolderId || targetFolderId == conflictFolderId) return false;

View File

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

View File

@@ -36,6 +36,40 @@ export default class NoteResource extends BaseModel {
await this.db().transactionExecBatch(queries); await this.db().transactionExecBatch(queries);
} }
// public static async updateResourceShareIds() {
// // Find all resources where share_id is different from parent note
// // share_id. Then update share_id on all these resources. Essentially it
// // makes it match the resource share_id to the note share_id.
// const sql = `
// SELECT r.id, n.share_id
// FROM note_resources nr
// LEFT JOIN resources r ON nr.resource_id = r.id
// LEFT JOIN notes n ON nr.note_id = n.id
// WHERE n.share_id != r.share_id`;
// const rows = await this.db().selectAll(sql);
// const updatedTime = Date.now();
// const queries: SqlQuery[] = [];
// for (const row of rows) {
// queries.push({
// sql: `
// UPDATE resources
// SET share_id = ?, updated_time = ?
// WHERE id = ?`,
// params: [
// row.share_id || '',
// updatedTime,
// row.id,
// ],
// });
// }
// await this.db().transactionExecBatch(queries);
// }
static async associatedNoteIds(resourceId: string): Promise<string[]> { static async associatedNoteIds(resourceId: string): Promise<string[]> {
const rows = await this.modelSelectAll('SELECT note_id FROM note_resources WHERE resource_id = ? AND is_associated = 1', [resourceId]); const rows = await this.modelSelectAll('SELECT note_id FROM note_resources WHERE resource_id = ? AND is_associated = 1', [resourceId]);
return rows.map((r: any) => r.note_id); return rows.map((r: any) => r.note_id);

View File

@@ -11,7 +11,7 @@ const pathUtils = require('../path-utils');
const { mime } = require('../mime-utils.js'); const { mime } = require('../mime-utils.js');
const { filename, safeFilename } = require('../path-utils'); const { filename, safeFilename } = require('../path-utils');
const { FsDriverDummy } = require('../fs-driver-dummy.js'); const { FsDriverDummy } = require('../fs-driver-dummy.js');
const JoplinError = require('../JoplinError'); import JoplinError from '../JoplinError';
export default class Resource extends BaseItem { export default class Resource extends BaseItem {
@@ -48,7 +48,7 @@ export default class Resource extends BaseItem {
} }
public static sharedResourceIds(): Promise<string[]> { public static sharedResourceIds(): Promise<string[]> {
return this.db().selectAllFields('SELECT id FROM resources WHERE is_shared = 1', {}, 'id'); return this.db().selectAllFields('SELECT id FROM resources WHERE is_shared = 1', [], 'id');
} }
static errorFetchStatuses() { static errorFetchStatuses() {

View File

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

View File

@@ -1,6 +1,5 @@
import shim from '../shim'; import shim from '../shim';
import { _, supportedLocalesToLanguages, defaultLocale } from '../locale'; import { _, supportedLocalesToLanguages, defaultLocale } from '../locale';
import { ltrimSlashes } from '../path-utils';
import eventManager from '../eventManager'; import eventManager from '../eventManager';
import BaseModel from '../BaseModel'; import BaseModel from '../BaseModel';
import Database from '../database'; import Database from '../database';
@@ -88,6 +87,31 @@ export enum SyncStartupOperation {
ClearLocalData = 2, ClearLocalData = 2,
} }
export enum Env {
Undefined = 'SET_ME',
Dev = 'dev',
Prod = 'prod',
}
export interface Constants {
env: Env;
isDemo: boolean;
appName: string;
appId: string;
appType: string;
resourceDirName: string;
resourceDir: string;
profileDir: string;
templateDir: string;
tempDir: string;
pluginDataDir: string;
cacheDir: string;
pluginDir: string;
flagOpenDevTools: boolean;
syncVersion: number;
startupDevPlugins: string[];
}
interface SettingSections { interface SettingSections {
[key: string]: SettingSection; [key: string]: SettingSection;
} }
@@ -151,8 +175,8 @@ class Setting extends BaseModel {
// Contains constants that are set by the application and // Contains constants that are set by the application and
// cannot be modified by the user: // cannot be modified by the user:
public static constants_: any = { public static constants_: Constants = {
env: 'SET_ME', env: Env.Undefined,
isDemo: false, isDemo: false,
appName: 'joplin', appName: 'joplin',
appId: 'SET_ME', // Each app should set this identifier appId: 'SET_ME', // Each app should set this identifier
@@ -452,20 +476,20 @@ class Setting extends BaseModel {
description: () => emptyDirWarning, description: () => emptyDirWarning,
storage: SettingStorage.File, storage: SettingStorage.File,
}, },
'sync.9.directory': { // 'sync.9.directory': {
value: 'Apps/Joplin', // value: 'Apps/Joplin',
type: SettingItemType.String, // type: SettingItemType.String,
section: 'sync', // section: 'sync',
show: (settings: any) => { // show: (settings: any) => {
return settings['sync.target'] == SyncTargetRegistry.nameToId('joplinServer'); // return settings['sync.target'] == SyncTargetRegistry.nameToId('joplinServer');
}, // },
filter: value => { // filter: value => {
return value ? ltrimSlashes(rtrimSlashes(value)) : ''; // return value ? ltrimSlashes(rtrimSlashes(value)) : '';
}, // },
public: true, // public: true,
label: () => _('Joplin Server Directory'), // label: () => _('Joplin Server Directory'),
storage: SettingStorage.File, // storage: SettingStorage.File,
}, // },
'sync.9.username': { 'sync.9.username': {
value: '', value: '',
type: SettingItemType.String, type: SettingItemType.String,
@@ -474,7 +498,7 @@ class Setting extends BaseModel {
return settings['sync.target'] == SyncTargetRegistry.nameToId('joplinServer'); return settings['sync.target'] == SyncTargetRegistry.nameToId('joplinServer');
}, },
public: true, public: true,
label: () => _('Joplin Server username'), label: () => _('Joplin Server email'),
storage: SettingStorage.File, storage: SettingStorage.File,
}, },
'sync.9.password': { 'sync.9.password': {
@@ -843,6 +867,12 @@ class Setting extends BaseModel {
public: false, public: false,
}, },
'sync.userId': {
value: '',
type: SettingItemType.String,
public: false,
},
// Deprecated in favour of windowContentZoomFactor // Deprecated in favour of windowContentZoomFactor
'style.zoom': { value: 100, type: SettingItemType.Int, public: false, storage: SettingStorage.File, appTypes: ['desktop'], section: 'appearance', label: () => '', minimum: 50, maximum: 500, step: 10 }, 'style.zoom': { value: 100, type: SettingItemType.Int, public: false, storage: SettingStorage.File, appTypes: ['desktop'], section: 'appearance', label: () => '', minimum: 50, maximum: 500, step: 10 },
@@ -1383,7 +1413,7 @@ class Setting extends BaseModel {
static setConstant(key: string, value: any) { static setConstant(key: string, value: any) {
if (!(key in this.constants_)) throw new Error(`Unknown constant key: ${key}`); if (!(key in this.constants_)) throw new Error(`Unknown constant key: ${key}`);
this.constants_[key] = value; (this.constants_ as any)[key] = value;
} }
public static setValue(key: string, value: any) { public static setValue(key: string, value: any) {
@@ -1546,7 +1576,7 @@ class Setting extends BaseModel {
} }
if (key in this.constants_) { if (key in this.constants_) {
const v = this.constants_[key]; const v = (this.constants_ as any)[key];
const output = typeof v === 'function' ? v() : v; const output = typeof v === 'function' ? v() : v;
if (output == 'SET_ME') throw new Error(`SET_ME constant has not been set: ${key}`); if (output == 'SET_ME') throw new Error(`SET_ME constant has not been set: ${key}`);
return output; return output;

View File

@@ -1,8 +1,10 @@
import produce, { Draft } from 'immer'; import produce, { Draft } from 'immer';
import pluginServiceReducer, { stateRootKey as pluginServiceStateRootKey, defaultState as pluginServiceDefaultState, State as PluginServiceState } from './services/plugins/reducer'; import pluginServiceReducer, { stateRootKey as pluginServiceStateRootKey, defaultState as pluginServiceDefaultState, State as PluginServiceState } from './services/plugins/reducer';
import shareServiceReducer, { stateRootKey as shareServiceStateRootKey, defaultState as shareServiceDefaultState, State as ShareServiceState } from './services/share/reducer';
import Note from './models/Note'; import Note from './models/Note';
import Folder from './models/Folder'; import Folder from './models/Folder';
import BaseModel from './BaseModel'; import BaseModel from './BaseModel';
import { Store } from 'redux';
const ArrayUtils = require('./ArrayUtils.js'); const ArrayUtils = require('./ArrayUtils.js');
const { ALL_NOTES_FILTER_ID } = require('./reserved-ids'); const { ALL_NOTES_FILTER_ID } = require('./reserved-ids');
const { createSelectorCreator, defaultMemoize } = require('reselect'); const { createSelectorCreator, defaultMemoize } = require('reselect');
@@ -16,6 +18,12 @@ additionalReducers.push({
reducer: pluginServiceReducer, reducer: pluginServiceReducer,
}); });
additionalReducers.push({
stateRootKey: shareServiceStateRootKey,
defaultState: shareServiceDefaultState,
reducer: shareServiceReducer,
});
interface StateLastSelectedNotesIds { interface StateLastSelectedNotesIds {
Folder: any; Folder: any;
Tag: any; Tag: any;
@@ -86,6 +94,7 @@ export interface State {
// Extra reducer keys go here: // Extra reducer keys go here:
pluginService: PluginServiceState; pluginService: PluginServiceState;
shareService: ShareServiceState;
} }
export const defaultState: State = { export const defaultState: State = {
@@ -153,12 +162,23 @@ export const defaultState: State = {
hasEncryptedItems: false, hasEncryptedItems: false,
pluginService: pluginServiceDefaultState, pluginService: pluginServiceDefaultState,
shareService: shareServiceDefaultState,
}; };
for (const additionalReducer of additionalReducers) { for (const additionalReducer of additionalReducers) {
(defaultState as any)[additionalReducer.stateRootKey] = additionalReducer.defaultState; (defaultState as any)[additionalReducer.stateRootKey] = additionalReducer.defaultState;
} }
let store_: Store<any> = null;
export function setStore(v: Store<any>) {
store_ = v;
}
export function store(): Store<any> {
return store_;
}
export const MAX_HISTORY = 200; export const MAX_HISTORY = 200;
const derivedStateCache_: any = {}; const derivedStateCache_: any = {};

View File

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

View File

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

View File

@@ -3,7 +3,11 @@ import ItemChange from '../models/ItemChange';
export default class ItemChangeUtils { export default class ItemChangeUtils {
static async deleteProcessedChanges() { static async deleteProcessedChanges() {
const lastProcessedChangeIds = [Setting.value('resourceService.lastProcessedChangeId'), Setting.value('searchEngine.lastProcessedChangeId'), Setting.value('revisionService.lastProcessedChangeId')]; const lastProcessedChangeIds = [
Setting.value('resourceService.lastProcessedChangeId'),
Setting.value('searchEngine.lastProcessedChangeId'),
Setting.value('revisionService.lastProcessedChangeId'),
];
const lowestChangeId = Math.min(...lastProcessedChangeIds); const lowestChangeId = Math.min(...lastProcessedChangeIds);
await ItemChange.deleteOldChanges(lowestChangeId); await ItemChange.deleteOldChanges(lowestChangeId);

View File

@@ -1,12 +1,49 @@
import { State, stateUtils } from '../../reducer'; import { State, stateUtils } from '../../reducer';
import BaseModel from '../../BaseModel'; import BaseModel from '../../BaseModel';
import Folder from '../../models/Folder'; import Folder from '../../models/Folder';
import MarkupToHtml from '@joplin/renderer/MarkupToHtml'; import MarkupToHtml from '@joplin/renderer/MarkupToHtml';
import { isRootSharedFolder, isSharedFolderOwner } from '../share/reducer';
import { FolderEntity, NoteEntity } from '../database/types';
export default function stateToWhenClauseContext(state: State) { export interface WhenClauseContextOptions {
const noteId = state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null; commandFolderId?: string;
const note = noteId ? BaseModel.byId(state.notes, noteId) : null; commandNoteId?: string;
}
export interface WhenClauseContext {
notesAreBeingSaved: boolean;
syncStarted: boolean;
inConflictFolder: boolean;
oneNoteSelected: boolean;
someNotesSelected: boolean;
multipleNotesSelected: boolean;
noNotesSelected: boolean;
historyhasBackwardNotes: boolean;
historyhasForwardNotes: boolean;
oneFolderSelected: boolean;
noteIsTodo: boolean;
noteTodoCompleted: boolean;
noteIsMarkdown: boolean;
noteIsHtml: boolean;
folderIsShareRootAndOwnedByUser: boolean;
folderIsShared: boolean;
}
export default function stateToWhenClauseContext(state: State, options: WhenClauseContextOptions = null): WhenClauseContext {
options = {
commandFolderId: '',
commandNoteId: '',
...options,
};
const selectedNoteId = state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null;
const selectedNote: NoteEntity = selectedNoteId ? BaseModel.byId(state.notes, selectedNoteId) : null;
// const commandNoteId = options.commandNoteId || selectedNoteId;
// const commandNote:NoteEntity = commandNoteId ? BaseModel.byId(state.notes, commandNoteId) : null;
const commandFolderId = options.commandFolderId;
const commandFolder: FolderEntity = commandFolderId ? BaseModel.byId(state.folders, commandFolderId) : null;
return { return {
// Application state // Application state
@@ -17,7 +54,7 @@ export default function stateToWhenClauseContext(state: State) {
inConflictFolder: state.selectedFolderId === Folder.conflictFolderId(), inConflictFolder: state.selectedFolderId === Folder.conflictFolderId(),
// Note selection // Note selection
oneNoteSelected: !!note, oneNoteSelected: !!selectedNote,
someNotesSelected: state.selectedNoteIds.length > 0, someNotesSelected: state.selectedNoteIds.length > 0,
multipleNotesSelected: state.selectedNoteIds.length > 1, multipleNotesSelected: state.selectedNoteIds.length > 1,
noNotesSelected: !state.selectedNoteIds.length, noNotesSelected: !state.selectedNoteIds.length,
@@ -30,9 +67,13 @@ export default function stateToWhenClauseContext(state: State) {
oneFolderSelected: !!state.selectedFolderId, oneFolderSelected: !!state.selectedFolderId,
// Current note properties // Current note properties
noteIsTodo: note ? !!note.is_todo : false, noteIsTodo: selectedNote ? !!selectedNote.is_todo : false,
noteTodoCompleted: note ? !!note.todo_completed : false, noteTodoCompleted: selectedNote ? !!selectedNote.todo_completed : false,
noteIsMarkdown: note ? note.markup_language === MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN : false, noteIsMarkdown: selectedNote ? selectedNote.markup_language === MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN : false,
noteIsHtml: note ? note.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML : false, noteIsHtml: selectedNote ? selectedNote.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML : false,
// Current context folder
folderIsShareRootAndOwnedByUser: commandFolder ? isRootSharedFolder(commandFolder) && isSharedFolderOwner(state, commandFolder.id) : false,
folderIsShared: commandFolder ? !!commandFolder.share_id : false,
}; };
} }

View File

@@ -1,227 +1,235 @@
// AUTO-GENERATED BY packages/tools/generate-database-types.js // AUTO-GENERATED BY packages/tools/generate-database-types.js
// This file was generated by a tool. /*
// Rerun sql-ts to regenerate this file. * This file was generated by a tool.
* Rerun sql-ts to regenerate this file.
*/
export interface AlarmEntity { export interface AlarmEntity {
'id'?: number | null; "id"?: number | null
'note_id'?: string; "note_id"?: string
'trigger_time'?: number; "trigger_time"?: number
'type_'?: number; "type_"?: number
} }
export interface DeletedItemEntity { export interface DeletedItemEntity {
'id'?: number | null; "id"?: number | null
'item_type'?: number; "item_type"?: number
'item_id'?: string; "item_id"?: string
'deleted_time'?: number; "deleted_time"?: number
'sync_target'?: number; "sync_target"?: number
'type_'?: number; "type_"?: number
} }
export interface FolderEntity { export interface FolderEntity {
'id'?: string | null; "id"?: string | null
'title'?: string; "title"?: string
'created_time'?: number; "created_time"?: number
'updated_time'?: number; "updated_time"?: number
'user_created_time'?: number; "user_created_time"?: number
'user_updated_time'?: number; "user_updated_time"?: number
'encryption_cipher_text'?: string; "encryption_cipher_text"?: string
'encryption_applied'?: number; "encryption_applied"?: number
'parent_id'?: string; "parent_id"?: string
'is_shared'?: number; "is_shared"?: number
'type_'?: number; "is_linked_folder"?: number
"source_folder_owner_id"?: string
"share_id"?: string
"type_"?: number
} }
export interface ItemChangeEntity { export interface ItemChangeEntity {
'id'?: number | null; "id"?: number | null
'item_type'?: number; "item_type"?: number
'item_id'?: string; "item_id"?: string
'type'?: number; "type"?: number
'created_time'?: number; "created_time"?: number
'source'?: number; "source"?: number
'before_change_item'?: string; "before_change_item"?: string
'type_'?: number; "type_"?: number
} }
export interface KeyValueEntity { export interface KeyValueEntity {
'id'?: number | null; "id"?: number | null
'key'?: string; "key"?: string
'value'?: string; "value"?: string
'type'?: number; "type"?: number
'updated_time'?: number; "updated_time"?: number
'type_'?: number; "type_"?: number
} }
export interface MasterKeyEntity { export interface MasterKeyEntity {
'id'?: string | null; "id"?: string | null
'created_time'?: number; "created_time"?: number
'updated_time'?: number; "updated_time"?: number
'source_application'?: string; "source_application"?: string
'encryption_method'?: number; "encryption_method"?: number
'checksum'?: string; "checksum"?: string
'content'?: string; "content"?: string
'type_'?: number; "type_"?: number
} }
export interface MigrationEntity { export interface MigrationEntity {
'id'?: number | null; "id"?: number | null
'number'?: number; "number"?: number
'updated_time'?: number; "updated_time"?: number
'created_time'?: number; "created_time"?: number
'type_'?: number; "type_"?: number
} }
export interface NoteResourceEntity { export interface NoteResourceEntity {
'id'?: number | null; "id"?: number | null
'note_id'?: string; "note_id"?: string
'resource_id'?: string; "resource_id"?: string
'is_associated'?: number; "is_associated"?: number
'last_seen_time'?: number; "last_seen_time"?: number
'type_'?: number; "type_"?: number
} }
export interface NoteTagEntity { export interface NoteTagEntity {
'id'?: string | null; "id"?: string | null
'note_id'?: string; "note_id"?: string
'tag_id'?: string; "tag_id"?: string
'created_time'?: number; "created_time"?: number
'updated_time'?: number; "updated_time"?: number
'user_created_time'?: number; "user_created_time"?: number
'user_updated_time'?: number; "user_updated_time"?: number
'encryption_cipher_text'?: string; "encryption_cipher_text"?: string
'encryption_applied'?: number; "encryption_applied"?: number
'is_shared'?: number; "is_shared"?: number
'type_'?: number; "type_"?: number
} }
export interface NoteEntity { export interface NoteEntity {
'id'?: string | null; "id"?: string | null
'parent_id'?: string; "parent_id"?: string
'title'?: string; "title"?: string
'body'?: string; "body"?: string
'created_time'?: number; "created_time"?: number
'updated_time'?: number; "updated_time"?: number
'is_conflict'?: number; "is_conflict"?: number
'latitude'?: number; "latitude"?: number
'longitude'?: number; "longitude"?: number
'altitude'?: number; "altitude"?: number
'author'?: string; "author"?: string
'source_url'?: string; "source_url"?: string
'is_todo'?: number; "is_todo"?: number
'todo_due'?: number; "todo_due"?: number
'todo_completed'?: number; "todo_completed"?: number
'source'?: string; "source"?: string
'source_application'?: string; "source_application"?: string
'application_data'?: string; "application_data"?: string
'order'?: number; "order"?: number
'user_created_time'?: number; "user_created_time"?: number
'user_updated_time'?: number; "user_updated_time"?: number
'encryption_cipher_text'?: string; "encryption_cipher_text"?: string
'encryption_applied'?: number; "encryption_applied"?: number
'markup_language'?: number; "markup_language"?: number
'is_shared'?: number; "is_shared"?: number
'type_'?: number; "share_id"?: string
"type_"?: number
} }
export interface NotesNormalizedEntity { export interface NotesNormalizedEntity {
'id'?: string; "id"?: string
'title'?: string; "title"?: string
'body'?: string; "body"?: string
'user_created_time'?: number; "user_created_time"?: number
'user_updated_time'?: number; "user_updated_time"?: number
'is_todo'?: number; "is_todo"?: number
'todo_completed'?: number; "todo_completed"?: number
'parent_id'?: string; "parent_id"?: string
'latitude'?: number; "latitude"?: number
'longitude'?: number; "longitude"?: number
'altitude'?: number; "altitude"?: number
'source_url'?: string; "source_url"?: string
'type_'?: number; "todo_due"?: number
"type_"?: number
} }
export interface ResourceLocalStateEntity { export interface ResourceLocalStateEntity {
'id'?: number | null; "id"?: number | null
'resource_id'?: string; "resource_id"?: string
'fetch_status'?: number; "fetch_status"?: number
'fetch_error'?: string; "fetch_error"?: string
'type_'?: number; "type_"?: number
} }
export interface ResourceEntity { export interface ResourceEntity {
'id'?: string | null; "id"?: string | null
'title'?: string; "title"?: string
'mime'?: string; "mime"?: string
'filename'?: string; "filename"?: string
'created_time'?: number; "created_time"?: number
'updated_time'?: number; "updated_time"?: number
'user_created_time'?: number; "user_created_time"?: number
'user_updated_time'?: number; "user_updated_time"?: number
'file_extension'?: string; "file_extension"?: string
'encryption_cipher_text'?: string; "encryption_cipher_text"?: string
'encryption_applied'?: number; "encryption_applied"?: number
'encryption_blob_encrypted'?: number; "encryption_blob_encrypted"?: number
'size'?: number; "size"?: number
'is_shared'?: number; "is_shared"?: number
'type_'?: number; "share_id"?: string
"type_"?: number
} }
export interface ResourcesToDownloadEntity { export interface ResourcesToDownloadEntity {
'id'?: number | null; "id"?: number | null
'resource_id'?: string; "resource_id"?: string
'updated_time'?: number; "updated_time"?: number
'created_time'?: number; "created_time"?: number
'type_'?: number; "type_"?: number
} }
export interface RevisionEntity { export interface RevisionEntity {
'id'?: string | null; "id"?: string | null
'parent_id'?: string; "parent_id"?: string
'item_type'?: number; "item_type"?: number
'item_id'?: string; "item_id"?: string
'item_updated_time'?: number; "item_updated_time"?: number
'title_diff'?: string; "title_diff"?: string
'body_diff'?: string; "body_diff"?: string
'metadata_diff'?: string; "metadata_diff"?: string
'encryption_cipher_text'?: string; "encryption_cipher_text"?: string
'encryption_applied'?: number; "encryption_applied"?: number
'updated_time'?: number; "updated_time"?: number
'created_time'?: number; "created_time"?: number
'type_'?: number; "type_"?: number
} }
export interface SettingEntity { export interface SettingEntity {
'key'?: string | null; "key"?: string | null
'value'?: string | null; "value"?: string | null
'type_'?: number; "type_"?: number
} }
export interface SyncItemEntity { export interface SyncItemEntity {
'id'?: number | null; "id"?: number | null
'sync_target'?: number; "sync_target"?: number
'sync_time'?: number; "sync_time"?: number
'item_type'?: number; "item_type"?: number
'item_id'?: string; "item_id"?: string
'sync_disabled'?: number; "sync_disabled"?: number
'sync_disabled_reason'?: string; "sync_disabled_reason"?: string
'force_sync'?: number; "force_sync"?: number
'item_location'?: number; "item_location"?: number
'type_'?: number; "type_"?: number
} }
export interface TableFieldEntity { export interface TableFieldEntity {
'id'?: number | null; "id"?: number | null
'table_name'?: string; "table_name"?: string
'field_name'?: string; "field_name"?: string
'field_type'?: number; "field_type"?: number
'field_default'?: string | null; "field_default"?: string | null
'type_'?: number; "type_"?: number
} }
export interface TagEntity { export interface TagEntity {
'id'?: string | null; "id"?: string | null
'title'?: string; "title"?: string
'created_time'?: number; "created_time"?: number
'updated_time'?: number; "updated_time"?: number
'user_created_time'?: number; "user_created_time"?: number
'user_updated_time'?: number; "user_updated_time"?: number
'encryption_cipher_text'?: string; "encryption_cipher_text"?: string
'encryption_applied'?: number; "encryption_applied"?: number
'is_shared'?: number; "is_shared"?: number
'parent_id'?: string; "parent_id"?: string
'type_'?: number; "type_"?: number
} }
export interface TagsWithNoteCountEntity { export interface TagsWithNoteCountEntity {
'id'?: string | null; "id"?: string | null
'title'?: string | null; "title"?: string | null
'created_time'?: number | null; "created_time"?: number | null
'updated_time'?: number | null; "updated_time"?: number | null
'note_count'?: any | null; "note_count"?: any | null
'type_'?: number; "type_"?: number
} }
export interface VersionEntity { export interface VersionEntity {
'version'?: number; "version"?: number
'table_fields_version'?: number; "table_fields_version"?: number
'type_'?: number; "type_"?: number
} }

View File

@@ -18,11 +18,17 @@ export default class KeychainService extends BaseService {
this.driver = driver; this.driver = driver;
} }
// This is to programatically disable the keychain service, regardless whether keychain // This is to programatically disable the keychain service, whether keychain
// is supported or not in the system (In other word, this might "enabled" but nothing // is supported or not in the system (In other word, this be might "enabled"
// will be saved to the keychain if there isn't one). // but nothing will be saved to the keychain if there isn't one).
public get enabled(): boolean { public get enabled(): boolean {
return this.enabled_; if (!this.enabled_) return false;
// Otherwise we assume it's enabled if "keychain.supported" is either -1
// (undetermined) or 1 (working). We make it work for -1 too because the
// setPassword() and password() functions need to work to test if the
// keychain is supported (in detectIfKeychainSupported).
return Setting.value('keychain.supported') !== 0;
} }
public set enabled(v: boolean) { public set enabled(v: boolean) {

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 { Dirnames } from './utils/types';
import shim from '../../shim'; import shim from '../../shim';
const JoplinError = require('../../JoplinError'); import JoplinError from '../../JoplinError';
import time from '../../time'; import time from '../../time';
const { fileExtension, filename } = require('../../path-utils'); const { fileExtension, filename } = require('../../path-utils');

View File

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

View File

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

View File

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

View File

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

7
packages/server/LICENSE Normal file
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": { "scripts": {
"start-dev": "nodemon --config nodemon.json dist/app.js --env dev", "start-dev": "nodemon --config nodemon.json dist/app.js --env dev",
"start": "node dist/app.js", "start": "node dist/app.js",
"generateTypes": "rm -f db-buildTypes.sqlite && npm run start -- --migrate-db --env buildTypes && node dist/tools/generateTypes.js && rm -f db-buildTypes.sqlite", "generateTypes": "rm -f db-buildTypes.sqlite && npm run start -- --migrate-db --env buildTypes && node dist/tools/generateTypes.js && mv db-buildTypes.sqlite schema.sqlite",
"tsc": "tsc --project tsconfig.json", "tsc": "tsc --project tsconfig.json",
"test": "jest", "test": "jest --verbose=false",
"test-ci": "npm run test", "test-ci": "npm run test",
"test-debug": "node --inspect node_modules/.bin/jest -- --verbose=false",
"clean": "rm -rf dist/",
"watch": "tsc --watch --project tsconfig.json" "watch": "tsc --watch --project tsconfig.json"
}, },
"dependencies": { "dependencies": {
@@ -22,7 +24,7 @@
"formidable": "^1.2.2", "formidable": "^1.2.2",
"fs-extra": "^8.1.0", "fs-extra": "^8.1.0",
"html-entities": "^1.3.1", "html-entities": "^1.3.1",
"knex": "^0.19.4", "knex": "0.95.4",
"koa": "^2.8.1", "koa": "^2.8.1",
"markdown-it": "^12.0.4", "markdown-it": "^12.0.4",
"mustache": "^3.1.0", "mustache": "^3.1.0",

View File

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

Binary file not shown.

View File

@@ -14,6 +14,9 @@ import routeHandler from './middleware/routeHandler';
import notificationHandler from './middleware/notificationHandler'; import notificationHandler from './middleware/notificationHandler';
import ownerHandler from './middleware/ownerHandler'; import ownerHandler from './middleware/ownerHandler';
import setupAppContext from './utils/setupAppContext'; import setupAppContext from './utils/setupAppContext';
import { initializeJoplinUtils } from './utils/joplinUtils';
import startServices from './utils/startServices';
// import { createItemTree } from './utils/testing/testUtils';
const nodeEnvFile = require('node-env-file'); const nodeEnvFile = require('node-env-file');
const { shimInit } = require('@joplin/lib/shim-init-node.js'); const { shimInit } = require('@joplin/lib/shim-init-node.js');
@@ -126,12 +129,46 @@ async function main() {
const appContext = app.context as AppContext; const appContext = app.context as AppContext;
await setupAppContext(appContext, env, connectionCheck.connection, appLogger); await setupAppContext(appContext, env, connectionCheck.connection, appLogger);
await initializeJoplinUtils(config(), appContext.models);
appLogger().info('Migrating database...'); appLogger().info('Migrating database...');
await migrateDb(appContext.db); await migrateDb(appContext.db);
appLogger().info('Starting services...');
await startServices(appContext);
// if (env !== Env.Prod) {
// const done = await handleDebugCommands(argv, appContext.db, config());
// if (done) {
// appLogger().info('Debug command has been executed. Now starting server...');
// }
// }
appLogger().info(`Call this for testing: \`curl ${config().baseUrl}/api/ping\``); appLogger().info(`Call this for testing: \`curl ${config().baseUrl}/api/ping\``);
// const tree: any = {
// '000000000000000000000000000000F1': {},
// '000000000000000000000000000000F2': {
// '00000000000000000000000000000001': null,
// '00000000000000000000000000000002': null,
// },
// '000000000000000000000000000000F3': {
// '00000000000000000000000000000003': null,
// '000000000000000000000000000000F4': {
// '00000000000000000000000000000004': null,
// '00000000000000000000000000000005': null,
// },
// },
// '00000000000000000000000000000006': null,
// '00000000000000000000000000000007': null,
// };
// const users = await appContext.models.user().all();
// const itemModel = appContext.models.item({ userId: users[0].id });
// await createItemTree(itemModel, '', tree);
app.listen(config().port); app.listen(config().port);
} }
} }

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

View File

@@ -1,4 +1,4 @@
import * as Knex from 'knex'; import { knex, Knex } from 'knex';
import { DatabaseConfig } from './utils/types'; import { DatabaseConfig } from './utils/types';
import * as pathUtils from 'path'; import * as pathUtils from 'path';
import time from '@joplin/lib/time'; import time from '@joplin/lib/time';
@@ -98,7 +98,7 @@ export async function waitForConnection(dbConfig: DatabaseConfig): Promise<Conne
} }
export async function connectDb(dbConfig: DatabaseConfig): Promise<DbConnection> { export async function connectDb(dbConfig: DatabaseConfig): Promise<DbConnection> {
return require('knex')(makeKnexConfig(dbConfig)); return knex(makeKnexConfig(dbConfig));
} }
export async function disconnectDb(db: DbConnection) { export async function disconnectDb(db: DbConnection) {
@@ -148,7 +148,19 @@ function isNoSuchTableError(error: any): boolean {
if (error.code === '42P01') return true; if (error.code === '42P01') return true;
// Sqlite3 error // Sqlite3 error
if (error.message && error.message.includes('no such table: knex_migrations')) return true; if (error.message && error.message.includes('SQLITE_ERROR: no such table:')) return true;
}
return false;
}
export function isUniqueConstraintError(error: any): boolean {
if (error) {
// Postgres error: 23505: unique_violation
if (error.code === '23505') return true;
// Sqlite3 error
if (error.code === 'SQLITE_CONSTRAINT' && error.message.includes('UNIQUE constraint')) return true;
} }
return false; return false;
@@ -201,8 +213,9 @@ export enum NotificationLevel {
} }
export enum ItemType { export enum ItemType {
File = 1, Item = 1,
User, UserItem = 2,
User,
} }
export enum ChangeType { export enum ChangeType {
@@ -211,6 +224,11 @@ export enum ChangeType {
Delete = 3, Delete = 3,
} }
export enum FileContentType {
Any = 1,
JoplinItem = 2,
}
export function changeTypeToString(t: ChangeType): string { export function changeTypeToString(t: ChangeType): string {
if (t === ChangeType.Create) return 'create'; if (t === ChangeType.Create) return 'create';
if (t === ChangeType.Update) return 'update'; if (t === ChangeType.Update) return 'update';
@@ -221,6 +239,13 @@ export function changeTypeToString(t: ChangeType): string {
export enum ShareType { export enum ShareType {
Link = 1, // When a note is shared via a public link Link = 1, // When a note is shared via a public link
App = 2, // When a note is shared with another user on the same server instance App = 2, // When a note is shared with another user on the same server instance
JoplinRootFolder = 3,
}
export enum ShareUserStatus {
Waiting = 0,
Accepted = 1,
Rejected = 2,
} }
export interface WithDates { export interface WithDates {
@@ -229,7 +254,7 @@ export interface WithDates {
} }
export interface WithUuid { export interface WithUuid {
id?: string; id?: Uuid;
} }
interface DatabaseTableColumn { interface DatabaseTableColumn {
@@ -258,33 +283,21 @@ export interface Session extends WithDates, WithUuid {
auth_code?: string; auth_code?: string;
} }
export interface Permission extends WithDates, WithUuid { export interface File {
user_id?: Uuid; id?: Uuid;
item_type?: ItemType;
item_id?: Uuid;
can_read?: number;
can_write?: number;
}
export interface File extends WithDates, WithUuid {
owner_id?: Uuid; owner_id?: Uuid;
name?: string; name?: string;
content?: Buffer; content?: any;
mime_type?: string; mime_type?: string;
size?: number; size?: number;
is_directory?: number; is_directory?: number;
is_root?: number; is_root?: number;
parent_id?: Uuid; parent_id?: Uuid;
} updated_time?: string;
created_time?: string;
export interface Change extends WithDates, WithUuid { source_file_id?: Uuid;
counter?: number; content_type?: number;
owner_id?: Uuid; content_id?: Uuid;
item_type?: ItemType;
parent_id?: Uuid;
item_id?: Uuid;
item_name?: string;
type?: ChangeType;
} }
export interface ApiClient extends WithDates, WithUuid { export interface ApiClient extends WithDates, WithUuid {
@@ -301,10 +314,59 @@ export interface Notification extends WithDates, WithUuid {
canBeDismissed?: number; canBeDismissed?: number;
} }
export interface ShareUser extends WithDates, WithUuid {
share_id?: Uuid;
user_id?: Uuid;
status?: ShareUserStatus;
}
export interface Item extends WithDates, WithUuid {
name?: string;
mime_type?: string;
content?: Buffer;
content_size?: number;
jop_id?: Uuid;
jop_parent_id?: Uuid;
jop_share_id?: Uuid;
jop_type?: number;
jop_encryption_applied?: number;
}
export interface UserItem extends WithDates {
id?: number;
user_id?: Uuid;
item_id?: Uuid;
}
export interface ItemResource {
id?: number;
item_id?: Uuid;
resource_id?: Uuid;
}
export interface KeyValue {
id?: number;
key?: string;
type?: number;
value?: string;
}
export interface Share extends WithDates, WithUuid { export interface Share extends WithDates, WithUuid {
owner_id?: Uuid; owner_id?: Uuid;
file_id?: Uuid; item_id?: Uuid;
type?: ShareType; type?: ShareType;
folder_id?: Uuid;
note_id?: Uuid;
}
export interface Change extends WithDates, WithUuid {
counter?: number;
item_type?: ItemType;
item_id?: Uuid;
item_name?: string;
type?: ChangeType;
previous_item?: string;
user_id?: Uuid;
} }
export const databaseSchema: DatabaseTables = { export const databaseSchema: DatabaseTables = {
@@ -324,16 +386,6 @@ export const databaseSchema: DatabaseTables = {
updated_time: { type: 'string' }, updated_time: { type: 'string' },
created_time: { type: 'string' }, created_time: { type: 'string' },
}, },
permissions: {
id: { type: 'string' },
user_id: { type: 'string' },
item_type: { type: 'number' },
item_id: { type: 'string' },
can_read: { type: 'number' },
can_write: { type: 'number' },
updated_time: { type: 'string' },
created_time: { type: 'string' },
},
files: { files: {
id: { type: 'string' }, id: { type: 'string' },
owner_id: { type: 'string' }, owner_id: { type: 'string' },
@@ -346,18 +398,9 @@ export const databaseSchema: DatabaseTables = {
parent_id: { type: 'string' }, parent_id: { type: 'string' },
updated_time: { type: 'string' }, updated_time: { type: 'string' },
created_time: { type: 'string' }, created_time: { type: 'string' },
}, source_file_id: { type: 'string' },
changes: { content_type: { type: 'number' },
counter: { type: 'number' }, content_id: { type: 'string' },
id: { type: 'string' },
owner_id: { type: 'string' },
item_type: { type: 'number' },
parent_id: { type: 'string' },
item_id: { type: 'string' },
item_name: { type: 'string' },
type: { type: 'number' },
updated_time: { type: 'string' },
created_time: { type: 'string' },
}, },
api_clients: { api_clients: {
id: { type: 'string' }, id: { type: 'string' },
@@ -377,13 +420,67 @@ export const databaseSchema: DatabaseTables = {
updated_time: { type: 'string' }, updated_time: { type: 'string' },
created_time: { type: 'string' }, created_time: { type: 'string' },
}, },
shares: { share_users: {
id: { type: 'string' }, id: { type: 'string' },
owner_id: { type: 'string' }, share_id: { type: 'string' },
file_id: { type: 'string' }, user_id: { type: 'string' },
type: { type: 'number' }, status: { type: 'number' },
updated_time: { type: 'string' }, updated_time: { type: 'string' },
created_time: { type: 'string' }, created_time: { type: 'string' },
}, },
items: {
id: { type: 'string' },
name: { type: 'string' },
mime_type: { type: 'string' },
updated_time: { type: 'string' },
created_time: { type: 'string' },
content: { type: 'any' },
content_size: { type: 'number' },
jop_id: { type: 'string' },
jop_parent_id: { type: 'string' },
jop_share_id: { type: 'string' },
jop_type: { type: 'number' },
jop_encryption_applied: { type: 'number' },
},
user_items: {
id: { type: 'number' },
user_id: { type: 'string' },
item_id: { type: 'string' },
updated_time: { type: 'string' },
created_time: { type: 'string' },
},
item_resources: {
id: { type: 'number' },
item_id: { type: 'string' },
resource_id: { type: 'string' },
},
key_values: {
id: { type: 'number' },
key: { type: 'string' },
type: { type: 'number' },
value: { type: 'string' },
},
shares: {
id: { type: 'string' },
owner_id: { type: 'string' },
item_id: { type: 'string' },
type: { type: 'number' },
updated_time: { type: 'string' },
created_time: { type: 'string' },
folder_id: { type: 'string' },
note_id: { type: 'string' },
},
changes: {
counter: { type: 'number' },
id: { type: 'string' },
item_type: { type: 'number' },
item_id: { type: 'string' },
item_name: { type: 'string' },
type: { type: 'number' },
updated_time: { type: 'string' },
created_time: { type: 'string' },
previous_item: { type: 'string' },
user_id: { type: 'string' },
},
}; };
// AUTO-GENERATED-TYPES // AUTO-GENERATED-TYPES

View File

@@ -17,9 +17,9 @@ describe('notificationHandler', function() {
}); });
test('should check admin password', async function() { test('should check admin password', async function() {
const { user, session } = await createUserAndSession(1, true); const { session } = await createUserAndSession(1, true);
const admin = await models().user({ userId: user.id }).save({ const admin = await models().user().save({
email: defaultAdminEmail, email: defaultAdminEmail,
password: defaultAdminPassword, password: defaultAdminPassword,
is_admin: 1, is_admin: 1,
@@ -38,7 +38,7 @@ describe('notificationHandler', function() {
} }
{ {
await models().user({ userId: admin.id }).save({ await models().user().save({
id: admin.id, id: admin.id,
password: 'changed!', password: 'changed!',
}); });

View File

@@ -12,20 +12,22 @@ async function handleChangeAdminPasswordNotification(ctx: AppContext) {
if (!ctx.owner.is_admin) return; if (!ctx.owner.is_admin) return;
const defaultAdmin = await ctx.models.user().login(defaultAdminEmail, defaultAdminPassword); const defaultAdmin = await ctx.models.user().login(defaultAdminEmail, defaultAdminPassword);
const notificationModel = ctx.models.notification({ userId: ctx.owner.id }); const notificationModel = ctx.models.notification();
if (defaultAdmin) { if (defaultAdmin) {
await notificationModel.add( await notificationModel.add(
ctx.owner.id,
'change_admin_password', 'change_admin_password',
NotificationLevel.Important, NotificationLevel.Important,
_('The default admin password is insecure and has not been changed! [Change it now](%s)', await ctx.models.user().profileUrl()) _('The default admin password is insecure and has not been changed! [Change it now](%s)', await ctx.models.user().profileUrl())
); );
} else { } else {
await notificationModel.markAsRead('change_admin_password'); await notificationModel.markAsRead(ctx.owner.id, 'change_admin_password');
} }
if (config().database.client === 'sqlite3' && ctx.env === 'prod') { if (config().database.client === 'sqlite3' && ctx.env === 'prod') {
await notificationModel.add( await notificationModel.add(
ctx.owner.id,
'using_sqlite_in_prod', 'using_sqlite_in_prod',
NotificationLevel.Important, NotificationLevel.Important,
'The server is currently using SQLite3 as a database. It is not recommended in production as it is slow and can cause locking issues. Please see the README for information on how to change it.' 'The server is currently using SQLite3 as a database. It is not recommended in production as it is slow and can cause locking issues. Please see the README for information on how to change it.'
@@ -36,10 +38,11 @@ async function handleChangeAdminPasswordNotification(ctx: AppContext) {
async function handleSqliteInProdNotification(ctx: AppContext) { async function handleSqliteInProdNotification(ctx: AppContext) {
if (!ctx.owner.is_admin) return; if (!ctx.owner.is_admin) return;
const notificationModel = ctx.models.notification({ userId: ctx.owner.id }); const notificationModel = ctx.models.notification();
if (config().database.client === 'sqlite3' && ctx.env === 'prod') { if (config().database.client === 'sqlite3' && ctx.env === 'prod') {
await notificationModel.add( await notificationModel.add(
ctx.owner.id,
'using_sqlite_in_prod', 'using_sqlite_in_prod',
NotificationLevel.Important, NotificationLevel.Important,
'The server is currently using SQLite3 as a database. It is not recommended in production as it is slow and can cause locking issues. Please see the README for information on how to change it.' 'The server is currently using SQLite3 as a database. It is not recommended in production as it is slow and can cause locking issues. Please see the README for information on how to change it.'
@@ -50,7 +53,7 @@ async function handleSqliteInProdNotification(ctx: AppContext) {
async function makeNotificationViews(ctx: AppContext): Promise<NotificationView[]> { async function makeNotificationViews(ctx: AppContext): Promise<NotificationView[]> {
const markdownIt = new MarkdownIt(); const markdownIt = new MarkdownIt();
const notificationModel = ctx.models.notification({ userId: ctx.owner.id }); const notificationModel = ctx.models.notification();
const notifications = await notificationModel.allUnreadByUserId(ctx.owner.id); const notifications = await notificationModel.allUnreadByUserId(ctx.owner.id);
const views: NotificationView[] = []; const views: NotificationView[] = [];
for (const n of notifications) { for (const n of notifications) {

View File

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

View File

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

View File

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

View File

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

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 TransactionHandler from '../utils/TransactionHandler';
import uuidgen from '../utils/uuidgen'; import uuidgen from '../utils/uuidgen';
import { ErrorUnprocessableEntity, ErrorBadRequest } from '../utils/errors'; import { ErrorUnprocessableEntity, ErrorBadRequest } from '../utils/errors';
import { Models } from './factory'; import { Models } from './factory';
import * as EventEmitter from 'events';
export interface ModelOptions {
userId?: string;
}
export interface SaveOptions { export interface SaveOptions {
isNew?: boolean; isNew?: boolean;
skipValidation?: boolean; skipValidation?: boolean;
validationRules?: any; validationRules?: any;
trackChanges?: boolean; previousItem?: any;
}
export interface LoadOptions {
fields?: string[];
} }
export interface DeleteOptions { export interface DeleteOptions {
validationRules?: any; validationRules?: any;
allowNoOp?: boolean;
deletedItemUserIds?: Record<Uuid, Uuid[]>;
} }
export interface ValidateOptions { export interface ValidateOptions {
@@ -24,24 +27,29 @@ export interface ValidateOptions {
rules?: any; rules?: any;
} }
export enum AclAction {
Create = 1,
Read = 2,
Update = 3,
Delete = 4,
List = 5,
}
export default abstract class BaseModel<T> { export default abstract class BaseModel<T> {
private options_: ModelOptions = null;
private defaultFields_: string[] = []; private defaultFields_: string[] = [];
private db_: DbConnection; private db_: DbConnection;
private transactionHandler_: TransactionHandler; private transactionHandler_: TransactionHandler;
private modelFactory_: Function; private modelFactory_: Function;
private baseUrl_: string; private baseUrl_: string;
private static eventEmitter_: EventEmitter = null;
public constructor(db: DbConnection, modelFactory: Function, baseUrl: string, options: ModelOptions = null) { public constructor(db: DbConnection, modelFactory: Function, baseUrl: string) {
this.db_ = db; this.db_ = db;
this.modelFactory_ = modelFactory; this.modelFactory_ = modelFactory;
this.baseUrl_ = baseUrl; this.baseUrl_ = baseUrl;
this.options_ = Object.assign({}, options);
this.transactionHandler_ = new TransactionHandler(db); this.transactionHandler_ = new TransactionHandler(db);
if ('userId' in this.options && !this.options.userId) throw new Error('If userId is set, it cannot be null');
} }
// When a model create an instance of another model, the active // When a model create an instance of another model, the active
@@ -55,14 +63,6 @@ export default abstract class BaseModel<T> {
return this.baseUrl_; return this.baseUrl_;
} }
protected get options(): ModelOptions {
return this.options_;
}
protected get userId(): string {
return this.options.userId;
}
protected get db(): DbConnection { protected get db(): DbConnection {
if (this.transactionHandler_.activeTransaction) return this.transactionHandler_.activeTransaction; if (this.transactionHandler_.activeTransaction) return this.transactionHandler_.activeTransaction;
return this.db_; return this.db_;
@@ -75,6 +75,34 @@ export default abstract class BaseModel<T> {
return this.defaultFields_.slice(); return this.defaultFields_.slice();
} }
public static get eventEmitter(): EventEmitter {
if (!this.eventEmitter_) {
this.eventEmitter_ = new EventEmitter();
}
return this.eventEmitter_;
}
public async checkIfAllowed(_user: User, _action: AclAction, _resource: T = null): Promise<void> {
throw new Error('Must be overriden');
}
protected selectFields(options: LoadOptions, defaultFields: string[] = null, mainTable: string = ''): string[] {
let output: string[] = [];
if (options && options.fields) {
output = options.fields;
} else if (defaultFields) {
output = defaultFields;
} else {
output = this.defaultFields;
}
if (mainTable) {
output = output.map(f => `${mainTable}.${f}`);
}
return output;
}
protected get tableName(): string { protected get tableName(): string {
throw new Error('Not implemented'); throw new Error('Not implemented');
} }
@@ -83,15 +111,11 @@ export default abstract class BaseModel<T> {
throw new Error('Not implemented'); throw new Error('Not implemented');
} }
protected get trackChanges(): boolean {
return false;
}
protected hasUuid(): boolean { protected hasUuid(): boolean {
return true; return true;
} }
protected hasDateProperties(): boolean { protected autoTimestampEnabled(): boolean {
return true; return true;
} }
@@ -121,7 +145,7 @@ export default abstract class BaseModel<T> {
// //
// The `name` argument is only for debugging, so that any stuck transaction // The `name` argument is only for debugging, so that any stuck transaction
// can be more easily identified. // can be more easily identified.
protected async withTransaction(fn: Function, name: string = null): Promise<void> { protected async withTransaction<T>(fn: Function, name: string = null): Promise<T> {
const debugTransaction = false; const debugTransaction = false;
const debugTimerId = debugTransaction ? setTimeout(() => { const debugTimerId = debugTransaction ? setTimeout(() => {
@@ -132,8 +156,10 @@ export default abstract class BaseModel<T> {
if (debugTransaction) console.info('START', name, txIndex); if (debugTransaction) console.info('START', name, txIndex);
let output: T = null;
try { try {
await fn(); output = await fn();
} catch (error) { } catch (error) {
await this.transactionHandler_.rollback(txIndex); await this.transactionHandler_.rollback(txIndex);
@@ -151,10 +177,12 @@ export default abstract class BaseModel<T> {
} }
await this.transactionHandler_.commit(txIndex); await this.transactionHandler_.commit(txIndex);
return output;
} }
public async all(): Promise<T[]> { public async all(options: LoadOptions = {}): Promise<T[]> {
return this.db(this.tableName).select(...this.defaultFields); const rows: any[] = await this.db(this.tableName).select(this.selectFields(options));
return rows as T[];
} }
public fromApiInput(object: T): T { public fromApiInput(object: T): T {
@@ -170,10 +198,18 @@ export default abstract class BaseModel<T> {
return output; return output;
} }
public toApiOutput(object: any): any { protected objectToApiOutput(object: T): T {
return { ...object }; return { ...object };
} }
public toApiOutput(object: T | T[]): T | T[] {
if (Array.isArray(object)) {
return object.map(f => this.objectToApiOutput(f));
} else {
return this.objectToApiOutput(object);
}
}
protected async validate(object: T, options: ValidateOptions = {}): Promise<T> { protected async validate(object: T, options: ValidateOptions = {}): Promise<T> {
if (!options.isNew && !(object as WithUuid).id) throw new ErrorUnprocessableEntity('id is missing'); if (!options.isNew && !(object as WithUuid).id) throw new ErrorUnprocessableEntity('id is missing');
return object; return object;
@@ -182,31 +218,10 @@ export default abstract class BaseModel<T> {
protected async isNew(object: T, options: SaveOptions): Promise<boolean> { protected async isNew(object: T, options: SaveOptions): Promise<boolean> {
if (options.isNew === false) return false; if (options.isNew === false) return false;
if (options.isNew === true) return true; if (options.isNew === true) return true;
if ('id' in object && !(object as WithUuid).id) throw new Error('ID cannot be undefined or null');
return !(object as WithUuid).id; return !(object as WithUuid).id;
} }
private async handleChangeTracking(options: SaveOptions, item: T, changeType: ChangeType): Promise<void> {
const trackChanges = this.trackChanges && options.trackChanges !== false;
if (!trackChanges) return;
let parentId = null;
if (this.hasParentId) {
if (!('parent_id' in item)) {
const temp: any = await this.db(this.tableName).select(['parent_id']).where('id', '=', (item as WithUuid).id).first();
parentId = temp.parent_id;
} else {
parentId = (item as any).parent_id;
}
}
// Sanity check - shouldn't happen
// Parent ID can be an empty string for root folders, but it shouldn't be null or undefined
if (this.hasParentId && !parentId && parentId !== '') throw new Error(`Could not find parent ID for item: ${(item as WithUuid).id}`);
const changeModel = this.models().change({ userId: this.userId });
await changeModel.add(this.itemType, parentId, (item as WithUuid).id, (item as any).name || '', changeType);
}
public async save(object: T, options: SaveOptions = {}): Promise<T> { public async save(object: T, options: SaveOptions = {}): Promise<T> {
if (!object) throw new Error('Object cannot be empty'); if (!object) throw new Error('Object cannot be empty');
@@ -214,11 +229,11 @@ export default abstract class BaseModel<T> {
const isNew = await this.isNew(object, options); const isNew = await this.isNew(object, options);
if (isNew && !(toSave as WithUuid).id) { if (this.hasUuid() && isNew && !(toSave as WithUuid).id) {
(toSave as WithUuid).id = uuidgen(); (toSave as WithUuid).id = uuidgen();
} }
if (this.hasDateProperties()) { if (this.autoTimestampEnabled()) {
const timestamp = Date.now(); const timestamp = Date.now();
if (isNew) { if (isNew) {
(toSave as WithDates).created_time = timestamp; (toSave as WithDates).created_time = timestamp;
@@ -231,7 +246,6 @@ export default abstract class BaseModel<T> {
await this.withTransaction(async () => { await this.withTransaction(async () => {
if (isNew) { if (isNew) {
await this.db(this.tableName).insert(toSave); await this.db(this.tableName).insert(toSave);
await this.handleChangeTracking(options, toSave, ChangeType.Create);
} else { } else {
const objectId: string = (toSave as WithUuid).id; const objectId: string = (toSave as WithUuid).id;
if (!objectId) throw new Error('Missing "id" property'); if (!objectId) throw new Error('Missing "id" property');
@@ -239,8 +253,6 @@ export default abstract class BaseModel<T> {
const updatedCount: number = await this.db(this.tableName).update(toSave).where({ id: objectId }); const updatedCount: number = await this.db(this.tableName).update(toSave).where({ id: objectId });
(toSave as WithUuid).id = objectId; (toSave as WithUuid).id = objectId;
await this.handleChangeTracking(options, toSave, ChangeType.Update);
// Sanity check: // Sanity check:
if (updatedCount !== 1) throw new ErrorBadRequest(`one row should have been updated, but ${updatedCount} row(s) were updated`); if (updatedCount !== 1) throw new ErrorBadRequest(`one row should have been updated, but ${updatedCount} row(s) were updated`);
} }
@@ -249,31 +261,24 @@ export default abstract class BaseModel<T> {
return toSave; return toSave;
} }
public async loadByIds(ids: string[]): Promise<T[]> { public async loadByIds(ids: string[], options: LoadOptions = {}): Promise<T[]> {
if (!ids.length) return []; if (!ids.length) return [];
return this.db(this.tableName).select(this.defaultFields).whereIn('id', ids); return this.db(this.tableName).select(options.fields || this.defaultFields).whereIn('id', ids);
} }
public async load(id: string): Promise<T> { public async load(id: string, options: LoadOptions = {}): Promise<T> {
if (!id) throw new Error('id cannot be empty'); if (!id) throw new Error('id cannot be empty');
return this.db(this.tableName).select(this.defaultFields).where({ id: id }).first(); return this.db(this.tableName).select(options.fields || this.defaultFields).where({ id: id }).first();
} }
public async delete(id: string | string[]): Promise<void> { public async delete(id: string | string[], options: DeleteOptions = {}): Promise<void> {
if (!id) throw new Error('id cannot be empty'); if (!id) throw new Error('id cannot be empty');
const ids = typeof id === 'string' ? [id] : id; const ids = typeof id === 'string' ? [id] : id;
if (!ids.length) throw new Error('no id provided'); if (!ids.length) throw new Error('no id provided');
const trackChanges = this.trackChanges;
let itemsWithParentIds: T[] = null;
if (trackChanges) {
itemsWithParentIds = await this.db(this.tableName).select(['id', 'parent_id', 'name']).whereIn('id', ids);
}
await this.withTransaction(async () => { await this.withTransaction(async () => {
const query = this.db(this.tableName).where({ id: ids[0] }); const query = this.db(this.tableName).where({ id: ids[0] });
for (let i = 1; i < ids.length; i++) { for (let i = 1; i < ids.length; i++) {
@@ -281,11 +286,7 @@ export default abstract class BaseModel<T> {
} }
const deletedCount = await query.del(); const deletedCount = await query.del();
if (deletedCount !== ids.length) throw new Error(`${ids.length} row(s) should have been deleted by ${deletedCount} row(s) were deleted`); if (!options.allowNoOp && deletedCount !== ids.length) throw new Error(`${ids.length} row(s) should have been deleted but ${deletedCount} row(s) were deleted`);
if (trackChanges) {
for (const item of itemsWithParentIds) await this.handleChangeTracking({}, item, ChangeType.Delete);
}
}, 'BaseModel::delete'); }, 'BaseModel::delete');
} }

View File

@@ -1,13 +1,11 @@
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, expectThrow } from '../utils/testing/testUtils'; import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, expectThrow, createFolder } from '../utils/testing/testUtils';
import { ChangeType, File } from '../db'; import { ChangeType, Item, Uuid } from '../db';
import FileModel from './FileModel';
import { msleep } from '../utils/time'; import { msleep } from '../utils/time';
import { ChangePagination } from './ChangeModel'; import { ChangePagination } from './ChangeModel';
async function makeTestFile(fileModel: FileModel): Promise<File> { async function makeTestItem(userId: Uuid, num: number): Promise<Item> {
return fileModel.save({ return models().item().saveForUser(userId, {
name: 'test', name: `0000000000000000000000000000000${num}.md`,
parent_id: await fileModel.userRootFileId(),
}); });
} }
@@ -25,95 +23,111 @@ describe('ChangeModel', function() {
await beforeEachDb(); await beforeEachDb();
}); });
test('should track changes - create', async function() { test('should track changes - create only', async function() {
const { user } = await createUserAndSession(1, true); const { session, user } = await createUserAndSession(1, true);
const fileModel = models().file({ userId: user.id }); const changeModel = models().change();
const changeModel = models().change({ userId: user.id });
const file1 = await makeTestFile(fileModel); const item1 = await createFolder(session.id, { title: 'folder' });
const dirId = await fileModel.userRootFileId();
{ {
const changes = (await changeModel.byDirectoryId(dirId, { limit: 20 })).items; const changes = (await changeModel.allForUser(user.id)).items;
expect(changes.length).toBe(1); expect(changes.length).toBe(1);
expect(changes[0].item.id).toBe(file1.id); expect(changes[0].item_id).toBe(item1.id);
expect(changes[0].type).toBe(ChangeType.Create); expect(changes[0].type).toBe(ChangeType.Create);
} }
}); });
test('should track changes - create, then update', async function() { test('should track changes - create, then update', async function() {
const { user } = await createUserAndSession(1, true); const { user } = await createUserAndSession(1, true);
const fileModel = models().file({ userId: user.id }); const itemModel = models().item();
const changeModel = models().change({ userId: user.id }); const changeModel = models().change();
let i = 1; await msleep(1); const item1 = await makeTestItem(user.id, 1); // [1] CREATE 1
await msleep(1); const file1 = await makeTestFile(fileModel); // CREATE 1 await msleep(1); await itemModel.saveForUser(user.id, { id: item1.id, name: '0000000000000000000000000000001A.md' }); // [2] UPDATE 1a
await msleep(1); await fileModel.save({ id: file1.id, name: `test_mod${i++}` }); // UPDATE 1 await msleep(1); await itemModel.saveForUser(user.id, { id: item1.id, name: '0000000000000000000000000000001B.md' }); // [3] UPDATE 1b
await msleep(1); await fileModel.save({ id: file1.id, name: `test_mod${i++}` }); // UPDATE 1 await msleep(1); const item2 = await makeTestItem(user.id, 2); // [4] CREATE 2
await msleep(1); const file2 = await makeTestFile(fileModel); // CREATE 2 await msleep(1); await itemModel.saveForUser(user.id, { id: item2.id, name: '0000000000000000000000000000002A.md' }); // [5] UPDATE 2a
await msleep(1); await fileModel.save({ id: file2.id, name: `test_mod${i++}` }); // UPDATE 2 await msleep(1); await itemModel.delete(item1.id); // [6] DELETE 1
await msleep(1); await fileModel.delete(file1.id); // DELETE 1 await msleep(1); await itemModel.saveForUser(user.id, { id: item2.id, name: '0000000000000000000000000000002B.md' }); // [7] UPDATE 2b
await msleep(1); await fileModel.save({ id: file2.id, name: `test_mod${i++}` }); // UPDATE 2 await msleep(1); const item3 = await makeTestItem(user.id, 3); // [8] CREATE 3
await msleep(1); const file3 = await makeTestFile(fileModel); // CREATE 3
const dirId = await fileModel.userRootFileId(); // Check that the 8 changes were created
const allUncompressedChanges = await changeModel.all();
expect(allUncompressedChanges.length).toBe(8);
{ {
const changes = (await changeModel.byDirectoryId(dirId, { limit: 20 })).items; // When we get all the changes, we only get CREATE 2 and CREATE 3.
// We don't get CREATE 1 because item 1 has been deleted. And we
// also don't get any UPDATE event since they've been compressed
// down to the CREATE events.
const changes = (await changeModel.allForUser(user.id)).items;
expect(changes.length).toBe(2); expect(changes.length).toBe(2);
expect(changes[0].item.id).toBe(file2.id); expect(changes[0].item_id).toBe(item2.id);
expect(changes[0].type).toBe(ChangeType.Create); expect(changes[0].type).toBe(ChangeType.Create);
expect(changes[1].item.id).toBe(file3.id); expect(changes[1].item_id).toBe(item3.id);
expect(changes[1].type).toBe(ChangeType.Create); expect(changes[1].type).toBe(ChangeType.Create);
} }
{ {
const pagination: ChangePagination = { limit: 5 }; const pagination: ChangePagination = { limit: 3 };
// In this page, the "create" change for file1 will not appear // Internally, when we request the first three changes, we get back:
// because this file has been deleted. The "delete" change will //
// however appear in the second page. // - CREATE 1
const page1 = (await changeModel.byDirectoryId(dirId, pagination)); // - CREATE 2
// - UPDATE 2a
//
// We don't get back UPDATE 1a and 1b because the associated item
// has been deleted.
//
// Unlike CREATE events, which come from "user_items" and are
// associated with a user, UPDATE events comes from "items" and are
// not associated with any specific user. Only if the user has a
// corresponding user_item do they get UPDATE events. But in this
// case, since the item has been deleted, there's no longer
// "user_items" objects.
//
// Then CREATE 1 is removed since item 1 has been deleted and UPDATE
// 2a is compressed down to CREATE 2.
const page1 = (await changeModel.allForUser(user.id, pagination));
let changes = page1.items; let changes = page1.items;
expect(changes.length).toBe(1); expect(changes.length).toBe(1);
expect(page1.has_more).toBe(true); expect(page1.has_more).toBe(true);
expect(changes[0].item.id).toBe(file2.id); expect(changes[0].item_id).toBe(item2.id);
expect(changes[0].type).toBe(ChangeType.Create); expect(changes[0].type).toBe(ChangeType.Create);
const page2 = (await changeModel.byDirectoryId(dirId, { ...pagination, cursor: page1.cursor })); // In the second page, we get all the expected events since nothing
// has been compressed.
const page2 = (await changeModel.allForUser(user.id, { ...pagination, cursor: page1.cursor }));
changes = page2.items; changes = page2.items;
expect(changes.length).toBe(3); expect(changes.length).toBe(3);
expect(page2.has_more).toBe(false); // Although there are no more changes, it's not possible to know
expect(changes[0].item.id).toBe(file1.id); // that without running the next query
expect(page2.has_more).toBe(true);
expect(changes[0].item_id).toBe(item1.id);
expect(changes[0].type).toBe(ChangeType.Delete); expect(changes[0].type).toBe(ChangeType.Delete);
expect(changes[1].item.id).toBe(file2.id); expect(changes[1].item_id).toBe(item2.id);
expect(changes[1].type).toBe(ChangeType.Update); expect(changes[1].type).toBe(ChangeType.Update);
expect(changes[2].item.id).toBe(file3.id); expect(changes[2].item_id).toBe(item3.id);
expect(changes[2].type).toBe(ChangeType.Create); expect(changes[2].type).toBe(ChangeType.Create);
// Check that we indeed reached the end of the feed.
const page3 = (await changeModel.allForUser(user.id, { ...pagination, cursor: page2.cursor }));
expect(page3.items.length).toBe(0);
expect(page3.has_more).toBe(false);
} }
}); });
test('should throw an error if cursor is invalid', async function() { test('should throw an error if cursor is invalid', async function() {
const { user } = await createUserAndSession(1, true); const { user } = await createUserAndSession(1, true);
const fileModel = models().file({ userId: user.id }); const itemModel = models().item();
const changeModel = models().change({ userId: user.id }); const changeModel = models().change();
const dirId = await fileModel.userRootFileId();
let i = 1; let i = 1;
await msleep(1); const file1 = await makeTestFile(fileModel); // CREATE 1 await msleep(1); const item1 = await makeTestItem(user.id, 1); // CREATE 1
await msleep(1); await fileModel.save({ id: file1.id, name: `test_mod${i++}` }); // UPDATE 1 await msleep(1); await itemModel.saveForUser(user.id, { id: item1.id, name: `test_mod${i++}` }); // UPDATE 1
await expectThrow(async () => changeModel.byDirectoryId(dirId, { limit: 1, cursor: 'invalid' }), 'resyncRequired'); await expectThrow(async () => changeModel.allForUser(user.id, { limit: 1, cursor: 'invalid' }), 'resyncRequired');
});
test('should throw an error if trying to do get changes for a file', async function() {
const { user } = await createUserAndSession(1, true);
const fileModel = models().file({ userId: user.id });
const changeModel = models().change({ userId: user.id });
const file1 = await makeTestFile(fileModel);
await expectThrow(async () => changeModel.byDirectoryId(file1.id));
}); });
}); });

View File

@@ -1,16 +1,17 @@
import { Change, ChangeType, File, ItemType, Uuid } from '../db'; import { Change, ChangeType, Item, Uuid } from '../db';
import { ErrorResyncRequired, ErrorUnprocessableEntity } from '../utils/errors'; import { md5 } from '../utils/crypto';
import BaseModel from './BaseModel'; import { ErrorResyncRequired } from '../utils/errors';
import { paginateDbQuery, PaginatedResults, Pagination } from './utils/pagination'; import BaseModel, { SaveOptions } from './BaseModel';
import { PaginatedResults } from './utils/pagination';
export interface ChangeWithItem { export interface ChangeWithItem {
item: File; item: Item;
updated_time: number; updated_time: number;
type: ChangeType; type: ChangeType;
} }
export interface PaginatedChanges extends PaginatedResults { export interface PaginatedChanges extends PaginatedResults {
items: ChangeWithItem[]; items: Change[];
} }
export interface ChangePagination { export interface ChangePagination {
@@ -18,6 +19,13 @@ export interface ChangePagination {
cursor?: string; cursor?: string;
} }
export interface ChangePreviousItem {
name: string;
jop_parent_id: string;
jop_resource_ids: string[];
jop_share_id: string;
}
export function defaultChangePagination(): ChangePagination { export function defaultChangePagination(): ChangePagination {
return { return {
limit: 100, limit: 100,
@@ -25,6 +33,10 @@ export function defaultChangePagination(): ChangePagination {
}; };
} }
interface AllForUserOptions {
compressChanges?: boolean;
}
export default class ChangeModel extends BaseModel<Change> { export default class ChangeModel extends BaseModel<Change> {
public get tableName(): string { public get tableName(): string {
@@ -32,44 +44,39 @@ export default class ChangeModel extends BaseModel<Change> {
} }
protected hasUuid(): boolean { protected hasUuid(): boolean {
return false; return true;
} }
public async add(itemType: ItemType, parentId: Uuid, itemId: Uuid, itemName: string, changeType: ChangeType): Promise<Change> { public serializePreviousItem(item: ChangePreviousItem): string {
const change: Change = { return JSON.stringify(item);
item_type: itemType,
parent_id: parentId || '',
item_id: itemId,
item_name: itemName,
type: changeType,
owner_id: this.userId,
};
return this.save(change) as Change;
} }
private async countByUser(userId: string): Promise<number> { public unserializePreviousItem(item: string): ChangePreviousItem {
const r: any = await this.db(this.tableName).where('owner_id', userId).count('id', { as: 'total' }).first(); if (!item) return null;
return r.total; return JSON.parse(item);
} }
public changeUrl(): string { public changeUrl(): string {
return `${this.baseUrl}/changes`; return `${this.baseUrl}/changes`;
} }
public async allWithPagination(pagination: Pagination): Promise<PaginatedChanges> { public async allFromId(id: string): Promise<Change[]> {
const results = await paginateDbQuery(this.db(this.tableName).select(...this.defaultFields).where('owner_id', '=', this.userId), pagination); const startChange: Change = id ? await this.load(id) : null;
const changeWithItems = await this.loadChangeItems(results.items); const query = this.db(this.tableName).select(...this.defaultFields);
return { if (startChange) void query.where('counter', '>', startChange.counter);
...results, void query.limit(1000);
items: changeWithItems, let results = await query;
page_count: Math.ceil(await this.countByUser(this.userId) / pagination.limit), results = await this.removeDeletedItems(results);
}; results = await this.compressChanges(results);
return results;
} }
// Note: doesn't currently support checking for changes recursively but this public async allForUser(userId: Uuid, pagination: ChangePagination = null, options: AllForUserOptions = null): Promise<PaginatedChanges> {
// is not needed for Joplin synchronisation. options = {
public async byDirectoryId(dirId: string, pagination: ChangePagination = null): Promise<PaginatedChanges> { compressChanges: true,
...options,
};
pagination = { pagination = {
...defaultChangePagination(), ...defaultChangePagination(),
...pagination, ...pagination,
@@ -82,37 +89,49 @@ export default class ChangeModel extends BaseModel<Change> {
if (!changeAtCursor) throw new ErrorResyncRequired(); if (!changeAtCursor) throw new ErrorResyncRequired();
} }
// Load the directory object to check that it exists and that we have // When need to get:
// the right permissions (loading will check permissions) //
const fileModel = this.models().file({ userId: this.userId }); // - All the CREATE and DELETE changes associated with the user
const directory = await fileModel.load(dirId); // - All the UPDATE changes that applies to items associated with the
if (!directory.is_directory) throw new ErrorUnprocessableEntity(`Item with id "${dirId}" is not a directory.`); // user.
//
// UPDATE changes do not have the user_id set because they are specific
// to the item, not to a particular user.
// Rather than query the changes, then use JS to compress them, it might const query = this
// be possible to do both in one query. .db('changes')
// https://stackoverflow.com/questions/65348794
const query = this.db(this.tableName)
.select([ .select([
'counter',
'id', 'id',
'item_id', 'item_id',
'item_name', 'item_name',
'type', 'type',
'updated_time',
]) ])
.where('parent_id', dirId) .where(function() {
.orderBy('counter', 'asc') void this.whereRaw('((type = ? OR type = ?) AND user_id = ?)', [ChangeType.Create, ChangeType.Delete, userId])
.limit(pagination.limit); // Need to use a RAW query here because Knex has a "not a
// bug" bug that makes it go into infinite loop in some
// contexts, possibly only when running inside Jest (didn't
// test outside).
// https://github.com/knex/knex/issues/1851
.orWhereRaw('type = ? AND item_id IN (SELECT item_id FROM user_items WHERE user_id = ?)', [ChangeType.Update, userId]);
});
// If a cursor was provided, apply it to both queries.
if (changeAtCursor) { if (changeAtCursor) {
void query.where('counter', '>', changeAtCursor.counter); void query.where('counter', '>', changeAtCursor.counter);
} }
const changes: Change[] = await query; void query
const compressedChanges = this.compressChanges(changes); .orderBy('counter', 'asc')
const changeWithItems = await this.loadChangeItems(compressedChanges); .limit(pagination.limit) as any[];
const changes = await query;
const finalChanges = options.compressChanges ? await this.removeDeletedItems(this.compressChanges(changes)) : changes;
return { return {
items: changeWithItems, items: finalChanges,
// If we have changes, we return the ID of the latest changes from which delta sync can resume. // If we have changes, we return the ID of the latest changes from which delta sync can resume.
// If there's no change, we return the previous cursor. // If there's no change, we return the previous cursor.
cursor: changes.length ? changes[changes.length - 1].id : pagination.cursor, cursor: changes.length ? changes[changes.length - 1].id : pagination.cursor,
@@ -120,15 +139,19 @@ export default class ChangeModel extends BaseModel<Change> {
}; };
} }
private async loadChangeItems(changes: Change[]): Promise<ChangeWithItem[]> { private async removeDeletedItems(changes: Change[]): Promise<Change[]> {
const itemIds = changes.map(c => c.item_id); const itemIds = changes.map(c => c.item_id);
const fileModel = this.models().file({ userId: this.userId });
const items: File[] = await fileModel.loadByIds(itemIds);
const output: ChangeWithItem[] = []; // We skip permission check here because, when an item is shared, we need
// to fetch files that don't belong to the current user. This check
// would not be needed anyway because the change items are generated in
// a context where permissions have already been checked.
const items: Item[] = await this.db('items').select('id').whereIn('items.id', itemIds);
const output: Change[] = [];
for (const change of changes) { for (const change of changes) {
let item = items.find(f => f.id === change.item_id); const item = items.find(f => f.id === change.item_id);
// If the item associated with this change has been deleted, we have // If the item associated with this change has been deleted, we have
// two cases: // two cases:
@@ -136,63 +159,78 @@ export default class ChangeModel extends BaseModel<Change> {
// - If it's anything else, skip it. The "delete" change will be // - If it's anything else, skip it. The "delete" change will be
// sent on one of the next pages. // sent on one of the next pages.
if (!item) { if (!item && change.type !== ChangeType.Delete) {
if (change.type === ChangeType.Delete) { continue;
item = {
id: change.item_id,
name: change.item_name,
};
} else {
continue;
}
} }
output.push({ output.push(change);
type: change.type,
updated_time: change.updated_time,
item: item,
});
} }
return output; return output;
} }
// Compresses the changes so that, for example, multiple updates on the same
// item are reduced down to one, because calling code usually only needs to
// know that the item has changed at least once. The reduction is basically:
//
// create - update => create
// create - delete => NOOP
// update - update => update
// update - delete => delete
//
// There's one exception for changes that include a "previous_item". This is
// used to save specific properties about the previous state of the item,
// such as "jop_parent_id" or "name", which is used by the share mechanism
// to know if an item has been moved from one folder to another. In that
// case, we need to know about each individual change, so they are not
// compressed.
private compressChanges(changes: Change[]): Change[] { private compressChanges(changes: Change[]): Change[] {
const itemChanges: Record<Uuid, Change> = {}; const itemChanges: Record<Uuid, Change> = {};
const uniqueUpdateChanges: Record<Uuid, Record<string, Change>> = {};
for (const change of changes) { for (const change of changes) {
const previous = itemChanges[change.item_id]; const itemId = change.item_id;
const previous = itemChanges[itemId];
if (change.type === ChangeType.Update) {
const key = md5(itemId + change.previous_item);
if (!uniqueUpdateChanges[itemId]) uniqueUpdateChanges[itemId] = {};
uniqueUpdateChanges[itemId][key] = change;
}
if (previous) { if (previous) {
// create - update => create
// create - delete => NOOP
// update - update => update
// update - delete => delete
if (previous.type === ChangeType.Create && change.type === ChangeType.Update) { if (previous.type === ChangeType.Create && change.type === ChangeType.Update) {
continue; continue;
} }
if (previous.type === ChangeType.Create && change.type === ChangeType.Delete) { if (previous.type === ChangeType.Create && change.type === ChangeType.Delete) {
delete itemChanges[change.item_id]; delete itemChanges[itemId];
} }
if (previous.type === ChangeType.Update && change.type === ChangeType.Update) { if (previous.type === ChangeType.Update && change.type === ChangeType.Update) {
itemChanges[change.item_id] = change; itemChanges[itemId] = change;
} }
if (previous.type === ChangeType.Update && change.type === ChangeType.Delete) { if (previous.type === ChangeType.Update && change.type === ChangeType.Delete) {
itemChanges[change.item_id] = change; itemChanges[itemId] = change;
} }
} else { } else {
itemChanges[change.item_id] = change; itemChanges[itemId] = change;
} }
} }
const output = []; const output: Change[] = [];
for (const itemId in itemChanges) { for (const itemId in itemChanges) {
output.push(itemChanges[itemId]); const change = itemChanges[itemId];
if (change.type === ChangeType.Update) {
for (const key of Object.keys(uniqueUpdateChanges[itemId])) {
output.push(uniqueUpdateChanges[itemId][key]);
}
} else {
output.push(change);
}
} }
output.sort((a: Change, b: Change) => a.counter < b.counter ? -1 : +1); output.sort((a: Change, b: Change) => a.counter < b.counter ? -1 : +1);
@@ -200,4 +238,10 @@ export default class ChangeModel extends BaseModel<Change> {
return output; return output;
} }
public async save(change: Change, options: SaveOptions = {}): Promise<Change> {
const savedChange = await super.save(change, options);
ChangeModel.eventEmitter.emit('saved');
return savedChange;
}
} }

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