You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-08-24 20:19:10 +02:00
Compare commits
163 Commits
plugin-rep
...
server_app
Author | SHA1 | Date | |
---|---|---|---|
|
047a5c0acf | ||
|
e0379464fc | ||
|
756e646615 | ||
|
d68c0bf5de | ||
|
fd57ec10a9 | ||
|
052ef1617f | ||
|
f776b68594 | ||
|
0d19f64734 | ||
|
fb5b73c582 | ||
|
e735dba5f5 | ||
|
7f3bab278f | ||
|
61ae86c11c | ||
|
8d7ecff4ed | ||
|
2a3f542cb9 | ||
|
f2860ed011 | ||
|
4b8a429252 | ||
|
1721be3072 | ||
|
3c00e96501 | ||
|
814b4d49d4 | ||
|
9974107941 | ||
|
5164e20f7c | ||
|
e9362c92f6 | ||
|
9a0ea2aa47 | ||
|
0117088f7f | ||
|
e96ee8f13e | ||
|
e404af0812 | ||
|
3f964fc8af | ||
|
134c36922d | ||
|
1686ae77f6 | ||
|
063fa8e92e | ||
|
88abebe044 | ||
|
c4f073cb77 | ||
|
2d8eced79d | ||
|
8176c78fb1 | ||
|
97e7313de4 | ||
|
b7289f0acb | ||
|
0c6222ad76 | ||
|
14d6336521 | ||
|
91c0372f16 | ||
|
b999222fe3 | ||
|
53f63f3e86 | ||
|
3ad36d4a56 | ||
|
1fb90dbbce | ||
|
2201a8865d | ||
|
17e80ef48f | ||
|
7ee8efb67f | ||
|
320fcc1c3e | ||
|
127fc3cb9a | ||
|
dd4ddd5d36 | ||
|
fa6491bf78 | ||
|
610631596d | ||
|
71cc77b30c | ||
|
765bbf5295 | ||
|
e9d3864022 | ||
|
631c2af353 | ||
|
b24f4d2a1d | ||
|
b6028a7014 | ||
|
166ff3866c | ||
|
af13ed6b95 | ||
|
b8d5a8b025 | ||
|
425a17e777 | ||
|
8d5c538e18 | ||
|
1c2d4306f9 | ||
|
99270bc1ea | ||
|
6130fcc5a8 | ||
|
614359c267 | ||
|
f438fcb50d | ||
|
bff42b8a82 | ||
|
c5a9208594 | ||
|
a43be838b5 | ||
|
9ae31583af | ||
|
0fa141881b | ||
|
6152f19712 | ||
|
2ade4c7951 | ||
|
85c887d004 | ||
|
47d49935bf | ||
|
14d1d8ca72 | ||
|
e903b17a3f | ||
|
10138260d9 | ||
|
6ddef009aa | ||
|
425d8acb2d | ||
|
11b00ccb70 | ||
|
c9288e117f | ||
|
da2bc801a6 | ||
|
95a9dc0de4 | ||
|
f437b8ee60 | ||
|
3e82fdab04 | ||
|
9555875b53 | ||
|
9c9d30e73f | ||
|
9bb920a98a | ||
|
8e8ad3193c | ||
|
5eb4193451 | ||
|
2d373f0c31 | ||
|
e78d3cfd3d | ||
|
21b4ce97dc | ||
|
2e9192d10a | ||
|
5820261577 | ||
|
d86a4f7437 | ||
|
13d77e44cf | ||
|
5895c8364f | ||
|
88183a4660 | ||
|
c6a1aaee72 | ||
|
4f8539fceb | ||
|
6a05349412 | ||
|
81e71e08be | ||
|
50bdbc165a | ||
|
9261d9c36b | ||
|
a19549573d | ||
|
e4435f52fc | ||
|
090518bb76 | ||
|
856f45507c | ||
|
5dc4551d60 | ||
|
a3f59691e5 | ||
|
e0aa8bd7fb | ||
|
9333b37147 | ||
|
bcbcf583c1 | ||
|
189aef7f91 | ||
|
1286716007 | ||
|
003f60aff1 | ||
|
9373aec3cf | ||
|
e89836ff9d | ||
|
c7996f66ee | ||
|
b4bac8ff31 | ||
|
c7c8631333 | ||
|
48cda86f54 | ||
|
fe54b383ca | ||
|
e904f9d90a | ||
|
41a3f9c052 | ||
|
c40b4eab17 | ||
|
5a186146ab | ||
|
d389886394 | ||
|
09930bc8a6 | ||
|
67dcd41bf7 | ||
|
f2bf1375bf | ||
|
7bb00e1338 | ||
|
8562909a4f | ||
|
48a499f741 | ||
|
4702976ceb | ||
|
2006288108 | ||
|
8446693e91 | ||
|
b3579d70e9 | ||
|
73ce9b2443 | ||
|
15f5b90211 | ||
|
0011b570aa | ||
|
aeb3c4a98d | ||
|
58a464d040 | ||
|
8e13ccb665 | ||
|
6dd14ff04b | ||
|
2022b5bc48 | ||
|
7ade9b2948 | ||
|
4157dad9f1 | ||
|
a088061de9 | ||
|
439d29387f | ||
|
2f15e4db59 | ||
|
0b37e99132 | ||
|
6d41787a29 | ||
|
28fc0374c5 | ||
|
726ee4a574 | ||
|
25e32226ef | ||
|
9efdbf9854 | ||
|
09c95f10f4 | ||
|
a6453af3e5 | ||
|
b8c8178b26 |
@@ -18,6 +18,7 @@ packages/turndown-plugin-gfm/
|
||||
node_modules/
|
||||
packages/lib/lib/lib.js
|
||||
packages/lib/locales/index.js
|
||||
packages/lib/services/database/types.ts
|
||||
packages/app-cli/build
|
||||
packages/app-cli/build/
|
||||
packages/app-cli/locales
|
||||
@@ -112,6 +113,9 @@ packages/app-cli/tests/Synchronizer.tools.js.map
|
||||
packages/app-cli/tests/dateTimeFormats.d.ts
|
||||
packages/app-cli/tests/dateTimeFormats.js
|
||||
packages/app-cli/tests/dateTimeFormats.js.map
|
||||
packages/app-cli/tests/file-api-driver.d.ts
|
||||
packages/app-cli/tests/file-api-driver.js
|
||||
packages/app-cli/tests/file-api-driver.js.map
|
||||
packages/app-cli/tests/fsDriver.d.ts
|
||||
packages/app-cli/tests/fsDriver.js
|
||||
packages/app-cli/tests/fsDriver.js.map
|
||||
@@ -121,6 +125,9 @@ packages/app-cli/tests/htmlUtils.js.map
|
||||
packages/app-cli/tests/models_Folder.d.ts
|
||||
packages/app-cli/tests/models_Folder.js
|
||||
packages/app-cli/tests/models_Folder.js.map
|
||||
packages/app-cli/tests/models_Folder.sharing.d.ts
|
||||
packages/app-cli/tests/models_Folder.sharing.js
|
||||
packages/app-cli/tests/models_Folder.sharing.js.map
|
||||
packages/app-cli/tests/models_Note.d.ts
|
||||
packages/app-cli/tests/models_Note.js
|
||||
packages/app-cli/tests/models_Note.js.map
|
||||
@@ -250,6 +257,15 @@ packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.js.ma
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.d.ts
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.js.map
|
||||
packages/app-desktop/gui/Dialog.d.ts
|
||||
packages/app-desktop/gui/Dialog.js
|
||||
packages/app-desktop/gui/Dialog.js.map
|
||||
packages/app-desktop/gui/DialogButtonRow.d.ts
|
||||
packages/app-desktop/gui/DialogButtonRow.js
|
||||
packages/app-desktop/gui/DialogButtonRow.js.map
|
||||
packages/app-desktop/gui/DialogTitle.d.ts
|
||||
packages/app-desktop/gui/DialogTitle.js
|
||||
packages/app-desktop/gui/DialogTitle.js.map
|
||||
packages/app-desktop/gui/DropboxLoginScreen.d.ts
|
||||
packages/app-desktop/gui/DropboxLoginScreen.js
|
||||
packages/app-desktop/gui/DropboxLoginScreen.js.map
|
||||
@@ -337,6 +353,9 @@ packages/app-desktop/gui/MainScreen/commands/showNoteContentProperties.js.map
|
||||
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.d.ts
|
||||
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.js
|
||||
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.js.map
|
||||
packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.d.ts
|
||||
packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.js
|
||||
packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.js.map
|
||||
packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.d.ts
|
||||
packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.js
|
||||
packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.js.map
|
||||
@@ -589,6 +608,9 @@ packages/app-desktop/gui/Root_UpgradeSyncTarget.js.map
|
||||
packages/app-desktop/gui/SearchBar/SearchBar.d.ts
|
||||
packages/app-desktop/gui/SearchBar/SearchBar.js
|
||||
packages/app-desktop/gui/SearchBar/SearchBar.js.map
|
||||
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.d.ts
|
||||
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js
|
||||
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js.map
|
||||
packages/app-desktop/gui/ShareNoteDialog.d.ts
|
||||
packages/app-desktop/gui/ShareNoteDialog.js
|
||||
packages/app-desktop/gui/ShareNoteDialog.js.map
|
||||
@@ -646,9 +668,15 @@ packages/app-desktop/gui/lib/ToggleButton/ToggleButton.js.map
|
||||
packages/app-desktop/gui/menuCommandNames.d.ts
|
||||
packages/app-desktop/gui/menuCommandNames.js
|
||||
packages/app-desktop/gui/menuCommandNames.js.map
|
||||
packages/app-desktop/gui/style/StyledFormLabel.d.ts
|
||||
packages/app-desktop/gui/style/StyledFormLabel.js
|
||||
packages/app-desktop/gui/style/StyledFormLabel.js.map
|
||||
packages/app-desktop/gui/style/StyledInput.d.ts
|
||||
packages/app-desktop/gui/style/StyledInput.js
|
||||
packages/app-desktop/gui/style/StyledInput.js.map
|
||||
packages/app-desktop/gui/style/StyledMessage.d.ts
|
||||
packages/app-desktop/gui/style/StyledMessage.js
|
||||
packages/app-desktop/gui/style/StyledMessage.js.map
|
||||
packages/app-desktop/gui/style/StyledTextInput.d.ts
|
||||
packages/app-desktop/gui/style/StyledTextInput.js
|
||||
packages/app-desktop/gui/style/StyledTextInput.js.map
|
||||
@@ -847,6 +875,9 @@ packages/lib/InMemoryCache.js.map
|
||||
packages/lib/JoplinDatabase.d.ts
|
||||
packages/lib/JoplinDatabase.js
|
||||
packages/lib/JoplinDatabase.js.map
|
||||
packages/lib/JoplinError.d.ts
|
||||
packages/lib/JoplinError.js
|
||||
packages/lib/JoplinError.js.map
|
||||
packages/lib/JoplinServerApi.d.ts
|
||||
packages/lib/JoplinServerApi.js
|
||||
packages/lib/JoplinServerApi.js.map
|
||||
@@ -877,6 +908,9 @@ packages/lib/commands/synchronize.js.map
|
||||
packages/lib/database.d.ts
|
||||
packages/lib/database.js
|
||||
packages/lib/database.js.map
|
||||
packages/lib/debug/DebugService.d.ts
|
||||
packages/lib/debug/DebugService.js
|
||||
packages/lib/debug/DebugService.js.map
|
||||
packages/lib/dummy.test.d.ts
|
||||
packages/lib/dummy.test.js
|
||||
packages/lib/dummy.test.js.map
|
||||
@@ -1333,6 +1367,12 @@ packages/lib/services/searchengine/filterParser.js.map
|
||||
packages/lib/services/searchengine/queryBuilder.d.ts
|
||||
packages/lib/services/searchengine/queryBuilder.js
|
||||
packages/lib/services/searchengine/queryBuilder.js.map
|
||||
packages/lib/services/share/ShareService.d.ts
|
||||
packages/lib/services/share/ShareService.js
|
||||
packages/lib/services/share/ShareService.js.map
|
||||
packages/lib/services/share/reducer.d.ts
|
||||
packages/lib/services/share/reducer.js
|
||||
packages/lib/services/share/reducer.js.map
|
||||
packages/lib/services/spellChecker/SpellCheckerService.d.ts
|
||||
packages/lib/services/spellChecker/SpellCheckerService.js
|
||||
packages/lib/services/spellChecker/SpellCheckerService.js.map
|
||||
|
39
.gitignore
vendored
39
.gitignore
vendored
@@ -99,6 +99,9 @@ packages/app-cli/tests/Synchronizer.tools.js.map
|
||||
packages/app-cli/tests/dateTimeFormats.d.ts
|
||||
packages/app-cli/tests/dateTimeFormats.js
|
||||
packages/app-cli/tests/dateTimeFormats.js.map
|
||||
packages/app-cli/tests/file-api-driver.d.ts
|
||||
packages/app-cli/tests/file-api-driver.js
|
||||
packages/app-cli/tests/file-api-driver.js.map
|
||||
packages/app-cli/tests/fsDriver.d.ts
|
||||
packages/app-cli/tests/fsDriver.js
|
||||
packages/app-cli/tests/fsDriver.js.map
|
||||
@@ -108,6 +111,9 @@ packages/app-cli/tests/htmlUtils.js.map
|
||||
packages/app-cli/tests/models_Folder.d.ts
|
||||
packages/app-cli/tests/models_Folder.js
|
||||
packages/app-cli/tests/models_Folder.js.map
|
||||
packages/app-cli/tests/models_Folder.sharing.d.ts
|
||||
packages/app-cli/tests/models_Folder.sharing.js
|
||||
packages/app-cli/tests/models_Folder.sharing.js.map
|
||||
packages/app-cli/tests/models_Note.d.ts
|
||||
packages/app-cli/tests/models_Note.js
|
||||
packages/app-cli/tests/models_Note.js.map
|
||||
@@ -237,6 +243,15 @@ packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.js.ma
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.d.ts
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.js.map
|
||||
packages/app-desktop/gui/Dialog.d.ts
|
||||
packages/app-desktop/gui/Dialog.js
|
||||
packages/app-desktop/gui/Dialog.js.map
|
||||
packages/app-desktop/gui/DialogButtonRow.d.ts
|
||||
packages/app-desktop/gui/DialogButtonRow.js
|
||||
packages/app-desktop/gui/DialogButtonRow.js.map
|
||||
packages/app-desktop/gui/DialogTitle.d.ts
|
||||
packages/app-desktop/gui/DialogTitle.js
|
||||
packages/app-desktop/gui/DialogTitle.js.map
|
||||
packages/app-desktop/gui/DropboxLoginScreen.d.ts
|
||||
packages/app-desktop/gui/DropboxLoginScreen.js
|
||||
packages/app-desktop/gui/DropboxLoginScreen.js.map
|
||||
@@ -324,6 +339,9 @@ packages/app-desktop/gui/MainScreen/commands/showNoteContentProperties.js.map
|
||||
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.d.ts
|
||||
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.js
|
||||
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.js.map
|
||||
packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.d.ts
|
||||
packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.js
|
||||
packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.js.map
|
||||
packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.d.ts
|
||||
packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.js
|
||||
packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.js.map
|
||||
@@ -576,6 +594,9 @@ packages/app-desktop/gui/Root_UpgradeSyncTarget.js.map
|
||||
packages/app-desktop/gui/SearchBar/SearchBar.d.ts
|
||||
packages/app-desktop/gui/SearchBar/SearchBar.js
|
||||
packages/app-desktop/gui/SearchBar/SearchBar.js.map
|
||||
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.d.ts
|
||||
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js
|
||||
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js.map
|
||||
packages/app-desktop/gui/ShareNoteDialog.d.ts
|
||||
packages/app-desktop/gui/ShareNoteDialog.js
|
||||
packages/app-desktop/gui/ShareNoteDialog.js.map
|
||||
@@ -633,9 +654,15 @@ packages/app-desktop/gui/lib/ToggleButton/ToggleButton.js.map
|
||||
packages/app-desktop/gui/menuCommandNames.d.ts
|
||||
packages/app-desktop/gui/menuCommandNames.js
|
||||
packages/app-desktop/gui/menuCommandNames.js.map
|
||||
packages/app-desktop/gui/style/StyledFormLabel.d.ts
|
||||
packages/app-desktop/gui/style/StyledFormLabel.js
|
||||
packages/app-desktop/gui/style/StyledFormLabel.js.map
|
||||
packages/app-desktop/gui/style/StyledInput.d.ts
|
||||
packages/app-desktop/gui/style/StyledInput.js
|
||||
packages/app-desktop/gui/style/StyledInput.js.map
|
||||
packages/app-desktop/gui/style/StyledMessage.d.ts
|
||||
packages/app-desktop/gui/style/StyledMessage.js
|
||||
packages/app-desktop/gui/style/StyledMessage.js.map
|
||||
packages/app-desktop/gui/style/StyledTextInput.d.ts
|
||||
packages/app-desktop/gui/style/StyledTextInput.js
|
||||
packages/app-desktop/gui/style/StyledTextInput.js.map
|
||||
@@ -834,6 +861,9 @@ packages/lib/InMemoryCache.js.map
|
||||
packages/lib/JoplinDatabase.d.ts
|
||||
packages/lib/JoplinDatabase.js
|
||||
packages/lib/JoplinDatabase.js.map
|
||||
packages/lib/JoplinError.d.ts
|
||||
packages/lib/JoplinError.js
|
||||
packages/lib/JoplinError.js.map
|
||||
packages/lib/JoplinServerApi.d.ts
|
||||
packages/lib/JoplinServerApi.js
|
||||
packages/lib/JoplinServerApi.js.map
|
||||
@@ -864,6 +894,9 @@ packages/lib/commands/synchronize.js.map
|
||||
packages/lib/database.d.ts
|
||||
packages/lib/database.js
|
||||
packages/lib/database.js.map
|
||||
packages/lib/debug/DebugService.d.ts
|
||||
packages/lib/debug/DebugService.js
|
||||
packages/lib/debug/DebugService.js.map
|
||||
packages/lib/dummy.test.d.ts
|
||||
packages/lib/dummy.test.js
|
||||
packages/lib/dummy.test.js.map
|
||||
@@ -1320,6 +1353,12 @@ packages/lib/services/searchengine/filterParser.js.map
|
||||
packages/lib/services/searchengine/queryBuilder.d.ts
|
||||
packages/lib/services/searchengine/queryBuilder.js
|
||||
packages/lib/services/searchengine/queryBuilder.js.map
|
||||
packages/lib/services/share/ShareService.d.ts
|
||||
packages/lib/services/share/ShareService.js
|
||||
packages/lib/services/share/ShareService.js.map
|
||||
packages/lib/services/share/reducer.d.ts
|
||||
packages/lib/services/share/reducer.js
|
||||
packages/lib/services/share/reducer.js.map
|
||||
packages/lib/services/spellChecker/SpellCheckerService.d.ts
|
||||
packages/lib/services/spellChecker/SpellCheckerService.js
|
||||
packages/lib/services/spellChecker/SpellCheckerService.js.map
|
||||
|
12
LICENSE
12
LICENSE
@@ -1,3 +1,15 @@
|
||||
All code in this repository is licensed under the MIT License **unless a
|
||||
directory contains a LICENSE file**, in which case that LICENSE file applies to
|
||||
the code in that sub-directory.
|
||||
|
||||
For example, packages/fork-sax contains a ISC LICENSE file, thus all files
|
||||
under the packages/fork-sax directory are licensed under ISC.
|
||||
|
||||
For example, packages/app-cli does NOT contain a LICENSE file, thus all files
|
||||
under that directory are licensed under the default license, which is MIT.
|
||||
|
||||
* * *
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2016-2020 Laurent Cozic
|
||||
|
@@ -35,7 +35,6 @@ module.exports = {
|
||||
'<rootDir>/build/',
|
||||
'<rootDir>/tests/test-utils.js',
|
||||
'<rootDir>/tests/test-utils-synchronizer.js',
|
||||
'<rootDir>/tests/file_api_driver.js',
|
||||
'<rootDir>/tests/tmp/',
|
||||
'<rootDir>/tests/test data/',
|
||||
],
|
||||
|
@@ -9,6 +9,7 @@ import Resource from '@joplin/lib/models/Resource';
|
||||
import ResourceFetcher from '@joplin/lib/services/ResourceFetcher';
|
||||
import MasterKey from '@joplin/lib/models/MasterKey';
|
||||
import BaseItem from '@joplin/lib/models/BaseItem';
|
||||
import { createFolderTree } from './test-utils';
|
||||
|
||||
let insideBeforeEach = false;
|
||||
|
||||
@@ -361,13 +362,26 @@ describe('Synchronizer.e2ee', function() {
|
||||
expect((await decryptionWorker().decryptionDisabledItems()).length).toBe(0);
|
||||
}));
|
||||
|
||||
it('should not encrypt notes that are shared', (async () => {
|
||||
it('should not encrypt notes that are shared by link', (async () => {
|
||||
Setting.setValue('encryption.enabled', true);
|
||||
await loadEncryptionMasterKey();
|
||||
|
||||
const folder1 = await Folder.save({ title: 'folder1' });
|
||||
const note1 = await Note.save({ title: 'un', parent_id: folder1.id });
|
||||
let note2 = await Note.save({ title: 'deux', parent_id: folder1.id });
|
||||
await createFolderTree('', [
|
||||
{
|
||||
title: 'folder1',
|
||||
children: [
|
||||
{
|
||||
title: 'un',
|
||||
},
|
||||
{
|
||||
title: 'deux',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const note1 = await Note.loadByTitle('un');
|
||||
let note2 = await Note.loadByTitle('deux');
|
||||
await synchronizerStart();
|
||||
|
||||
await switchClient(2);
|
||||
@@ -400,4 +414,61 @@ describe('Synchronizer.e2ee', function() {
|
||||
expect(note1_2.title).toBe('');
|
||||
}));
|
||||
|
||||
it('should not encrypt items that are shared by folder', (async () => {
|
||||
Setting.setValue('encryption.enabled', true);
|
||||
await loadEncryptionMasterKey();
|
||||
|
||||
const folder1 = await createFolderTree('', [
|
||||
{
|
||||
title: 'folder1',
|
||||
children: [
|
||||
{
|
||||
title: 'note1',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'folder2',
|
||||
children: [
|
||||
{
|
||||
title: 'note2',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await synchronizerStart();
|
||||
|
||||
await switchClient(2);
|
||||
|
||||
await synchronizerStart();
|
||||
|
||||
await switchClient(1);
|
||||
|
||||
// Simulate that the folder has been shared
|
||||
await Folder.save({ id: folder1.id, share_id: 'abcd' });
|
||||
|
||||
await synchronizerStart();
|
||||
|
||||
await switchClient(2);
|
||||
|
||||
await synchronizerStart();
|
||||
|
||||
// The shared items should be decrypted
|
||||
{
|
||||
const folder1 = await Folder.loadByTitle('folder1');
|
||||
const note1 = await Note.loadByTitle('note1');
|
||||
expect(folder1.title).toBe('folder1');
|
||||
expect(note1.title).toBe('note1');
|
||||
}
|
||||
|
||||
// The non-shared items should be encrypted
|
||||
{
|
||||
const folder2 = await Folder.loadByTitle('folder2');
|
||||
const note2 = await Note.loadByTitle('note2');
|
||||
expect(folder2).toBeFalsy();
|
||||
expect(note2).toBeFalsy();
|
||||
}
|
||||
}));
|
||||
|
||||
});
|
||||
|
290
packages/app-cli/tests/Synchronizer.share.js
Normal file
290
packages/app-cli/tests/Synchronizer.share.js
Normal file
@@ -0,0 +1,290 @@
|
||||
'use strict';
|
||||
const __awaiter = (this && this.__awaiter) || function(thisArg, _arguments, P, generator) {
|
||||
function adopt(value) { return value instanceof P ? value : new P(function(resolve) { resolve(value); }); }
|
||||
return new (P || (P = Promise))(function(resolve, reject) {
|
||||
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||
function rejected(value) { try { step(generator['throw'](value)); } catch (e) { reject(e); } }
|
||||
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||
});
|
||||
};
|
||||
Object.defineProperty(exports, '__esModule', { value: true });
|
||||
const Setting_1 = require('@joplin/lib/models/Setting');
|
||||
const test_utils_synchronizer_1 = require('./test-utils-synchronizer');
|
||||
const { syncTargetName, afterAllCleanUp, synchronizerStart, setupDatabaseAndSynchronizer, synchronizer, sleep, switchClient, syncTargetId, fileApi } = require('./test-utils.js');
|
||||
const Folder_1 = require('@joplin/lib/models/Folder');
|
||||
const Note_1 = require('@joplin/lib/models/Note');
|
||||
const BaseItem_1 = require('@joplin/lib/models/BaseItem');
|
||||
const WelcomeUtils = require('@joplin/lib/WelcomeUtils');
|
||||
describe('Synchronizer.basics', function() {
|
||||
beforeEach((done) => __awaiter(this, void 0, void 0, function* () {
|
||||
yield setupDatabaseAndSynchronizer(1);
|
||||
yield setupDatabaseAndSynchronizer(2);
|
||||
yield switchClient(1);
|
||||
done();
|
||||
}));
|
||||
afterAll(() => __awaiter(this, void 0, void 0, function* () {
|
||||
yield afterAllCleanUp();
|
||||
}));
|
||||
it('should create remote items', (() => __awaiter(this, void 0, void 0, function* () {
|
||||
const folder = yield Folder_1.default.save({ title: 'folder1' });
|
||||
yield Note_1.default.save({ title: 'un', parent_id: folder.id });
|
||||
const all = yield test_utils_synchronizer_1.allNotesFolders();
|
||||
yield synchronizerStart();
|
||||
yield test_utils_synchronizer_1.localNotesFoldersSameAsRemote(all, expect);
|
||||
})));
|
||||
it('should update remote items', (() => __awaiter(this, void 0, void 0, function* () {
|
||||
const folder = yield Folder_1.default.save({ title: 'folder1' });
|
||||
const note = yield Note_1.default.save({ title: 'un', parent_id: folder.id });
|
||||
yield synchronizerStart();
|
||||
yield Note_1.default.save({ title: 'un UPDATE', id: note.id });
|
||||
const all = yield test_utils_synchronizer_1.allNotesFolders();
|
||||
yield synchronizerStart();
|
||||
yield test_utils_synchronizer_1.localNotesFoldersSameAsRemote(all, expect);
|
||||
})));
|
||||
it('should create local items', (() => __awaiter(this, void 0, void 0, function* () {
|
||||
const folder = yield Folder_1.default.save({ title: 'folder1' });
|
||||
yield Note_1.default.save({ title: 'un', parent_id: folder.id });
|
||||
yield synchronizerStart();
|
||||
yield switchClient(2);
|
||||
yield synchronizerStart();
|
||||
const all = yield test_utils_synchronizer_1.allNotesFolders();
|
||||
yield test_utils_synchronizer_1.localNotesFoldersSameAsRemote(all, expect);
|
||||
})));
|
||||
it('should update local items', (() => __awaiter(this, void 0, void 0, function* () {
|
||||
const folder1 = yield Folder_1.default.save({ title: 'folder1' });
|
||||
const note1 = yield Note_1.default.save({ title: 'un', parent_id: folder1.id });
|
||||
yield synchronizerStart();
|
||||
yield switchClient(2);
|
||||
yield synchronizerStart();
|
||||
yield sleep(0.1);
|
||||
let note2 = yield Note_1.default.load(note1.id);
|
||||
note2.title = 'Updated on client 2';
|
||||
yield Note_1.default.save(note2);
|
||||
note2 = yield Note_1.default.load(note2.id);
|
||||
yield synchronizerStart();
|
||||
yield switchClient(1);
|
||||
yield synchronizerStart();
|
||||
const all = yield test_utils_synchronizer_1.allNotesFolders();
|
||||
yield test_utils_synchronizer_1.localNotesFoldersSameAsRemote(all, expect);
|
||||
})));
|
||||
it('should delete remote notes', (() => __awaiter(this, void 0, void 0, function* () {
|
||||
const folder1 = yield Folder_1.default.save({ title: 'folder1' });
|
||||
const note1 = yield Note_1.default.save({ title: 'un', parent_id: folder1.id });
|
||||
yield synchronizerStart();
|
||||
yield switchClient(2);
|
||||
yield synchronizerStart();
|
||||
yield sleep(0.1);
|
||||
yield Note_1.default.delete(note1.id);
|
||||
yield synchronizerStart();
|
||||
const remotes = yield test_utils_synchronizer_1.remoteNotesAndFolders();
|
||||
expect(remotes.length).toBe(1);
|
||||
expect(remotes[0].id).toBe(folder1.id);
|
||||
const deletedItems = yield BaseItem_1.default.deletedItems(syncTargetId());
|
||||
expect(deletedItems.length).toBe(0);
|
||||
})));
|
||||
it('should not created deleted_items entries for items deleted via sync', (() => __awaiter(this, void 0, void 0, function* () {
|
||||
const folder1 = yield Folder_1.default.save({ title: 'folder1' });
|
||||
yield Note_1.default.save({ title: 'un', parent_id: folder1.id });
|
||||
yield synchronizerStart();
|
||||
yield switchClient(2);
|
||||
yield synchronizerStart();
|
||||
yield Folder_1.default.delete(folder1.id);
|
||||
yield synchronizerStart();
|
||||
yield switchClient(1);
|
||||
yield synchronizerStart();
|
||||
const deletedItems = yield BaseItem_1.default.deletedItems(syncTargetId());
|
||||
expect(deletedItems.length).toBe(0);
|
||||
})));
|
||||
it('should delete local notes', (() => __awaiter(this, void 0, void 0, function* () {
|
||||
const folder1 = yield Folder_1.default.save({ title: 'folder1' });
|
||||
const note1 = yield Note_1.default.save({ title: 'un', parent_id: folder1.id });
|
||||
const note2 = yield Note_1.default.save({ title: 'deux', parent_id: folder1.id });
|
||||
yield synchronizerStart();
|
||||
yield switchClient(2);
|
||||
yield synchronizerStart();
|
||||
yield Note_1.default.delete(note1.id);
|
||||
yield synchronizerStart();
|
||||
yield switchClient(1);
|
||||
yield synchronizerStart();
|
||||
const items = yield test_utils_synchronizer_1.allNotesFolders();
|
||||
expect(items.length).toBe(2);
|
||||
const deletedItems = yield BaseItem_1.default.deletedItems(syncTargetId());
|
||||
expect(deletedItems.length).toBe(0);
|
||||
yield Note_1.default.delete(note2.id);
|
||||
yield synchronizerStart();
|
||||
})));
|
||||
it('should delete remote folder', (() => __awaiter(this, void 0, void 0, function* () {
|
||||
yield Folder_1.default.save({ title: 'folder1' });
|
||||
const folder2 = yield Folder_1.default.save({ title: 'folder2' });
|
||||
yield synchronizerStart();
|
||||
yield switchClient(2);
|
||||
yield synchronizerStart();
|
||||
yield sleep(0.1);
|
||||
yield Folder_1.default.delete(folder2.id);
|
||||
yield synchronizerStart();
|
||||
const all = yield test_utils_synchronizer_1.allNotesFolders();
|
||||
yield test_utils_synchronizer_1.localNotesFoldersSameAsRemote(all, expect);
|
||||
})));
|
||||
it('should delete local folder', (() => __awaiter(this, void 0, void 0, function* () {
|
||||
yield Folder_1.default.save({ title: 'folder1' });
|
||||
const folder2 = yield Folder_1.default.save({ title: 'folder2' });
|
||||
yield synchronizerStart();
|
||||
yield switchClient(2);
|
||||
yield synchronizerStart();
|
||||
yield Folder_1.default.delete(folder2.id);
|
||||
yield synchronizerStart();
|
||||
yield switchClient(1);
|
||||
yield synchronizerStart();
|
||||
const items = yield test_utils_synchronizer_1.allNotesFolders();
|
||||
yield test_utils_synchronizer_1.localNotesFoldersSameAsRemote(items, expect);
|
||||
})));
|
||||
it('should cross delete all folders', (() => __awaiter(this, void 0, void 0, function* () {
|
||||
// If client1 and 2 have two folders, client 1 deletes item 1 and client
|
||||
// 2 deletes item 2, they should both end up with no items after sync.
|
||||
const folder1 = yield Folder_1.default.save({ title: 'folder1' });
|
||||
const folder2 = yield Folder_1.default.save({ title: 'folder2' });
|
||||
yield synchronizerStart();
|
||||
yield switchClient(2);
|
||||
yield synchronizerStart();
|
||||
yield sleep(0.1);
|
||||
yield Folder_1.default.delete(folder1.id);
|
||||
yield switchClient(1);
|
||||
yield Folder_1.default.delete(folder2.id);
|
||||
yield synchronizerStart();
|
||||
yield switchClient(2);
|
||||
yield synchronizerStart();
|
||||
const items2 = yield test_utils_synchronizer_1.allNotesFolders();
|
||||
yield switchClient(1);
|
||||
yield synchronizerStart();
|
||||
const items1 = yield test_utils_synchronizer_1.allNotesFolders();
|
||||
expect(items1.length).toBe(0);
|
||||
expect(items1.length).toBe(items2.length);
|
||||
})));
|
||||
it('items should be downloaded again when user cancels in the middle of delta operation', (() => __awaiter(this, void 0, void 0, function* () {
|
||||
const folder1 = yield Folder_1.default.save({ title: 'folder1' });
|
||||
yield Note_1.default.save({ title: 'un', is_todo: 1, parent_id: folder1.id });
|
||||
yield synchronizerStart();
|
||||
yield switchClient(2);
|
||||
synchronizer().testingHooks_ = ['cancelDeltaLoop2'];
|
||||
yield synchronizerStart();
|
||||
let notes = yield Note_1.default.all();
|
||||
expect(notes.length).toBe(0);
|
||||
synchronizer().testingHooks_ = [];
|
||||
yield synchronizerStart();
|
||||
notes = yield Note_1.default.all();
|
||||
expect(notes.length).toBe(1);
|
||||
})));
|
||||
it('should skip items that cannot be synced', (() => __awaiter(this, void 0, void 0, function* () {
|
||||
const folder1 = yield Folder_1.default.save({ title: 'folder1' });
|
||||
const note1 = yield Note_1.default.save({ title: 'un', is_todo: 1, parent_id: folder1.id });
|
||||
const noteId = note1.id;
|
||||
yield synchronizerStart();
|
||||
let disabledItems = yield BaseItem_1.default.syncDisabledItems(syncTargetId());
|
||||
expect(disabledItems.length).toBe(0);
|
||||
yield Note_1.default.save({ id: noteId, title: 'un mod' });
|
||||
synchronizer().testingHooks_ = ['notesRejectedByTarget'];
|
||||
yield synchronizerStart();
|
||||
synchronizer().testingHooks_ = [];
|
||||
yield synchronizerStart(); // Another sync to check that this item is now excluded from sync
|
||||
yield switchClient(2);
|
||||
yield synchronizerStart();
|
||||
const notes = yield Note_1.default.all();
|
||||
expect(notes.length).toBe(1);
|
||||
expect(notes[0].title).toBe('un');
|
||||
yield switchClient(1);
|
||||
disabledItems = yield BaseItem_1.default.syncDisabledItems(syncTargetId());
|
||||
expect(disabledItems.length).toBe(1);
|
||||
})));
|
||||
it('should allow duplicate folder titles', (() => __awaiter(this, void 0, void 0, function* () {
|
||||
yield Folder_1.default.save({ title: 'folder' });
|
||||
yield switchClient(2);
|
||||
let remoteF2 = yield Folder_1.default.save({ title: 'folder' });
|
||||
yield synchronizerStart();
|
||||
yield switchClient(1);
|
||||
yield sleep(0.1);
|
||||
yield synchronizerStart();
|
||||
const localF2 = yield Folder_1.default.load(remoteF2.id);
|
||||
expect(localF2.title == remoteF2.title).toBe(true);
|
||||
// Then that folder that has been renamed locally should be set in such a way
|
||||
// that synchronizing it applies the title change remotely, and that new title
|
||||
// should be retrieved by client 2.
|
||||
yield synchronizerStart();
|
||||
yield switchClient(2);
|
||||
yield sleep(0.1);
|
||||
yield synchronizerStart();
|
||||
remoteF2 = yield Folder_1.default.load(remoteF2.id);
|
||||
expect(remoteF2.title == localF2.title).toBe(true);
|
||||
})));
|
||||
it('should create remote items with UTF-8 content', (() => __awaiter(this, void 0, void 0, function* () {
|
||||
const folder = yield Folder_1.default.save({ title: 'Fahrräder' });
|
||||
yield Note_1.default.save({ title: 'Fahrräder', body: 'Fahrräder', parent_id: folder.id });
|
||||
const all = yield test_utils_synchronizer_1.allNotesFolders();
|
||||
yield synchronizerStart();
|
||||
yield test_utils_synchronizer_1.localNotesFoldersSameAsRemote(all, expect);
|
||||
})));
|
||||
it('should update remote items but not pull remote changes', (() => __awaiter(this, void 0, void 0, function* () {
|
||||
const folder = yield Folder_1.default.save({ title: 'folder1' });
|
||||
const note = yield Note_1.default.save({ title: 'un', parent_id: folder.id });
|
||||
yield synchronizerStart();
|
||||
yield switchClient(2);
|
||||
yield synchronizerStart();
|
||||
yield Note_1.default.save({ title: 'deux', parent_id: folder.id });
|
||||
yield synchronizerStart();
|
||||
yield switchClient(1);
|
||||
yield Note_1.default.save({ title: 'un UPDATE', id: note.id });
|
||||
yield synchronizerStart(null, { syncSteps: ['update_remote'] });
|
||||
const all = yield test_utils_synchronizer_1.allNotesFolders();
|
||||
expect(all.length).toBe(2);
|
||||
yield switchClient(2);
|
||||
yield synchronizerStart();
|
||||
const note2 = yield Note_1.default.load(note.id);
|
||||
expect(note2.title).toBe('un UPDATE');
|
||||
})));
|
||||
it('should create a new Welcome notebook on each client', (() => __awaiter(this, void 0, void 0, function* () {
|
||||
// Create the Welcome items on two separate clients
|
||||
yield WelcomeUtils.createWelcomeItems();
|
||||
yield synchronizerStart();
|
||||
yield switchClient(2);
|
||||
yield WelcomeUtils.createWelcomeItems();
|
||||
const beforeFolderCount = (yield Folder_1.default.all()).length;
|
||||
const beforeNoteCount = (yield Note_1.default.all()).length;
|
||||
expect(beforeFolderCount === 1).toBe(true);
|
||||
expect(beforeNoteCount > 1).toBe(true);
|
||||
yield synchronizerStart();
|
||||
const afterFolderCount = (yield Folder_1.default.all()).length;
|
||||
const afterNoteCount = (yield Note_1.default.all()).length;
|
||||
expect(afterFolderCount).toBe(beforeFolderCount * 2);
|
||||
expect(afterNoteCount).toBe(beforeNoteCount * 2);
|
||||
// Changes to the Welcome items should be synced to all clients
|
||||
const f1 = (yield Folder_1.default.all())[0];
|
||||
yield Folder_1.default.save({ id: f1.id, title: 'Welcome MOD' });
|
||||
yield synchronizerStart();
|
||||
yield switchClient(1);
|
||||
yield synchronizerStart();
|
||||
const f1_1 = yield Folder_1.default.load(f1.id);
|
||||
expect(f1_1.title).toBe('Welcome MOD');
|
||||
})));
|
||||
it('should not wipe out user data when syncing with an empty target', (() => __awaiter(this, void 0, void 0, function* () {
|
||||
// Only these targets support the wipeOutFailSafe flag (in other words, the targets that use basicDelta)
|
||||
if (!['nextcloud', 'memory', 'filesystem', 'amazon_s3'].includes(syncTargetName())) { return; }
|
||||
for (let i = 0; i < 10; i++) { yield Note_1.default.save({ title: 'note' }); }
|
||||
Setting_1.default.setValue('sync.wipeOutFailSafe', true);
|
||||
yield synchronizerStart();
|
||||
yield fileApi().clearRoot(); // oops
|
||||
yield synchronizerStart();
|
||||
expect((yield Note_1.default.all()).length).toBe(10); // but since the fail-safe if on, the notes have not been deleted
|
||||
Setting_1.default.setValue('sync.wipeOutFailSafe', false); // Now switch it off
|
||||
yield synchronizerStart();
|
||||
expect((yield Note_1.default.all()).length).toBe(0); // Since the fail-safe was off, the data has been cleared
|
||||
// Handle case where the sync target has been wiped out, then the user creates one note and sync.
|
||||
for (let i = 0; i < 10; i++) { yield Note_1.default.save({ title: 'note' }); }
|
||||
Setting_1.default.setValue('sync.wipeOutFailSafe', true);
|
||||
yield synchronizerStart();
|
||||
yield fileApi().clearRoot();
|
||||
yield Note_1.default.save({ title: 'ma note encore' });
|
||||
yield synchronizerStart();
|
||||
expect((yield Note_1.default.all()).length).toBe(11);
|
||||
})));
|
||||
});
|
||||
// # sourceMappingURL=Synchronizer.share.js.map
|
@@ -1,39 +1,70 @@
|
||||
import { afterAllCleanUp, synchronizerStart, setupDatabaseAndSynchronizer, switchClient } from './test-utils';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import BaseItem from '@joplin/lib/models/BaseItem';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
|
||||
describe('Synchronizer.sharing', function() {
|
||||
|
||||
beforeEach(async (done) => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await setupDatabaseAndSynchronizer(2);
|
||||
await switchClient(1);
|
||||
done();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await afterAllCleanUp();
|
||||
});
|
||||
|
||||
it('should mark link resources as shared before syncing', (async () => {
|
||||
let note1 = await Note.save({ title: 'note1' });
|
||||
note1 = await shim.attachFileToNote(note1, `${__dirname}/../tests/support/photo.jpg`);
|
||||
const resourceId1 = (await Note.linkedResourceIds(note1.body))[0];
|
||||
|
||||
const note2 = await Note.save({ title: 'note2' });
|
||||
await shim.attachFileToNote(note2, `${__dirname}/../tests/support/photo.jpg`);
|
||||
|
||||
expect((await Resource.sharedResourceIds()).length).toBe(0);
|
||||
|
||||
await BaseItem.updateShareStatus(note1, true);
|
||||
|
||||
await synchronizerStart();
|
||||
|
||||
const sharedResourceIds = await Resource.sharedResourceIds();
|
||||
expect(sharedResourceIds.length).toBe(1);
|
||||
expect(sharedResourceIds[0]).toBe(resourceId1);
|
||||
it('should skip', (async () => {
|
||||
expect(true).toBe(true);
|
||||
}));
|
||||
|
||||
});
|
||||
|
||||
// import { afterAllCleanUp, synchronizerStart, setupDatabaseAndSynchronizer, switchClient, joplinServerApi } from './test-utils';
|
||||
// import Note from '@joplin/lib/models/Note';
|
||||
// import BaseItem from '@joplin/lib/models/BaseItem';
|
||||
// import shim from '@joplin/lib/shim';
|
||||
// import Resource from '@joplin/lib/models/Resource';
|
||||
// import Folder from '@joplin/lib/models/Folder';
|
||||
|
||||
// describe('Synchronizer.sharing', function() {
|
||||
|
||||
// beforeEach(async (done) => {
|
||||
// await setupDatabaseAndSynchronizer(1);
|
||||
// await switchClient(1);
|
||||
// done();
|
||||
// });
|
||||
|
||||
// afterAll(async () => {
|
||||
// await afterAllCleanUp();
|
||||
// });
|
||||
|
||||
// it('should mark link resources as shared before syncing', (async () => {
|
||||
// let note1 = await Note.save({ title: 'note1' });
|
||||
// note1 = await shim.attachFileToNote(note1, `${__dirname}/../tests/support/photo.jpg`);
|
||||
// const resourceId1 = (await Note.linkedResourceIds(note1.body))[0];
|
||||
|
||||
// const note2 = await Note.save({ title: 'note2' });
|
||||
// await shim.attachFileToNote(note2, `${__dirname}/../tests/support/photo.jpg`);
|
||||
|
||||
// expect((await Resource.sharedResourceIds()).length).toBe(0);
|
||||
|
||||
// await BaseItem.updateShareStatus(note1, true);
|
||||
|
||||
// await synchronizerStart();
|
||||
|
||||
// const sharedResourceIds = await Resource.sharedResourceIds();
|
||||
// expect(sharedResourceIds.length).toBe(1);
|
||||
// expect(sharedResourceIds[0]).toBe(resourceId1);
|
||||
// }));
|
||||
|
||||
// it('should share items', (async () => {
|
||||
// await setupDatabaseAndSynchronizer(1, { userEmail: 'user1@example.com' });
|
||||
// await switchClient(1);
|
||||
|
||||
// const api = joplinServerApi();
|
||||
// await api.exec('POST', 'api/debug', null, { action: 'createTestUsers' });
|
||||
// await api.clearSession();
|
||||
|
||||
// const folder1 = await Folder.save({ title: 'folder1' });
|
||||
// await Note.save({ title: 'note1', parent_id: folder1.id });
|
||||
|
||||
// await synchronizerStart();
|
||||
|
||||
// await setupDatabaseAndSynchronizer(2, { userEmail: 'user2@example.com' });
|
||||
// await switchClient(2);
|
||||
|
||||
// await synchronizerStart();
|
||||
|
||||
// await switchClient(1);
|
||||
|
||||
// console.info(await Note.all());
|
||||
// }));
|
||||
|
||||
// });
|
||||
|
69
packages/app-cli/tests/file-api-driver.ts
Normal file
69
packages/app-cli/tests/file-api-driver.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { afterAllCleanUp, setupDatabaseAndSynchronizer, switchClient, fileApi } from './test-utils';
|
||||
|
||||
describe('file-api-driver', function() {
|
||||
|
||||
beforeEach(async (done) => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
await fileApi().clearRoot();
|
||||
done();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await afterAllCleanUp();
|
||||
});
|
||||
|
||||
it('should create a file', (async () => {
|
||||
await fileApi().put('test.txt', 'testing');
|
||||
const content = await fileApi().get('test.txt');
|
||||
expect(content).toBe('testing');
|
||||
}));
|
||||
|
||||
it('should get a file info', (async () => {
|
||||
await fileApi().put('test1.txt', 'testing');
|
||||
await fileApi().mkdir('sub');
|
||||
await fileApi().put('sub/test2.txt', 'testing');
|
||||
|
||||
// Note: Although the stat object includes an "isDir" property, this is
|
||||
// not actually used by the synchronizer so not required by any sync
|
||||
// target.
|
||||
|
||||
{
|
||||
const stat = await fileApi().stat('test1.txt');
|
||||
expect(stat.path).toBe('test1.txt');
|
||||
expect(!!stat.updated_time).toBe(true);
|
||||
expect(stat.isDir).toBe(false);
|
||||
}
|
||||
|
||||
{
|
||||
const stat = await fileApi().stat('sub/test2.txt');
|
||||
expect(stat.path).toBe('sub/test2.txt');
|
||||
expect(!!stat.updated_time).toBe(true);
|
||||
expect(stat.isDir).toBe(false);
|
||||
}
|
||||
}));
|
||||
|
||||
it('should create a file in a subdirectory', (async () => {
|
||||
await fileApi().mkdir('subdir');
|
||||
await fileApi().put('subdir/test.txt', 'testing');
|
||||
const content = await fileApi().get('subdir/test.txt');
|
||||
expect(content).toBe('testing');
|
||||
}));
|
||||
|
||||
it('should list files', (async () => {
|
||||
await fileApi().mkdir('subdir');
|
||||
await fileApi().put('subdir/test1.txt', 'testing1');
|
||||
await fileApi().put('subdir/test2.txt', 'testing2');
|
||||
const files = await fileApi().list('subdir');
|
||||
expect(files.items.length).toBe(2);
|
||||
expect(files.items.map((f: any) => f.path).sort()).toEqual(['test1.txt', 'test2.txt'].sort());
|
||||
}));
|
||||
|
||||
it('should delete a file', (async () => {
|
||||
await fileApi().put('test1.txt', 'testing1');
|
||||
await fileApi().delete('test1.txt');
|
||||
const files = await fileApi().list('');
|
||||
expect(files.items.length).toBe(0);
|
||||
}));
|
||||
|
||||
});
|
@@ -1,135 +0,0 @@
|
||||
/* eslint-disable no-unused-vars */
|
||||
|
||||
|
||||
const uuid = require('@joplin/lib/uuid').default;
|
||||
const time = require('@joplin/lib/time').default;
|
||||
const { sleep, fileApi, fileContentEqual, checkThrowAsync } = require('./test-utils.js');
|
||||
const shim = require('@joplin/lib/shim').default;
|
||||
const fs = require('fs-extra');
|
||||
const Setting = require('@joplin/lib/models/Setting').default;
|
||||
|
||||
const api = null;
|
||||
|
||||
// Adding empty test for Jest
|
||||
it('will pass', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
// NOTE: These tests work with S3 and memory driver, but not
|
||||
// with other targets like file system or Nextcloud.
|
||||
// All this is tested in an indirect way in tests/synchronizer
|
||||
// anyway.
|
||||
// We keep the file here as it could be useful as a spec for
|
||||
// what calls a sync target should support, but it would
|
||||
// need to be fixed first.
|
||||
|
||||
|
||||
|
||||
// To test out an FileApi implementation:
|
||||
// * add a SyncTarget for your driver in `test-utils.js`
|
||||
// * set `syncTargetId_` to your New SyncTarget:
|
||||
// `const syncTargetId_ = SyncTargetRegistry.nameToId('memory');`
|
||||
// describe('fileApi', function() {
|
||||
|
||||
// beforeEach(async (done) => {
|
||||
// api = new fileApi();
|
||||
// api.clearRoot();
|
||||
// done();
|
||||
// });
|
||||
|
||||
// describe('list', function() {
|
||||
// it('should return items with relative path', (async () => {
|
||||
// await api.mkdir('.subfolder');
|
||||
// await api.put('1', 'something on root 1');
|
||||
// await api.put('.subfolder/1', 'something subfolder 1');
|
||||
// await api.put('.subfolder/2', 'something subfolder 2');
|
||||
// await api.put('.subfolder/3', 'something subfolder 3');
|
||||
// sleep(0.8);
|
||||
|
||||
// const response = await api.list('.subfolder');
|
||||
// const items = response.items;
|
||||
// expect(items.length).toBe(3);
|
||||
// expect(items[0].path).toBe('1');
|
||||
// expect(items[0].updated_time).toMatch(/^\d+$/); // make sure it's using epoch timestamp
|
||||
// }));
|
||||
|
||||
// it('should default to only files on root directory', (async () => {
|
||||
// await api.mkdir('.subfolder');
|
||||
// await api.put('.subfolder/1', 'something subfolder 1');
|
||||
// await api.put('file1', 'something 1');
|
||||
// await api.put('file2', 'something 2');
|
||||
// sleep(0.6);
|
||||
|
||||
// const response = await api.list();
|
||||
// expect(response.items.length).toBe(2);
|
||||
// }));
|
||||
// }); // list
|
||||
|
||||
// describe('delete', function() {
|
||||
// it('should not error if file does not exist', (async () => {
|
||||
// const hasThrown = await checkThrowAsync(async () => await api.delete('nonexistant_file'));
|
||||
// expect(hasThrown).toBe(false);
|
||||
// }));
|
||||
|
||||
// it('should delete specific file given full path', (async () => {
|
||||
// await api.mkdir('deleteDir');
|
||||
// await api.put('deleteDir/1', 'something 1');
|
||||
// await api.put('deleteDir/2', 'something 2');
|
||||
// sleep(0.4);
|
||||
|
||||
// await api.delete('deleteDir/1');
|
||||
// let response = await api.list('deleteDir');
|
||||
// expect(response.items.length).toBe(1);
|
||||
// response = await api.list('deleteDir/1');
|
||||
// expect(response.items.length).toBe(0);
|
||||
// }));
|
||||
// }); // delete
|
||||
|
||||
// describe('get', function() {
|
||||
// it('should return null if object does not exist', (async () => {
|
||||
// const response = await api.get('nonexistant_file');
|
||||
// expect(response).toBe(null);
|
||||
// }));
|
||||
|
||||
// it('should return UTF-8 encoded string by default', (async () => {
|
||||
// await api.put('testnote.md', 'something 2');
|
||||
|
||||
// const response = await api.get('testnote.md');
|
||||
// expect(response).toBe('something 2');
|
||||
// }));
|
||||
|
||||
// it('should return a Response object and writes file to options.path, if options.target is "file"', (async () => {
|
||||
// const localFilePath = `${Setting.value('tempDir')}/${uuid.create()}.md`;
|
||||
// await api.put('testnote.md', 'something 2');
|
||||
// sleep(0.2);
|
||||
|
||||
// const response = await api.get('testnote.md', { target: 'file', path: localFilePath });
|
||||
// expect(typeof response).toBe('object');
|
||||
// // expect(response.path).toBe(localFilePath);
|
||||
// expect(fs.existsSync(localFilePath)).toBe(true);
|
||||
// expect(fs.readFileSync(localFilePath, 'utf8')).toBe('something 2');
|
||||
// }));
|
||||
// }); // get
|
||||
|
||||
// describe('put', function() {
|
||||
// it('should create file to remote path and content', (async () => {
|
||||
// await api.put('putTest.md', 'I am your content');
|
||||
// sleep(0.2);
|
||||
|
||||
// const response = await api.get('putTest.md');
|
||||
// expect(response).toBe('I am your content');
|
||||
// }));
|
||||
|
||||
// it('should upload file in options.path to remote path, if options.source is "file"', (async () => {
|
||||
// const localFilePath = `${Setting.value('tempDir')}/${uuid.create()}.md`;
|
||||
// fs.writeFileSync(localFilePath, 'I am the local file.');
|
||||
|
||||
// await api.put('testfile', 'ignore me', { source: 'file', path: localFilePath });
|
||||
// sleep(0.2);
|
||||
|
||||
// const response = await api.get('testfile');
|
||||
// expect(response).toBe('I am the local file.');
|
||||
// }));
|
||||
// }); // put
|
||||
|
||||
// });
|
362
packages/app-cli/tests/models_Folder.sharing.ts
Normal file
362
packages/app-cli/tests/models_Folder.sharing.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
import { setupDatabaseAndSynchronizer, switchClient, createFolderTree } from './test-utils';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import { allNotesFolders } from './test-utils-synchronizer';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
import { FolderEntity, NoteEntity, ResourceEntity } from '@joplin/lib/services/database/types';
|
||||
import ResourceService from '@joplin/lib/services/ResourceService';
|
||||
|
||||
const testImagePath = `${__dirname}/../tests/support/photo.jpg`;
|
||||
|
||||
describe('models_Folder.sharing', function() {
|
||||
|
||||
beforeEach(async (done) => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
done();
|
||||
});
|
||||
|
||||
it('should apply the share ID to all children', (async () => {
|
||||
const folder = await createFolderTree('', [
|
||||
{
|
||||
title: 'folder 1',
|
||||
children: [
|
||||
{
|
||||
title: 'note 1',
|
||||
},
|
||||
{
|
||||
title: 'note 2',
|
||||
},
|
||||
{
|
||||
title: 'folder 2',
|
||||
children: [
|
||||
{
|
||||
title: 'note 3',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await Folder.save({ id: folder.id, share_id: 'abcd1234' });
|
||||
await Folder.updateAllShareIds();
|
||||
|
||||
const allItems = await allNotesFolders();
|
||||
for (const item of allItems) {
|
||||
expect(item.share_id).toBe('abcd1234');
|
||||
}
|
||||
}));
|
||||
|
||||
it('should apply the share ID to all sub-folders', (async () => {
|
||||
let folder1 = await createFolderTree('', [
|
||||
{
|
||||
title: 'folder 1',
|
||||
children: [
|
||||
{
|
||||
title: 'note 1',
|
||||
},
|
||||
{
|
||||
title: 'note 2',
|
||||
},
|
||||
{
|
||||
title: 'folder 2',
|
||||
children: [
|
||||
{
|
||||
title: 'note 3',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'folder 3',
|
||||
children: [
|
||||
{
|
||||
title: 'folder 4',
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'folder 5',
|
||||
children: [],
|
||||
},
|
||||
]);
|
||||
|
||||
await Folder.save({ id: folder1.id, share_id: 'abcd1234' });
|
||||
|
||||
await Folder.updateAllShareIds();
|
||||
|
||||
folder1 = await Folder.loadByTitle('folder 1');
|
||||
const folder2 = await Folder.loadByTitle('folder 2');
|
||||
const folder3 = await Folder.loadByTitle('folder 3');
|
||||
const folder4 = await Folder.loadByTitle('folder 4');
|
||||
const folder5 = await Folder.loadByTitle('folder 5');
|
||||
|
||||
expect(folder1.share_id).toBe('abcd1234');
|
||||
expect(folder2.share_id).toBe('abcd1234');
|
||||
expect(folder3.share_id).toBe('abcd1234');
|
||||
expect(folder4.share_id).toBe('abcd1234');
|
||||
expect(folder5.share_id).toBe('');
|
||||
}));
|
||||
|
||||
it('should update the share ID when a folder is moved in or out of shared folder', (async () => {
|
||||
let folder1 = await createFolderTree('', [
|
||||
{
|
||||
title: 'folder 1',
|
||||
children: [
|
||||
{
|
||||
title: 'folder 2',
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'folder 3',
|
||||
children: [],
|
||||
},
|
||||
]);
|
||||
|
||||
await Folder.save({ id: folder1.id, share_id: 'abcd1234' });
|
||||
|
||||
await Folder.updateAllShareIds();
|
||||
|
||||
folder1 = await Folder.loadByTitle('folder 1');
|
||||
let folder2 = await Folder.loadByTitle('folder 2');
|
||||
const folder3 = await Folder.loadByTitle('folder 3');
|
||||
|
||||
expect(folder1.share_id).toBe('abcd1234');
|
||||
expect(folder2.share_id).toBe('abcd1234');
|
||||
|
||||
// Move the folder outside the shared folder
|
||||
|
||||
await Folder.save({ id: folder2.id, parent_id: folder3.id });
|
||||
await Folder.updateAllShareIds();
|
||||
folder2 = await Folder.loadByTitle('folder 2');
|
||||
expect(folder2.share_id).toBe('');
|
||||
|
||||
// Move the folder inside the shared folder
|
||||
|
||||
{
|
||||
await Folder.save({ id: folder2.id, parent_id: folder1.id });
|
||||
await Folder.updateAllShareIds();
|
||||
folder2 = await Folder.loadByTitle('folder 2');
|
||||
expect(folder2.share_id).toBe('abcd1234');
|
||||
}
|
||||
}));
|
||||
|
||||
it('should apply the share ID to all notes', (async () => {
|
||||
const folder1 = await createFolderTree('', [
|
||||
{
|
||||
title: 'folder 1',
|
||||
children: [
|
||||
{
|
||||
title: 'note 1',
|
||||
},
|
||||
{
|
||||
title: 'note 2',
|
||||
},
|
||||
{
|
||||
title: 'folder 2',
|
||||
children: [
|
||||
{
|
||||
title: 'note 3',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'folder 5',
|
||||
children: [
|
||||
{
|
||||
title: 'note 4',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await Folder.save({ id: folder1.id, share_id: 'abcd1234' });
|
||||
|
||||
await Folder.updateAllShareIds();
|
||||
|
||||
const note1: NoteEntity = await Note.loadByTitle('note 1');
|
||||
const note2: NoteEntity = await Note.loadByTitle('note 2');
|
||||
const note3: NoteEntity = await Note.loadByTitle('note 3');
|
||||
const note4: NoteEntity = await Note.loadByTitle('note 4');
|
||||
|
||||
expect(note1.share_id).toBe('abcd1234');
|
||||
expect(note2.share_id).toBe('abcd1234');
|
||||
expect(note3.share_id).toBe('abcd1234');
|
||||
expect(note4.share_id).toBe('');
|
||||
}));
|
||||
|
||||
it('should remove the share ID when a note is moved in or out of shared folder', (async () => {
|
||||
const folder1 = await createFolderTree('', [
|
||||
{
|
||||
title: 'folder 1',
|
||||
children: [
|
||||
{
|
||||
title: 'note 1',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'folder 2',
|
||||
children: [],
|
||||
},
|
||||
]);
|
||||
|
||||
await Folder.save({ id: folder1.id, share_id: 'abcd1234' });
|
||||
await Folder.updateAllShareIds();
|
||||
const note1: NoteEntity = await Note.loadByTitle('note 1');
|
||||
const folder2: FolderEntity = await Folder.loadByTitle('folder 2');
|
||||
expect(note1.share_id).toBe('abcd1234');
|
||||
|
||||
// Move the note outside of the shared folder
|
||||
|
||||
await Note.save({ id: note1.id, parent_id: folder2.id });
|
||||
await Folder.updateAllShareIds();
|
||||
|
||||
{
|
||||
const note1: NoteEntity = await Note.loadByTitle('note 1');
|
||||
expect(note1.share_id).toBe('');
|
||||
}
|
||||
|
||||
// Move the note back inside the shared folder
|
||||
|
||||
await Note.save({ id: note1.id, parent_id: folder1.id });
|
||||
await Folder.updateAllShareIds();
|
||||
|
||||
{
|
||||
const note1: NoteEntity = await Note.loadByTitle('note 1');
|
||||
expect(note1.share_id).toBe('abcd1234');
|
||||
}
|
||||
}));
|
||||
|
||||
it('should not remove the share ID of non-modified notes', (async () => {
|
||||
const folder1 = await createFolderTree('', [
|
||||
{
|
||||
title: 'folder 1',
|
||||
children: [
|
||||
{
|
||||
title: 'note 1',
|
||||
},
|
||||
{
|
||||
title: 'note 2',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'folder 2',
|
||||
children: [],
|
||||
},
|
||||
]);
|
||||
|
||||
await Folder.save({ id: folder1.id, share_id: 'abcd1234' });
|
||||
await Folder.updateAllShareIds();
|
||||
|
||||
let note1: NoteEntity = await Note.loadByTitle('note 1');
|
||||
let note2: NoteEntity = await Note.loadByTitle('note 2');
|
||||
const folder2: FolderEntity = await Folder.loadByTitle('folder 2');
|
||||
|
||||
expect(note1.share_id).toBe('abcd1234');
|
||||
expect(note2.share_id).toBe('abcd1234');
|
||||
|
||||
await Note.save({ id: note1.id, parent_id: folder2.id });
|
||||
await Folder.updateAllShareIds();
|
||||
|
||||
note1 = await Note.loadByTitle('note 1');
|
||||
note2 = await Note.loadByTitle('note 2');
|
||||
expect(note1.share_id).toBe('');
|
||||
expect(note2.share_id).toBe('abcd1234');
|
||||
}));
|
||||
|
||||
it('should apply the note share ID to its resources', async () => {
|
||||
const resourceService = new ResourceService();
|
||||
|
||||
const folder = await createFolderTree('', [
|
||||
{
|
||||
title: 'folder 1',
|
||||
children: [
|
||||
{
|
||||
title: 'note 1',
|
||||
},
|
||||
{
|
||||
title: 'note 2',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'folder 2',
|
||||
children: [],
|
||||
},
|
||||
]);
|
||||
|
||||
await Folder.save({ id: folder.id, share_id: 'abcd1234' });
|
||||
await Folder.updateAllShareIds();
|
||||
|
||||
const folder2: FolderEntity = await Folder.loadByTitle('folder 2');
|
||||
const note1: NoteEntity = await Note.loadByTitle('note 1');
|
||||
await shim.attachFileToNote(note1, testImagePath);
|
||||
|
||||
// We need to index the resources to populate the note_resources table
|
||||
await resourceService.indexNoteResources();
|
||||
|
||||
const resourceId: string = (await Resource.all())[0].id;
|
||||
|
||||
{
|
||||
const resource: ResourceEntity = await Resource.load(resourceId);
|
||||
expect(resource.share_id).toBe('');
|
||||
}
|
||||
|
||||
await Folder.updateAllShareIds();
|
||||
|
||||
// await NoteResource.updateResourceShareIds();
|
||||
|
||||
{
|
||||
const resource: ResourceEntity = await Resource.load(resourceId);
|
||||
expect(resource.share_id).toBe(note1.share_id);
|
||||
}
|
||||
|
||||
await Note.save({ id: note1.id, parent_id: folder2.id });
|
||||
await resourceService.indexNoteResources();
|
||||
|
||||
await Folder.updateAllShareIds();
|
||||
|
||||
// await NoteResource.updateResourceShareIds();
|
||||
|
||||
{
|
||||
const resource: ResourceEntity = await Resource.load(resourceId);
|
||||
expect(resource.share_id).toBe('');
|
||||
}
|
||||
});
|
||||
|
||||
// it('should not recursively delete when non-owner deletes a shared folder', async () => {
|
||||
// const folder = await createFolderTree('', [
|
||||
// {
|
||||
// title: 'folder 1',
|
||||
// children: [
|
||||
// {
|
||||
// title: 'note 1',
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// ]);
|
||||
|
||||
// BaseItem.shareService_ = {
|
||||
// isSharedFolderOwner: (_folderId: string) => false,
|
||||
// } as any;
|
||||
|
||||
// await Folder.save({ id: folder.id, share_id: 'abcd1234' });
|
||||
// await Folder.updateAllShareIds();
|
||||
|
||||
// await Folder.delete(folder.id);
|
||||
|
||||
// expect((await Folder.all()).length).toBe(0);
|
||||
// expect((await Note.all()).length).toBe(1);
|
||||
// });
|
||||
|
||||
});
|
@@ -1,5 +1,5 @@
|
||||
import { FolderEntity } from '@joplin/lib/services/database/types';
|
||||
import { createNTestNotes, setupDatabaseAndSynchronizer, sleep, switchClient, checkThrowAsync } from './test-utils';
|
||||
import { createNTestNotes, setupDatabaseAndSynchronizer, sleep, switchClient, checkThrowAsync, createFolderTree } from './test-utils';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
|
||||
@@ -225,4 +225,61 @@ describe('models_Folder', function() {
|
||||
const hasThrown = await checkThrowAsync(() => Folder.save({ id: f1.id, parent_id: f1.id }, { userSideValidation: true }));
|
||||
expect(hasThrown).toBe(true);
|
||||
}));
|
||||
|
||||
it('should get all the children of a folder', (async () => {
|
||||
const folder = await createFolderTree('', [
|
||||
{
|
||||
title: 'folder 1',
|
||||
children: [
|
||||
{
|
||||
title: 'note 1',
|
||||
},
|
||||
{
|
||||
title: 'note 2',
|
||||
},
|
||||
{
|
||||
title: 'folder 2',
|
||||
children: [
|
||||
{
|
||||
title: 'note 3',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'folder 3',
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'folder 4',
|
||||
children: [
|
||||
{
|
||||
title: 'folder 5',
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const folder2 = await Folder.loadByTitle('folder 2');
|
||||
const folder3 = await Folder.loadByTitle('folder 3');
|
||||
const folder4 = await Folder.loadByTitle('folder 4');
|
||||
const folder5 = await Folder.loadByTitle('folder 5');
|
||||
|
||||
{
|
||||
const children = await Folder.allChildrenFolders(folder.id);
|
||||
expect(children.map(c => c.id).sort()).toEqual([folder2.id, folder3.id].sort());
|
||||
}
|
||||
|
||||
{
|
||||
const children = await Folder.allChildrenFolders(folder4.id);
|
||||
expect(children.map(c => c.id).sort()).toEqual([folder5.id].sort());
|
||||
}
|
||||
|
||||
{
|
||||
const children = await Folder.allChildrenFolders(folder5.id);
|
||||
expect(children.map(c => c.id).sort()).toEqual([].sort());
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
@@ -1,17 +0,0 @@
|
||||
import { ExportModule, ImportModule } from './types';
|
||||
/**
|
||||
* Provides a way to create modules to import external data into Joplin or to export notes into any arbitrary format.
|
||||
*
|
||||
* [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/json_export)
|
||||
*
|
||||
* To implement an import or export module, you would simply define an object with various event handlers that are called
|
||||
* by the application during the import/export process.
|
||||
*
|
||||
* See the documentation of the [[ExportModule]] and [[ImportModule]] for more information.
|
||||
*
|
||||
* You may also want to refer to the Joplin API documentation to see the list of properties for each item (note, notebook, etc.) - https://joplinapp.org/api/references/rest_api/
|
||||
*/
|
||||
export default class JoplinInterop {
|
||||
registerExportModule(module: ExportModule): Promise<void>;
|
||||
registerImportModule(module: ImportModule): Promise<void>;
|
||||
}
|
@@ -53,6 +53,7 @@ import ResourceFetcher from '@joplin/lib/services/ResourceFetcher';
|
||||
const WebDavApi = require('@joplin/lib/WebDavApi');
|
||||
const DropboxApi = require('@joplin/lib/DropboxApi');
|
||||
import JoplinServerApi from '@joplin/lib/JoplinServerApi';
|
||||
import { FolderEntity } from '@joplin/lib/services/database/types';
|
||||
const { loadKeychainServiceAndSettings } = require('@joplin/lib/services/SettingUtils');
|
||||
const md5 = require('md5');
|
||||
const S3 = require('aws-sdk/clients/s3');
|
||||
@@ -346,6 +347,29 @@ async function setupDatabase(id: number = null, options: any = null) {
|
||||
await loadKeychainServiceAndSettings(options.keychainEnabled ? KeychainServiceDriver : KeychainServiceDriverDummy);
|
||||
}
|
||||
|
||||
export async function createFolderTree(parentId: string, tree: any[], num: number = 0): Promise<FolderEntity> {
|
||||
let rootFolder: FolderEntity = null;
|
||||
|
||||
for (const item of tree) {
|
||||
const isFolder = !!item.children;
|
||||
|
||||
num++;
|
||||
|
||||
const data = { ...item };
|
||||
delete data.children;
|
||||
|
||||
if (isFolder) {
|
||||
const folder = await Folder.save({ title: `Folder ${num}`, parent_id: parentId, ...data });
|
||||
if (!rootFolder) rootFolder = folder;
|
||||
if (item.children.length) await createFolderTree(folder.id, item.children, num);
|
||||
} else {
|
||||
await Note.save({ title: `Note ${num}`, parent_id: parentId, ...data });
|
||||
}
|
||||
}
|
||||
|
||||
return rootFolder;
|
||||
}
|
||||
|
||||
function exportDir(id: number = null) {
|
||||
if (id === null) id = currentClient_;
|
||||
return `${dataDir}/export`;
|
||||
@@ -385,7 +409,7 @@ async function setupDatabaseAndSynchronizer(id: number, options: any = null) {
|
||||
if (!synchronizers_[id]) {
|
||||
const SyncTargetClass = SyncTargetRegistry.classById(syncTargetId_);
|
||||
const syncTarget = new SyncTargetClass(db(id));
|
||||
await initFileApi(suiteName_);
|
||||
await initFileApi();
|
||||
syncTarget.setFileApi(fileApi());
|
||||
syncTarget.setLogger(logger);
|
||||
synchronizers_[id] = await syncTarget.synchronizer();
|
||||
@@ -484,7 +508,13 @@ async function loadEncryptionMasterKey(id: number = null, useExisting = false) {
|
||||
return masterKey;
|
||||
}
|
||||
|
||||
async function initFileApi(suiteName: string) {
|
||||
function mustRunInBand() {
|
||||
if (!process.argv.includes('--runInBand')) {
|
||||
throw new Error('Tests must be run sequentially for this sync target, with the --runInBand arg. eg `npm test -- --runInBand`');
|
||||
}
|
||||
}
|
||||
|
||||
async function initFileApi() {
|
||||
if (fileApis_[syncTargetId_]) return;
|
||||
|
||||
let fileApi = null;
|
||||
@@ -524,9 +554,7 @@ async function initFileApi(suiteName: string) {
|
||||
// OneDrive app directory, and it's not clear how to get that
|
||||
// working.
|
||||
|
||||
if (!process.argv.includes('--runInBand')) {
|
||||
throw new Error('OneDrive tests must be run sequentially, with the --runInBand arg. eg `npm test -- --runInBand`');
|
||||
}
|
||||
mustRunInBand();
|
||||
|
||||
const { parameters, setEnvOverride } = require('@joplin/lib/parameters.js');
|
||||
Setting.setConstant('env', 'dev');
|
||||
@@ -550,6 +578,8 @@ async function initFileApi(suiteName: string) {
|
||||
const api = new S3({ accessKeyId: amazonS3Creds.accessKeyId, secretAccessKey: amazonS3Creds.secretAccessKey, s3UseArnRegion: true });
|
||||
fileApi = new FileApi('', new FileApiDriverAmazonS3(api, amazonS3Creds.bucket));
|
||||
} else if (syncTargetId_ == SyncTargetRegistry.nameToId('joplinServer')) {
|
||||
mustRunInBand();
|
||||
|
||||
// Note that to test the API in parallel mode, you need to use Postgres
|
||||
// as database, as the SQLite database is not reliable when being
|
||||
// read/write from multiple processes at the same time.
|
||||
@@ -558,7 +588,8 @@ async function initFileApi(suiteName: string) {
|
||||
username: () => 'admin@localhost',
|
||||
password: () => 'admin',
|
||||
});
|
||||
fileApi = new FileApi(`Apps/Joplin-${suiteName}`, new FileApiDriverJoplinServer(api));
|
||||
|
||||
fileApi = new FileApi('', new FileApiDriverJoplinServer(api));
|
||||
}
|
||||
|
||||
fileApi.setLogger(logger);
|
||||
|
3
packages/app-desktop/.gitignore
vendored
3
packages/app-desktop/.gitignore
vendored
@@ -7,4 +7,5 @@ gui/note-viewer/pluginAssets/
|
||||
pluginAssets/
|
||||
gui/note-viewer/fonts/
|
||||
gui/note-viewer/lib.js
|
||||
gui/NoteEditor/NoteBody/TinyMCE/supportedLocales.js
|
||||
gui/NoteEditor/NoteBody/TinyMCE/supportedLocales.js
|
||||
runForSharingCommands-*
|
||||
|
@@ -13,6 +13,7 @@ import Logger, { TargetType } from '@joplin/lib/Logger';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import actionApi from '@joplin/lib/services/rest/actionApi.desktop';
|
||||
import BaseApplication from '@joplin/lib/BaseApplication';
|
||||
import DebugService from '@joplin/lib/debug/DebugService';
|
||||
import { _, setLocale } from '@joplin/lib/locale';
|
||||
import SpellCheckerService from '@joplin/lib/services/spellChecker/SpellCheckerService';
|
||||
import SpellCheckerServiceDriverNative from './services/spellChecker/SpellCheckerServiceDriverNative';
|
||||
@@ -58,6 +59,7 @@ const commands = [
|
||||
require('./gui/MainScreen/commands/openTag'),
|
||||
require('./gui/MainScreen/commands/print'),
|
||||
require('./gui/MainScreen/commands/renameFolder'),
|
||||
require('./gui/MainScreen/commands/showShareFolderDialog'),
|
||||
require('./gui/MainScreen/commands/renameTag'),
|
||||
require('./gui/MainScreen/commands/search'),
|
||||
require('./gui/MainScreen/commands/selectTemplate'),
|
||||
@@ -100,6 +102,7 @@ const globalCommands = [
|
||||
];
|
||||
|
||||
import editorCommandDeclarations from './gui/NoteEditor/commands/editorCommandDeclarations';
|
||||
import ShareService from '@joplin/lib/services/share/ShareService';
|
||||
|
||||
const pluginClasses = [
|
||||
require('./plugins/GotoAnything').default,
|
||||
@@ -730,6 +733,8 @@ class Application extends BaseApplication {
|
||||
bridge().window().show();
|
||||
}
|
||||
|
||||
void ShareService.instance().maintenance();
|
||||
|
||||
ResourceService.runInBackground();
|
||||
|
||||
if (Setting.value('env') === 'dev') {
|
||||
@@ -764,15 +769,16 @@ class Application extends BaseApplication {
|
||||
RevisionService.instance().runInBackground();
|
||||
|
||||
// Make it available to the console window - useful to call revisionService.collectRevisions()
|
||||
(window as any).joplin = () => {
|
||||
return {
|
||||
if (Setting.value('env') === 'dev') {
|
||||
(window as any).joplin = {
|
||||
revisionService: RevisionService.instance(),
|
||||
migrationService: MigrationService.instance(),
|
||||
decryptionWorker: DecryptionWorker.instance(),
|
||||
commandService: CommandService.instance(),
|
||||
bridge: bridge(),
|
||||
debug: new DebugService(reg.db()),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
bridge().addEventListener('nativeThemeUpdated', this.bridge_nativeThemeUpdated);
|
||||
|
||||
|
40
packages/app-desktop/gui/Dialog.tsx
Normal file
40
packages/app-desktop/gui/Dialog.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const DialogModalLayer = styled.div`
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0,0,0,0.6);
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const DialogRoot = styled.div`
|
||||
background-color: ${props => props.theme.backgroundColor};
|
||||
padding: 16px;
|
||||
box-shadow: 6px 6px 20px rgba(0,0,0,0.5);
|
||||
margin-top: 20px;
|
||||
min-height: fit-content;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 50%;
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
renderContent: Function;
|
||||
}
|
||||
|
||||
export default function Dialog(props: Props) {
|
||||
return (
|
||||
<DialogModalLayer>
|
||||
<DialogRoot>
|
||||
{props.renderContent()}
|
||||
</DialogRoot>
|
||||
</DialogModalLayer>
|
||||
);
|
||||
}
|
@@ -2,7 +2,26 @@ const React = require('react');
|
||||
const { _ } = require('@joplin/lib/locale');
|
||||
const { themeStyle } = require('@joplin/lib/theme');
|
||||
|
||||
function DialogButtonRow(props) {
|
||||
export interface ButtonSpec {
|
||||
name: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ClickEvent {
|
||||
buttonName: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
onClick?: (event: ClickEvent)=> void;
|
||||
okButtonShow?: boolean;
|
||||
cancelButtonShow?: boolean;
|
||||
cancelButtonLabel?: string;
|
||||
okButtonRef?: any;
|
||||
customButtons?: ButtonSpec[];
|
||||
}
|
||||
|
||||
export default function DialogButtonRow(props: Props) {
|
||||
const theme = themeStyle(props.themeId);
|
||||
|
||||
const okButton_click = () => {
|
||||
@@ -13,7 +32,11 @@ function DialogButtonRow(props) {
|
||||
if (props.onClick) props.onClick({ buttonName: 'cancel' });
|
||||
};
|
||||
|
||||
const onKeyDown = (event) => {
|
||||
const customButton_click = (event: ClickEvent) => {
|
||||
if (props.onClick) props.onClick(event);
|
||||
};
|
||||
|
||||
const onKeyDown = (event: any) => {
|
||||
if (event.keyCode === 13) {
|
||||
okButton_click();
|
||||
} else if (event.keyCode === 27) {
|
||||
@@ -23,6 +46,16 @@ function DialogButtonRow(props) {
|
||||
|
||||
const buttonComps = [];
|
||||
|
||||
if (props.customButtons) {
|
||||
for (const b of props.customButtons) {
|
||||
buttonComps.push(
|
||||
<button key={b.name} style={theme.buttonStyle} onClick={() => customButton_click({ buttonName: b.name })} onKeyDown={onKeyDown}>
|
||||
{b.label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (props.okButtonShow !== false) {
|
||||
buttonComps.push(
|
||||
<button key="ok" style={theme.buttonStyle} onClick={okButton_click} ref={props.okButtonRef} onKeyDown={onKeyDown}>
|
||||
@@ -41,5 +74,3 @@ function DialogButtonRow(props) {
|
||||
|
||||
return <div style={{ textAlign: 'right', marginTop: 10 }}>{buttonComps}</div>;
|
||||
}
|
||||
|
||||
module.exports = DialogButtonRow;
|
21
packages/app-desktop/gui/DialogTitle.tsx
Normal file
21
packages/app-desktop/gui/DialogTitle.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Root = styled.div`
|
||||
font-family: ${props => props.theme.fontFamily};
|
||||
font-size: ${props => props.theme.fontSize * 1.5};
|
||||
line-height: 1.6em;
|
||||
color: ${props => props.theme.color};
|
||||
font-weight: bold;
|
||||
margin-bottom: 1.2em;
|
||||
`;
|
||||
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function DialogTitle(props: Props) {
|
||||
return (
|
||||
<Root>{props.title}</Root>
|
||||
);
|
||||
}
|
@@ -29,12 +29,16 @@ import { themeStyle } from '@joplin/lib/theme';
|
||||
import validateLayout from '../ResizableLayout/utils/validateLayout';
|
||||
import iterateItems from '../ResizableLayout/utils/iterateItems';
|
||||
import removeItem from '../ResizableLayout/utils/removeItem';
|
||||
import EncryptionService from '@joplin/lib/services/EncryptionService';
|
||||
import ShareFolderDialog from '../ShareFolderDialog/ShareFolderDialog';
|
||||
import { ShareInvitation } from '@joplin/lib/services/share/reducer';
|
||||
import ShareService from '@joplin/lib/services/share/ShareService';
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
|
||||
const { connect } = require('react-redux');
|
||||
const { PromptDialog } = require('../PromptDialog.min.js');
|
||||
const NotePropertiesDialog = require('../NotePropertiesDialog.min.js');
|
||||
const PluginManager = require('@joplin/lib/services/PluginManager');
|
||||
import EncryptionService from '@joplin/lib/services/EncryptionService';
|
||||
const ipcRenderer = require('electron').ipcRenderer;
|
||||
|
||||
interface LayerModalState {
|
||||
@@ -63,15 +67,22 @@ interface Props {
|
||||
settingEditorCodeView: boolean;
|
||||
pluginsLegacy: any;
|
||||
startupPluginsLoaded: boolean;
|
||||
shareInvitations: ShareInvitation[];
|
||||
isSafeMode: boolean;
|
||||
}
|
||||
|
||||
interface ShareFolderDialogOptions {
|
||||
folderId: string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
interface State {
|
||||
promptOptions: any;
|
||||
modalLayer: LayerModalState;
|
||||
notePropertiesDialogOptions: any;
|
||||
noteContentPropertiesDialogOptions: any;
|
||||
shareNoteDialogOptions: any;
|
||||
shareFolderDialogOptions: ShareFolderDialogOptions;
|
||||
}
|
||||
|
||||
const StyledUserWebviewDialogContainer = styled.div`
|
||||
@@ -105,6 +116,7 @@ const commands = [
|
||||
require('./commands/newTodo'),
|
||||
require('./commands/print'),
|
||||
require('./commands/renameFolder'),
|
||||
require('./commands/showShareFolderDialog'),
|
||||
require('./commands/renameTag'),
|
||||
require('./commands/search'),
|
||||
require('./commands/selectTemplate'),
|
||||
@@ -144,6 +156,10 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
notePropertiesDialogOptions: {},
|
||||
noteContentPropertiesDialogOptions: {},
|
||||
shareNoteDialogOptions: {},
|
||||
shareFolderDialogOptions: {
|
||||
visible: false,
|
||||
folderId: '',
|
||||
},
|
||||
};
|
||||
|
||||
this.updateMainLayout(this.buildLayout(props.plugins));
|
||||
@@ -155,6 +171,7 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
this.notePropertiesDialog_close = this.notePropertiesDialog_close.bind(this);
|
||||
this.noteContentPropertiesDialog_close = this.noteContentPropertiesDialog_close.bind(this);
|
||||
this.shareNoteDialog_close = this.shareNoteDialog_close.bind(this);
|
||||
this.shareFolderDialog_close = this.shareFolderDialog_close.bind(this);
|
||||
this.resizableLayout_resize = this.resizableLayout_resize.bind(this);
|
||||
this.resizableLayout_renderItem = this.resizableLayout_renderItem.bind(this);
|
||||
this.resizableLayout_moveButtonClick = this.resizableLayout_moveButtonClick.bind(this);
|
||||
@@ -204,6 +221,10 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
return newLayout !== layout ? validateLayout(newLayout) : layout;
|
||||
}
|
||||
|
||||
private showShareInvitationNotification(props: Props): boolean {
|
||||
return !!props.shareInvitations.find(i => i.status === 0);
|
||||
}
|
||||
|
||||
private buildLayout(plugins: PluginStates): LayoutItem {
|
||||
const rootLayoutSize = this.rootLayoutSize();
|
||||
|
||||
@@ -268,10 +289,14 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
this.setState({ noteContentPropertiesDialogOptions: {} });
|
||||
}
|
||||
|
||||
shareNoteDialog_close() {
|
||||
private shareNoteDialog_close() {
|
||||
this.setState({ shareNoteDialogOptions: {} });
|
||||
}
|
||||
|
||||
private shareFolderDialog_close() {
|
||||
this.setState({ shareFolderDialogOptions: { visible: false, folderId: '' } });
|
||||
}
|
||||
|
||||
updateMainLayout(layout: LayoutItem) {
|
||||
this.props.dispatch({
|
||||
type: 'MAIN_LAYOUT_SET',
|
||||
@@ -321,6 +346,13 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.state.shareFolderDialogOptions !== prevState.shareFolderDialogOptions) {
|
||||
this.props.dispatch({
|
||||
type: this.state.shareFolderDialogOptions && this.state.shareFolderDialogOptions.visible ? 'VISIBLE_DIALOGS_ADD' : 'VISIBLE_DIALOGS_REMOVE',
|
||||
name: 'shareFolder',
|
||||
});
|
||||
}
|
||||
|
||||
if (this.props.mainLayout !== prevProps.mainLayout) {
|
||||
const toSave = saveLayout(this.props.mainLayout);
|
||||
Setting.setValue('ui.layout', toSave);
|
||||
@@ -496,6 +528,12 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
bridge().restart();
|
||||
};
|
||||
|
||||
const onInvitationRespond = async (shareUserId: string, accept: boolean) => {
|
||||
await ShareService.instance().respondInvitation(shareUserId, accept);
|
||||
await ShareService.instance().refreshShareInvitations();
|
||||
void reg.scheduleSync(1000);
|
||||
};
|
||||
|
||||
let msg = null;
|
||||
|
||||
if (this.props.isSafeMode) {
|
||||
@@ -516,15 +554,6 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
</a>
|
||||
</span>
|
||||
);
|
||||
} else if (this.props.hasDisabledSyncItems) {
|
||||
msg = (
|
||||
<span>
|
||||
{_('Some items cannot be synchronised.')}{' '}
|
||||
<a href="#" onClick={() => onViewStatusScreen()}>
|
||||
{_('View them now')}
|
||||
</a>
|
||||
</span>
|
||||
);
|
||||
} else if (this.props.hasDisabledEncryptionItems) {
|
||||
msg = (
|
||||
<span>
|
||||
@@ -534,15 +563,6 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
</a>
|
||||
</span>
|
||||
);
|
||||
} else if (this.props.showMissingMasterKeyMessage) {
|
||||
msg = (
|
||||
<span>
|
||||
{_('One or more master keys need a password.')}{' '}
|
||||
<a href="#" onClick={() => onViewEncryptionConfigScreen()}>
|
||||
{_('Set the password')}
|
||||
</a>
|
||||
</span>
|
||||
);
|
||||
} else if (this.props.showNeedUpgradingMasterKeyMessage) {
|
||||
msg = (
|
||||
<span>
|
||||
@@ -561,6 +581,40 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
</a>
|
||||
</span>
|
||||
);
|
||||
} else if (this.showShareInvitationNotification(this.props)) {
|
||||
const invitation = this.props.shareInvitations[0];
|
||||
const sharer = invitation.share.user;
|
||||
|
||||
msg = (
|
||||
<span>
|
||||
{_('%s (%s) would like to share a notebook with you.', sharer.full_name, sharer.email)}{' '}
|
||||
<a href="#" onClick={() => onInvitationRespond(invitation.id, true)}>
|
||||
{_('Accept')}
|
||||
</a>
|
||||
{' / '}
|
||||
<a href="#" onClick={() => onInvitationRespond(invitation.id,true)}>
|
||||
{_('Reject')}
|
||||
</a>
|
||||
</span>
|
||||
);
|
||||
} else if (this.props.hasDisabledSyncItems) {
|
||||
msg = (
|
||||
<span>
|
||||
{_('Some items cannot be synchronised.')}{' '}
|
||||
<a href="#" onClick={() => onViewStatusScreen()}>
|
||||
{_('View them now')}
|
||||
</a>
|
||||
</span>
|
||||
);
|
||||
} else if (this.props.showMissingMasterKeyMessage) {
|
||||
msg = (
|
||||
<span>
|
||||
{_('One or more master keys need a password.')}{' '}
|
||||
<a href="#" onClick={() => onViewEncryptionConfigScreen()}>
|
||||
{_('Set the password')}
|
||||
</a>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -572,7 +626,7 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
|
||||
messageBoxVisible(props: Props = null) {
|
||||
if (!props) props = this.props;
|
||||
return props.hasDisabledSyncItems || props.showMissingMasterKeyMessage || props.showNeedUpgradingMasterKeyMessage || props.showShouldReencryptMessage || props.hasDisabledEncryptionItems || this.props.shouldUpgradeSyncTarget || props.isSafeMode;
|
||||
return props.hasDisabledSyncItems || props.showMissingMasterKeyMessage || props.showNeedUpgradingMasterKeyMessage || props.showShouldReencryptMessage || props.hasDisabledEncryptionItems || this.props.shouldUpgradeSyncTarget || props.isSafeMode || this.showShareInvitationNotification(props);
|
||||
}
|
||||
|
||||
registerCommands() {
|
||||
@@ -739,6 +793,7 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
const notePropertiesDialogOptions = this.state.notePropertiesDialogOptions;
|
||||
const noteContentPropertiesDialogOptions = this.state.noteContentPropertiesDialogOptions;
|
||||
const shareNoteDialogOptions = this.state.shareNoteDialogOptions;
|
||||
const shareFolderDialogOptions = this.state.shareFolderDialogOptions;
|
||||
|
||||
const layoutComp = this.props.mainLayout ? (
|
||||
<ResizableLayout
|
||||
@@ -759,6 +814,7 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
{noteContentPropertiesDialogOptions.visible && <NoteContentPropertiesDialog markupLanguage={noteContentPropertiesDialogOptions.markupLanguage} themeId={this.props.themeId} onClose={this.noteContentPropertiesDialog_close} text={noteContentPropertiesDialogOptions.text}/>}
|
||||
{notePropertiesDialogOptions.visible && <NotePropertiesDialog themeId={this.props.themeId} noteId={notePropertiesDialogOptions.noteId} onClose={this.notePropertiesDialog_close} onRevisionLinkClick={notePropertiesDialogOptions.onRevisionLinkClick} />}
|
||||
{shareNoteDialogOptions.visible && <ShareNoteDialog themeId={this.props.themeId} noteIds={shareNoteDialogOptions.noteIds} onClose={this.shareNoteDialog_close} />}
|
||||
{shareFolderDialogOptions.visible && <ShareFolderDialog themeId={this.props.themeId} folderId={shareFolderDialogOptions.folderId} onClose={this.shareFolderDialog_close} />}
|
||||
|
||||
<PromptDialog autocomplete={promptOptions && 'autocomplete' in promptOptions ? promptOptions.autocomplete : null} defaultValue={promptOptions && promptOptions.value ? promptOptions.value : ''} themeId={this.props.themeId} style={styles.prompt} onClose={this.promptOnClose_} label={promptOptions ? promptOptions.label : ''} description={promptOptions ? promptOptions.description : null} visible={!!this.state.promptOptions} buttons={promptOptions && 'buttons' in promptOptions ? promptOptions.buttons : null} inputType={promptOptions && 'inputType' in promptOptions ? promptOptions.inputType : null} />
|
||||
|
||||
@@ -794,6 +850,7 @@ const mapStateToProps = (state: AppState) => {
|
||||
layoutMoveMode: state.layoutMoveMode,
|
||||
mainLayout: state.mainLayout,
|
||||
startupPluginsLoaded: state.startupPluginsLoaded,
|
||||
shareInvitations: state.shareService.shareInvitations,
|
||||
isSafeMode: state.settings.isSafeMode,
|
||||
};
|
||||
};
|
||||
|
@@ -0,0 +1,23 @@
|
||||
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'showShareFolderDialog',
|
||||
label: () => _('Share notebook...'),
|
||||
};
|
||||
|
||||
export const runtime = (comp: any): CommandRuntime => {
|
||||
return {
|
||||
execute: async (context: CommandContext, folderId: string = null) => {
|
||||
folderId = folderId || context.state.selectedFolderId;
|
||||
|
||||
comp.setState({
|
||||
shareFolderDialogOptions: {
|
||||
folderId,
|
||||
visible: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
enabledCondition: 'folderIsShareRootAndOwnedByUser || !folderIsShared',
|
||||
};
|
||||
};
|
@@ -1,8 +1,8 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import DialogButtonRow from './DialogButtonRow';
|
||||
const { themeStyle } = require('@joplin/lib/theme');
|
||||
const DialogButtonRow = require('./DialogButtonRow.min');
|
||||
const Countable = require('countable');
|
||||
import markupLanguageUtils from '../utils/markupLanguageUtils';
|
||||
|
||||
|
@@ -2,7 +2,7 @@ const React = require('react');
|
||||
const { _ } = require('@joplin/lib/locale');
|
||||
const { themeStyle } = require('@joplin/lib/theme');
|
||||
const time = require('@joplin/lib/time').default;
|
||||
const DialogButtonRow = require('./DialogButtonRow.min');
|
||||
const DialogButtonRow = require('./DialogButtonRow').default;
|
||||
const Datetime = require('react-datetime');
|
||||
const Note = require('@joplin/lib/models/Note').default;
|
||||
const formatcoords = require('formatcoords');
|
||||
|
336
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.tsx
Normal file
336
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
import Dialog from '../Dialog';
|
||||
import DialogButtonRow, { ClickEvent, ButtonSpec } from '../DialogButtonRow';
|
||||
import DialogTitle from '../DialogTitle';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FolderEntity } from '@joplin/lib/services/database/types';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import ShareService from '@joplin/lib/services/share/ShareService';
|
||||
import styled from 'styled-components';
|
||||
import StyledFormLabel from '../style/StyledFormLabel';
|
||||
import StyledInput from '../style/StyledInput';
|
||||
import Button from '../Button/Button';
|
||||
import Logger from '@joplin/lib/Logger';
|
||||
import StyledMessage from '../style/StyledMessage';
|
||||
import { ShareUserStatus, StateShare, StateShareUser } from '@joplin/lib/services/share/reducer';
|
||||
import { State } from '@joplin/lib/reducer';
|
||||
import { connect } from 'react-redux';
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
|
||||
const logger = Logger.create('ShareFolderDialog');
|
||||
|
||||
const StyledFolder = styled.div`
|
||||
border: 1px solid ${(props) => props.theme.dividerColor};
|
||||
padding: 0.5em;
|
||||
margin-bottom: 1em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const StyledRecipientControls = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
`;
|
||||
|
||||
const StyledRecipientInput = styled(StyledInput)`
|
||||
width: 100%;
|
||||
margin-right: 10px;
|
||||
`;
|
||||
|
||||
const StyledAddRecipient = styled.div`
|
||||
margin-bottom: 1em;
|
||||
`;
|
||||
|
||||
const StyledRecipient = styled(StyledMessage)`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: .6em 1em;
|
||||
background-color: ${props => props.index % 2 === 0 ? props.theme.backgroundColor : props.theme.oddBackgroundColor};
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const StyledRecipientName = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const StyledRecipientStatusIcon = styled.i`
|
||||
margin-right: .6em;
|
||||
`;
|
||||
|
||||
const StyledRecipients = styled.div`
|
||||
|
||||
`;
|
||||
|
||||
const StyledRecipientList = styled.div`
|
||||
border: 1px solid ${(props: any) => props.theme.dividerColor};
|
||||
border-radius: 3px;
|
||||
height: 300px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
`;
|
||||
|
||||
const StyledError = styled(StyledMessage)`
|
||||
word-break: break-all;
|
||||
margin-bottom: 1em;
|
||||
`;
|
||||
|
||||
const StyledShareState = styled(StyledMessage)`
|
||||
word-break: break-all;
|
||||
margin-bottom: 1em;
|
||||
`;
|
||||
|
||||
const StyledIcon = styled.i`
|
||||
margin-right: 8px;
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
folderId: string;
|
||||
onClose(): void;
|
||||
shares: StateShare[];
|
||||
shareUsers: Record<string, StateShareUser[]>;
|
||||
}
|
||||
|
||||
interface RecipientDeleteEvent {
|
||||
shareUserId: string;
|
||||
}
|
||||
|
||||
interface AsyncEffectEvent {
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
function useAsyncEffect(effect: Function, dependencies: any[]) {
|
||||
useEffect(() => {
|
||||
const event = { cancelled: false };
|
||||
effect(event);
|
||||
return () => {
|
||||
event.cancelled = true;
|
||||
};
|
||||
}, dependencies);
|
||||
}
|
||||
|
||||
enum ShareState {
|
||||
Idle = 0,
|
||||
Synchronizing = 1,
|
||||
Creating = 2,
|
||||
}
|
||||
|
||||
function ShareFolderDialog(props: Props) {
|
||||
const [folder, setFolder] = useState<FolderEntity>(null);
|
||||
const [recipientEmail, setRecipientEmail] = useState<string>('');
|
||||
const [latestError, setLatestError] = useState<Error>(null);
|
||||
const [share, setShare] = useState<StateShare>(null);
|
||||
const [shareUsers, setShareUsers] = useState<StateShareUser[]>([]);
|
||||
const [shareState, setShareState] = useState<ShareState>(ShareState.Idle);
|
||||
const [customButtons, setCustomButtons] = useState<ButtonSpec[]>([]);
|
||||
|
||||
async function synchronize(event: AsyncEffectEvent = null) {
|
||||
setShareState(ShareState.Synchronizing);
|
||||
await reg.waitForSyncFinishedThenSync();
|
||||
if (event && event.cancelled) return;
|
||||
setShareState(ShareState.Idle);
|
||||
}
|
||||
|
||||
useAsyncEffect(async (event: AsyncEffectEvent) => {
|
||||
const f = await Folder.load(props.folderId);
|
||||
if (event.cancelled) return;
|
||||
setFolder(f);
|
||||
}, [props.folderId]);
|
||||
|
||||
useEffect(() => {
|
||||
void ShareService.instance().refreshShares();
|
||||
}, []);
|
||||
|
||||
useAsyncEffect(async (event: AsyncEffectEvent) => {
|
||||
await synchronize(event);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const s = props.shares.find(s => s.folder_id === props.folderId);
|
||||
setShare(s);
|
||||
}, [props.shares]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!share) return;
|
||||
void ShareService.instance().refreshShareUsers(share.id);
|
||||
}, [share]);
|
||||
|
||||
useEffect(() => {
|
||||
setCustomButtons(share ? [{
|
||||
name: 'unshare',
|
||||
label: _('Unshare'),
|
||||
}] : []);
|
||||
}, [share]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!share) return;
|
||||
const sus = props.shareUsers[share.id];
|
||||
if (!sus) return;
|
||||
setShareUsers(sus);
|
||||
}, [share, props.shareUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
void ShareService.instance().refreshShares();
|
||||
}, [props.folderId]);
|
||||
|
||||
async function shareRecipient_click() {
|
||||
setShareState(ShareState.Creating);
|
||||
|
||||
try {
|
||||
setLatestError(null);
|
||||
const share = await ShareService.instance().shareFolder(props.folderId);
|
||||
await ShareService.instance().addShareRecipient(share.id, recipientEmail);
|
||||
await Promise.all([
|
||||
ShareService.instance().refreshShares(),
|
||||
ShareService.instance().refreshShareUsers(share.id),
|
||||
]);
|
||||
setRecipientEmail('');
|
||||
|
||||
await synchronize();
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
setLatestError(error);
|
||||
} finally {
|
||||
setShareState(ShareState.Idle);
|
||||
}
|
||||
}
|
||||
|
||||
function recipientEmail_change(event: any) {
|
||||
setRecipientEmail(event.target.value);
|
||||
}
|
||||
|
||||
async function recipient_delete(event: RecipientDeleteEvent) {
|
||||
if (!confirm(_('Delete this invitation? The recipient will no longer have access to this shared notebook.'))) return;
|
||||
|
||||
await ShareService.instance().deleteShareRecipient(event.shareUserId);
|
||||
await ShareService.instance().refreshShareUsers(share.id);
|
||||
}
|
||||
|
||||
function renderFolder() {
|
||||
return (
|
||||
<StyledFolder>
|
||||
<StyledIcon className="icon-notebooks"/>{folder ? folder.title : '...'}
|
||||
</StyledFolder>
|
||||
);
|
||||
}
|
||||
|
||||
function renderAddRecipient() {
|
||||
const disabled = shareState !== ShareState.Idle;
|
||||
return (
|
||||
<StyledAddRecipient>
|
||||
<StyledFormLabel>{_('Add recipient:')}</StyledFormLabel>
|
||||
<StyledRecipientControls>
|
||||
<StyledRecipientInput disabled={disabled} type="email" placeholder="example@domain.com" value={recipientEmail} onChange={recipientEmail_change} />
|
||||
<Button disabled={disabled} title={_('Share')} onClick={shareRecipient_click}></Button>
|
||||
</StyledRecipientControls>
|
||||
</StyledAddRecipient>
|
||||
);
|
||||
}
|
||||
|
||||
function renderRecipient(index: number, shareUser: StateShareUser) {
|
||||
const statusToIcon = {
|
||||
[ShareUserStatus.Waiting]: 'fas fa-question',
|
||||
[ShareUserStatus.Rejected]: 'fas fa-times',
|
||||
[ShareUserStatus.Accepted]: 'fas fa-check',
|
||||
};
|
||||
|
||||
const statusToMessage = {
|
||||
[ShareUserStatus.Waiting]: _('Recipient has not yet accepted the invitation'),
|
||||
[ShareUserStatus.Rejected]: _('Recipient has rejected the invitation'),
|
||||
[ShareUserStatus.Accepted]: _('Recipient has accepted the invitation'),
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledRecipient key={shareUser.user.email} index={index}>
|
||||
<StyledRecipientName>{shareUser.user.email}</StyledRecipientName>
|
||||
<StyledRecipientStatusIcon title={statusToMessage[shareUser.status]} className={statusToIcon[shareUser.status]}></StyledRecipientStatusIcon>
|
||||
<Button iconName="far fa-times-circle" onClick={() => recipient_delete({ shareUserId: shareUser.id })}/>
|
||||
</StyledRecipient>
|
||||
);
|
||||
}
|
||||
|
||||
function renderRecipients() {
|
||||
const listItems = shareUsers.map((su: StateShareUser, index: number) => renderRecipient(index, su));
|
||||
|
||||
return (
|
||||
<StyledRecipients>
|
||||
<StyledFormLabel>{_('Recipients:')}</StyledFormLabel>
|
||||
<StyledRecipientList>
|
||||
{listItems}
|
||||
</StyledRecipientList>
|
||||
</StyledRecipients>
|
||||
);
|
||||
}
|
||||
|
||||
function renderError() {
|
||||
if (!latestError) return null;
|
||||
|
||||
return (
|
||||
<StyledError type="error">
|
||||
{latestError.message}
|
||||
</StyledError>
|
||||
);
|
||||
}
|
||||
|
||||
function renderShareState() {
|
||||
if (shareState === ShareState.Idle) return null;
|
||||
|
||||
const messages = {
|
||||
[ShareState.Synchronizing]: _('Synchronizing...'),
|
||||
[ShareState.Creating]: _('Sharing notebook...'),
|
||||
};
|
||||
|
||||
const message = messages[shareState];
|
||||
if (!message) throw new Error(`Unsupported state: ${shareState}`);
|
||||
|
||||
return (
|
||||
<StyledShareState>
|
||||
{message}
|
||||
</StyledShareState>
|
||||
);
|
||||
}
|
||||
|
||||
async function buttonRow_click(event: ClickEvent) {
|
||||
if (event.buttonName === 'unshare') {
|
||||
if (!confirm(_('Unshare this notebook? The recipients will no longer have access to its content.'))) return;
|
||||
await ShareService.instance().unshareFolder(props.folderId);
|
||||
void synchronize();
|
||||
}
|
||||
|
||||
props.onClose();
|
||||
}
|
||||
|
||||
function renderContent() {
|
||||
return (
|
||||
<div>
|
||||
<DialogTitle title={_('Share Notebook')}/>
|
||||
{renderFolder()}
|
||||
{renderAddRecipient()}
|
||||
{renderShareState()}
|
||||
{renderError()}
|
||||
{renderRecipients()}
|
||||
<DialogButtonRow
|
||||
themeId={props.themeId}
|
||||
onClick={buttonRow_click}
|
||||
okButtonShow={false}
|
||||
cancelButtonLabel={_('Close')}
|
||||
customButtons={customButtons}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog renderContent={renderContent}/>
|
||||
);
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: State) => {
|
||||
return {
|
||||
shares: state.shareService.shares,
|
||||
shareUsers: state.shareService.shareUsers,
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(ShareFolderDialog as any);
|
@@ -4,12 +4,12 @@ import JoplinServerApi from '@joplin/lib/JoplinServerApi';
|
||||
import { _, _n } from '@joplin/lib/locale';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import BaseItem from '@joplin/lib/models/BaseItem';
|
||||
import SyncTargetJoplinServer from '@joplin/lib/SyncTargetJoplinServer';
|
||||
|
||||
const { themeStyle, buildStyle } = require('@joplin/lib/theme');
|
||||
const DialogButtonRow = require('./DialogButtonRow.min');
|
||||
import DialogButtonRow from './DialogButtonRow';
|
||||
import { themeStyle, buildStyle } from '@joplin/lib/theme';
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
import Dialog from './Dialog';
|
||||
import DialogTitle from './DialogTitle';
|
||||
import ShareService from '@joplin/lib/services/share/ShareService';
|
||||
const { clipboard } = require('electron');
|
||||
|
||||
interface ShareNoteDialogProps {
|
||||
@@ -83,26 +83,19 @@ export default function ShareNoteDialog(props: ShareNoteDialogProps) {
|
||||
void fetchNotes();
|
||||
}, [props.noteIds]);
|
||||
|
||||
const fileApi = async () => {
|
||||
const syncTarget = reg.syncTarget() as SyncTargetJoplinServer;
|
||||
return syncTarget.fileApi();
|
||||
};
|
||||
|
||||
const joplinServerApi = async (): Promise<JoplinServerApi> => {
|
||||
return (await fileApi()).driver().api();
|
||||
};
|
||||
|
||||
const buttonRow_click = () => {
|
||||
props.onClose();
|
||||
};
|
||||
|
||||
const copyLinksToClipboard = (api: JoplinServerApi, shares: SharesMap) => {
|
||||
const copyLinksToClipboard = (shares: SharesMap) => {
|
||||
const links = [];
|
||||
for (const n in shares) links.push(api.shareUrl(shares[n]));
|
||||
for (const n in shares) links.push(ShareService.instance().shareUrl(shares[n]));
|
||||
clipboard.writeText(links.join('\n'));
|
||||
};
|
||||
|
||||
const shareLinkButton_click = async () => {
|
||||
const service = ShareService.instance();
|
||||
|
||||
let hasSynced = false;
|
||||
let tryToSync = false;
|
||||
while (true) {
|
||||
@@ -116,29 +109,20 @@ export default function ShareNoteDialog(props: ShareNoteDialogProps) {
|
||||
|
||||
setSharesState('creating');
|
||||
|
||||
const api = await joplinServerApi();
|
||||
|
||||
const newShares = Object.assign({}, shares);
|
||||
let sharedStatusChanged = false;
|
||||
|
||||
for (const note of notes) {
|
||||
const fullPath = (await fileApi()).fullPath(BaseItem.systemPath(note.id));
|
||||
const share = await api.shareFile(fullPath);
|
||||
const share = await service.shareNote(note.id);
|
||||
newShares[note.id] = share;
|
||||
|
||||
const changed = await BaseItem.updateShareStatus(note, true);
|
||||
if (changed) sharedStatusChanged = true;
|
||||
}
|
||||
|
||||
setShares(newShares);
|
||||
|
||||
if (sharedStatusChanged) {
|
||||
setSharesState('synchronizing');
|
||||
await reg.waitForSyncFinishedThenSync();
|
||||
setSharesState('creating');
|
||||
}
|
||||
setSharesState('synchronizing');
|
||||
await reg.waitForSyncFinishedThenSync();
|
||||
setSharesState('creating');
|
||||
|
||||
copyLinksToClipboard(api, newShares);
|
||||
copyLinksToClipboard(newShares);
|
||||
|
||||
setSharesState('created');
|
||||
} catch (error) {
|
||||
@@ -202,24 +186,20 @@ export default function ShareNoteDialog(props: ShareNoteDialogProps) {
|
||||
return <div style={theme.textStyle}>{_('Note: When a note is shared, it will no longer be encrypted on the server.')}<hr/></div>;
|
||||
}
|
||||
|
||||
function renderBetaWarningMessage() {
|
||||
return <div style={theme.textStyle}>{'Sharing notes via Joplin Server is a Beta feature and the API might change later on. What it means is that if you share a note, the link might become invalid after an upgrade, and you will have to share it again.'}</div>;
|
||||
}
|
||||
|
||||
const rootStyle = Object.assign({}, theme.dialogBox);
|
||||
rootStyle.width = '50%';
|
||||
|
||||
return (
|
||||
<div style={theme.dialogModalLayer}>
|
||||
<div style={rootStyle}>
|
||||
<div style={theme.dialogTitle}>{_('Share Notes')}</div>
|
||||
function renderContent() {
|
||||
return (
|
||||
<div>
|
||||
<DialogTitle title={_('Share Notes')}/>
|
||||
{renderNoteList(notes)}
|
||||
<button disabled={['creating', 'synchronizing'].indexOf(sharesState) >= 0} style={styles.copyShareLinkButton} onClick={shareLinkButton_click}>{_n('Copy Shareable Link', 'Copy Shareable Links', noteCount)}</button>
|
||||
<div style={theme.textStyle}>{statusMessage(sharesState)}</div>
|
||||
{renderEncryptionWarningMessage()}
|
||||
{renderBetaWarningMessage()}
|
||||
<DialogButtonRow themeId={props.themeId} onClick={buttonRow_click} okButtonShow={false} cancelButtonLabel={_('Close')}/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog renderContent={renderContent}/>
|
||||
);
|
||||
}
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { StyledRoot, StyledAddButton, StyledHeader, StyledHeaderIcon, StyledAllNotesIcon, StyledHeaderLabel, StyledListItem, StyledListItemAnchor, StyledExpandLink, StyledNoteCount, StyledSyncReportText, StyledSyncReport, StyledSynchronizeButton } from './styles';
|
||||
import { StyledRoot, StyledAddButton, StyledShareIcon, StyledHeader, StyledHeaderIcon, StyledAllNotesIcon, StyledHeaderLabel, StyledListItem, StyledListItemAnchor, StyledExpandLink, StyledNoteCount, StyledSyncReportText, StyledSyncReport, StyledSynchronizeButton } from './styles';
|
||||
import { ButtonLevel } from '../Button/Button';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import InteropService from '@joplin/lib/services/interop/InteropService';
|
||||
@@ -12,13 +12,16 @@ import { PluginStates, utils as pluginUtils } from '@joplin/lib/services/plugins
|
||||
import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types';
|
||||
import { AppState } from '../../app';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
|
||||
const { connect } = require('react-redux');
|
||||
const shared = require('@joplin/lib/components/shared/side-menu-shared.js');
|
||||
import BaseModel from '@joplin/lib/BaseModel';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Tag from '@joplin/lib/models/Tag';
|
||||
import Logger from '@joplin/lib/Logger';
|
||||
import { FolderEntity } from '@joplin/lib/services/database/types';
|
||||
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
|
||||
import { store } from '@joplin/lib/reducer';
|
||||
const { connect } = require('react-redux');
|
||||
const shared = require('@joplin/lib/components/shared/side-menu-shared.js');
|
||||
const { themeStyle } = require('@joplin/lib/theme');
|
||||
const bridge = require('electron').remote.require('./bridge').default;
|
||||
const Menu = bridge().Menu;
|
||||
@@ -26,6 +29,8 @@ const MenuItem = bridge().MenuItem;
|
||||
const { substrWithEllipsis } = require('@joplin/lib/string-utils');
|
||||
const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids');
|
||||
|
||||
const logger = Logger.create('Sidebar');
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
dispatch: Function;
|
||||
@@ -70,10 +75,12 @@ function ExpandLink(props: any) {
|
||||
}
|
||||
|
||||
function FolderItem(props: any) {
|
||||
const { hasChildren, isExpanded, depth, selected, folderId, folderTitle, anchorRef, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_ } = props;
|
||||
const { hasChildren, isExpanded, parentId, depth, selected, folderId, folderTitle, anchorRef, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_, shareId } = props;
|
||||
|
||||
const noteCountComp = noteCount ? <StyledNoteCount>{noteCount}</StyledNoteCount> : null;
|
||||
|
||||
const shareIcon = shareId && !parentId ? <StyledShareIcon className="fas fa-share-alt"></StyledShareIcon> : null;
|
||||
|
||||
return (
|
||||
<StyledListItem depth={depth} selected={selected} className={`list-item-container list-item-depth-${depth}`} onDragStart={onFolderDragStart_} onDragOver={onFolderDragOver_} onDrop={onFolderDrop_} draggable={true} data-folder-id={folderId}>
|
||||
<ExpandLink themeId={props.themeId} hasChildren={hasChildren} folderId={folderId} onClick={onFolderToggleClick_} isExpanded={isExpanded}/>
|
||||
@@ -83,6 +90,7 @@ function FolderItem(props: any) {
|
||||
isConflictFolder={folderId === Folder.conflictFolderId()}
|
||||
href="#"
|
||||
selected={selected}
|
||||
shareId={shareId}
|
||||
data-id={folderId}
|
||||
data-type={BaseModel.TYPE_FOLDER}
|
||||
onContextMenu={itemContextMenu}
|
||||
@@ -92,7 +100,7 @@ function FolderItem(props: any) {
|
||||
}}
|
||||
onDoubleClick={onFolderToggleClick_}
|
||||
>
|
||||
{folderTitle} {noteCountComp}
|
||||
{folderTitle} {shareIcon} {noteCountComp}
|
||||
</StyledListItemAnchor>
|
||||
</StyledListItem>
|
||||
);
|
||||
@@ -158,22 +166,27 @@ class SidebarComponent extends React.Component<Props, State> {
|
||||
// to put the dropped folder at the root. But for notes, folderId needs to always be defined
|
||||
// since there's no such thing as a root note.
|
||||
|
||||
if (dt.types.indexOf('text/x-jop-note-ids') >= 0) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
if (dt.types.indexOf('text/x-jop-note-ids') >= 0) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!folderId) return;
|
||||
if (!folderId) return;
|
||||
|
||||
const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids'));
|
||||
for (let i = 0; i < noteIds.length; i++) {
|
||||
await Note.moveToFolder(noteIds[i], folderId);
|
||||
}
|
||||
} else if (dt.types.indexOf('text/x-jop-folder-ids') >= 0) {
|
||||
event.preventDefault();
|
||||
|
||||
const folderIds = JSON.parse(dt.getData('text/x-jop-folder-ids'));
|
||||
for (let i = 0; i < folderIds.length; i++) {
|
||||
await Folder.moveToFolder(folderIds[i], folderId);
|
||||
const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids'));
|
||||
for (let i = 0; i < noteIds.length; i++) {
|
||||
await Note.moveToFolder(noteIds[i], folderId);
|
||||
}
|
||||
} else if (dt.types.indexOf('text/x-jop-folder-ids') >= 0) {
|
||||
event.preventDefault();
|
||||
|
||||
const folderIds = JSON.parse(dt.getData('text/x-jop-folder-ids'));
|
||||
for (let i = 0; i < folderIds.length; i++) {
|
||||
await Folder.moveToFolder(folderIds[i], folderId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,12 +235,14 @@ class SidebarComponent extends React.Component<Props, State> {
|
||||
const itemType = Number(event.currentTarget.getAttribute('data-type'));
|
||||
if (!itemId || !itemType) throw new Error('No data on element');
|
||||
|
||||
const state: AppState = store().getState();
|
||||
|
||||
let deleteMessage = '';
|
||||
let buttonLabel = _('Remove');
|
||||
let deleteButtonLabel = _('Remove');
|
||||
if (itemType === BaseModel.TYPE_FOLDER) {
|
||||
const folder = await Folder.load(itemId);
|
||||
deleteMessage = _('Delete notebook "%s"?\n\nAll notes and sub-notebooks within this notebook will also be deleted.', substrWithEllipsis(folder.title, 0, 32));
|
||||
buttonLabel = _('Delete');
|
||||
deleteButtonLabel = _('Delete');
|
||||
} else if (itemType === BaseModel.TYPE_TAG) {
|
||||
const tag = await Tag.load(itemId);
|
||||
deleteMessage = _('Remove tag "%s" from all notes?', substrWithEllipsis(tag.title, 0, 32));
|
||||
@@ -250,10 +265,10 @@ class SidebarComponent extends React.Component<Props, State> {
|
||||
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: buttonLabel,
|
||||
label: deleteButtonLabel,
|
||||
click: async () => {
|
||||
const ok = bridge().showConfirmMessageBox(deleteMessage, {
|
||||
buttons: [buttonLabel, _('Cancel')],
|
||||
buttons: [deleteButtonLabel, _('Cancel')],
|
||||
defaultId: 1,
|
||||
});
|
||||
if (!ok) return;
|
||||
@@ -294,6 +309,14 @@ class SidebarComponent extends React.Component<Props, State> {
|
||||
);
|
||||
}
|
||||
|
||||
// We don't display the "Share notebook" menu item for sub-notebooks
|
||||
// that are within a shared notebook. If user wants to do this,
|
||||
// they'd have to move the notebook out of the shared notebook
|
||||
// first.
|
||||
if (CommandService.instance().isEnabled('showShareFolderDialog', stateToWhenClauseContext(state, { commandFolderId: itemId }))) {
|
||||
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('showShareFolderDialog', itemId)));
|
||||
}
|
||||
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: _('Export'),
|
||||
@@ -387,10 +410,10 @@ class SidebarComponent extends React.Component<Props, State> {
|
||||
);
|
||||
}
|
||||
|
||||
renderFolderItem(folder: any, selected: boolean, hasChildren: boolean, depth: number) {
|
||||
renderFolderItem(folder: FolderEntity, selected: boolean, hasChildren: boolean, depth: number) {
|
||||
const anchorRef = this.anchorItemRef('folder', folder.id);
|
||||
const isExpanded = this.props.collapsedFolderIds.indexOf(folder.id) < 0;
|
||||
let noteCount = folder.note_count;
|
||||
let noteCount = (folder as any).note_count;
|
||||
|
||||
// Thunderbird count: Subtract children note_count from parent folder if it expanded.
|
||||
if (isExpanded) {
|
||||
@@ -418,6 +441,8 @@ class SidebarComponent extends React.Component<Props, State> {
|
||||
itemContextMenu={this.itemContextMenu}
|
||||
folderItem_click={this.folderItem_click}
|
||||
onFolderToggleClick_={this.onFolderToggleClick_}
|
||||
shareId={folder.share_id}
|
||||
parentId={folder.parent_id}
|
||||
/>;
|
||||
}
|
||||
|
||||
|
@@ -63,6 +63,7 @@ export const StyledListItem = styled.div`
|
||||
function listItemTextColor(props: any) {
|
||||
if (props.isConflictFolder) return props.theme.colorError2;
|
||||
if (props.isSpecialItem) return props.theme.colorFaded2;
|
||||
if (props.shareId) return props.theme.colorWarn2;
|
||||
return props.theme.color2;
|
||||
}
|
||||
|
||||
@@ -72,7 +73,7 @@ export const StyledListItemAnchor = styled.a`
|
||||
text-decoration: none;
|
||||
color: ${(props: any) => listItemTextColor(props)};
|
||||
cursor: default;
|
||||
opacity: ${(props: any) => props.selected ? 1 : 0.8};
|
||||
opacity: ${(props: any) => props.selected || props.shareId ? 1 : 0.8};
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -81,6 +82,10 @@ export const StyledListItemAnchor = styled.a`
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
export const StyledShareIcon = styled.i`
|
||||
margin-left: 8px;
|
||||
`;
|
||||
|
||||
export const StyledExpandLink = styled.a`
|
||||
color: ${(props: any) => props.theme.color2};
|
||||
cursor: default;
|
||||
|
11
packages/app-desktop/gui/style/StyledFormLabel.tsx
Normal file
11
packages/app-desktop/gui/style/StyledFormLabel.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledFormLabel = styled.div`
|
||||
font-size: ${props => props.theme.fontSize * 1.083333}px;
|
||||
color: ${props => props.theme.color};
|
||||
font-family: ${props => props.theme.fontFamily};
|
||||
font-weight: 500;
|
||||
margin-bottom: ${props => props.theme.mainPadding / 2}px;
|
||||
`;
|
||||
|
||||
export default StyledFormLabel;
|
12
packages/app-desktop/gui/style/StyledMessage.tsx
Normal file
12
packages/app-desktop/gui/style/StyledMessage.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledMessage = styled.div`
|
||||
border-radius: 3px;
|
||||
background-color: ${props => props.type === 'error' ? props.theme.warningBackgroundColor : 'transparent'};
|
||||
font-size: ${props => props.theme.fontSize}px;
|
||||
color: ${props => props.theme.color};
|
||||
font-family: ${props => props.theme.fontFamily};
|
||||
padding: ${props => props.type === 'error' ? props.theme.mainPadding : '0'}px;
|
||||
`;
|
||||
|
||||
export default StyledMessage;
|
4
packages/app-desktop/package-lock.json
generated
4
packages/app-desktop/package-lock.json
generated
@@ -3199,7 +3199,7 @@
|
||||
},
|
||||
"ini": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz",
|
||||
"resolved": false,
|
||||
"integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
@@ -7659,7 +7659,7 @@
|
||||
},
|
||||
"ini": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz",
|
||||
"resolved": false,
|
||||
"integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
|
44
packages/app-desktop/runForSharing.sh
Executable file
44
packages/app-desktop/runForSharing.sh
Executable file
@@ -0,0 +1,44 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Setup the sync parameters for user X and create a few folders and notes to
|
||||
# allow sharing. Also calls the API to create the test users and clear the data.
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
|
||||
ROOT_DIR="$SCRIPT_DIR/../.."
|
||||
|
||||
if [ "$1" == "" ]; then
|
||||
echo "User number is required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
USER_NUM=$1
|
||||
|
||||
CMD_FILE="$SCRIPT_DIR/runForSharingCommands-$USER_NUM.txt"
|
||||
rm -f "$CMD_FILE"
|
||||
|
||||
USER_EMAIL="user$USER_NUM@example.com"
|
||||
PROFILE_DIR=~/.config/joplindev-desktop-$USER_NUM
|
||||
rm -rf "$PROFILE_DIR"
|
||||
|
||||
echo "config keychain.supported 0" >> "$CMD_FILE"
|
||||
echo "config sync.target 9" >> "$CMD_FILE"
|
||||
echo "config sync.9.path http://localhost:22300" >> "$CMD_FILE"
|
||||
echo "config sync.9.username $USER_EMAIL" >> "$CMD_FILE"
|
||||
echo "config sync.9.password 123456" >> "$CMD_FILE"
|
||||
|
||||
if [ "$1" == "1" ]; then
|
||||
curl --data '{"action": "createTestUsers"}' http://localhost:22300/api/debug
|
||||
|
||||
echo 'mkbook "shared"' >> "$CMD_FILE"
|
||||
echo 'mkbook "other"' >> "$CMD_FILE"
|
||||
echo 'use "shared"' >> "$CMD_FILE"
|
||||
echo 'mknote "note 1"' >> "$CMD_FILE"
|
||||
fi
|
||||
|
||||
cd "$ROOT_DIR/packages/app-cli"
|
||||
npm start -- --profile "$PROFILE_DIR" batch "$CMD_FILE"
|
||||
|
||||
cd "$ROOT_DIR/packages/app-desktop"
|
||||
npm start -- --profile "$PROFILE_DIR"
|
@@ -4,12 +4,12 @@
|
||||
// one.
|
||||
|
||||
import { AppState } from '../../app';
|
||||
import libStateToWhenClauseContext from '@joplin/lib/services/commands/stateToWhenClauseContext';
|
||||
import libStateToWhenClauseContext, { WhenClauseContextOptions } from '@joplin/lib/services/commands/stateToWhenClauseContext';
|
||||
import layoutItemProp from '../../gui/ResizableLayout/utils/layoutItemProp';
|
||||
|
||||
export default function stateToWhenClauseContext(state: AppState) {
|
||||
export default function stateToWhenClauseContext(state: AppState, options: WhenClauseContextOptions = null) {
|
||||
return {
|
||||
...libStateToWhenClauseContext(state),
|
||||
...libStateToWhenClauseContext(state, options),
|
||||
|
||||
// UI elements
|
||||
markdownEditorVisible: !!state.settings['editor.codeView'],
|
||||
|
@@ -372,7 +372,7 @@ class SideMenuContentComponent extends Component {
|
||||
items.push(this.renderSidebarButton('folder_header', _('Notebooks'), 'md-folder'));
|
||||
|
||||
if (this.props.folders.length) {
|
||||
const result = shared.renderFolders(this.props, this.renderFolderItem);
|
||||
const result = shared.renderFolders(this.props, this.renderFolderItem, false);
|
||||
const folderItems = result.items;
|
||||
items = items.concat(folderItems);
|
||||
}
|
||||
|
@@ -36,7 +36,7 @@ const SafeAreaView = require('./components/SafeAreaView');
|
||||
const { connect, Provider } = require('react-redux');
|
||||
const { BackButtonService } = require('./services/back-button.js');
|
||||
import NavService from '@joplin/lib/services/NavService';
|
||||
const { createStore, applyMiddleware } = require('redux');
|
||||
import { createStore, applyMiddleware } from 'redux';
|
||||
const reduxSharedMiddleware = require('@joplin/lib/components/shared/reduxSharedMiddleware');
|
||||
const { shimInit } = require('./utils/shim-init-react.js');
|
||||
const { AppNav } = require('./components/app-nav.js');
|
||||
@@ -97,6 +97,7 @@ import EncryptionService from '@joplin/lib/services/EncryptionService';
|
||||
import MigrationService from '@joplin/lib/services/MigrationService';
|
||||
import { clearSharedFilesCache } from './utils/ShareUtils';
|
||||
import setIgnoreTlsErrors from './utils/TlsUtils';
|
||||
import ShareService from '@joplin/lib/services/share/ShareService';
|
||||
|
||||
let storeDispatch = function(_action: any) {};
|
||||
|
||||
@@ -525,6 +526,7 @@ async function initialize(dispatch: Function) {
|
||||
EncryptionService.instance().setLogger(mainLogger);
|
||||
// eslint-disable-next-line require-atomic-updates
|
||||
BaseItem.encryptionService_ = EncryptionService.instance();
|
||||
BaseItem.shareService_ = ShareService.instance();
|
||||
DecryptionWorker.instance().dispatch = dispatch;
|
||||
DecryptionWorker.instance().setLogger(mainLogger);
|
||||
DecryptionWorker.instance().setKvStore(KvStore.instance());
|
||||
@@ -536,6 +538,8 @@ async function initialize(dispatch: Function) {
|
||||
// / E2EE SETUP
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
await ShareService.instance().initialize(store);
|
||||
|
||||
reg.logger().info('Loading folders...');
|
||||
|
||||
await FoldersScreenUtils.refreshFolders();
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { Platform, NativeModules } from 'react-native';
|
||||
const { Platform, NativeModules } = require('react-native');
|
||||
|
||||
export default async function setIgnoreTlsErrors(ignore: boolean): Promise<boolean> {
|
||||
if (Platform.OS === 'android') {
|
||||
|
@@ -2,14 +2,14 @@ import Setting from './models/Setting';
|
||||
import Logger, { TargetType, LoggerWrapper } from './Logger';
|
||||
import shim from './shim';
|
||||
import BaseService from './services/BaseService';
|
||||
import reducer from './reducer';
|
||||
import reducer, { setStore } from './reducer';
|
||||
import KeychainServiceDriver from './services/keychain/KeychainServiceDriver.node';
|
||||
import { _, setLocale } from './locale';
|
||||
import KvStore from './services/KvStore';
|
||||
import SyncTargetJoplinServer from './SyncTargetJoplinServer';
|
||||
import SyncTargetOneDrive from './SyncTargetOneDrive';
|
||||
|
||||
const { createStore, applyMiddleware } = require('redux');
|
||||
import { createStore, applyMiddleware, Store } from 'redux';
|
||||
const { defaultState, stateUtils } = require('./reducer');
|
||||
import JoplinDatabase from './JoplinDatabase';
|
||||
const { FoldersScreenUtils } = require('./folders-screen-utils.js');
|
||||
@@ -26,7 +26,7 @@ import BaseSyncTarget from './BaseSyncTarget';
|
||||
const reduxSharedMiddleware = require('./components/shared/reduxSharedMiddleware');
|
||||
const os = require('os');
|
||||
const fs = require('fs-extra');
|
||||
const JoplinError = require('./JoplinError');
|
||||
import JoplinError from './JoplinError';
|
||||
const EventEmitter = require('events');
|
||||
const syswidecas = require('./vendor/syswide-cas');
|
||||
const SyncTargetRegistry = require('./SyncTargetRegistry.js');
|
||||
@@ -44,6 +44,7 @@ import ResourceService from './services/ResourceService';
|
||||
import DecryptionWorker from './services/DecryptionWorker';
|
||||
const { loadKeychainServiceAndSettings } = require('./services/SettingUtils');
|
||||
import MigrationService from './services/MigrationService';
|
||||
import ShareService from './services/share/ShareService';
|
||||
import handleSyncStartupOperation from './services/synchronizer/utils/handleSyncStartupOperation';
|
||||
const { toSystemSlashes } = require('./path-utils');
|
||||
const { setAutoFreeze } = require('immer');
|
||||
@@ -67,7 +68,7 @@ export default class BaseApplication {
|
||||
// state and UI out of sync.
|
||||
private currentFolder_: any = null;
|
||||
|
||||
protected store_: any = null;
|
||||
protected store_: Store<any> = null;
|
||||
|
||||
constructor() {
|
||||
this.eventEmitter_ = new EventEmitter();
|
||||
@@ -602,13 +603,15 @@ export default class BaseApplication {
|
||||
}
|
||||
|
||||
initRedux() {
|
||||
this.store_ = createStore(this.reducer, applyMiddleware(this.generalMiddlewareFn()));
|
||||
this.store_ = createStore(this.reducer, applyMiddleware(this.generalMiddlewareFn() as any));
|
||||
setStore(this.store_);
|
||||
BaseModel.dispatch = this.store().dispatch;
|
||||
FoldersScreenUtils.dispatch = this.store().dispatch;
|
||||
// reg.dispatch = this.store().dispatch;
|
||||
BaseSyncTarget.dispatch = this.store().dispatch;
|
||||
DecryptionWorker.instance().dispatch = this.store().dispatch;
|
||||
ResourceFetcher.instance().dispatch = this.store().dispatch;
|
||||
ShareService.instance().initialize(this.store());
|
||||
}
|
||||
|
||||
deinitRedux() {
|
||||
@@ -793,6 +796,7 @@ export default class BaseApplication {
|
||||
|
||||
EncryptionService.instance().setLogger(globalLogger);
|
||||
BaseItem.encryptionService_ = EncryptionService.instance();
|
||||
BaseItem.shareService_ = ShareService.instance();
|
||||
DecryptionWorker.instance().setLogger(globalLogger);
|
||||
DecryptionWorker.instance().setEncryptionService(EncryptionService.instance());
|
||||
DecryptionWorker.instance().setKvStore(KvStore.instance());
|
||||
|
@@ -3,6 +3,7 @@ import Synchronizer from './Synchronizer';
|
||||
import EncryptionService from './services/EncryptionService';
|
||||
import shim from './shim';
|
||||
import ResourceService from './services/ResourceService';
|
||||
import ShareService from './services/share/ShareService';
|
||||
|
||||
export default class BaseSyncTarget {
|
||||
|
||||
@@ -113,6 +114,7 @@ export default class BaseSyncTarget {
|
||||
this.synchronizer_.setLogger(this.logger());
|
||||
this.synchronizer_.setEncryptionService(EncryptionService.instance());
|
||||
this.synchronizer_.setResourceService(ResourceService.instance());
|
||||
this.synchronizer_.setShareService(ShareService.instance());
|
||||
this.synchronizer_.dispatch = BaseSyncTarget.dispatch;
|
||||
this.initState_ = 'ready';
|
||||
return this.synchronizer_;
|
||||
|
@@ -1,6 +1,6 @@
|
||||
const Logger = require('./Logger').default;
|
||||
const shim = require('./shim').default;
|
||||
const JoplinError = require('./JoplinError');
|
||||
const JoplinError = require('./JoplinError').default;
|
||||
const time = require('./time').default;
|
||||
const EventDispatcher = require('./EventDispatcher');
|
||||
|
||||
|
@@ -138,20 +138,20 @@ export default class JoplinDatabase extends Database {
|
||||
private tableFieldNames_: Record<string, string[]> = {};
|
||||
private tableDescriptions_: any;
|
||||
|
||||
constructor(driver: any) {
|
||||
public constructor(driver: any) {
|
||||
super(driver);
|
||||
}
|
||||
|
||||
initialized() {
|
||||
public initialized() {
|
||||
return this.initialized_;
|
||||
}
|
||||
|
||||
async open(options: any) {
|
||||
public async open(options: any) {
|
||||
await super.open(options);
|
||||
return this.initialize();
|
||||
}
|
||||
|
||||
tableFieldNames(tableName: string) {
|
||||
public tableFieldNames(tableName: string) {
|
||||
if (this.tableFieldNames_[tableName]) return this.tableFieldNames_[tableName].slice();
|
||||
|
||||
const tf = this.tableFields(tableName);
|
||||
@@ -164,7 +164,7 @@ export default class JoplinDatabase extends Database {
|
||||
return output.slice();
|
||||
}
|
||||
|
||||
tableFields(tableName: string, options: any = null) {
|
||||
public tableFields(tableName: string, options: any = null) {
|
||||
if (options === null) options = {};
|
||||
|
||||
if (!this.tableFields_) throw new Error('Fields have not been loaded yet');
|
||||
@@ -180,7 +180,7 @@ export default class JoplinDatabase extends Database {
|
||||
return output;
|
||||
}
|
||||
|
||||
async clearForTesting() {
|
||||
public async clearForTesting() {
|
||||
const tableNames = [
|
||||
'notes',
|
||||
'folders',
|
||||
@@ -220,7 +220,7 @@ export default class JoplinDatabase extends Database {
|
||||
await this.transactionExecBatch(queries);
|
||||
}
|
||||
|
||||
createDefaultRow(tableName: string) {
|
||||
public createDefaultRow(tableName: string) {
|
||||
const row: any = {};
|
||||
const fields = this.tableFields(tableName);
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
@@ -230,7 +230,7 @@ export default class JoplinDatabase extends Database {
|
||||
return row;
|
||||
}
|
||||
|
||||
fieldByName(tableName: string, fieldName: string) {
|
||||
public fieldByName(tableName: string, fieldName: string) {
|
||||
const fields = this.tableFields(tableName);
|
||||
for (const field of fields) {
|
||||
if (field.name === fieldName) return field;
|
||||
@@ -238,11 +238,11 @@ export default class JoplinDatabase extends Database {
|
||||
throw new Error(`No such field: ${tableName}: ${fieldName}`);
|
||||
}
|
||||
|
||||
fieldDefaultValue(tableName: string, fieldName: string) {
|
||||
public fieldDefaultValue(tableName: string, fieldName: string) {
|
||||
return this.fieldByName(tableName, fieldName).default;
|
||||
}
|
||||
|
||||
fieldDescription(tableName: string, fieldName: string) {
|
||||
public fieldDescription(tableName: string, fieldName: string) {
|
||||
const sp = sprintf;
|
||||
|
||||
if (!this.tableDescriptions_) {
|
||||
@@ -278,7 +278,7 @@ export default class JoplinDatabase extends Database {
|
||||
return d && d[fieldName] ? d[fieldName] : '';
|
||||
}
|
||||
|
||||
refreshTableFields(newVersion: number) {
|
||||
public refreshTableFields(newVersion: number) {
|
||||
this.logger().info('Initializing tables...');
|
||||
const queries: SqlQuery[] = [];
|
||||
queries.push(this.wrapQuery('DELETE FROM table_fields'));
|
||||
@@ -323,12 +323,12 @@ export default class JoplinDatabase extends Database {
|
||||
});
|
||||
}
|
||||
|
||||
addMigrationFile(num: number) {
|
||||
public addMigrationFile(num: number) {
|
||||
const timestamp = Date.now();
|
||||
return { sql: 'INSERT INTO migrations (number, created_time, updated_time) VALUES (?, ?, ?)', params: [num, timestamp, timestamp] };
|
||||
}
|
||||
|
||||
async upgradeDatabase(fromVersion: number) {
|
||||
public async upgradeDatabase(fromVersion: number) {
|
||||
// INSTRUCTIONS TO UPGRADE THE DATABASE:
|
||||
//
|
||||
// 1. Add the new version number to the existingDatabaseVersions array
|
||||
@@ -343,7 +343,7 @@ export default class JoplinDatabase extends Database {
|
||||
// must be set in the synchronizer too.
|
||||
|
||||
// Note: v16 and v17 don't do anything. They were used to debug an issue.
|
||||
const existingDatabaseVersions = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35];
|
||||
const existingDatabaseVersions = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37];
|
||||
|
||||
let currentVersionIndex = existingDatabaseVersions.indexOf(fromVersion);
|
||||
|
||||
@@ -870,6 +870,12 @@ export default class JoplinDatabase extends Database {
|
||||
queries.push(this.addMigrationFile(35));
|
||||
}
|
||||
|
||||
if (targetVersion == 36) {
|
||||
queries.push('ALTER TABLE folders ADD COLUMN share_id TEXT NOT NULL DEFAULT ""');
|
||||
queries.push('ALTER TABLE notes ADD COLUMN share_id TEXT NOT NULL DEFAULT ""');
|
||||
queries.push('ALTER TABLE resources ADD COLUMN share_id TEXT NOT NULL DEFAULT ""');
|
||||
}
|
||||
|
||||
const updateVersionQuery = { sql: 'UPDATE version SET version = ?', params: [targetVersion] };
|
||||
|
||||
queries.push(updateVersionQuery);
|
||||
@@ -906,7 +912,7 @@ export default class JoplinDatabase extends Database {
|
||||
return latestVersion;
|
||||
}
|
||||
|
||||
async ftsEnabled() {
|
||||
public async ftsEnabled() {
|
||||
try {
|
||||
await this.selectOne('SELECT count(*) FROM notes_fts');
|
||||
} catch (error) {
|
||||
@@ -919,7 +925,7 @@ export default class JoplinDatabase extends Database {
|
||||
return true;
|
||||
}
|
||||
|
||||
async fuzzySearchEnabled() {
|
||||
public async fuzzySearchEnabled() {
|
||||
try {
|
||||
await this.selectOne('SELECT count(*) FROM notes_spellfix');
|
||||
} catch (error) {
|
||||
@@ -930,11 +936,11 @@ export default class JoplinDatabase extends Database {
|
||||
return true;
|
||||
}
|
||||
|
||||
version() {
|
||||
public version() {
|
||||
return this.version_;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
public async initialize() {
|
||||
this.logger().info('Checking for database schema update...');
|
||||
|
||||
let versionRow = null;
|
||||
|
@@ -1,8 +1,13 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
class JoplinError extends Error {
|
||||
constructor(message, code = null) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
constructor(message, code = null, details = null) {
|
||||
super(message);
|
||||
this.code = null;
|
||||
this.details = '';
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = JoplinError;
|
||||
exports.default = JoplinError;
|
||||
//# sourceMappingURL=JoplinError.js.map
|
12
packages/lib/JoplinError.ts
Normal file
12
packages/lib/JoplinError.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export default class JoplinError extends Error {
|
||||
|
||||
public code: any = null;
|
||||
public details: string = '';
|
||||
|
||||
public constructor(message: string, code: any = null, details: string = null) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
}
|
@@ -1,13 +1,15 @@
|
||||
import shim from './shim';
|
||||
import { _ } from './locale';
|
||||
const { rtrimSlashes } = require('./path-utils.js');
|
||||
const JoplinError = require('./JoplinError');
|
||||
import JoplinError from './JoplinError';
|
||||
import { Env } from './models/Setting';
|
||||
const { stringify } = require('query-string');
|
||||
|
||||
interface Options {
|
||||
baseUrl(): string;
|
||||
username(): string;
|
||||
password(): string;
|
||||
env?: Env;
|
||||
}
|
||||
|
||||
enum ExecOptionsResponseFormat {
|
||||
@@ -27,22 +29,30 @@ interface ExecOptions {
|
||||
source?: string;
|
||||
}
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
export default class JoplinServerApi {
|
||||
|
||||
private options_: Options;
|
||||
private session_: any;
|
||||
private session_: Session;
|
||||
private debugRequests_: boolean = false;
|
||||
|
||||
public constructor(options: Options) {
|
||||
this.options_ = options;
|
||||
|
||||
if (options.env === Env.Dev) {
|
||||
this.debugRequests_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
private baseUrl() {
|
||||
public baseUrl() {
|
||||
return rtrimSlashes(this.options_.baseUrl());
|
||||
}
|
||||
|
||||
private async session() {
|
||||
// TODO: handle invalid session
|
||||
if (this.session_) return this.session_;
|
||||
|
||||
this.session_ = await this.exec('POST', 'api/sessions', null, {
|
||||
@@ -58,11 +68,8 @@ export default class JoplinServerApi {
|
||||
return session ? session.id : '';
|
||||
}
|
||||
|
||||
public async shareFile(pathOrId: string) {
|
||||
return this.exec('POST', 'api/shares', null, {
|
||||
file_id: pathOrId,
|
||||
type: 1, // ShareType.Link
|
||||
});
|
||||
public get userId(): string {
|
||||
return this.session_ ? this.session_.user_id : '';
|
||||
}
|
||||
|
||||
public static connectionErrorMessage(error: any) {
|
||||
@@ -70,10 +77,6 @@ export default class JoplinServerApi {
|
||||
return _('Could not connect to Joplin Server. Please check the Synchronisation options in the config screen. Full error was:\n\n%s', msg);
|
||||
}
|
||||
|
||||
public shareUrl(share: any): string {
|
||||
return `${this.baseUrl()}/shares/${share.id}`;
|
||||
}
|
||||
|
||||
private requestToCurl_(url: string, options: any) {
|
||||
const output = [];
|
||||
output.push('curl');
|
||||
@@ -85,8 +88,11 @@ export default class JoplinServerApi {
|
||||
output.push(`${'-H ' + '"'}${n}: ${options.headers[n]}"`);
|
||||
}
|
||||
}
|
||||
if (options.body) output.push(`${'--data ' + '\''}${JSON.stringify(options.body)}'`);
|
||||
output.push(url);
|
||||
if (options.body) {
|
||||
const serialized = typeof options.body !== 'string' ? JSON.stringify(options.body) : options.body;
|
||||
output.push(`${'--data ' + '\''}${serialized}'`);
|
||||
}
|
||||
output.push(`'${url}'`);
|
||||
|
||||
return output.join(' ');
|
||||
}
|
||||
@@ -150,14 +156,17 @@ export default class JoplinServerApi {
|
||||
|
||||
const responseText = await response.text();
|
||||
|
||||
// console.info('Joplin API Response', responseText);
|
||||
if (this.debugRequests_) {
|
||||
console.info('Joplin API Response', responseText);
|
||||
}
|
||||
|
||||
// Creates an error object with as much data as possible as it will appear in the log, which will make debugging easier
|
||||
const newError = (message: string, code: number = 0) => {
|
||||
// Gives a shorter response for error messages. Useful for cases where a full HTML page is accidentally loaded instead of
|
||||
// JSON. That way the error message will still show there's a problem but without filling up the log or screen.
|
||||
const shortResponseText = (`${responseText}`).substr(0, 1024);
|
||||
return new JoplinError(`${method} ${path}: ${message} (${code}): ${shortResponseText}`, code);
|
||||
// return new JoplinError(`${method} ${path}: ${message} (${code}): ${shortResponseText}`, code);
|
||||
return new JoplinError(message, code, `${method} ${path}: ${message} (${code}): ${shortResponseText}`);
|
||||
};
|
||||
|
||||
let responseJson_: any = null;
|
||||
|
@@ -10,7 +10,6 @@ interface FileApiOptions {
|
||||
path(): string;
|
||||
username(): string;
|
||||
password(): string;
|
||||
directory(): string;
|
||||
}
|
||||
|
||||
export default class SyncTargetJoplinServer extends BaseSyncTarget {
|
||||
@@ -28,7 +27,7 @@ export default class SyncTargetJoplinServer extends BaseSyncTarget {
|
||||
}
|
||||
|
||||
public static label() {
|
||||
return _('Joplin Server');
|
||||
return `${_('Joplin Server')} (Beta)`;
|
||||
}
|
||||
|
||||
public async isAuthenticated() {
|
||||
@@ -44,11 +43,12 @@ export default class SyncTargetJoplinServer extends BaseSyncTarget {
|
||||
baseUrl: () => options.path(),
|
||||
username: () => options.username(),
|
||||
password: () => options.password(),
|
||||
env: Setting.value('env'),
|
||||
};
|
||||
|
||||
const api = new JoplinServerApi(apiOptions);
|
||||
const driver = new FileApiDriverJoplinServer(api);
|
||||
const fileApi = new FileApi(options.directory, driver);
|
||||
const fileApi = new FileApi('', driver);
|
||||
fileApi.setSyncTargetId(this.id());
|
||||
await fileApi.initialize();
|
||||
return fileApi;
|
||||
@@ -64,8 +64,10 @@ export default class SyncTargetJoplinServer extends BaseSyncTarget {
|
||||
const fileApi = await SyncTargetJoplinServer.newFileApi_(options);
|
||||
fileApi.requestRepeatCount_ = 0;
|
||||
|
||||
const result = await fileApi.stat('');
|
||||
if (!result) throw new Error(`Sync directory not found: "${options.directory()}" on server "${options.path()}"`);
|
||||
await fileApi.put('testing.txt', 'testing');
|
||||
const result = await fileApi.get('testing.txt');
|
||||
if (result !== 'testing') throw new Error(`Could not access data on server "${options.path()}"`);
|
||||
await fileApi.delete('testing.txt');
|
||||
output.ok = true;
|
||||
} catch (error) {
|
||||
output.errorMessage = error.message;
|
||||
@@ -80,7 +82,6 @@ export default class SyncTargetJoplinServer extends BaseSyncTarget {
|
||||
path: () => Setting.value('sync.9.path'),
|
||||
username: () => Setting.value('sync.9.username'),
|
||||
password: () => Setting.value('sync.9.password'),
|
||||
directory: () => Setting.value('sync.9.directory'),
|
||||
});
|
||||
|
||||
fileApi.setLogger(this.logger());
|
||||
|
@@ -5,7 +5,6 @@ import shim from './shim';
|
||||
import MigrationHandler from './services/synchronizer/MigrationHandler';
|
||||
import eventManager from './eventManager';
|
||||
import { _ } from './locale';
|
||||
|
||||
import BaseItem from './models/BaseItem';
|
||||
import Folder from './models/Folder';
|
||||
import Note from './models/Note';
|
||||
@@ -14,12 +13,12 @@ import ItemChange from './models/ItemChange';
|
||||
import ResourceLocalState from './models/ResourceLocalState';
|
||||
import MasterKey from './models/MasterKey';
|
||||
import BaseModel from './BaseModel';
|
||||
const { sprintf } = require('sprintf-js');
|
||||
import time from './time';
|
||||
import ResourceService from './services/ResourceService';
|
||||
import EncryptionService from './services/EncryptionService';
|
||||
import NoteResource from './models/NoteResource';
|
||||
const JoplinError = require('./JoplinError');
|
||||
import JoplinError from './JoplinError';
|
||||
import ShareService from './services/share/ShareService';
|
||||
const { sprintf } = require('sprintf-js');
|
||||
const TaskQueue = require('./TaskQueue');
|
||||
const { Dirnames } = require('./services/synchronizer/utils/types');
|
||||
|
||||
@@ -45,6 +44,7 @@ export default class Synchronizer {
|
||||
private encryptionService_: EncryptionService = null;
|
||||
private resourceService_: ResourceService = null;
|
||||
private syncTargetIsLocked_: boolean = false;
|
||||
private shareService_: ShareService = null;
|
||||
|
||||
// Debug flags are used to test certain hard-to-test conditions
|
||||
// such as cancelling in the middle of a loop.
|
||||
@@ -108,6 +108,10 @@ export default class Synchronizer {
|
||||
return this.appType_ === 'mobile' ? 100 * 1000 * 1000 : Infinity;
|
||||
}
|
||||
|
||||
public setShareService(v: ShareService) {
|
||||
this.shareService_ = v;
|
||||
}
|
||||
|
||||
public setEncryptionService(v: any) {
|
||||
this.encryptionService_ = v;
|
||||
}
|
||||
@@ -351,12 +355,15 @@ export default class Synchronizer {
|
||||
if (this.resourceService()) {
|
||||
this.logger().info('Indexing resources...');
|
||||
await this.resourceService().indexNoteResources();
|
||||
await NoteResource.applySharedStatusToLinkedResources();
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger().error('Error indexing resources:', error);
|
||||
}
|
||||
|
||||
// Before synchronising make sure all share_id properties are set
|
||||
// correctly so as to share/unshare the right items.
|
||||
await Folder.updateAllShareIds();
|
||||
|
||||
let errorToThrow = null;
|
||||
let syncLock = null;
|
||||
|
||||
@@ -505,7 +512,7 @@ export default class Synchronizer {
|
||||
this.logger().warn(`Uploading a large resource (resourceId: ${local.id}, size:${local.size} bytes) which may tie up the sync process.`);
|
||||
}
|
||||
|
||||
await this.apiCall('put', remoteContentPath, null, { path: localResourceContentPath, source: 'file' });
|
||||
await this.apiCall('put', remoteContentPath, null, { path: localResourceContentPath, source: 'file', shareId: local.share_id });
|
||||
} catch (error) {
|
||||
if (error && ['rejectedByTarget', 'fileNotFound'].indexOf(error.code) >= 0) {
|
||||
await handleCannotSyncItem(ItemClass, syncTargetId, local, error.message);
|
||||
@@ -924,6 +931,16 @@ export default class Synchronizer {
|
||||
this.cancelling_ = false;
|
||||
}
|
||||
|
||||
// After syncing, we run the share service maintenance, which is going
|
||||
// to fetch share invitations, if any.
|
||||
if (this.shareService_) {
|
||||
try {
|
||||
await this.shareService_.maintenance();
|
||||
} catch (error) {
|
||||
this.logger().error('Could not run share service maintenance:', error);
|
||||
}
|
||||
}
|
||||
|
||||
this.progressReport_.completedTime = time.unixMs();
|
||||
|
||||
this.logSyncOperation('finished', null, null, `Synchronisation finished [${synchronizationId}]`);
|
||||
|
@@ -1,7 +1,7 @@
|
||||
const Logger = require('./Logger').default;
|
||||
const shim = require('./shim').default;
|
||||
const parseXmlString = require('xml2js').parseString;
|
||||
const JoplinError = require('./JoplinError');
|
||||
const JoplinError = require('./JoplinError').default;
|
||||
const URL = require('url-parse');
|
||||
const { rtrimSlashes } = require('./path-utils');
|
||||
const base64 = require('base-64');
|
||||
|
@@ -4,7 +4,7 @@ import shim from './shim';
|
||||
|
||||
const Mutex = require('async-mutex').Mutex;
|
||||
|
||||
type SqlParams = Record<string, any>;
|
||||
type SqlParams = any[];
|
||||
|
||||
export interface SqlQuery {
|
||||
sql: string;
|
||||
|
47
packages/lib/debug/DebugService.ts
Normal file
47
packages/lib/debug/DebugService.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import JoplinDatabase from '../JoplinDatabase';
|
||||
import Setting from '../models/Setting';
|
||||
import SyncTargetJoplinServer from '../SyncTargetJoplinServer';
|
||||
|
||||
export default class DebugService {
|
||||
|
||||
private db_: JoplinDatabase;
|
||||
|
||||
public constructor(db: JoplinDatabase) {
|
||||
this.db_ = db;
|
||||
}
|
||||
|
||||
private get db(): JoplinDatabase {
|
||||
return this.db_;
|
||||
}
|
||||
|
||||
public async clearSyncState() {
|
||||
const tableNames = [
|
||||
'item_changes',
|
||||
'deleted_items',
|
||||
'sync_items',
|
||||
'key_values',
|
||||
];
|
||||
|
||||
const queries = [];
|
||||
for (const n of tableNames) {
|
||||
queries.push(`DELETE FROM ${n}`);
|
||||
queries.push(`DELETE FROM sqlite_sequence WHERE name="${n}"`); // Reset autoincremented IDs
|
||||
}
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
queries.push(`DELETE FROM settings WHERE key="sync.${i}.context"`);
|
||||
queries.push(`DELETE FROM settings WHERE key="sync.${i}.auth"`);
|
||||
}
|
||||
|
||||
await this.db.transactionExecBatch(queries);
|
||||
}
|
||||
|
||||
public async setupJoplinServerUser(num: number) {
|
||||
const id = SyncTargetJoplinServer.id();
|
||||
Setting.setValue('sync.target', id);
|
||||
Setting.setValue(`sync.${id}.path`, 'http://localhost:22300');
|
||||
Setting.setValue(`sync.${id}.username`, `user${num}@example.com`);
|
||||
Setting.setValue(`sync.${id}.password`, '123456');
|
||||
}
|
||||
|
||||
}
|
@@ -1,7 +1,7 @@
|
||||
const { basicDelta } = require('./file-api');
|
||||
const { basename } = require('./path-utils');
|
||||
const shim = require('./shim').default;
|
||||
const JoplinError = require('./JoplinError');
|
||||
const JoplinError = require('./JoplinError').default;
|
||||
const { Buffer } = require('buffer');
|
||||
|
||||
const S3_MAX_DELETES = 1000;
|
||||
|
@@ -1,6 +1,6 @@
|
||||
const time = require('./time').default;
|
||||
const shim = require('./shim').default;
|
||||
const JoplinError = require('./JoplinError');
|
||||
const JoplinError = require('./JoplinError').default;
|
||||
|
||||
class FileApiDriverDropbox {
|
||||
constructor(api) {
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import JoplinServerApi from './JoplinServerApi';
|
||||
import { trimSlashes, dirname, basename } from './path-utils';
|
||||
import { trimSlashes } from './path-utils';
|
||||
|
||||
// All input paths should be in the format: "path/to/file". This is converted to
|
||||
// "root:/path/to/file:" when doing the API call.
|
||||
@@ -34,38 +34,35 @@ export default class FileApiDriverJoplinServer {
|
||||
return 3;
|
||||
}
|
||||
|
||||
private metadataToStat_(md: any, path: string, isDeleted: boolean = false) {
|
||||
private metadataToStat_(md: any, path: string, isDeleted: boolean = false, rootPath: string) {
|
||||
const output = {
|
||||
path: path,
|
||||
path: rootPath ? path.substr(rootPath.length + 1) : path,
|
||||
updated_time: md.updated_time,
|
||||
isDir: !!md.is_directory,
|
||||
isDir: false, // !!md.is_directory,
|
||||
isDeleted: isDeleted,
|
||||
};
|
||||
|
||||
// TODO - HANDLE DELETED
|
||||
// if (md['.tag'] === 'deleted') output.isDeleted = true;
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private metadataToStats_(mds: any[]) {
|
||||
private metadataToStats_(mds: any[], rootPath: string) {
|
||||
const output = [];
|
||||
for (let i = 0; i < mds.length; i++) {
|
||||
output.push(this.metadataToStat_(mds[i], mds[i].name));
|
||||
output.push(this.metadataToStat_(mds[i], mds[i].name, false, rootPath));
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
// Transforms a path such as "Apps/Joplin/file.txt" to a complete a complete
|
||||
// API URL path: "api/files/root:/Apps/Joplin/file.txt:"
|
||||
// API URL path: "api/items/root:/Apps/Joplin/file.txt:"
|
||||
private apiFilePath_(p: string) {
|
||||
return `api/files/root:/${trimSlashes(p)}:`;
|
||||
return `api/items/root:/${trimSlashes(p)}:`;
|
||||
}
|
||||
|
||||
public async stat(path: string) {
|
||||
try {
|
||||
const response = await this.api().exec('GET', this.apiFilePath_(path));
|
||||
return this.metadataToStat_(response, path);
|
||||
return this.metadataToStat_(response, path, false, '');
|
||||
} catch (error) {
|
||||
if (error.code === 404) return null;
|
||||
throw error;
|
||||
@@ -80,9 +77,13 @@ export default class FileApiDriverJoplinServer {
|
||||
try {
|
||||
const query = cursor ? { cursor } : {};
|
||||
const response = await this.api().exec('GET', `${this.apiFilePath_(path)}/delta`, query);
|
||||
const stats = response.items.map((item: any) => {
|
||||
return this.metadataToStat_(item.item, item.item.name, item.type === 3);
|
||||
});
|
||||
const stats = response.items
|
||||
.filter((item: any) => {
|
||||
return item.item_name.indexOf('locks/') !== 0 && item.item_name.indexOf('temp/') !== 0;
|
||||
})
|
||||
.map((item: any) => {
|
||||
return this.metadataToStat_(item, item.item_name, item.type === 3, '');
|
||||
});
|
||||
|
||||
const output = {
|
||||
items: stats,
|
||||
@@ -108,15 +109,22 @@ export default class FileApiDriverJoplinServer {
|
||||
...options,
|
||||
};
|
||||
|
||||
let isUsingWildcard = false;
|
||||
let searchPath = path;
|
||||
if (searchPath) {
|
||||
searchPath += '/*';
|
||||
isUsingWildcard = true;
|
||||
}
|
||||
|
||||
const query = options.context?.cursor ? { cursor: options.context.cursor } : null;
|
||||
|
||||
const results = await this.api().exec('GET', `${this.apiFilePath_(path)}/children`, query);
|
||||
const results = await this.api().exec('GET', `${this.apiFilePath_(searchPath)}/children`, query);
|
||||
|
||||
const newContext: any = {};
|
||||
if (results.cursor) newContext.cursor = results.cursor;
|
||||
|
||||
return {
|
||||
items: this.metadataToStats_(results.items),
|
||||
items: this.metadataToStats_(results.items, isUsingWildcard ? path : ''),
|
||||
hasMore: results.has_more,
|
||||
context: newContext,
|
||||
} as any;
|
||||
@@ -134,32 +142,13 @@ export default class FileApiDriverJoplinServer {
|
||||
}
|
||||
}
|
||||
|
||||
private parentPath_(path: string) {
|
||||
return dirname(path);
|
||||
}
|
||||
|
||||
private basename_(path: string) {
|
||||
return basename(path);
|
||||
}
|
||||
|
||||
public async mkdir(path: string) {
|
||||
const parentPath = this.parentPath_(path);
|
||||
const filename = this.basename_(path);
|
||||
|
||||
try {
|
||||
const response = await this.api().exec('POST', `${this.apiFilePath_(parentPath)}/children`, null, {
|
||||
name: filename,
|
||||
is_directory: 1,
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
// 409 is OK - directory already exists
|
||||
if (error.code !== 409) throw error;
|
||||
}
|
||||
public async mkdir(_path: string) {
|
||||
// This is a no-op because all items technically are at the root, but
|
||||
// they can have names such as ".resources/xxxxxxxxxx'
|
||||
}
|
||||
|
||||
public async put(path: string, content: any, options: any = null) {
|
||||
return this.api().exec('PUT', `${this.apiFilePath_(path)}/content`, null, content, {
|
||||
return this.api().exec('PUT', `${this.apiFilePath_(path)}/content`, options && options.shareId ? { share_id: options.shareId } : null, content, {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
}, options);
|
||||
}
|
||||
@@ -174,6 +163,5 @@ export default class FileApiDriverJoplinServer {
|
||||
|
||||
public async clearRoot(path: string) {
|
||||
await this.delete(path);
|
||||
await this.mkdir(path);
|
||||
}
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
const { basicDelta } = require('./file-api');
|
||||
const { rtrimSlashes, ltrimSlashes } = require('./path-utils');
|
||||
const JoplinError = require('./JoplinError');
|
||||
const JoplinError = require('./JoplinError').default;
|
||||
|
||||
class FileApiDriverWebDav {
|
||||
constructor(api) {
|
||||
|
@@ -4,7 +4,7 @@ import BaseItem from './models/BaseItem';
|
||||
import time from './time';
|
||||
|
||||
const { isHidden } = require('./path-utils');
|
||||
const JoplinError = require('./JoplinError');
|
||||
import JoplinError from './JoplinError';
|
||||
const ArrayUtils = require('./ArrayUtils');
|
||||
const { sprintf } = require('sprintf-js');
|
||||
const Mutex = require('async-mutex').Mutex;
|
||||
@@ -14,6 +14,10 @@ const logger = Logger.create('FileApi');
|
||||
function requestCanBeRepeated(error: any) {
|
||||
const errorCode = typeof error === 'object' && error.code ? error.code : null;
|
||||
|
||||
// Unauthorized error - means username or password is incorrect or other
|
||||
// permission issue, which won't be fixed by repeating the request.
|
||||
if (errorCode === 403) return false;
|
||||
|
||||
// The target is explicitely rejecting the item so repeating wouldn't make a difference.
|
||||
if (errorCode === 'rejectedByTarget') return false;
|
||||
|
||||
|
@@ -8,10 +8,19 @@ import { _ } from '../locale';
|
||||
|
||||
import Database from '../database';
|
||||
import ItemChange from './ItemChange';
|
||||
import ShareService from '../services/share/ShareService';
|
||||
const JoplinError = require('../JoplinError.js');
|
||||
const { sprintf } = require('sprintf-js');
|
||||
const moment = require('moment');
|
||||
|
||||
export interface BaseItemEntity {
|
||||
id?: string;
|
||||
encryption_applied?: boolean;
|
||||
is_shared?: number;
|
||||
share_id?: string;
|
||||
type_?: ModelType;
|
||||
}
|
||||
|
||||
export interface ItemsThatNeedDecryptionResult {
|
||||
hasMore: boolean;
|
||||
items: any[];
|
||||
@@ -21,6 +30,7 @@ export default class BaseItem extends BaseModel {
|
||||
|
||||
public static encryptionService_: any = null;
|
||||
public static revisionService_: any = null;
|
||||
public static shareService_: ShareService = null;
|
||||
|
||||
// Also update:
|
||||
// - itemsThatNeedSync()
|
||||
@@ -382,14 +392,19 @@ export default class BaseItem extends BaseModel {
|
||||
return this.revisionService_;
|
||||
}
|
||||
|
||||
static async serializeForSync(item: any) {
|
||||
protected static shareService() {
|
||||
if (!this.shareService_) throw new Error('BaseItem.shareService_ is not set!!');
|
||||
return this.shareService_;
|
||||
}
|
||||
|
||||
public static async serializeForSync(item: BaseItemEntity) {
|
||||
const ItemClass = this.itemClass(item);
|
||||
const shownKeys = ItemClass.fieldNames();
|
||||
shownKeys.push('type_');
|
||||
|
||||
const serialized = await ItemClass.serialize(item, shownKeys);
|
||||
|
||||
if (!Setting.value('encryption.enabled') || !ItemClass.encryptionSupported() || item.is_shared) {
|
||||
if (!Setting.value('encryption.enabled') || !ItemClass.encryptionSupported() || item.is_shared || item.share_id) {
|
||||
// Normally not possible since itemsThatNeedSync should only return decrypted items
|
||||
if (item.encryption_applied) throw new JoplinError('Item is encrypted but encryption is currently disabled', 'cannotSyncEncrypted');
|
||||
return serialized;
|
||||
@@ -415,13 +430,13 @@ export default class BaseItem extends BaseModel {
|
||||
|
||||
// List of keys that won't be encrypted - mostly foreign keys required to link items
|
||||
// with each others and timestamp required for synchronisation.
|
||||
const keepKeys = ['id', 'note_id', 'tag_id', 'parent_id', 'updated_time', 'type_'];
|
||||
const keepKeys = ['id', 'note_id', 'tag_id', 'parent_id', 'share_id', 'updated_time', 'type_'];
|
||||
const reducedItem: any = {};
|
||||
|
||||
for (let i = 0; i < keepKeys.length; i++) {
|
||||
const n = keepKeys[i];
|
||||
if (!item.hasOwnProperty(n)) continue;
|
||||
reducedItem[n] = item[n];
|
||||
reducedItem[n] = (item as any)[n];
|
||||
}
|
||||
|
||||
reducedItem.encryption_applied = 1;
|
||||
@@ -792,7 +807,7 @@ export default class BaseItem extends BaseModel {
|
||||
}
|
||||
}
|
||||
|
||||
static async updateShareStatus(item: any, isShared: boolean) {
|
||||
static async updateShareStatus(item: BaseItemEntity, isShared: boolean) {
|
||||
if (!item.id || !item.type_) throw new Error('Item must have an ID and a type');
|
||||
if (!!item.is_shared === !!isShared) return false;
|
||||
const ItemClass = this.getClassByItemType(item.type_);
|
||||
|
@@ -2,10 +2,11 @@ import { FolderEntity } from '../services/database/types';
|
||||
import BaseModel from '../BaseModel';
|
||||
import time from '../time';
|
||||
import { _ } from '../locale';
|
||||
|
||||
import Note from './Note';
|
||||
import Database from '../database';
|
||||
import BaseItem from './BaseItem';
|
||||
import Resource from './Resource';
|
||||
import { isRootSharedFolder } from '../services/share/reducer';
|
||||
const { substrWithEllipsis } = require('../string-utils.js');
|
||||
|
||||
interface FolderEntityWithChildren extends FolderEntity {
|
||||
@@ -75,8 +76,10 @@ export default class Folder extends BaseItem {
|
||||
}
|
||||
|
||||
static async delete(folderId: string, options: any = null) {
|
||||
if (!options) options = {};
|
||||
if (!('deleteChildren' in options)) options.deleteChildren = true;
|
||||
options = {
|
||||
deleteChildren: true,
|
||||
...options,
|
||||
};
|
||||
|
||||
const folder = await Folder.load(folderId);
|
||||
if (!folder) return; // noop
|
||||
@@ -256,6 +259,120 @@ export default class Folder extends BaseItem {
|
||||
}
|
||||
}
|
||||
|
||||
public static async allChildrenFolders(folderId: string): Promise<FolderEntity[]> {
|
||||
const sql = `
|
||||
WITH RECURSIVE
|
||||
folders_cte(id, parent_id, share_id) AS (
|
||||
SELECT id, parent_id, share_id
|
||||
FROM folders
|
||||
WHERE parent_id = ?
|
||||
UNION ALL
|
||||
SELECT folders.id, folders.parent_id, folders.share_id
|
||||
FROM folders
|
||||
INNER JOIN folders_cte AS folders_cte ON (folders.parent_id = folders_cte.id)
|
||||
)
|
||||
SELECT id, parent_id, share_id FROM folders_cte;
|
||||
`;
|
||||
|
||||
return this.db().selectAll(sql, [folderId]);
|
||||
}
|
||||
|
||||
private static async rootSharedFolders(): Promise<FolderEntity[]> {
|
||||
return this.db().selectAll('SELECT id, share_id FROM folders WHERE parent_id = "" AND share_id != ""');
|
||||
}
|
||||
|
||||
public static async updateFolderShareIds(): Promise<void> {
|
||||
// Get all the sub-folders of the shared folders, and set the share_id
|
||||
// property.
|
||||
const rootFolders = await this.rootSharedFolders();
|
||||
|
||||
let sharedFolderIds: string[] = [];
|
||||
|
||||
for (const rootFolder of rootFolders) {
|
||||
const children = await this.allChildrenFolders(rootFolder.id);
|
||||
|
||||
for (const child of children) {
|
||||
if (child.share_id !== rootFolder.share_id) {
|
||||
await this.save({
|
||||
id: child.id,
|
||||
share_id: rootFolder.share_id,
|
||||
updated_time: Date.now(),
|
||||
}, { autoTimestamp: false });
|
||||
}
|
||||
}
|
||||
|
||||
sharedFolderIds.push(rootFolder.id);
|
||||
sharedFolderIds = sharedFolderIds.concat(children.map(c => c.id));
|
||||
}
|
||||
|
||||
// Now that we've set the share ID on all the sub-folders of the shared
|
||||
// folders, those that remain should not be shared anymore. For example,
|
||||
// if they've been moved out of a shared folder.
|
||||
// await this.unshareItems(ModelType.Folder, sharedFolderIds);
|
||||
|
||||
const sql = ['SELECT id FROM folders WHERE share_id != ""'];
|
||||
if (sharedFolderIds.length) {
|
||||
sql.push(` AND id NOT IN ("${sharedFolderIds.join('","')}")`);
|
||||
}
|
||||
|
||||
const foldersToUnshare = await this.db().selectAll(sql.join(' '));
|
||||
for (const item of foldersToUnshare) {
|
||||
await this.save({
|
||||
id: item.id,
|
||||
share_id: '',
|
||||
updated_time: Date.now(),
|
||||
}, { autoTimestamp: false });
|
||||
}
|
||||
}
|
||||
|
||||
public static async updateNoteShareIds() {
|
||||
// Find all the notes where the share_id is not the same as the
|
||||
// parent share_id because we only need to update those.
|
||||
const rows = await this.db().selectAll(`
|
||||
SELECT notes.id, folders.share_id
|
||||
FROM notes
|
||||
LEFT JOIN folders ON notes.parent_id = folders.id
|
||||
WHERE notes.share_id != folders.share_id
|
||||
`);
|
||||
|
||||
for (const row of rows) {
|
||||
await Note.save({
|
||||
id: row.id,
|
||||
share_id: row.share_id || '',
|
||||
updated_time: Date.now(),
|
||||
}, { autoTimestamp: false });
|
||||
}
|
||||
}
|
||||
|
||||
public static async updateResourceShareIds() {
|
||||
// Find all resources where share_id is different from parent note
|
||||
// share_id. Then update share_id on all these resources. Essentially it
|
||||
// makes it match the resource share_id to the note share_id.
|
||||
const rows = await this.db().selectAll(`
|
||||
SELECT r.id, n.share_id, n.is_shared
|
||||
FROM note_resources nr
|
||||
LEFT JOIN resources r ON nr.resource_id = r.id
|
||||
LEFT JOIN notes n ON nr.note_id = n.id
|
||||
WHERE n.share_id != r.share_id
|
||||
OR n.is_shared != r.is_shared
|
||||
`);
|
||||
|
||||
for (const row of rows) {
|
||||
await Resource.save({
|
||||
id: row.id,
|
||||
share_id: row.share_id || '',
|
||||
is_shared: row.is_shared,
|
||||
updated_time: Date.now(),
|
||||
}, { autoTimestamp: false });
|
||||
}
|
||||
}
|
||||
|
||||
public static async updateAllShareIds() {
|
||||
await this.updateFolderShareIds();
|
||||
await this.updateNoteShareIds();
|
||||
await this.updateResourceShareIds();
|
||||
}
|
||||
|
||||
static async allAsTree(folders: FolderEntity[] = null, options: any = null) {
|
||||
const all = folders ? folders : await this.all(options);
|
||||
|
||||
@@ -392,6 +509,9 @@ export default class Folder extends BaseItem {
|
||||
static async canNestUnder(folderId: string, targetFolderId: string) {
|
||||
if (folderId === targetFolderId) return false;
|
||||
|
||||
const folder = await Folder.load(folderId);
|
||||
if (isRootSharedFolder(folder)) return false;
|
||||
|
||||
const conflictFolderId = Folder.conflictFolderId();
|
||||
if (folderId == conflictFolderId || targetFolderId == conflictFolderId) return false;
|
||||
|
||||
|
@@ -111,7 +111,7 @@ export default class Note extends BaseItem {
|
||||
return BaseModel.TYPE_NOTE;
|
||||
}
|
||||
|
||||
static linkedItemIds(body: string): string[] {
|
||||
public static linkedItemIds(body: string): string[] {
|
||||
if (!body || body.length <= 32) return [];
|
||||
|
||||
const links = urlUtils.extractResourceUrls(body);
|
||||
|
@@ -36,6 +36,40 @@ export default class NoteResource extends BaseModel {
|
||||
await this.db().transactionExecBatch(queries);
|
||||
}
|
||||
|
||||
// public static async updateResourceShareIds() {
|
||||
// // Find all resources where share_id is different from parent note
|
||||
// // share_id. Then update share_id on all these resources. Essentially it
|
||||
// // makes it match the resource share_id to the note share_id.
|
||||
|
||||
// const sql = `
|
||||
// SELECT r.id, n.share_id
|
||||
// FROM note_resources nr
|
||||
// LEFT JOIN resources r ON nr.resource_id = r.id
|
||||
// LEFT JOIN notes n ON nr.note_id = n.id
|
||||
// WHERE n.share_id != r.share_id`;
|
||||
|
||||
// const rows = await this.db().selectAll(sql);
|
||||
|
||||
// const updatedTime = Date.now();
|
||||
// const queries: SqlQuery[] = [];
|
||||
|
||||
// for (const row of rows) {
|
||||
// queries.push({
|
||||
// sql: `
|
||||
// UPDATE resources
|
||||
// SET share_id = ?, updated_time = ?
|
||||
// WHERE id = ?`,
|
||||
// params: [
|
||||
// row.share_id || '',
|
||||
// updatedTime,
|
||||
// row.id,
|
||||
// ],
|
||||
// });
|
||||
// }
|
||||
|
||||
// await this.db().transactionExecBatch(queries);
|
||||
// }
|
||||
|
||||
static async associatedNoteIds(resourceId: string): Promise<string[]> {
|
||||
const rows = await this.modelSelectAll('SELECT note_id FROM note_resources WHERE resource_id = ? AND is_associated = 1', [resourceId]);
|
||||
return rows.map((r: any) => r.note_id);
|
||||
|
@@ -11,7 +11,7 @@ const pathUtils = require('../path-utils');
|
||||
const { mime } = require('../mime-utils.js');
|
||||
const { filename, safeFilename } = require('../path-utils');
|
||||
const { FsDriverDummy } = require('../fs-driver-dummy.js');
|
||||
const JoplinError = require('../JoplinError');
|
||||
import JoplinError from '../JoplinError';
|
||||
|
||||
export default class Resource extends BaseItem {
|
||||
|
||||
@@ -48,7 +48,7 @@ export default class Resource extends BaseItem {
|
||||
}
|
||||
|
||||
public static sharedResourceIds(): Promise<string[]> {
|
||||
return this.db().selectAllFields('SELECT id FROM resources WHERE is_shared = 1', {}, 'id');
|
||||
return this.db().selectAllFields('SELECT id FROM resources WHERE is_shared = 1', [], 'id');
|
||||
}
|
||||
|
||||
static errorFetchStatuses() {
|
||||
|
@@ -3,7 +3,7 @@ import { RevisionEntity } from '../services/database/types';
|
||||
import BaseItem from './BaseItem';
|
||||
const DiffMatchPatch = require('diff-match-patch');
|
||||
const ArrayUtils = require('../ArrayUtils.js');
|
||||
const JoplinError = require('../JoplinError');
|
||||
import JoplinError from '../JoplinError';
|
||||
const { sprintf } = require('sprintf-js');
|
||||
|
||||
const dmp = new DiffMatchPatch();
|
||||
|
@@ -1,6 +1,5 @@
|
||||
import shim from '../shim';
|
||||
import { _, supportedLocalesToLanguages, defaultLocale } from '../locale';
|
||||
import { ltrimSlashes } from '../path-utils';
|
||||
import eventManager from '../eventManager';
|
||||
import BaseModel from '../BaseModel';
|
||||
import Database from '../database';
|
||||
@@ -88,6 +87,31 @@ export enum SyncStartupOperation {
|
||||
ClearLocalData = 2,
|
||||
}
|
||||
|
||||
export enum Env {
|
||||
Undefined = 'SET_ME',
|
||||
Dev = 'dev',
|
||||
Prod = 'prod',
|
||||
}
|
||||
|
||||
export interface Constants {
|
||||
env: Env;
|
||||
isDemo: boolean;
|
||||
appName: string;
|
||||
appId: string;
|
||||
appType: string;
|
||||
resourceDirName: string;
|
||||
resourceDir: string;
|
||||
profileDir: string;
|
||||
templateDir: string;
|
||||
tempDir: string;
|
||||
pluginDataDir: string;
|
||||
cacheDir: string;
|
||||
pluginDir: string;
|
||||
flagOpenDevTools: boolean;
|
||||
syncVersion: number;
|
||||
startupDevPlugins: string[];
|
||||
}
|
||||
|
||||
interface SettingSections {
|
||||
[key: string]: SettingSection;
|
||||
}
|
||||
@@ -151,8 +175,8 @@ class Setting extends BaseModel {
|
||||
|
||||
// Contains constants that are set by the application and
|
||||
// cannot be modified by the user:
|
||||
public static constants_: any = {
|
||||
env: 'SET_ME',
|
||||
public static constants_: Constants = {
|
||||
env: Env.Undefined,
|
||||
isDemo: false,
|
||||
appName: 'joplin',
|
||||
appId: 'SET_ME', // Each app should set this identifier
|
||||
@@ -452,20 +476,20 @@ class Setting extends BaseModel {
|
||||
description: () => emptyDirWarning,
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
'sync.9.directory': {
|
||||
value: 'Apps/Joplin',
|
||||
type: SettingItemType.String,
|
||||
section: 'sync',
|
||||
show: (settings: any) => {
|
||||
return settings['sync.target'] == SyncTargetRegistry.nameToId('joplinServer');
|
||||
},
|
||||
filter: value => {
|
||||
return value ? ltrimSlashes(rtrimSlashes(value)) : '';
|
||||
},
|
||||
public: true,
|
||||
label: () => _('Joplin Server Directory'),
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
// 'sync.9.directory': {
|
||||
// value: 'Apps/Joplin',
|
||||
// type: SettingItemType.String,
|
||||
// section: 'sync',
|
||||
// show: (settings: any) => {
|
||||
// return settings['sync.target'] == SyncTargetRegistry.nameToId('joplinServer');
|
||||
// },
|
||||
// filter: value => {
|
||||
// return value ? ltrimSlashes(rtrimSlashes(value)) : '';
|
||||
// },
|
||||
// public: true,
|
||||
// label: () => _('Joplin Server Directory'),
|
||||
// storage: SettingStorage.File,
|
||||
// },
|
||||
'sync.9.username': {
|
||||
value: '',
|
||||
type: SettingItemType.String,
|
||||
@@ -474,7 +498,7 @@ class Setting extends BaseModel {
|
||||
return settings['sync.target'] == SyncTargetRegistry.nameToId('joplinServer');
|
||||
},
|
||||
public: true,
|
||||
label: () => _('Joplin Server username'),
|
||||
label: () => _('Joplin Server email'),
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
'sync.9.password': {
|
||||
@@ -843,6 +867,12 @@ class Setting extends BaseModel {
|
||||
public: false,
|
||||
},
|
||||
|
||||
'sync.userId': {
|
||||
value: '',
|
||||
type: SettingItemType.String,
|
||||
public: false,
|
||||
},
|
||||
|
||||
// Deprecated in favour of windowContentZoomFactor
|
||||
'style.zoom': { value: 100, type: SettingItemType.Int, public: false, storage: SettingStorage.File, appTypes: ['desktop'], section: 'appearance', label: () => '', minimum: 50, maximum: 500, step: 10 },
|
||||
|
||||
@@ -1383,7 +1413,7 @@ class Setting extends BaseModel {
|
||||
|
||||
static setConstant(key: string, value: any) {
|
||||
if (!(key in this.constants_)) throw new Error(`Unknown constant key: ${key}`);
|
||||
this.constants_[key] = value;
|
||||
(this.constants_ as any)[key] = value;
|
||||
}
|
||||
|
||||
public static setValue(key: string, value: any) {
|
||||
@@ -1546,7 +1576,7 @@ class Setting extends BaseModel {
|
||||
}
|
||||
|
||||
if (key in this.constants_) {
|
||||
const v = this.constants_[key];
|
||||
const v = (this.constants_ as any)[key];
|
||||
const output = typeof v === 'function' ? v() : v;
|
||||
if (output == 'SET_ME') throw new Error(`SET_ME constant has not been set: ${key}`);
|
||||
return output;
|
||||
|
@@ -1,8 +1,10 @@
|
||||
import produce, { Draft } from 'immer';
|
||||
import pluginServiceReducer, { stateRootKey as pluginServiceStateRootKey, defaultState as pluginServiceDefaultState, State as PluginServiceState } from './services/plugins/reducer';
|
||||
import shareServiceReducer, { stateRootKey as shareServiceStateRootKey, defaultState as shareServiceDefaultState, State as ShareServiceState } from './services/share/reducer';
|
||||
import Note from './models/Note';
|
||||
import Folder from './models/Folder';
|
||||
import BaseModel from './BaseModel';
|
||||
import { Store } from 'redux';
|
||||
const ArrayUtils = require('./ArrayUtils.js');
|
||||
const { ALL_NOTES_FILTER_ID } = require('./reserved-ids');
|
||||
const { createSelectorCreator, defaultMemoize } = require('reselect');
|
||||
@@ -16,6 +18,12 @@ additionalReducers.push({
|
||||
reducer: pluginServiceReducer,
|
||||
});
|
||||
|
||||
additionalReducers.push({
|
||||
stateRootKey: shareServiceStateRootKey,
|
||||
defaultState: shareServiceDefaultState,
|
||||
reducer: shareServiceReducer,
|
||||
});
|
||||
|
||||
interface StateLastSelectedNotesIds {
|
||||
Folder: any;
|
||||
Tag: any;
|
||||
@@ -86,6 +94,7 @@ export interface State {
|
||||
|
||||
// Extra reducer keys go here:
|
||||
pluginService: PluginServiceState;
|
||||
shareService: ShareServiceState;
|
||||
}
|
||||
|
||||
export const defaultState: State = {
|
||||
@@ -153,12 +162,23 @@ export const defaultState: State = {
|
||||
hasEncryptedItems: false,
|
||||
|
||||
pluginService: pluginServiceDefaultState,
|
||||
shareService: shareServiceDefaultState,
|
||||
};
|
||||
|
||||
for (const additionalReducer of additionalReducers) {
|
||||
(defaultState as any)[additionalReducer.stateRootKey] = additionalReducer.defaultState;
|
||||
}
|
||||
|
||||
let store_: Store<any> = null;
|
||||
|
||||
export function setStore(v: Store<any>) {
|
||||
store_ = v;
|
||||
}
|
||||
|
||||
export function store(): Store<any> {
|
||||
return store_;
|
||||
}
|
||||
|
||||
export const MAX_HISTORY = 200;
|
||||
|
||||
const derivedStateCache_: any = {};
|
||||
|
@@ -99,8 +99,8 @@ class Registry {
|
||||
}
|
||||
|
||||
if (Setting.value('env') === 'dev' && delay !== 0) {
|
||||
this.logger().info('Schedule sync DISABLED!!!');
|
||||
return;
|
||||
// this.logger().info('Schedule sync DISABLED!!!');
|
||||
// return;
|
||||
}
|
||||
|
||||
this.logger().debug('Scheduling sync operation...', delay);
|
||||
|
@@ -6,7 +6,7 @@ import MasterKey from '../models/MasterKey';
|
||||
import BaseItem from '../models/BaseItem';
|
||||
|
||||
const { padLeft } = require('../string-utils.js');
|
||||
const JoplinError = require('../JoplinError');
|
||||
import JoplinError from '../JoplinError';
|
||||
|
||||
function hexPad(s: string, length: number) {
|
||||
return padLeft(s, length, '0');
|
||||
|
@@ -3,7 +3,11 @@ import ItemChange from '../models/ItemChange';
|
||||
|
||||
export default class ItemChangeUtils {
|
||||
static async deleteProcessedChanges() {
|
||||
const lastProcessedChangeIds = [Setting.value('resourceService.lastProcessedChangeId'), Setting.value('searchEngine.lastProcessedChangeId'), Setting.value('revisionService.lastProcessedChangeId')];
|
||||
const lastProcessedChangeIds = [
|
||||
Setting.value('resourceService.lastProcessedChangeId'),
|
||||
Setting.value('searchEngine.lastProcessedChangeId'),
|
||||
Setting.value('revisionService.lastProcessedChangeId'),
|
||||
];
|
||||
|
||||
const lowestChangeId = Math.min(...lastProcessedChangeIds);
|
||||
await ItemChange.deleteOldChanges(lowestChangeId);
|
||||
|
@@ -1,12 +1,49 @@
|
||||
import { State, stateUtils } from '../../reducer';
|
||||
|
||||
import BaseModel from '../../BaseModel';
|
||||
import Folder from '../../models/Folder';
|
||||
import MarkupToHtml from '@joplin/renderer/MarkupToHtml';
|
||||
import { isRootSharedFolder, isSharedFolderOwner } from '../share/reducer';
|
||||
import { FolderEntity, NoteEntity } from '../database/types';
|
||||
|
||||
export default function stateToWhenClauseContext(state: State) {
|
||||
const noteId = state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null;
|
||||
const note = noteId ? BaseModel.byId(state.notes, noteId) : null;
|
||||
export interface WhenClauseContextOptions {
|
||||
commandFolderId?: string;
|
||||
commandNoteId?: string;
|
||||
}
|
||||
|
||||
export interface WhenClauseContext {
|
||||
notesAreBeingSaved: boolean;
|
||||
syncStarted: boolean;
|
||||
inConflictFolder: boolean;
|
||||
oneNoteSelected: boolean;
|
||||
someNotesSelected: boolean;
|
||||
multipleNotesSelected: boolean;
|
||||
noNotesSelected: boolean;
|
||||
historyhasBackwardNotes: boolean;
|
||||
historyhasForwardNotes: boolean;
|
||||
oneFolderSelected: boolean;
|
||||
noteIsTodo: boolean;
|
||||
noteTodoCompleted: boolean;
|
||||
noteIsMarkdown: boolean;
|
||||
noteIsHtml: boolean;
|
||||
folderIsShareRootAndOwnedByUser: boolean;
|
||||
folderIsShared: boolean;
|
||||
}
|
||||
|
||||
export default function stateToWhenClauseContext(state: State, options: WhenClauseContextOptions = null): WhenClauseContext {
|
||||
options = {
|
||||
commandFolderId: '',
|
||||
commandNoteId: '',
|
||||
...options,
|
||||
};
|
||||
|
||||
const selectedNoteId = state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null;
|
||||
const selectedNote: NoteEntity = selectedNoteId ? BaseModel.byId(state.notes, selectedNoteId) : null;
|
||||
|
||||
// const commandNoteId = options.commandNoteId || selectedNoteId;
|
||||
// const commandNote:NoteEntity = commandNoteId ? BaseModel.byId(state.notes, commandNoteId) : null;
|
||||
|
||||
const commandFolderId = options.commandFolderId;
|
||||
const commandFolder: FolderEntity = commandFolderId ? BaseModel.byId(state.folders, commandFolderId) : null;
|
||||
|
||||
return {
|
||||
// Application state
|
||||
@@ -17,7 +54,7 @@ export default function stateToWhenClauseContext(state: State) {
|
||||
inConflictFolder: state.selectedFolderId === Folder.conflictFolderId(),
|
||||
|
||||
// Note selection
|
||||
oneNoteSelected: !!note,
|
||||
oneNoteSelected: !!selectedNote,
|
||||
someNotesSelected: state.selectedNoteIds.length > 0,
|
||||
multipleNotesSelected: state.selectedNoteIds.length > 1,
|
||||
noNotesSelected: !state.selectedNoteIds.length,
|
||||
@@ -30,9 +67,13 @@ export default function stateToWhenClauseContext(state: State) {
|
||||
oneFolderSelected: !!state.selectedFolderId,
|
||||
|
||||
// Current note properties
|
||||
noteIsTodo: note ? !!note.is_todo : false,
|
||||
noteTodoCompleted: note ? !!note.todo_completed : false,
|
||||
noteIsMarkdown: note ? note.markup_language === MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN : false,
|
||||
noteIsHtml: note ? note.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML : false,
|
||||
noteIsTodo: selectedNote ? !!selectedNote.is_todo : false,
|
||||
noteTodoCompleted: selectedNote ? !!selectedNote.todo_completed : false,
|
||||
noteIsMarkdown: selectedNote ? selectedNote.markup_language === MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN : false,
|
||||
noteIsHtml: selectedNote ? selectedNote.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML : false,
|
||||
|
||||
// Current context folder
|
||||
folderIsShareRootAndOwnedByUser: commandFolder ? isRootSharedFolder(commandFolder) && isSharedFolderOwner(state, commandFolder.id) : false,
|
||||
folderIsShared: commandFolder ? !!commandFolder.share_id : false,
|
||||
};
|
||||
}
|
||||
|
@@ -1,227 +1,235 @@
|
||||
// AUTO-GENERATED BY packages/tools/generate-database-types.js
|
||||
|
||||
// This file was generated by a tool.
|
||||
// Rerun sql-ts to regenerate this file.
|
||||
/*
|
||||
* This file was generated by a tool.
|
||||
* Rerun sql-ts to regenerate this file.
|
||||
*/
|
||||
export interface AlarmEntity {
|
||||
'id'?: number | null;
|
||||
'note_id'?: string;
|
||||
'trigger_time'?: number;
|
||||
'type_'?: number;
|
||||
"id"?: number | null
|
||||
"note_id"?: string
|
||||
"trigger_time"?: number
|
||||
"type_"?: number
|
||||
}
|
||||
export interface DeletedItemEntity {
|
||||
'id'?: number | null;
|
||||
'item_type'?: number;
|
||||
'item_id'?: string;
|
||||
'deleted_time'?: number;
|
||||
'sync_target'?: number;
|
||||
'type_'?: number;
|
||||
"id"?: number | null
|
||||
"item_type"?: number
|
||||
"item_id"?: string
|
||||
"deleted_time"?: number
|
||||
"sync_target"?: number
|
||||
"type_"?: number
|
||||
}
|
||||
export interface FolderEntity {
|
||||
'id'?: string | null;
|
||||
'title'?: string;
|
||||
'created_time'?: number;
|
||||
'updated_time'?: number;
|
||||
'user_created_time'?: number;
|
||||
'user_updated_time'?: number;
|
||||
'encryption_cipher_text'?: string;
|
||||
'encryption_applied'?: number;
|
||||
'parent_id'?: string;
|
||||
'is_shared'?: number;
|
||||
'type_'?: number;
|
||||
"id"?: string | null
|
||||
"title"?: string
|
||||
"created_time"?: number
|
||||
"updated_time"?: number
|
||||
"user_created_time"?: number
|
||||
"user_updated_time"?: number
|
||||
"encryption_cipher_text"?: string
|
||||
"encryption_applied"?: number
|
||||
"parent_id"?: string
|
||||
"is_shared"?: number
|
||||
"is_linked_folder"?: number
|
||||
"source_folder_owner_id"?: string
|
||||
"share_id"?: string
|
||||
"type_"?: number
|
||||
}
|
||||
export interface ItemChangeEntity {
|
||||
'id'?: number | null;
|
||||
'item_type'?: number;
|
||||
'item_id'?: string;
|
||||
'type'?: number;
|
||||
'created_time'?: number;
|
||||
'source'?: number;
|
||||
'before_change_item'?: string;
|
||||
'type_'?: number;
|
||||
"id"?: number | null
|
||||
"item_type"?: number
|
||||
"item_id"?: string
|
||||
"type"?: number
|
||||
"created_time"?: number
|
||||
"source"?: number
|
||||
"before_change_item"?: string
|
||||
"type_"?: number
|
||||
}
|
||||
export interface KeyValueEntity {
|
||||
'id'?: number | null;
|
||||
'key'?: string;
|
||||
'value'?: string;
|
||||
'type'?: number;
|
||||
'updated_time'?: number;
|
||||
'type_'?: number;
|
||||
"id"?: number | null
|
||||
"key"?: string
|
||||
"value"?: string
|
||||
"type"?: number
|
||||
"updated_time"?: number
|
||||
"type_"?: number
|
||||
}
|
||||
export interface MasterKeyEntity {
|
||||
'id'?: string | null;
|
||||
'created_time'?: number;
|
||||
'updated_time'?: number;
|
||||
'source_application'?: string;
|
||||
'encryption_method'?: number;
|
||||
'checksum'?: string;
|
||||
'content'?: string;
|
||||
'type_'?: number;
|
||||
"id"?: string | null
|
||||
"created_time"?: number
|
||||
"updated_time"?: number
|
||||
"source_application"?: string
|
||||
"encryption_method"?: number
|
||||
"checksum"?: string
|
||||
"content"?: string
|
||||
"type_"?: number
|
||||
}
|
||||
export interface MigrationEntity {
|
||||
'id'?: number | null;
|
||||
'number'?: number;
|
||||
'updated_time'?: number;
|
||||
'created_time'?: number;
|
||||
'type_'?: number;
|
||||
"id"?: number | null
|
||||
"number"?: number
|
||||
"updated_time"?: number
|
||||
"created_time"?: number
|
||||
"type_"?: number
|
||||
}
|
||||
export interface NoteResourceEntity {
|
||||
'id'?: number | null;
|
||||
'note_id'?: string;
|
||||
'resource_id'?: string;
|
||||
'is_associated'?: number;
|
||||
'last_seen_time'?: number;
|
||||
'type_'?: number;
|
||||
"id"?: number | null
|
||||
"note_id"?: string
|
||||
"resource_id"?: string
|
||||
"is_associated"?: number
|
||||
"last_seen_time"?: number
|
||||
"type_"?: number
|
||||
}
|
||||
export interface NoteTagEntity {
|
||||
'id'?: string | null;
|
||||
'note_id'?: string;
|
||||
'tag_id'?: string;
|
||||
'created_time'?: number;
|
||||
'updated_time'?: number;
|
||||
'user_created_time'?: number;
|
||||
'user_updated_time'?: number;
|
||||
'encryption_cipher_text'?: string;
|
||||
'encryption_applied'?: number;
|
||||
'is_shared'?: number;
|
||||
'type_'?: number;
|
||||
"id"?: string | null
|
||||
"note_id"?: string
|
||||
"tag_id"?: string
|
||||
"created_time"?: number
|
||||
"updated_time"?: number
|
||||
"user_created_time"?: number
|
||||
"user_updated_time"?: number
|
||||
"encryption_cipher_text"?: string
|
||||
"encryption_applied"?: number
|
||||
"is_shared"?: number
|
||||
"type_"?: number
|
||||
}
|
||||
export interface NoteEntity {
|
||||
'id'?: string | null;
|
||||
'parent_id'?: string;
|
||||
'title'?: string;
|
||||
'body'?: string;
|
||||
'created_time'?: number;
|
||||
'updated_time'?: number;
|
||||
'is_conflict'?: number;
|
||||
'latitude'?: number;
|
||||
'longitude'?: number;
|
||||
'altitude'?: number;
|
||||
'author'?: string;
|
||||
'source_url'?: string;
|
||||
'is_todo'?: number;
|
||||
'todo_due'?: number;
|
||||
'todo_completed'?: number;
|
||||
'source'?: string;
|
||||
'source_application'?: string;
|
||||
'application_data'?: string;
|
||||
'order'?: number;
|
||||
'user_created_time'?: number;
|
||||
'user_updated_time'?: number;
|
||||
'encryption_cipher_text'?: string;
|
||||
'encryption_applied'?: number;
|
||||
'markup_language'?: number;
|
||||
'is_shared'?: number;
|
||||
'type_'?: number;
|
||||
"id"?: string | null
|
||||
"parent_id"?: string
|
||||
"title"?: string
|
||||
"body"?: string
|
||||
"created_time"?: number
|
||||
"updated_time"?: number
|
||||
"is_conflict"?: number
|
||||
"latitude"?: number
|
||||
"longitude"?: number
|
||||
"altitude"?: number
|
||||
"author"?: string
|
||||
"source_url"?: string
|
||||
"is_todo"?: number
|
||||
"todo_due"?: number
|
||||
"todo_completed"?: number
|
||||
"source"?: string
|
||||
"source_application"?: string
|
||||
"application_data"?: string
|
||||
"order"?: number
|
||||
"user_created_time"?: number
|
||||
"user_updated_time"?: number
|
||||
"encryption_cipher_text"?: string
|
||||
"encryption_applied"?: number
|
||||
"markup_language"?: number
|
||||
"is_shared"?: number
|
||||
"share_id"?: string
|
||||
"type_"?: number
|
||||
}
|
||||
export interface NotesNormalizedEntity {
|
||||
'id'?: string;
|
||||
'title'?: string;
|
||||
'body'?: string;
|
||||
'user_created_time'?: number;
|
||||
'user_updated_time'?: number;
|
||||
'is_todo'?: number;
|
||||
'todo_completed'?: number;
|
||||
'parent_id'?: string;
|
||||
'latitude'?: number;
|
||||
'longitude'?: number;
|
||||
'altitude'?: number;
|
||||
'source_url'?: string;
|
||||
'type_'?: number;
|
||||
"id"?: string
|
||||
"title"?: string
|
||||
"body"?: string
|
||||
"user_created_time"?: number
|
||||
"user_updated_time"?: number
|
||||
"is_todo"?: number
|
||||
"todo_completed"?: number
|
||||
"parent_id"?: string
|
||||
"latitude"?: number
|
||||
"longitude"?: number
|
||||
"altitude"?: number
|
||||
"source_url"?: string
|
||||
"todo_due"?: number
|
||||
"type_"?: number
|
||||
}
|
||||
export interface ResourceLocalStateEntity {
|
||||
'id'?: number | null;
|
||||
'resource_id'?: string;
|
||||
'fetch_status'?: number;
|
||||
'fetch_error'?: string;
|
||||
'type_'?: number;
|
||||
"id"?: number | null
|
||||
"resource_id"?: string
|
||||
"fetch_status"?: number
|
||||
"fetch_error"?: string
|
||||
"type_"?: number
|
||||
}
|
||||
export interface ResourceEntity {
|
||||
'id'?: string | null;
|
||||
'title'?: string;
|
||||
'mime'?: string;
|
||||
'filename'?: string;
|
||||
'created_time'?: number;
|
||||
'updated_time'?: number;
|
||||
'user_created_time'?: number;
|
||||
'user_updated_time'?: number;
|
||||
'file_extension'?: string;
|
||||
'encryption_cipher_text'?: string;
|
||||
'encryption_applied'?: number;
|
||||
'encryption_blob_encrypted'?: number;
|
||||
'size'?: number;
|
||||
'is_shared'?: number;
|
||||
'type_'?: number;
|
||||
"id"?: string | null
|
||||
"title"?: string
|
||||
"mime"?: string
|
||||
"filename"?: string
|
||||
"created_time"?: number
|
||||
"updated_time"?: number
|
||||
"user_created_time"?: number
|
||||
"user_updated_time"?: number
|
||||
"file_extension"?: string
|
||||
"encryption_cipher_text"?: string
|
||||
"encryption_applied"?: number
|
||||
"encryption_blob_encrypted"?: number
|
||||
"size"?: number
|
||||
"is_shared"?: number
|
||||
"share_id"?: string
|
||||
"type_"?: number
|
||||
}
|
||||
export interface ResourcesToDownloadEntity {
|
||||
'id'?: number | null;
|
||||
'resource_id'?: string;
|
||||
'updated_time'?: number;
|
||||
'created_time'?: number;
|
||||
'type_'?: number;
|
||||
"id"?: number | null
|
||||
"resource_id"?: string
|
||||
"updated_time"?: number
|
||||
"created_time"?: number
|
||||
"type_"?: number
|
||||
}
|
||||
export interface RevisionEntity {
|
||||
'id'?: string | null;
|
||||
'parent_id'?: string;
|
||||
'item_type'?: number;
|
||||
'item_id'?: string;
|
||||
'item_updated_time'?: number;
|
||||
'title_diff'?: string;
|
||||
'body_diff'?: string;
|
||||
'metadata_diff'?: string;
|
||||
'encryption_cipher_text'?: string;
|
||||
'encryption_applied'?: number;
|
||||
'updated_time'?: number;
|
||||
'created_time'?: number;
|
||||
'type_'?: number;
|
||||
"id"?: string | null
|
||||
"parent_id"?: string
|
||||
"item_type"?: number
|
||||
"item_id"?: string
|
||||
"item_updated_time"?: number
|
||||
"title_diff"?: string
|
||||
"body_diff"?: string
|
||||
"metadata_diff"?: string
|
||||
"encryption_cipher_text"?: string
|
||||
"encryption_applied"?: number
|
||||
"updated_time"?: number
|
||||
"created_time"?: number
|
||||
"type_"?: number
|
||||
}
|
||||
export interface SettingEntity {
|
||||
'key'?: string | null;
|
||||
'value'?: string | null;
|
||||
'type_'?: number;
|
||||
"key"?: string | null
|
||||
"value"?: string | null
|
||||
"type_"?: number
|
||||
}
|
||||
export interface SyncItemEntity {
|
||||
'id'?: number | null;
|
||||
'sync_target'?: number;
|
||||
'sync_time'?: number;
|
||||
'item_type'?: number;
|
||||
'item_id'?: string;
|
||||
'sync_disabled'?: number;
|
||||
'sync_disabled_reason'?: string;
|
||||
'force_sync'?: number;
|
||||
'item_location'?: number;
|
||||
'type_'?: number;
|
||||
"id"?: number | null
|
||||
"sync_target"?: number
|
||||
"sync_time"?: number
|
||||
"item_type"?: number
|
||||
"item_id"?: string
|
||||
"sync_disabled"?: number
|
||||
"sync_disabled_reason"?: string
|
||||
"force_sync"?: number
|
||||
"item_location"?: number
|
||||
"type_"?: number
|
||||
}
|
||||
export interface TableFieldEntity {
|
||||
'id'?: number | null;
|
||||
'table_name'?: string;
|
||||
'field_name'?: string;
|
||||
'field_type'?: number;
|
||||
'field_default'?: string | null;
|
||||
'type_'?: number;
|
||||
"id"?: number | null
|
||||
"table_name"?: string
|
||||
"field_name"?: string
|
||||
"field_type"?: number
|
||||
"field_default"?: string | null
|
||||
"type_"?: number
|
||||
}
|
||||
export interface TagEntity {
|
||||
'id'?: string | null;
|
||||
'title'?: string;
|
||||
'created_time'?: number;
|
||||
'updated_time'?: number;
|
||||
'user_created_time'?: number;
|
||||
'user_updated_time'?: number;
|
||||
'encryption_cipher_text'?: string;
|
||||
'encryption_applied'?: number;
|
||||
'is_shared'?: number;
|
||||
'parent_id'?: string;
|
||||
'type_'?: number;
|
||||
"id"?: string | null
|
||||
"title"?: string
|
||||
"created_time"?: number
|
||||
"updated_time"?: number
|
||||
"user_created_time"?: number
|
||||
"user_updated_time"?: number
|
||||
"encryption_cipher_text"?: string
|
||||
"encryption_applied"?: number
|
||||
"is_shared"?: number
|
||||
"parent_id"?: string
|
||||
"type_"?: number
|
||||
}
|
||||
export interface TagsWithNoteCountEntity {
|
||||
'id'?: string | null;
|
||||
'title'?: string | null;
|
||||
'created_time'?: number | null;
|
||||
'updated_time'?: number | null;
|
||||
'note_count'?: any | null;
|
||||
'type_'?: number;
|
||||
"id"?: string | null
|
||||
"title"?: string | null
|
||||
"created_time"?: number | null
|
||||
"updated_time"?: number | null
|
||||
"note_count"?: any | null
|
||||
"type_"?: number
|
||||
}
|
||||
export interface VersionEntity {
|
||||
'version'?: number;
|
||||
'table_fields_version'?: number;
|
||||
'type_'?: number;
|
||||
"version"?: number
|
||||
"table_fields_version"?: number
|
||||
"type_"?: number
|
||||
}
|
||||
|
@@ -18,11 +18,17 @@ export default class KeychainService extends BaseService {
|
||||
this.driver = driver;
|
||||
}
|
||||
|
||||
// This is to programatically disable the keychain service, regardless whether keychain
|
||||
// is supported or not in the system (In other word, this might "enabled" but nothing
|
||||
// will be saved to the keychain if there isn't one).
|
||||
// This is to programatically disable the keychain service, whether keychain
|
||||
// is supported or not in the system (In other word, this be might "enabled"
|
||||
// but nothing will be saved to the keychain if there isn't one).
|
||||
public get enabled(): boolean {
|
||||
return this.enabled_;
|
||||
if (!this.enabled_) return false;
|
||||
|
||||
// Otherwise we assume it's enabled if "keychain.supported" is either -1
|
||||
// (undetermined) or 1 (working). We make it work for -1 too because the
|
||||
// setPassword() and password() functions need to work to test if the
|
||||
// keychain is supported (in detectIfKeychainSupported).
|
||||
return Setting.value('keychain.supported') !== 0;
|
||||
}
|
||||
|
||||
public set enabled(v: boolean) {
|
||||
|
200
packages/lib/services/share/ShareService.ts
Normal file
200
packages/lib/services/share/ShareService.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { Store } from 'redux';
|
||||
import JoplinServerApi from '../../JoplinServerApi';
|
||||
import Folder from '../../models/Folder';
|
||||
import Note from '../../models/Note';
|
||||
import Setting from '../../models/Setting';
|
||||
import { State, stateRootKey, StateShare } from './reducer';
|
||||
|
||||
export default class ShareService {
|
||||
|
||||
private static instance_: ShareService;
|
||||
private api_: JoplinServerApi = null;
|
||||
private store_: Store<any> = null;
|
||||
|
||||
public static instance(): ShareService {
|
||||
if (this.instance_) return this.instance_;
|
||||
this.instance_ = new ShareService();
|
||||
return this.instance_;
|
||||
}
|
||||
|
||||
public initialize(store: Store<any>) {
|
||||
this.store_ = store;
|
||||
}
|
||||
|
||||
public get enabled(): boolean {
|
||||
return Setting.value('sync.target') === 9; // Joplin Server target
|
||||
}
|
||||
|
||||
private get store(): Store<any> {
|
||||
return this.store_;
|
||||
}
|
||||
|
||||
public get state(): State {
|
||||
return this.store.getState()[stateRootKey] as State;
|
||||
}
|
||||
|
||||
private api(): JoplinServerApi {
|
||||
if (this.api_) return this.api_;
|
||||
|
||||
this.api_ = new JoplinServerApi({
|
||||
baseUrl: () => Setting.value('sync.9.path'),
|
||||
username: () => Setting.value('sync.9.username'),
|
||||
password: () => Setting.value('sync.9.password'),
|
||||
});
|
||||
|
||||
return this.api_;
|
||||
}
|
||||
|
||||
public async shareFolder(folderId: string) {
|
||||
const folder = await Folder.load(folderId);
|
||||
if (!folder) throw new Error(`No such folder: ${folderId}`);
|
||||
|
||||
if (folder.parent_id) {
|
||||
await Folder.save({ id: folder.id, parent_id: '' });
|
||||
}
|
||||
|
||||
const share = await this.api().exec('POST', 'api/shares', {}, { folder_id: folderId });
|
||||
|
||||
// Note: race condition if the share is created but the app crashes
|
||||
// before setting share_id on the folder. See unshareFolder() for info.
|
||||
await Folder.save({ id: folder.id, share_id: share.id });
|
||||
await Folder.updateAllShareIds();
|
||||
|
||||
return share;
|
||||
}
|
||||
|
||||
public async unshareFolder(folderId: string) {
|
||||
const folder = await Folder.load(folderId);
|
||||
if (!folder) throw new Error(`No such folder: ${folderId}`);
|
||||
|
||||
const share = this.shares.find(s => s.folder_id === folderId);
|
||||
if (!share) throw new Error(`No share for folder: ${folderId}`);
|
||||
|
||||
// First, delete the share - which in turns is going to remove the items
|
||||
// for all users, except the owner.
|
||||
await this.deleteShare(share.id);
|
||||
|
||||
// Then reset the "share_id" field for the folder and all sub-items.
|
||||
// This could potentially be done server-side, when deleting the share,
|
||||
// but since clients are normally responsible for maintaining the
|
||||
// share_id property, we do it here for consistency. It will also avoid
|
||||
// conflicts because changes will come only from the clients.
|
||||
//
|
||||
// Note that there could be a race condition here if the share is
|
||||
// deleted, but the app crashes just before setting share_id to "". It's
|
||||
// very unlikely to happen so we leave like this for now.
|
||||
//
|
||||
// We could potentially have a clean up process at some point:
|
||||
//
|
||||
// - It would download all share objects
|
||||
// - Then look for all items where the share_id is not in any of these
|
||||
// shares objects
|
||||
// - And set those to ""
|
||||
//
|
||||
// Likewise, it could apply the share_id to folders based on
|
||||
// share.folder_id
|
||||
//
|
||||
// Setting the share_id is not critical - what matters is that when the
|
||||
// share is deleted, other users no longer have access to the item, so
|
||||
// can't change or read them.
|
||||
await Folder.save({ id: folder.id, share_id: '' });
|
||||
|
||||
// It's ok if updateAllShareIds() doesn't run because it's executed on
|
||||
// each sync too.
|
||||
await Folder.updateAllShareIds();
|
||||
}
|
||||
|
||||
public async shareNote(noteId: string) {
|
||||
const note = await Note.load(noteId);
|
||||
if (!note) throw new Error(`No such note: ${noteId}`);
|
||||
|
||||
const share = await this.api().exec('POST', 'api/shares', {}, { note_id: noteId });
|
||||
|
||||
await Note.save({ id: note.id, is_shared: 1 });
|
||||
|
||||
return share;
|
||||
}
|
||||
|
||||
public shareUrl(share: StateShare): string {
|
||||
return `${this.api().baseUrl()}/shares/${share.id}`;
|
||||
}
|
||||
|
||||
public get shares() {
|
||||
return this.state.shares;
|
||||
}
|
||||
|
||||
public get shareLinkNoteIds(): string[] {
|
||||
return this.shares.filter(s => !!s.note_id).map(s => s.note_id);
|
||||
}
|
||||
|
||||
public async addShareRecipient(shareId: string, recipientEmail: string) {
|
||||
return this.api().exec('POST', `api/shares/${shareId}/users`, {}, {
|
||||
email: recipientEmail,
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteShareRecipient(shareUserId: string) {
|
||||
await this.api().exec('DELETE', `api/share_users/${shareUserId}`);
|
||||
}
|
||||
|
||||
public async deleteShare(shareId: string) {
|
||||
await this.api().exec('DELETE', `api/shares/${shareId}`);
|
||||
}
|
||||
|
||||
private async loadShares() {
|
||||
return this.api().exec('GET', 'api/shares');
|
||||
}
|
||||
|
||||
private async loadShareUsers(shareId: string) {
|
||||
return this.api().exec('GET', `api/shares/${shareId}/users`);
|
||||
}
|
||||
|
||||
private async loadShareInvitations() {
|
||||
return this.api().exec('GET', 'api/share_users');
|
||||
}
|
||||
|
||||
public async respondInvitation(shareUserId: string, accept: boolean) {
|
||||
if (accept) {
|
||||
await this.api().exec('PATCH', `api/share_users/${shareUserId}`, null, { status: 1 });
|
||||
} else {
|
||||
await this.api().exec('PATCH', `api/share_users/${shareUserId}`, null, { status: 2 });
|
||||
}
|
||||
}
|
||||
|
||||
public async refreshShareInvitations() {
|
||||
const result = await this.loadShareInvitations();
|
||||
|
||||
this.store.dispatch({
|
||||
type: 'SHARE_INVITATION_SET',
|
||||
shareInvitations: result.items,
|
||||
});
|
||||
}
|
||||
|
||||
public async refreshShares() {
|
||||
const result = await this.loadShares();
|
||||
|
||||
this.store.dispatch({
|
||||
type: 'SHARE_SET',
|
||||
shares: result.items,
|
||||
});
|
||||
}
|
||||
|
||||
public async refreshShareUsers(shareId: string) {
|
||||
const result = await this.loadShareUsers(shareId);
|
||||
|
||||
this.store.dispatch({
|
||||
type: 'SHARE_USER_SET',
|
||||
shareId: shareId,
|
||||
shareUsers: result.items,
|
||||
});
|
||||
}
|
||||
|
||||
public async maintenance() {
|
||||
if (this.enabled) {
|
||||
await this.refreshShareInvitations();
|
||||
await this.refreshShares();
|
||||
Setting.setValue('sync.userId', this.api().userId);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
92
packages/lib/services/share/reducer.ts
Normal file
92
packages/lib/services/share/reducer.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { State as RootState } from '../../reducer';
|
||||
import { Draft } from 'immer';
|
||||
import { FolderEntity } from '../database/types';
|
||||
|
||||
interface StateShareUserUser {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name?: string;
|
||||
}
|
||||
|
||||
export enum ShareUserStatus {
|
||||
Waiting = 0,
|
||||
Accepted = 1,
|
||||
Rejected = 2,
|
||||
}
|
||||
|
||||
export interface StateShareUser {
|
||||
id: string;
|
||||
status: ShareUserStatus;
|
||||
user: StateShareUserUser;
|
||||
}
|
||||
|
||||
export interface StateShare {
|
||||
id: string;
|
||||
type: number;
|
||||
folder_id: string;
|
||||
note_id: string;
|
||||
user?: StateShareUserUser;
|
||||
}
|
||||
|
||||
export interface ShareInvitation {
|
||||
id: string;
|
||||
share: StateShare;
|
||||
status: ShareUserStatus;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
shares: StateShare[];
|
||||
shareUsers: Record<string, StateShareUser>;
|
||||
shareInvitations: ShareInvitation[];
|
||||
}
|
||||
|
||||
export const stateRootKey = 'shareService';
|
||||
|
||||
export const defaultState: State = {
|
||||
shares: [],
|
||||
shareUsers: {},
|
||||
shareInvitations: [],
|
||||
};
|
||||
|
||||
export function isSharedFolderOwner(state: RootState, folderId: string): boolean {
|
||||
const userId = state.settings['sync.userId'];
|
||||
const share = state[stateRootKey].shares.find(s => s.folder_id === folderId);
|
||||
if (!share) return false;
|
||||
return share.user.id === userId;
|
||||
}
|
||||
|
||||
export function isRootSharedFolder(folder: FolderEntity): boolean {
|
||||
return !!folder.share_id && !folder.parent_id;
|
||||
}
|
||||
|
||||
const reducer = (draftRoot: Draft<RootState>, action: any) => {
|
||||
if (action.type.indexOf('SHARE_') !== 0) return;
|
||||
|
||||
const draft = draftRoot.shareService;
|
||||
|
||||
try {
|
||||
switch (action.type) {
|
||||
|
||||
case 'SHARE_SET':
|
||||
|
||||
draft.shares = action.shares;
|
||||
break;
|
||||
|
||||
case 'SHARE_USER_SET':
|
||||
|
||||
draft.shareUsers[action.shareId] = action.shareUsers;
|
||||
break;
|
||||
|
||||
case 'SHARE_INVITATION_SET':
|
||||
|
||||
draft.shareInvitations = action.shareInvitations;
|
||||
break;
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
error.message = `In share reducer: ${error.message} Action: ${JSON.stringify(action)}`;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default reducer;
|
@@ -1,7 +1,7 @@
|
||||
import { Dirnames } from './utils/types';
|
||||
import shim from '../../shim';
|
||||
|
||||
const JoplinError = require('../../JoplinError');
|
||||
import JoplinError from '../../JoplinError';
|
||||
import time from '../../time';
|
||||
const { fileExtension, filename } = require('../../path-utils');
|
||||
|
||||
|
@@ -15,7 +15,7 @@ const migrations = [
|
||||
|
||||
import Setting from '../../models/Setting';
|
||||
const { sprintf } = require('sprintf-js');
|
||||
const JoplinError = require('../../JoplinError');
|
||||
import JoplinError from '../../JoplinError';
|
||||
|
||||
interface SyncTargetInfo {
|
||||
version: number;
|
||||
|
@@ -24,6 +24,7 @@ const theme: Theme = {
|
||||
color2: '#ffffff',
|
||||
selectedColor2: '#013F74',
|
||||
colorError2: '#ff6c6c',
|
||||
colorWarn2: '#ffcb81',
|
||||
|
||||
// Color scheme "3" is used for the config screens for example/
|
||||
// It's dark text over gray background.
|
||||
|
@@ -8,7 +8,7 @@ const theme: Theme = {
|
||||
// content. It's basically dark gray text on white background
|
||||
backgroundColor: '#ffffff',
|
||||
backgroundColorTransparent: 'rgba(255,255,255,0.9)',
|
||||
oddBackgroundColor: '#dddddd',
|
||||
oddBackgroundColor: '#eeeeee',
|
||||
color: '#32373F', // For regular text
|
||||
colorError: 'red',
|
||||
colorWarn: '#9A5B00',
|
||||
@@ -24,6 +24,7 @@ const theme: Theme = {
|
||||
color2: '#ffffff',
|
||||
selectedColor2: '#131313',
|
||||
colorError2: '#ff6c6c',
|
||||
colorWarn2: '#ffcb81',
|
||||
|
||||
// Color scheme "3" is used for the config screens for example/
|
||||
// It's dark text over gray background.
|
||||
|
@@ -26,6 +26,7 @@ export interface Theme {
|
||||
color2: string;
|
||||
selectedColor2: string;
|
||||
colorError2: string;
|
||||
colorWarn2: string;
|
||||
|
||||
// Color scheme "3" is used for the config screens for example/
|
||||
// It's dark text over gray background.
|
||||
|
7
packages/server/LICENSE
Normal file
7
packages/server/LICENSE
Normal file
@@ -0,0 +1,7 @@
|
||||
Copyright (c) 2017-2021 Laurent Cozic
|
||||
|
||||
Personal Use License
|
||||
|
||||
Joplin Server is available for personal use only. For example you may host the software on your own server for non-commercial activity.
|
||||
|
||||
To obtain a license for commercial purposes, please contact us.
|
317
packages/server/package-lock.json
generated
317
packages/server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,12 @@
|
||||
"scripts": {
|
||||
"start-dev": "nodemon --config nodemon.json dist/app.js --env dev",
|
||||
"start": "node dist/app.js",
|
||||
"generateTypes": "rm -f db-buildTypes.sqlite && npm run start -- --migrate-db --env buildTypes && node dist/tools/generateTypes.js && rm -f db-buildTypes.sqlite",
|
||||
"generateTypes": "rm -f db-buildTypes.sqlite && npm run start -- --migrate-db --env buildTypes && node dist/tools/generateTypes.js && mv db-buildTypes.sqlite schema.sqlite",
|
||||
"tsc": "tsc --project tsconfig.json",
|
||||
"test": "jest",
|
||||
"test": "jest --verbose=false",
|
||||
"test-ci": "npm run test",
|
||||
"test-debug": "node --inspect node_modules/.bin/jest -- --verbose=false",
|
||||
"clean": "rm -rf dist/",
|
||||
"watch": "tsc --watch --project tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -22,7 +24,7 @@
|
||||
"formidable": "^1.2.2",
|
||||
"fs-extra": "^8.1.0",
|
||||
"html-entities": "^1.3.1",
|
||||
"knex": "^0.19.4",
|
||||
"knex": "0.95.4",
|
||||
"koa": "^2.8.1",
|
||||
"markdown-it": "^12.0.4",
|
||||
"mustache": "^3.1.0",
|
||||
|
@@ -10,12 +10,12 @@ function addPluginAssets(appBaseUrl, assets) {
|
||||
|
||||
if (asset.mime === 'application/javascript') {
|
||||
const script = document.createElement('script');
|
||||
script.src = `${appBaseUrl}/${asset.path}`;
|
||||
script.src = `${appBaseUrl}/js/${asset.path}`;
|
||||
pluginAssetsContainer.appendChild(script);
|
||||
} else if (asset.mime === 'text/css') {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = `${appBaseUrl}/${asset.path}`;
|
||||
link.href = `${appBaseUrl}/css/${asset.path}`;
|
||||
pluginAssetsContainer.appendChild(link);
|
||||
}
|
||||
}
|
BIN
packages/server/schema.sqlite
Normal file
BIN
packages/server/schema.sqlite
Normal file
Binary file not shown.
@@ -14,6 +14,9 @@ import routeHandler from './middleware/routeHandler';
|
||||
import notificationHandler from './middleware/notificationHandler';
|
||||
import ownerHandler from './middleware/ownerHandler';
|
||||
import setupAppContext from './utils/setupAppContext';
|
||||
import { initializeJoplinUtils } from './utils/joplinUtils';
|
||||
import startServices from './utils/startServices';
|
||||
// import { createItemTree } from './utils/testing/testUtils';
|
||||
|
||||
const nodeEnvFile = require('node-env-file');
|
||||
const { shimInit } = require('@joplin/lib/shim-init-node.js');
|
||||
@@ -126,12 +129,46 @@ async function main() {
|
||||
const appContext = app.context as AppContext;
|
||||
|
||||
await setupAppContext(appContext, env, connectionCheck.connection, appLogger);
|
||||
await initializeJoplinUtils(config(), appContext.models);
|
||||
|
||||
appLogger().info('Migrating database...');
|
||||
await migrateDb(appContext.db);
|
||||
|
||||
appLogger().info('Starting services...');
|
||||
await startServices(appContext);
|
||||
|
||||
// if (env !== Env.Prod) {
|
||||
// const done = await handleDebugCommands(argv, appContext.db, config());
|
||||
// if (done) {
|
||||
// appLogger().info('Debug command has been executed. Now starting server...');
|
||||
// }
|
||||
// }
|
||||
|
||||
appLogger().info(`Call this for testing: \`curl ${config().baseUrl}/api/ping\``);
|
||||
|
||||
// const tree: any = {
|
||||
// '000000000000000000000000000000F1': {},
|
||||
// '000000000000000000000000000000F2': {
|
||||
// '00000000000000000000000000000001': null,
|
||||
// '00000000000000000000000000000002': null,
|
||||
// },
|
||||
// '000000000000000000000000000000F3': {
|
||||
// '00000000000000000000000000000003': null,
|
||||
// '000000000000000000000000000000F4': {
|
||||
// '00000000000000000000000000000004': null,
|
||||
// '00000000000000000000000000000005': null,
|
||||
// },
|
||||
// },
|
||||
// '00000000000000000000000000000006': null,
|
||||
// '00000000000000000000000000000007': null,
|
||||
// };
|
||||
|
||||
// const users = await appContext.models.user().all();
|
||||
|
||||
// const itemModel = appContext.models.item({ userId: users[0].id });
|
||||
|
||||
// await createItemTree(itemModel, '', tree);
|
||||
|
||||
app.listen(config().port);
|
||||
}
|
||||
}
|
||||
|
@@ -1,278 +0,0 @@
|
||||
import JoplinDatabase from '@joplin/lib/JoplinDatabase';
|
||||
import Logger from '@joplin/lib/Logger';
|
||||
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
|
||||
import BaseItem from '@joplin/lib/models/BaseItem';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import { File, Share, Uuid } from '../../db';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import { MarkupToHtml } from '@joplin/renderer';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
import FileModel from '../../models/FileModel';
|
||||
import { ErrorNotFound } from '../../utils/errors';
|
||||
import BaseApplication from '../../services/BaseApplication';
|
||||
import { formatDateTime } from '../../utils/time';
|
||||
import { OptionsResourceModel } from '@joplin/renderer/MarkupToHtml';
|
||||
const { DatabaseDriverNode } = require('@joplin/lib/database-driver-node.js');
|
||||
const { themeStyle } = require('@joplin/lib/theme');
|
||||
|
||||
const logger = Logger.create('JoplinApp');
|
||||
|
||||
export interface FileViewerResponse {
|
||||
body: any;
|
||||
mime: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface ResourceInfo {
|
||||
localState: any;
|
||||
item: any;
|
||||
}
|
||||
|
||||
interface LinkedItemInfo {
|
||||
item: any;
|
||||
file: File;
|
||||
}
|
||||
|
||||
type LinkedItemInfos = Record<Uuid, LinkedItemInfo>;
|
||||
|
||||
type ResourceInfos = Record<Uuid, ResourceInfo>;
|
||||
|
||||
export default class Application extends BaseApplication {
|
||||
|
||||
// Although we don't use the database to store data, we still need to setup
|
||||
// so that its schema can be accessed. This is needed for example by
|
||||
// Note.unserialize to know what fields are valid for a note, and to format
|
||||
// the field values correctly.
|
||||
private db_: JoplinDatabase;
|
||||
|
||||
private pluginAssetRootDir_: string;
|
||||
|
||||
public async initialize() {
|
||||
this.mustache.prefersDarkEnabled = false;
|
||||
this.pluginAssetRootDir_ = require('path').resolve(__dirname, '../../..', 'node_modules/@joplin/renderer/assets');
|
||||
|
||||
const filePath = `${this.config.tempDir}/joplin.sqlite`;
|
||||
|
||||
this.db_ = new JoplinDatabase(new DatabaseDriverNode());
|
||||
this.db_.setLogger(logger as Logger);
|
||||
await this.db_.open({ name: filePath });
|
||||
|
||||
BaseModel.setDb(this.db_);
|
||||
|
||||
// Only load the classes that will be needed to render the notes and
|
||||
// resources.
|
||||
BaseItem.loadClass('Note', Note);
|
||||
BaseItem.loadClass('Resource', Resource);
|
||||
}
|
||||
|
||||
public async localFileFromUrl(url: string): Promise<string> {
|
||||
const pluginAssetPrefix = 'apps/joplin/pluginAssets/';
|
||||
|
||||
if (url.indexOf(pluginAssetPrefix) === 0) {
|
||||
return `${this.pluginAssetRootDir_}/${url.substr(pluginAssetPrefix.length)}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private itemIdFilename(itemId: string): string {
|
||||
return `${itemId}.md`;
|
||||
}
|
||||
|
||||
private async itemMetadataFile(parentId: Uuid, itemId: string): Promise<File | null> {
|
||||
const file = await this.models.file().fileByName(parentId, this.itemIdFilename(itemId), { skipPermissionCheck: true });
|
||||
if (!file) {
|
||||
// We don't throw an error because it can happen if the note
|
||||
// contains an invalid link to a resource or note.
|
||||
logger.error(`Could not find item with ID "${itemId}" on parent "${parentId}"`);
|
||||
return null;
|
||||
}
|
||||
return this.models.file().loadWithContent(file.id, { skipPermissionCheck: true });
|
||||
}
|
||||
|
||||
private async unserializeItem(file: File): Promise<any> {
|
||||
const content = file.content.toString();
|
||||
return BaseItem.unserialize(content);
|
||||
}
|
||||
|
||||
private async resourceInfos(linkedItemInfos: LinkedItemInfos): Promise<ResourceInfos> {
|
||||
const output: Record<string, any> = {};
|
||||
|
||||
for (const itemId of Object.keys(linkedItemInfos)) {
|
||||
const info = linkedItemInfos[itemId];
|
||||
|
||||
if (info.item.type_ !== ModelType.Resource) continue;
|
||||
|
||||
output[info.item.id] = {
|
||||
item: info.item,
|
||||
localState: {
|
||||
fetch_status: Resource.FETCH_STATUS_DONE,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private async noteLinkedItemInfos(noteFileParentId: string, note: NoteEntity): Promise<LinkedItemInfos> {
|
||||
const itemIds = await Note.linkedItemIds(note.body);
|
||||
const output: LinkedItemInfos = {};
|
||||
|
||||
for (const itemId of itemIds) {
|
||||
const itemFile = await this.itemMetadataFile(noteFileParentId, itemId);
|
||||
if (!itemFile) continue;
|
||||
|
||||
output[itemId] = {
|
||||
item: await this.unserializeItem(itemFile),
|
||||
file: itemFile,
|
||||
};
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private async resourceDir(fileModel: FileModel, parentId: Uuid): Promise<File> {
|
||||
const parent = await fileModel.load(parentId);
|
||||
const parentFullPath = await fileModel.itemFullPath(parent);
|
||||
const dirPath = fileModel.resolve(parentFullPath, '.resource');
|
||||
return fileModel.pathToFile(dirPath);
|
||||
}
|
||||
|
||||
private async itemFile(fileModel: FileModel, parentId: Uuid, itemType: ModelType, itemId: string): Promise<File> {
|
||||
let output: File = null;
|
||||
|
||||
if (itemType === ModelType.Resource) {
|
||||
const resourceDir = await this.resourceDir(fileModel, parentId);
|
||||
output = await fileModel.fileByName(resourceDir.id, itemId);
|
||||
} else if (itemType === ModelType.Note) {
|
||||
output = await fileModel.fileByName(parentId, this.itemIdFilename(itemId));
|
||||
} else {
|
||||
throw new Error(`Unsupported type: ${itemType}`);
|
||||
}
|
||||
|
||||
return fileModel.loadWithContent(output.id);
|
||||
}
|
||||
|
||||
private async renderResource(file: File): Promise<FileViewerResponse> {
|
||||
return {
|
||||
body: file.content,
|
||||
mime: file.mime_type,
|
||||
size: file.size,
|
||||
};
|
||||
}
|
||||
|
||||
private async renderNote(share: Share, note: NoteEntity, resourceInfos: ResourceInfos, linkedItemInfos: LinkedItemInfos): Promise<FileViewerResponse> {
|
||||
const markupToHtml = new MarkupToHtml({
|
||||
ResourceModel: Resource as OptionsResourceModel,
|
||||
});
|
||||
|
||||
const renderOptions: any = {
|
||||
resources: resourceInfos,
|
||||
|
||||
itemIdToUrl: (itemId: Uuid) => {
|
||||
if (!linkedItemInfos[itemId]) return '#';
|
||||
|
||||
const item = linkedItemInfos[itemId].item;
|
||||
if (!item) throw new Error(`No such item in this note: ${itemId}`);
|
||||
|
||||
if (item.type_ === ModelType.Note) {
|
||||
return '#';
|
||||
} else if (item.type_ === ModelType.Resource) {
|
||||
return `${this.models.share().shareUrl(share.id)}?resource_id=${item.id}&t=${item.updated_time}`;
|
||||
} else {
|
||||
throw new Error(`Unsupported item type: ${item.type_}`);
|
||||
}
|
||||
},
|
||||
|
||||
// Switch-off the media players because there's no option to toggle
|
||||
// them on and off.
|
||||
audioPlayerEnabled: false,
|
||||
videoPlayerEnabled: false,
|
||||
pdfViewerEnabled: false,
|
||||
};
|
||||
|
||||
const result = await markupToHtml.render(note.markup_language, note.body, themeStyle(Setting.THEME_LIGHT), renderOptions);
|
||||
|
||||
const bodyHtml = await this.mustache.renderView({
|
||||
cssFiles: ['note'],
|
||||
jsFiles: ['note'],
|
||||
name: 'note',
|
||||
path: 'note',
|
||||
content: {
|
||||
note: {
|
||||
...note,
|
||||
bodyHtml: result.html,
|
||||
updatedDateTime: formatDateTime(note.updated_time),
|
||||
},
|
||||
cssStrings: result.cssStrings.join('\n'),
|
||||
assetsJs: `
|
||||
const joplinNoteViewer = {
|
||||
pluginAssets: ${JSON.stringify(result.pluginAssets)},
|
||||
appBaseUrl: ${JSON.stringify(this.appBaseUrl)},
|
||||
};
|
||||
`,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
body: bodyHtml,
|
||||
mime: 'text/html',
|
||||
size: bodyHtml.length,
|
||||
};
|
||||
}
|
||||
|
||||
public async renderFile(file: File, share: Share, query: Record<string, any>): Promise<FileViewerResponse> {
|
||||
const fileModel = this.models.file({ userId: file.owner_id });
|
||||
|
||||
const rootNote: NoteEntity = await this.unserializeItem(file);
|
||||
const linkedItemInfos = await this.noteLinkedItemInfos(file.parent_id, rootNote);
|
||||
const resourceInfos = await this.resourceInfos(linkedItemInfos);
|
||||
|
||||
const fileToRender = {
|
||||
file: file,
|
||||
itemId: rootNote.id,
|
||||
};
|
||||
|
||||
if (query.resource_id) {
|
||||
fileToRender.file = await this.itemFile(fileModel, file.parent_id, ModelType.Resource, query.resource_id);
|
||||
fileToRender.itemId = query.resource_id;
|
||||
}
|
||||
|
||||
// No longer supported - need to decide what to do about note links.
|
||||
|
||||
// if (query.note_id) {
|
||||
// fileToRender.file = await this.itemFile(fileModel, file.parent_id, ModelType.Note, query.note_id);
|
||||
// fileToRender.itemId = query.note_id;
|
||||
// }
|
||||
|
||||
if (fileToRender.file !== file && !linkedItemInfos[fileToRender.itemId]) {
|
||||
throw new ErrorNotFound(`Item "${fileToRender.itemId}" does not belong to this note`);
|
||||
}
|
||||
|
||||
const itemToRender = fileToRender.file === file ? rootNote : linkedItemInfos[fileToRender.itemId].item;
|
||||
const itemType: ModelType = itemToRender.type_;
|
||||
|
||||
if (itemType === ModelType.Resource) {
|
||||
return this.renderResource(fileToRender.file);
|
||||
} else if (itemType === ModelType.Note) {
|
||||
return this.renderNote(share, itemToRender, resourceInfos, linkedItemInfos);
|
||||
} else {
|
||||
throw new Error(`Cannot render item with type "${itemType}"`);
|
||||
}
|
||||
}
|
||||
|
||||
public async isItemFile(file: File): Promise<boolean> {
|
||||
if (file.mime_type !== 'text/markdown') return false;
|
||||
|
||||
try {
|
||||
await this.unserializeItem(file);
|
||||
} catch (error) {
|
||||
// No need to log - it means it's not a note file
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
@@ -67,7 +67,7 @@ function baseUrlFromEnv(env: any, appPort: number): string {
|
||||
|
||||
let config_: Config = null;
|
||||
|
||||
export function initConfig(env: EnvVariables) {
|
||||
export function initConfig(env: EnvVariables, overrides: any = null) {
|
||||
runningInDocker_ = !!env.RUNNING_IN_DOCKER;
|
||||
|
||||
const rootDir = pathUtils.dirname(__dirname);
|
||||
@@ -83,6 +83,7 @@ export function initConfig(env: EnvVariables) {
|
||||
database: databaseConfigFromEnv(runningInDocker_, env),
|
||||
port: appPort,
|
||||
baseUrl: baseUrlFromEnv(env, appPort),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import * as Knex from 'knex';
|
||||
import { knex, Knex } from 'knex';
|
||||
import { DatabaseConfig } from './utils/types';
|
||||
import * as pathUtils from 'path';
|
||||
import time from '@joplin/lib/time';
|
||||
@@ -98,7 +98,7 @@ export async function waitForConnection(dbConfig: DatabaseConfig): Promise<Conne
|
||||
}
|
||||
|
||||
export async function connectDb(dbConfig: DatabaseConfig): Promise<DbConnection> {
|
||||
return require('knex')(makeKnexConfig(dbConfig));
|
||||
return knex(makeKnexConfig(dbConfig));
|
||||
}
|
||||
|
||||
export async function disconnectDb(db: DbConnection) {
|
||||
@@ -148,7 +148,19 @@ function isNoSuchTableError(error: any): boolean {
|
||||
if (error.code === '42P01') return true;
|
||||
|
||||
// Sqlite3 error
|
||||
if (error.message && error.message.includes('no such table: knex_migrations')) return true;
|
||||
if (error.message && error.message.includes('SQLITE_ERROR: no such table:')) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isUniqueConstraintError(error: any): boolean {
|
||||
if (error) {
|
||||
// Postgres error: 23505: unique_violation
|
||||
if (error.code === '23505') return true;
|
||||
|
||||
// Sqlite3 error
|
||||
if (error.code === 'SQLITE_CONSTRAINT' && error.message.includes('UNIQUE constraint')) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -201,8 +213,9 @@ export enum NotificationLevel {
|
||||
}
|
||||
|
||||
export enum ItemType {
|
||||
File = 1,
|
||||
User,
|
||||
Item = 1,
|
||||
UserItem = 2,
|
||||
User,
|
||||
}
|
||||
|
||||
export enum ChangeType {
|
||||
@@ -211,6 +224,11 @@ export enum ChangeType {
|
||||
Delete = 3,
|
||||
}
|
||||
|
||||
export enum FileContentType {
|
||||
Any = 1,
|
||||
JoplinItem = 2,
|
||||
}
|
||||
|
||||
export function changeTypeToString(t: ChangeType): string {
|
||||
if (t === ChangeType.Create) return 'create';
|
||||
if (t === ChangeType.Update) return 'update';
|
||||
@@ -221,6 +239,13 @@ export function changeTypeToString(t: ChangeType): string {
|
||||
export enum ShareType {
|
||||
Link = 1, // When a note is shared via a public link
|
||||
App = 2, // When a note is shared with another user on the same server instance
|
||||
JoplinRootFolder = 3,
|
||||
}
|
||||
|
||||
export enum ShareUserStatus {
|
||||
Waiting = 0,
|
||||
Accepted = 1,
|
||||
Rejected = 2,
|
||||
}
|
||||
|
||||
export interface WithDates {
|
||||
@@ -229,7 +254,7 @@ export interface WithDates {
|
||||
}
|
||||
|
||||
export interface WithUuid {
|
||||
id?: string;
|
||||
id?: Uuid;
|
||||
}
|
||||
|
||||
interface DatabaseTableColumn {
|
||||
@@ -258,33 +283,21 @@ export interface Session extends WithDates, WithUuid {
|
||||
auth_code?: string;
|
||||
}
|
||||
|
||||
export interface Permission extends WithDates, WithUuid {
|
||||
user_id?: Uuid;
|
||||
item_type?: ItemType;
|
||||
item_id?: Uuid;
|
||||
can_read?: number;
|
||||
can_write?: number;
|
||||
}
|
||||
|
||||
export interface File extends WithDates, WithUuid {
|
||||
export interface File {
|
||||
id?: Uuid;
|
||||
owner_id?: Uuid;
|
||||
name?: string;
|
||||
content?: Buffer;
|
||||
content?: any;
|
||||
mime_type?: string;
|
||||
size?: number;
|
||||
is_directory?: number;
|
||||
is_root?: number;
|
||||
parent_id?: Uuid;
|
||||
}
|
||||
|
||||
export interface Change extends WithDates, WithUuid {
|
||||
counter?: number;
|
||||
owner_id?: Uuid;
|
||||
item_type?: ItemType;
|
||||
parent_id?: Uuid;
|
||||
item_id?: Uuid;
|
||||
item_name?: string;
|
||||
type?: ChangeType;
|
||||
updated_time?: string;
|
||||
created_time?: string;
|
||||
source_file_id?: Uuid;
|
||||
content_type?: number;
|
||||
content_id?: Uuid;
|
||||
}
|
||||
|
||||
export interface ApiClient extends WithDates, WithUuid {
|
||||
@@ -301,10 +314,59 @@ export interface Notification extends WithDates, WithUuid {
|
||||
canBeDismissed?: number;
|
||||
}
|
||||
|
||||
export interface ShareUser extends WithDates, WithUuid {
|
||||
share_id?: Uuid;
|
||||
user_id?: Uuid;
|
||||
status?: ShareUserStatus;
|
||||
}
|
||||
|
||||
export interface Item extends WithDates, WithUuid {
|
||||
name?: string;
|
||||
mime_type?: string;
|
||||
content?: Buffer;
|
||||
content_size?: number;
|
||||
jop_id?: Uuid;
|
||||
jop_parent_id?: Uuid;
|
||||
jop_share_id?: Uuid;
|
||||
jop_type?: number;
|
||||
jop_encryption_applied?: number;
|
||||
}
|
||||
|
||||
export interface UserItem extends WithDates {
|
||||
id?: number;
|
||||
user_id?: Uuid;
|
||||
item_id?: Uuid;
|
||||
}
|
||||
|
||||
export interface ItemResource {
|
||||
id?: number;
|
||||
item_id?: Uuid;
|
||||
resource_id?: Uuid;
|
||||
}
|
||||
|
||||
export interface KeyValue {
|
||||
id?: number;
|
||||
key?: string;
|
||||
type?: number;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface Share extends WithDates, WithUuid {
|
||||
owner_id?: Uuid;
|
||||
file_id?: Uuid;
|
||||
item_id?: Uuid;
|
||||
type?: ShareType;
|
||||
folder_id?: Uuid;
|
||||
note_id?: Uuid;
|
||||
}
|
||||
|
||||
export interface Change extends WithDates, WithUuid {
|
||||
counter?: number;
|
||||
item_type?: ItemType;
|
||||
item_id?: Uuid;
|
||||
item_name?: string;
|
||||
type?: ChangeType;
|
||||
previous_item?: string;
|
||||
user_id?: Uuid;
|
||||
}
|
||||
|
||||
export const databaseSchema: DatabaseTables = {
|
||||
@@ -324,16 +386,6 @@ export const databaseSchema: DatabaseTables = {
|
||||
updated_time: { type: 'string' },
|
||||
created_time: { type: 'string' },
|
||||
},
|
||||
permissions: {
|
||||
id: { type: 'string' },
|
||||
user_id: { type: 'string' },
|
||||
item_type: { type: 'number' },
|
||||
item_id: { type: 'string' },
|
||||
can_read: { type: 'number' },
|
||||
can_write: { type: 'number' },
|
||||
updated_time: { type: 'string' },
|
||||
created_time: { type: 'string' },
|
||||
},
|
||||
files: {
|
||||
id: { type: 'string' },
|
||||
owner_id: { type: 'string' },
|
||||
@@ -346,18 +398,9 @@ export const databaseSchema: DatabaseTables = {
|
||||
parent_id: { type: 'string' },
|
||||
updated_time: { type: 'string' },
|
||||
created_time: { type: 'string' },
|
||||
},
|
||||
changes: {
|
||||
counter: { type: 'number' },
|
||||
id: { type: 'string' },
|
||||
owner_id: { type: 'string' },
|
||||
item_type: { type: 'number' },
|
||||
parent_id: { type: 'string' },
|
||||
item_id: { type: 'string' },
|
||||
item_name: { type: 'string' },
|
||||
type: { type: 'number' },
|
||||
updated_time: { type: 'string' },
|
||||
created_time: { type: 'string' },
|
||||
source_file_id: { type: 'string' },
|
||||
content_type: { type: 'number' },
|
||||
content_id: { type: 'string' },
|
||||
},
|
||||
api_clients: {
|
||||
id: { type: 'string' },
|
||||
@@ -377,13 +420,67 @@ export const databaseSchema: DatabaseTables = {
|
||||
updated_time: { type: 'string' },
|
||||
created_time: { type: 'string' },
|
||||
},
|
||||
shares: {
|
||||
share_users: {
|
||||
id: { type: 'string' },
|
||||
owner_id: { type: 'string' },
|
||||
file_id: { type: 'string' },
|
||||
type: { type: 'number' },
|
||||
share_id: { type: 'string' },
|
||||
user_id: { type: 'string' },
|
||||
status: { type: 'number' },
|
||||
updated_time: { type: 'string' },
|
||||
created_time: { type: 'string' },
|
||||
},
|
||||
items: {
|
||||
id: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
mime_type: { type: 'string' },
|
||||
updated_time: { type: 'string' },
|
||||
created_time: { type: 'string' },
|
||||
content: { type: 'any' },
|
||||
content_size: { type: 'number' },
|
||||
jop_id: { type: 'string' },
|
||||
jop_parent_id: { type: 'string' },
|
||||
jop_share_id: { type: 'string' },
|
||||
jop_type: { type: 'number' },
|
||||
jop_encryption_applied: { type: 'number' },
|
||||
},
|
||||
user_items: {
|
||||
id: { type: 'number' },
|
||||
user_id: { type: 'string' },
|
||||
item_id: { type: 'string' },
|
||||
updated_time: { type: 'string' },
|
||||
created_time: { type: 'string' },
|
||||
},
|
||||
item_resources: {
|
||||
id: { type: 'number' },
|
||||
item_id: { type: 'string' },
|
||||
resource_id: { type: 'string' },
|
||||
},
|
||||
key_values: {
|
||||
id: { type: 'number' },
|
||||
key: { type: 'string' },
|
||||
type: { type: 'number' },
|
||||
value: { type: 'string' },
|
||||
},
|
||||
shares: {
|
||||
id: { type: 'string' },
|
||||
owner_id: { type: 'string' },
|
||||
item_id: { type: 'string' },
|
||||
type: { type: 'number' },
|
||||
updated_time: { type: 'string' },
|
||||
created_time: { type: 'string' },
|
||||
folder_id: { type: 'string' },
|
||||
note_id: { type: 'string' },
|
||||
},
|
||||
changes: {
|
||||
counter: { type: 'number' },
|
||||
id: { type: 'string' },
|
||||
item_type: { type: 'number' },
|
||||
item_id: { type: 'string' },
|
||||
item_name: { type: 'string' },
|
||||
type: { type: 'number' },
|
||||
updated_time: { type: 'string' },
|
||||
created_time: { type: 'string' },
|
||||
previous_item: { type: 'string' },
|
||||
user_id: { type: 'string' },
|
||||
},
|
||||
};
|
||||
// AUTO-GENERATED-TYPES
|
||||
|
@@ -17,9 +17,9 @@ describe('notificationHandler', function() {
|
||||
});
|
||||
|
||||
test('should check admin password', async function() {
|
||||
const { user, session } = await createUserAndSession(1, true);
|
||||
const { session } = await createUserAndSession(1, true);
|
||||
|
||||
const admin = await models().user({ userId: user.id }).save({
|
||||
const admin = await models().user().save({
|
||||
email: defaultAdminEmail,
|
||||
password: defaultAdminPassword,
|
||||
is_admin: 1,
|
||||
@@ -38,7 +38,7 @@ describe('notificationHandler', function() {
|
||||
}
|
||||
|
||||
{
|
||||
await models().user({ userId: admin.id }).save({
|
||||
await models().user().save({
|
||||
id: admin.id,
|
||||
password: 'changed!',
|
||||
});
|
||||
|
@@ -12,20 +12,22 @@ async function handleChangeAdminPasswordNotification(ctx: AppContext) {
|
||||
if (!ctx.owner.is_admin) return;
|
||||
|
||||
const defaultAdmin = await ctx.models.user().login(defaultAdminEmail, defaultAdminPassword);
|
||||
const notificationModel = ctx.models.notification({ userId: ctx.owner.id });
|
||||
const notificationModel = ctx.models.notification();
|
||||
|
||||
if (defaultAdmin) {
|
||||
await notificationModel.add(
|
||||
ctx.owner.id,
|
||||
'change_admin_password',
|
||||
NotificationLevel.Important,
|
||||
_('The default admin password is insecure and has not been changed! [Change it now](%s)', await ctx.models.user().profileUrl())
|
||||
);
|
||||
} else {
|
||||
await notificationModel.markAsRead('change_admin_password');
|
||||
await notificationModel.markAsRead(ctx.owner.id, 'change_admin_password');
|
||||
}
|
||||
|
||||
if (config().database.client === 'sqlite3' && ctx.env === 'prod') {
|
||||
await notificationModel.add(
|
||||
ctx.owner.id,
|
||||
'using_sqlite_in_prod',
|
||||
NotificationLevel.Important,
|
||||
'The server is currently using SQLite3 as a database. It is not recommended in production as it is slow and can cause locking issues. Please see the README for information on how to change it.'
|
||||
@@ -36,10 +38,11 @@ async function handleChangeAdminPasswordNotification(ctx: AppContext) {
|
||||
async function handleSqliteInProdNotification(ctx: AppContext) {
|
||||
if (!ctx.owner.is_admin) return;
|
||||
|
||||
const notificationModel = ctx.models.notification({ userId: ctx.owner.id });
|
||||
const notificationModel = ctx.models.notification();
|
||||
|
||||
if (config().database.client === 'sqlite3' && ctx.env === 'prod') {
|
||||
await notificationModel.add(
|
||||
ctx.owner.id,
|
||||
'using_sqlite_in_prod',
|
||||
NotificationLevel.Important,
|
||||
'The server is currently using SQLite3 as a database. It is not recommended in production as it is slow and can cause locking issues. Please see the README for information on how to change it.'
|
||||
@@ -50,7 +53,7 @@ async function handleSqliteInProdNotification(ctx: AppContext) {
|
||||
async function makeNotificationViews(ctx: AppContext): Promise<NotificationView[]> {
|
||||
const markdownIt = new MarkdownIt();
|
||||
|
||||
const notificationModel = ctx.models.notification({ userId: ctx.owner.id });
|
||||
const notificationModel = ctx.models.notification();
|
||||
const notifications = await notificationModel.allUnreadByUserId(ctx.owner.id);
|
||||
const views: NotificationView[] = [];
|
||||
for (const n of notifications) {
|
||||
|
@@ -1,7 +1,5 @@
|
||||
import routes from '../routes/routes';
|
||||
import { ErrorForbidden, ErrorNotFound } from '../utils/errors';
|
||||
import { routeResponseFormat, findMatchingRoute, Response, RouteResponseFormat, MatchedRoute } from '../utils/routeUtils';
|
||||
import { AppContext, Env, HttpMethod } from '../utils/types';
|
||||
import { routeResponseFormat, Response, RouteResponseFormat, execRequest } from '../utils/routeUtils';
|
||||
import { AppContext, Env } from '../utils/types';
|
||||
import MustacheService, { isView, View } from '../services/MustacheService';
|
||||
import config from '../config';
|
||||
|
||||
@@ -16,38 +14,21 @@ function mustache(): MustacheService {
|
||||
export default async function(ctx: AppContext) {
|
||||
ctx.appLogger().info(`${ctx.request.method} ${ctx.path}`);
|
||||
|
||||
const match: MatchedRoute = null;
|
||||
|
||||
try {
|
||||
const match = findMatchingRoute(ctx.path, routes);
|
||||
const responseObject = await execRequest(ctx.routes, ctx);
|
||||
|
||||
if (match) {
|
||||
let responseObject = null;
|
||||
|
||||
const routeHandler = match.route.findEndPoint(ctx.request.method as HttpMethod, match.subPath.schema);
|
||||
|
||||
// This is a generic catch-all for all private end points - if we
|
||||
// couldn't get a valid session, we exit now. Individual end points
|
||||
// might have additional permission checks depending on the action.
|
||||
if (!match.route.public && !ctx.owner) throw new ErrorForbidden();
|
||||
|
||||
responseObject = await routeHandler(match.subPath, ctx);
|
||||
|
||||
if (responseObject instanceof Response) {
|
||||
ctx.response = responseObject.response;
|
||||
} else if (isView(responseObject)) {
|
||||
ctx.response.status = 200;
|
||||
ctx.response.body = await mustache().renderView(responseObject, {
|
||||
notifications: ctx.notifications || [],
|
||||
hasNotifications: !!ctx.notifications && !!ctx.notifications.length,
|
||||
owner: ctx.owner,
|
||||
});
|
||||
} else {
|
||||
ctx.response.status = 200;
|
||||
ctx.response.body = [undefined, null].includes(responseObject) ? '' : responseObject;
|
||||
}
|
||||
if (responseObject instanceof Response) {
|
||||
ctx.response = responseObject.response;
|
||||
} else if (isView(responseObject)) {
|
||||
ctx.response.status = 200;
|
||||
ctx.response.body = await mustache().renderView(responseObject, {
|
||||
notifications: ctx.notifications || [],
|
||||
hasNotifications: !!ctx.notifications && !!ctx.notifications.length,
|
||||
owner: ctx.owner,
|
||||
});
|
||||
} else {
|
||||
throw new ErrorNotFound();
|
||||
ctx.response.status = 200;
|
||||
ctx.response.body = [undefined, null].includes(responseObject) ? '' : responseObject;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.httpCode >= 400 && error.httpCode < 500) {
|
||||
@@ -56,9 +37,12 @@ export default async function(ctx: AppContext) {
|
||||
ctx.appLogger().error(error);
|
||||
}
|
||||
|
||||
// Uncomment this when getting HTML blobs as errors while running tests.
|
||||
// console.error(error);
|
||||
|
||||
ctx.response.status = error.httpCode ? error.httpCode : 500;
|
||||
|
||||
const responseFormat = routeResponseFormat(match, ctx);
|
||||
const responseFormat = routeResponseFormat(ctx);
|
||||
|
||||
if (responseFormat === RouteResponseFormat.Html) {
|
||||
ctx.response.set('Content-Type', 'text/html');
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import * as Knex from 'knex';
|
||||
import { Knex } from 'knex';
|
||||
import { DbConnection, defaultAdminEmail, defaultAdminPassword } from '../db';
|
||||
import { hashPassword } from '../utils/auth';
|
||||
import uuidgen from '../utils/uuidgen';
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import * as Knex from 'knex';
|
||||
import { Knex } from 'knex';
|
||||
import { DbConnection } from '../db';
|
||||
|
||||
export async function up(db: DbConnection): Promise<any> {
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import * as Knex from 'knex';
|
||||
import { Knex } from 'knex';
|
||||
import { DbConnection } from '../db';
|
||||
|
||||
export async function up(db: DbConnection): Promise<any> {
|
||||
|
45
packages/server/src/migrations/20210201143859_app_share.ts
Normal file
45
packages/server/src/migrations/20210201143859_app_share.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Knex } from 'knex';
|
||||
import { DbConnection } from '../db';
|
||||
|
||||
export async function up(db: DbConnection): Promise<any> {
|
||||
await db.schema.createTable('share_users', function(table: Knex.CreateTableBuilder) {
|
||||
table.string('id', 32).unique().primary().notNullable();
|
||||
table.string('share_id', 32).notNullable();
|
||||
table.string('user_id', 32).notNullable();
|
||||
table.integer('status').defaultTo(0).notNullable();
|
||||
table.bigInteger('updated_time').notNullable();
|
||||
table.bigInteger('created_time').notNullable();
|
||||
});
|
||||
|
||||
await db.schema.alterTable('share_users', function(table: Knex.CreateTableBuilder) {
|
||||
table.unique(['share_id', 'user_id']);
|
||||
});
|
||||
|
||||
await db.schema.alterTable('files', function(table: Knex.CreateTableBuilder) {
|
||||
table.string('source_file_id', 32).defaultTo('').notNullable();
|
||||
});
|
||||
|
||||
await db.schema.alterTable('files', function(table: Knex.CreateTableBuilder) {
|
||||
table.index(['owner_id']);
|
||||
table.index(['source_file_id']);
|
||||
});
|
||||
|
||||
await db.schema.alterTable('changes', function(table: Knex.CreateTableBuilder) {
|
||||
table.index(['item_id']);
|
||||
});
|
||||
|
||||
await db.schema.dropTable('shares');
|
||||
|
||||
await db.schema.createTable('shares', function(table: Knex.CreateTableBuilder) {
|
||||
table.string('id', 32).unique().primary().notNullable();
|
||||
table.string('owner_id', 32).notNullable();
|
||||
table.string('item_id', 32).notNullable();
|
||||
table.integer('type').notNullable();
|
||||
table.bigInteger('updated_time').notNullable();
|
||||
table.bigInteger('created_time').notNullable();
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(db: DbConnection): Promise<any> {
|
||||
await db.schema.dropTable('share_users');
|
||||
}
|
@@ -0,0 +1,25 @@
|
||||
import { Knex } from 'knex';
|
||||
import { DbConnection } from '../db';
|
||||
|
||||
export async function up(db: DbConnection): Promise<any> {
|
||||
await db.schema.createTable('joplin_file_contents', function(table: Knex.CreateTableBuilder) {
|
||||
table.string('id', 32).unique().primary().notNullable();
|
||||
table.string('owner_id', 32).notNullable();
|
||||
table.string('item_id', 32).notNullable();
|
||||
table.string('parent_id', 32).defaultTo('').notNullable();
|
||||
table.integer('type', 2).notNullable();
|
||||
table.bigInteger('updated_time').notNullable();
|
||||
table.bigInteger('created_time').notNullable();
|
||||
table.integer('encryption_applied', 1).notNullable();
|
||||
table.binary('content').defaultTo('').notNullable();
|
||||
});
|
||||
|
||||
await db.schema.alterTable('files', function(table: Knex.CreateTableBuilder) {
|
||||
table.integer('content_type', 2).defaultTo(1).notNullable();
|
||||
table.string('content_id', 32).defaultTo('').notNullable();
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(db: DbConnection): Promise<any> {
|
||||
await db.schema.dropTable('joplin_file_contents');
|
||||
}
|
@@ -0,0 +1,13 @@
|
||||
import { Knex } from 'knex';
|
||||
import { DbConnection } from '../db';
|
||||
|
||||
export async function up(db: DbConnection): Promise<any> {
|
||||
await db.schema.alterTable('shares', function(table: Knex.CreateTableBuilder) {
|
||||
table.string('folder_id', 32).defaultTo('').notNullable();
|
||||
table.integer('is_auto', 1).defaultTo(0).notNullable();
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(db: DbConnection): Promise<any> {
|
||||
await db.schema.dropTable('joplin_items');
|
||||
}
|
116
packages/server/src/migrations/20210412110640_item_refactor.ts
Normal file
116
packages/server/src/migrations/20210412110640_item_refactor.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Knex } from 'knex';
|
||||
import { DbConnection } from '../db';
|
||||
|
||||
// parent_id: ${'parent_id' in note ? note.parent_id : defaultFolderId}
|
||||
// created_time: 2020-10-15T10:34:16.044Z
|
||||
// updated_time: 2021-01-28T23:10:30.054Z
|
||||
// is_conflict: 0
|
||||
// latitude: 0.00000000
|
||||
// longitude: 0.00000000
|
||||
// altitude: 0.0000
|
||||
// author:
|
||||
// source_url:
|
||||
// is_todo: 1
|
||||
// todo_due: 1602760405000
|
||||
// todo_completed: 0
|
||||
// source: joplindev-desktop
|
||||
// source_application: net.cozic.joplindev-desktop
|
||||
// application_data:
|
||||
// order: 0
|
||||
// user_created_time: 2020-10-15T10:34:16.044Z
|
||||
// user_updated_time: 2020-10-19T17:21:03.394Z
|
||||
// encryption_cipher_text:
|
||||
// encryption_applied: 0
|
||||
// markup_language: 1
|
||||
// is_shared: 1
|
||||
// type_: 1`;
|
||||
|
||||
export async function up(db: DbConnection): Promise<any> {
|
||||
await db.schema.createTable('items', function(table: Knex.CreateTableBuilder) {
|
||||
table.string('id', 32).unique().primary().notNullable();
|
||||
table.text('name').notNullable();
|
||||
table.string('mime_type', 128).defaultTo('application/octet-stream').notNullable();
|
||||
table.bigInteger('updated_time').notNullable();
|
||||
table.bigInteger('created_time').notNullable();
|
||||
table.binary('content').defaultTo('').notNullable();
|
||||
table.integer('content_size').defaultTo(0).notNullable();
|
||||
table.string('jop_id', 32).defaultTo('').notNullable();
|
||||
table.string('jop_parent_id', 32).defaultTo('').notNullable();
|
||||
table.string('jop_share_id', 32).defaultTo('').notNullable();
|
||||
table.integer('jop_type', 2).defaultTo(0).notNullable();
|
||||
table.integer('jop_encryption_applied', 1).defaultTo(0).notNullable();
|
||||
});
|
||||
|
||||
await db.schema.alterTable('items', function(table: Knex.CreateTableBuilder) {
|
||||
table.index('name');
|
||||
table.index('jop_id');
|
||||
table.index('jop_parent_id');
|
||||
table.index('jop_type');
|
||||
table.index('jop_share_id');
|
||||
});
|
||||
|
||||
await db.schema.createTable('user_items', function(table: Knex.CreateTableBuilder) {
|
||||
table.increments('id').unique().primary().notNullable();
|
||||
table.string('user_id', 32).notNullable();
|
||||
table.string('item_id', 32).notNullable();
|
||||
table.bigInteger('updated_time').notNullable();
|
||||
table.bigInteger('created_time').notNullable();
|
||||
});
|
||||
|
||||
await db.schema.alterTable('user_items', function(table: Knex.CreateTableBuilder) {
|
||||
table.unique(['user_id', 'item_id']);
|
||||
table.index('user_id');
|
||||
table.index('item_id');
|
||||
});
|
||||
|
||||
await db.schema.createTable('item_resources', function(table: Knex.CreateTableBuilder) {
|
||||
table.increments('id').unique().primary().notNullable();
|
||||
table.string('item_id', 32).notNullable();
|
||||
table.string('resource_id', 32).notNullable();
|
||||
});
|
||||
|
||||
await db.schema.alterTable('item_resources', function(table: Knex.CreateTableBuilder) {
|
||||
table.unique(['item_id', 'resource_id']);
|
||||
table.index(['item_id', 'resource_id']);
|
||||
});
|
||||
|
||||
await db.schema.createTable('key_values', function(table: Knex.CreateTableBuilder) {
|
||||
table.increments('id').unique().primary().notNullable();
|
||||
table.text('key').notNullable();
|
||||
table.integer('type').notNullable();
|
||||
table.text('value').notNullable();
|
||||
});
|
||||
|
||||
await db.schema.alterTable('key_values', function(table: Knex.CreateTableBuilder) {
|
||||
table.index(['key']);
|
||||
});
|
||||
|
||||
await db.schema.alterTable('shares', function(table: Knex.CreateTableBuilder) {
|
||||
table.dropColumn('is_auto');
|
||||
table.string('note_id', 32).defaultTo('').notNullable();
|
||||
table.index(['note_id']);
|
||||
table.index(['folder_id']);
|
||||
table.index(['item_id']);
|
||||
});
|
||||
|
||||
await db.schema.alterTable('changes', function(table: Knex.CreateTableBuilder) {
|
||||
table.text('previous_item').defaultTo('').notNullable();
|
||||
table.string('user_id', 32).defaultTo('').notNullable();
|
||||
|
||||
table.dropColumn('owner_id');
|
||||
table.dropColumn('parent_id');
|
||||
|
||||
table.index('user_id');
|
||||
});
|
||||
|
||||
// Previous changes aren't relevant anymore since they relate to a "files"
|
||||
// table that is no longer used.
|
||||
await db('changes').truncate();
|
||||
|
||||
await db.schema.dropTable('permissions');
|
||||
await db.schema.dropTable('joplin_file_contents');
|
||||
}
|
||||
|
||||
export async function down(db: DbConnection): Promise<any> {
|
||||
await db.schema.dropTable('items');
|
||||
}
|
@@ -1,22 +1,25 @@
|
||||
import { WithDates, WithUuid, databaseSchema, DbConnection, ItemType, ChangeType } from '../db';
|
||||
import { WithDates, WithUuid, databaseSchema, DbConnection, ItemType, Uuid, User } from '../db';
|
||||
import TransactionHandler from '../utils/TransactionHandler';
|
||||
import uuidgen from '../utils/uuidgen';
|
||||
import { ErrorUnprocessableEntity, ErrorBadRequest } from '../utils/errors';
|
||||
import { Models } from './factory';
|
||||
|
||||
export interface ModelOptions {
|
||||
userId?: string;
|
||||
}
|
||||
import * as EventEmitter from 'events';
|
||||
|
||||
export interface SaveOptions {
|
||||
isNew?: boolean;
|
||||
skipValidation?: boolean;
|
||||
validationRules?: any;
|
||||
trackChanges?: boolean;
|
||||
previousItem?: any;
|
||||
}
|
||||
|
||||
export interface LoadOptions {
|
||||
fields?: string[];
|
||||
}
|
||||
|
||||
export interface DeleteOptions {
|
||||
validationRules?: any;
|
||||
allowNoOp?: boolean;
|
||||
deletedItemUserIds?: Record<Uuid, Uuid[]>;
|
||||
}
|
||||
|
||||
export interface ValidateOptions {
|
||||
@@ -24,24 +27,29 @@ export interface ValidateOptions {
|
||||
rules?: any;
|
||||
}
|
||||
|
||||
export enum AclAction {
|
||||
Create = 1,
|
||||
Read = 2,
|
||||
Update = 3,
|
||||
Delete = 4,
|
||||
List = 5,
|
||||
}
|
||||
|
||||
export default abstract class BaseModel<T> {
|
||||
|
||||
private options_: ModelOptions = null;
|
||||
private defaultFields_: string[] = [];
|
||||
private db_: DbConnection;
|
||||
private transactionHandler_: TransactionHandler;
|
||||
private modelFactory_: Function;
|
||||
private baseUrl_: string;
|
||||
private static eventEmitter_: EventEmitter = null;
|
||||
|
||||
public constructor(db: DbConnection, modelFactory: Function, baseUrl: string, options: ModelOptions = null) {
|
||||
public constructor(db: DbConnection, modelFactory: Function, baseUrl: string) {
|
||||
this.db_ = db;
|
||||
this.modelFactory_ = modelFactory;
|
||||
this.baseUrl_ = baseUrl;
|
||||
this.options_ = Object.assign({}, options);
|
||||
|
||||
this.transactionHandler_ = new TransactionHandler(db);
|
||||
|
||||
if ('userId' in this.options && !this.options.userId) throw new Error('If userId is set, it cannot be null');
|
||||
}
|
||||
|
||||
// When a model create an instance of another model, the active
|
||||
@@ -55,14 +63,6 @@ export default abstract class BaseModel<T> {
|
||||
return this.baseUrl_;
|
||||
}
|
||||
|
||||
protected get options(): ModelOptions {
|
||||
return this.options_;
|
||||
}
|
||||
|
||||
protected get userId(): string {
|
||||
return this.options.userId;
|
||||
}
|
||||
|
||||
protected get db(): DbConnection {
|
||||
if (this.transactionHandler_.activeTransaction) return this.transactionHandler_.activeTransaction;
|
||||
return this.db_;
|
||||
@@ -75,6 +75,34 @@ export default abstract class BaseModel<T> {
|
||||
return this.defaultFields_.slice();
|
||||
}
|
||||
|
||||
public static get eventEmitter(): EventEmitter {
|
||||
if (!this.eventEmitter_) {
|
||||
this.eventEmitter_ = new EventEmitter();
|
||||
}
|
||||
return this.eventEmitter_;
|
||||
}
|
||||
|
||||
public async checkIfAllowed(_user: User, _action: AclAction, _resource: T = null): Promise<void> {
|
||||
throw new Error('Must be overriden');
|
||||
}
|
||||
|
||||
protected selectFields(options: LoadOptions, defaultFields: string[] = null, mainTable: string = ''): string[] {
|
||||
let output: string[] = [];
|
||||
if (options && options.fields) {
|
||||
output = options.fields;
|
||||
} else if (defaultFields) {
|
||||
output = defaultFields;
|
||||
} else {
|
||||
output = this.defaultFields;
|
||||
}
|
||||
|
||||
if (mainTable) {
|
||||
output = output.map(f => `${mainTable}.${f}`);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
protected get tableName(): string {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
@@ -83,15 +111,11 @@ export default abstract class BaseModel<T> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
protected get trackChanges(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
protected hasUuid(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected hasDateProperties(): boolean {
|
||||
protected autoTimestampEnabled(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -121,7 +145,7 @@ export default abstract class BaseModel<T> {
|
||||
//
|
||||
// The `name` argument is only for debugging, so that any stuck transaction
|
||||
// can be more easily identified.
|
||||
protected async withTransaction(fn: Function, name: string = null): Promise<void> {
|
||||
protected async withTransaction<T>(fn: Function, name: string = null): Promise<T> {
|
||||
const debugTransaction = false;
|
||||
|
||||
const debugTimerId = debugTransaction ? setTimeout(() => {
|
||||
@@ -132,8 +156,10 @@ export default abstract class BaseModel<T> {
|
||||
|
||||
if (debugTransaction) console.info('START', name, txIndex);
|
||||
|
||||
let output: T = null;
|
||||
|
||||
try {
|
||||
await fn();
|
||||
output = await fn();
|
||||
} catch (error) {
|
||||
await this.transactionHandler_.rollback(txIndex);
|
||||
|
||||
@@ -151,10 +177,12 @@ export default abstract class BaseModel<T> {
|
||||
}
|
||||
|
||||
await this.transactionHandler_.commit(txIndex);
|
||||
return output;
|
||||
}
|
||||
|
||||
public async all(): Promise<T[]> {
|
||||
return this.db(this.tableName).select(...this.defaultFields);
|
||||
public async all(options: LoadOptions = {}): Promise<T[]> {
|
||||
const rows: any[] = await this.db(this.tableName).select(this.selectFields(options));
|
||||
return rows as T[];
|
||||
}
|
||||
|
||||
public fromApiInput(object: T): T {
|
||||
@@ -170,10 +198,18 @@ export default abstract class BaseModel<T> {
|
||||
return output;
|
||||
}
|
||||
|
||||
public toApiOutput(object: any): any {
|
||||
protected objectToApiOutput(object: T): T {
|
||||
return { ...object };
|
||||
}
|
||||
|
||||
public toApiOutput(object: T | T[]): T | T[] {
|
||||
if (Array.isArray(object)) {
|
||||
return object.map(f => this.objectToApiOutput(f));
|
||||
} else {
|
||||
return this.objectToApiOutput(object);
|
||||
}
|
||||
}
|
||||
|
||||
protected async validate(object: T, options: ValidateOptions = {}): Promise<T> {
|
||||
if (!options.isNew && !(object as WithUuid).id) throw new ErrorUnprocessableEntity('id is missing');
|
||||
return object;
|
||||
@@ -182,31 +218,10 @@ export default abstract class BaseModel<T> {
|
||||
protected async isNew(object: T, options: SaveOptions): Promise<boolean> {
|
||||
if (options.isNew === false) return false;
|
||||
if (options.isNew === true) return true;
|
||||
if ('id' in object && !(object as WithUuid).id) throw new Error('ID cannot be undefined or null');
|
||||
return !(object as WithUuid).id;
|
||||
}
|
||||
|
||||
private async handleChangeTracking(options: SaveOptions, item: T, changeType: ChangeType): Promise<void> {
|
||||
const trackChanges = this.trackChanges && options.trackChanges !== false;
|
||||
if (!trackChanges) return;
|
||||
|
||||
let parentId = null;
|
||||
if (this.hasParentId) {
|
||||
if (!('parent_id' in item)) {
|
||||
const temp: any = await this.db(this.tableName).select(['parent_id']).where('id', '=', (item as WithUuid).id).first();
|
||||
parentId = temp.parent_id;
|
||||
} else {
|
||||
parentId = (item as any).parent_id;
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity check - shouldn't happen
|
||||
// Parent ID can be an empty string for root folders, but it shouldn't be null or undefined
|
||||
if (this.hasParentId && !parentId && parentId !== '') throw new Error(`Could not find parent ID for item: ${(item as WithUuid).id}`);
|
||||
|
||||
const changeModel = this.models().change({ userId: this.userId });
|
||||
await changeModel.add(this.itemType, parentId, (item as WithUuid).id, (item as any).name || '', changeType);
|
||||
}
|
||||
|
||||
public async save(object: T, options: SaveOptions = {}): Promise<T> {
|
||||
if (!object) throw new Error('Object cannot be empty');
|
||||
|
||||
@@ -214,11 +229,11 @@ export default abstract class BaseModel<T> {
|
||||
|
||||
const isNew = await this.isNew(object, options);
|
||||
|
||||
if (isNew && !(toSave as WithUuid).id) {
|
||||
if (this.hasUuid() && isNew && !(toSave as WithUuid).id) {
|
||||
(toSave as WithUuid).id = uuidgen();
|
||||
}
|
||||
|
||||
if (this.hasDateProperties()) {
|
||||
if (this.autoTimestampEnabled()) {
|
||||
const timestamp = Date.now();
|
||||
if (isNew) {
|
||||
(toSave as WithDates).created_time = timestamp;
|
||||
@@ -231,7 +246,6 @@ export default abstract class BaseModel<T> {
|
||||
await this.withTransaction(async () => {
|
||||
if (isNew) {
|
||||
await this.db(this.tableName).insert(toSave);
|
||||
await this.handleChangeTracking(options, toSave, ChangeType.Create);
|
||||
} else {
|
||||
const objectId: string = (toSave as WithUuid).id;
|
||||
if (!objectId) throw new Error('Missing "id" property');
|
||||
@@ -239,8 +253,6 @@ export default abstract class BaseModel<T> {
|
||||
const updatedCount: number = await this.db(this.tableName).update(toSave).where({ id: objectId });
|
||||
(toSave as WithUuid).id = objectId;
|
||||
|
||||
await this.handleChangeTracking(options, toSave, ChangeType.Update);
|
||||
|
||||
// Sanity check:
|
||||
if (updatedCount !== 1) throw new ErrorBadRequest(`one row should have been updated, but ${updatedCount} row(s) were updated`);
|
||||
}
|
||||
@@ -249,31 +261,24 @@ export default abstract class BaseModel<T> {
|
||||
return toSave;
|
||||
}
|
||||
|
||||
public async loadByIds(ids: string[]): Promise<T[]> {
|
||||
public async loadByIds(ids: string[], options: LoadOptions = {}): Promise<T[]> {
|
||||
if (!ids.length) return [];
|
||||
return this.db(this.tableName).select(this.defaultFields).whereIn('id', ids);
|
||||
return this.db(this.tableName).select(options.fields || this.defaultFields).whereIn('id', ids);
|
||||
}
|
||||
|
||||
public async load(id: string): Promise<T> {
|
||||
public async load(id: string, options: LoadOptions = {}): Promise<T> {
|
||||
if (!id) throw new Error('id cannot be empty');
|
||||
|
||||
return this.db(this.tableName).select(this.defaultFields).where({ id: id }).first();
|
||||
return this.db(this.tableName).select(options.fields || this.defaultFields).where({ id: id }).first();
|
||||
}
|
||||
|
||||
public async delete(id: string | string[]): Promise<void> {
|
||||
public async delete(id: string | string[], options: DeleteOptions = {}): Promise<void> {
|
||||
if (!id) throw new Error('id cannot be empty');
|
||||
|
||||
const ids = typeof id === 'string' ? [id] : id;
|
||||
|
||||
if (!ids.length) throw new Error('no id provided');
|
||||
|
||||
const trackChanges = this.trackChanges;
|
||||
|
||||
let itemsWithParentIds: T[] = null;
|
||||
if (trackChanges) {
|
||||
itemsWithParentIds = await this.db(this.tableName).select(['id', 'parent_id', 'name']).whereIn('id', ids);
|
||||
}
|
||||
|
||||
await this.withTransaction(async () => {
|
||||
const query = this.db(this.tableName).where({ id: ids[0] });
|
||||
for (let i = 1; i < ids.length; i++) {
|
||||
@@ -281,11 +286,7 @@ export default abstract class BaseModel<T> {
|
||||
}
|
||||
|
||||
const deletedCount = await query.del();
|
||||
if (deletedCount !== ids.length) throw new Error(`${ids.length} row(s) should have been deleted by ${deletedCount} row(s) were deleted`);
|
||||
|
||||
if (trackChanges) {
|
||||
for (const item of itemsWithParentIds) await this.handleChangeTracking({}, item, ChangeType.Delete);
|
||||
}
|
||||
if (!options.allowNoOp && deletedCount !== ids.length) throw new Error(`${ids.length} row(s) should have been deleted but ${deletedCount} row(s) were deleted`);
|
||||
}, 'BaseModel::delete');
|
||||
}
|
||||
|
||||
|
@@ -1,13 +1,11 @@
|
||||
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, expectThrow } from '../utils/testing/testUtils';
|
||||
import { ChangeType, File } from '../db';
|
||||
import FileModel from './FileModel';
|
||||
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, expectThrow, createFolder } from '../utils/testing/testUtils';
|
||||
import { ChangeType, Item, Uuid } from '../db';
|
||||
import { msleep } from '../utils/time';
|
||||
import { ChangePagination } from './ChangeModel';
|
||||
|
||||
async function makeTestFile(fileModel: FileModel): Promise<File> {
|
||||
return fileModel.save({
|
||||
name: 'test',
|
||||
parent_id: await fileModel.userRootFileId(),
|
||||
async function makeTestItem(userId: Uuid, num: number): Promise<Item> {
|
||||
return models().item().saveForUser(userId, {
|
||||
name: `0000000000000000000000000000000${num}.md`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,95 +23,111 @@ describe('ChangeModel', function() {
|
||||
await beforeEachDb();
|
||||
});
|
||||
|
||||
test('should track changes - create', async function() {
|
||||
const { user } = await createUserAndSession(1, true);
|
||||
const fileModel = models().file({ userId: user.id });
|
||||
const changeModel = models().change({ userId: user.id });
|
||||
test('should track changes - create only', async function() {
|
||||
const { session, user } = await createUserAndSession(1, true);
|
||||
const changeModel = models().change();
|
||||
|
||||
const file1 = await makeTestFile(fileModel);
|
||||
const dirId = await fileModel.userRootFileId();
|
||||
const item1 = await createFolder(session.id, { title: 'folder' });
|
||||
|
||||
{
|
||||
const changes = (await changeModel.byDirectoryId(dirId, { limit: 20 })).items;
|
||||
const changes = (await changeModel.allForUser(user.id)).items;
|
||||
expect(changes.length).toBe(1);
|
||||
expect(changes[0].item.id).toBe(file1.id);
|
||||
expect(changes[0].item_id).toBe(item1.id);
|
||||
expect(changes[0].type).toBe(ChangeType.Create);
|
||||
}
|
||||
});
|
||||
|
||||
test('should track changes - create, then update', async function() {
|
||||
const { user } = await createUserAndSession(1, true);
|
||||
const fileModel = models().file({ userId: user.id });
|
||||
const changeModel = models().change({ userId: user.id });
|
||||
const itemModel = models().item();
|
||||
const changeModel = models().change();
|
||||
|
||||
let i = 1;
|
||||
await msleep(1); const file1 = await makeTestFile(fileModel); // CREATE 1
|
||||
await msleep(1); await fileModel.save({ id: file1.id, name: `test_mod${i++}` }); // UPDATE 1
|
||||
await msleep(1); await fileModel.save({ id: file1.id, name: `test_mod${i++}` }); // UPDATE 1
|
||||
await msleep(1); const file2 = await makeTestFile(fileModel); // CREATE 2
|
||||
await msleep(1); await fileModel.save({ id: file2.id, name: `test_mod${i++}` }); // UPDATE 2
|
||||
await msleep(1); await fileModel.delete(file1.id); // DELETE 1
|
||||
await msleep(1); await fileModel.save({ id: file2.id, name: `test_mod${i++}` }); // UPDATE 2
|
||||
await msleep(1); const file3 = await makeTestFile(fileModel); // CREATE 3
|
||||
await msleep(1); const item1 = await makeTestItem(user.id, 1); // [1] CREATE 1
|
||||
await msleep(1); await itemModel.saveForUser(user.id, { id: item1.id, name: '0000000000000000000000000000001A.md' }); // [2] UPDATE 1a
|
||||
await msleep(1); await itemModel.saveForUser(user.id, { id: item1.id, name: '0000000000000000000000000000001B.md' }); // [3] UPDATE 1b
|
||||
await msleep(1); const item2 = await makeTestItem(user.id, 2); // [4] CREATE 2
|
||||
await msleep(1); await itemModel.saveForUser(user.id, { id: item2.id, name: '0000000000000000000000000000002A.md' }); // [5] UPDATE 2a
|
||||
await msleep(1); await itemModel.delete(item1.id); // [6] DELETE 1
|
||||
await msleep(1); await itemModel.saveForUser(user.id, { id: item2.id, name: '0000000000000000000000000000002B.md' }); // [7] UPDATE 2b
|
||||
await msleep(1); const item3 = await makeTestItem(user.id, 3); // [8] CREATE 3
|
||||
|
||||
const dirId = await fileModel.userRootFileId();
|
||||
// Check that the 8 changes were created
|
||||
const allUncompressedChanges = await changeModel.all();
|
||||
expect(allUncompressedChanges.length).toBe(8);
|
||||
|
||||
{
|
||||
const changes = (await changeModel.byDirectoryId(dirId, { limit: 20 })).items;
|
||||
// When we get all the changes, we only get CREATE 2 and CREATE 3.
|
||||
// We don't get CREATE 1 because item 1 has been deleted. And we
|
||||
// also don't get any UPDATE event since they've been compressed
|
||||
// down to the CREATE events.
|
||||
const changes = (await changeModel.allForUser(user.id)).items;
|
||||
expect(changes.length).toBe(2);
|
||||
expect(changes[0].item.id).toBe(file2.id);
|
||||
expect(changes[0].item_id).toBe(item2.id);
|
||||
expect(changes[0].type).toBe(ChangeType.Create);
|
||||
expect(changes[1].item.id).toBe(file3.id);
|
||||
expect(changes[1].item_id).toBe(item3.id);
|
||||
expect(changes[1].type).toBe(ChangeType.Create);
|
||||
}
|
||||
|
||||
{
|
||||
const pagination: ChangePagination = { limit: 5 };
|
||||
const pagination: ChangePagination = { limit: 3 };
|
||||
|
||||
// In this page, the "create" change for file1 will not appear
|
||||
// because this file has been deleted. The "delete" change will
|
||||
// however appear in the second page.
|
||||
const page1 = (await changeModel.byDirectoryId(dirId, pagination));
|
||||
// Internally, when we request the first three changes, we get back:
|
||||
//
|
||||
// - CREATE 1
|
||||
// - CREATE 2
|
||||
// - UPDATE 2a
|
||||
//
|
||||
// We don't get back UPDATE 1a and 1b because the associated item
|
||||
// has been deleted.
|
||||
//
|
||||
// Unlike CREATE events, which come from "user_items" and are
|
||||
// associated with a user, UPDATE events comes from "items" and are
|
||||
// not associated with any specific user. Only if the user has a
|
||||
// corresponding user_item do they get UPDATE events. But in this
|
||||
// case, since the item has been deleted, there's no longer
|
||||
// "user_items" objects.
|
||||
//
|
||||
// Then CREATE 1 is removed since item 1 has been deleted and UPDATE
|
||||
// 2a is compressed down to CREATE 2.
|
||||
const page1 = (await changeModel.allForUser(user.id, pagination));
|
||||
let changes = page1.items;
|
||||
expect(changes.length).toBe(1);
|
||||
expect(page1.has_more).toBe(true);
|
||||
expect(changes[0].item.id).toBe(file2.id);
|
||||
expect(changes[0].item_id).toBe(item2.id);
|
||||
expect(changes[0].type).toBe(ChangeType.Create);
|
||||
|
||||
const page2 = (await changeModel.byDirectoryId(dirId, { ...pagination, cursor: page1.cursor }));
|
||||
// In the second page, we get all the expected events since nothing
|
||||
// has been compressed.
|
||||
const page2 = (await changeModel.allForUser(user.id, { ...pagination, cursor: page1.cursor }));
|
||||
changes = page2.items;
|
||||
expect(changes.length).toBe(3);
|
||||
expect(page2.has_more).toBe(false);
|
||||
expect(changes[0].item.id).toBe(file1.id);
|
||||
// Although there are no more changes, it's not possible to know
|
||||
// that without running the next query
|
||||
expect(page2.has_more).toBe(true);
|
||||
expect(changes[0].item_id).toBe(item1.id);
|
||||
expect(changes[0].type).toBe(ChangeType.Delete);
|
||||
expect(changes[1].item.id).toBe(file2.id);
|
||||
expect(changes[1].item_id).toBe(item2.id);
|
||||
expect(changes[1].type).toBe(ChangeType.Update);
|
||||
expect(changes[2].item.id).toBe(file3.id);
|
||||
expect(changes[2].item_id).toBe(item3.id);
|
||||
expect(changes[2].type).toBe(ChangeType.Create);
|
||||
|
||||
// Check that we indeed reached the end of the feed.
|
||||
const page3 = (await changeModel.allForUser(user.id, { ...pagination, cursor: page2.cursor }));
|
||||
expect(page3.items.length).toBe(0);
|
||||
expect(page3.has_more).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('should throw an error if cursor is invalid', async function() {
|
||||
const { user } = await createUserAndSession(1, true);
|
||||
const fileModel = models().file({ userId: user.id });
|
||||
const changeModel = models().change({ userId: user.id });
|
||||
|
||||
const dirId = await fileModel.userRootFileId();
|
||||
const itemModel = models().item();
|
||||
const changeModel = models().change();
|
||||
|
||||
let i = 1;
|
||||
await msleep(1); const file1 = await makeTestFile(fileModel); // CREATE 1
|
||||
await msleep(1); await fileModel.save({ id: file1.id, name: `test_mod${i++}` }); // UPDATE 1
|
||||
await msleep(1); const item1 = await makeTestItem(user.id, 1); // CREATE 1
|
||||
await msleep(1); await itemModel.saveForUser(user.id, { id: item1.id, name: `test_mod${i++}` }); // UPDATE 1
|
||||
|
||||
await expectThrow(async () => changeModel.byDirectoryId(dirId, { limit: 1, cursor: 'invalid' }), 'resyncRequired');
|
||||
});
|
||||
|
||||
test('should throw an error if trying to do get changes for a file', async function() {
|
||||
const { user } = await createUserAndSession(1, true);
|
||||
const fileModel = models().file({ userId: user.id });
|
||||
const changeModel = models().change({ userId: user.id });
|
||||
const file1 = await makeTestFile(fileModel);
|
||||
|
||||
await expectThrow(async () => changeModel.byDirectoryId(file1.id));
|
||||
await expectThrow(async () => changeModel.allForUser(user.id, { limit: 1, cursor: 'invalid' }), 'resyncRequired');
|
||||
});
|
||||
|
||||
});
|
||||
|
@@ -1,16 +1,17 @@
|
||||
import { Change, ChangeType, File, ItemType, Uuid } from '../db';
|
||||
import { ErrorResyncRequired, ErrorUnprocessableEntity } from '../utils/errors';
|
||||
import BaseModel from './BaseModel';
|
||||
import { paginateDbQuery, PaginatedResults, Pagination } from './utils/pagination';
|
||||
import { Change, ChangeType, Item, Uuid } from '../db';
|
||||
import { md5 } from '../utils/crypto';
|
||||
import { ErrorResyncRequired } from '../utils/errors';
|
||||
import BaseModel, { SaveOptions } from './BaseModel';
|
||||
import { PaginatedResults } from './utils/pagination';
|
||||
|
||||
export interface ChangeWithItem {
|
||||
item: File;
|
||||
item: Item;
|
||||
updated_time: number;
|
||||
type: ChangeType;
|
||||
}
|
||||
|
||||
export interface PaginatedChanges extends PaginatedResults {
|
||||
items: ChangeWithItem[];
|
||||
items: Change[];
|
||||
}
|
||||
|
||||
export interface ChangePagination {
|
||||
@@ -18,6 +19,13 @@ export interface ChangePagination {
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
export interface ChangePreviousItem {
|
||||
name: string;
|
||||
jop_parent_id: string;
|
||||
jop_resource_ids: string[];
|
||||
jop_share_id: string;
|
||||
}
|
||||
|
||||
export function defaultChangePagination(): ChangePagination {
|
||||
return {
|
||||
limit: 100,
|
||||
@@ -25,6 +33,10 @@ export function defaultChangePagination(): ChangePagination {
|
||||
};
|
||||
}
|
||||
|
||||
interface AllForUserOptions {
|
||||
compressChanges?: boolean;
|
||||
}
|
||||
|
||||
export default class ChangeModel extends BaseModel<Change> {
|
||||
|
||||
public get tableName(): string {
|
||||
@@ -32,44 +44,39 @@ export default class ChangeModel extends BaseModel<Change> {
|
||||
}
|
||||
|
||||
protected hasUuid(): boolean {
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public async add(itemType: ItemType, parentId: Uuid, itemId: Uuid, itemName: string, changeType: ChangeType): Promise<Change> {
|
||||
const change: Change = {
|
||||
item_type: itemType,
|
||||
parent_id: parentId || '',
|
||||
item_id: itemId,
|
||||
item_name: itemName,
|
||||
type: changeType,
|
||||
owner_id: this.userId,
|
||||
};
|
||||
|
||||
return this.save(change) as Change;
|
||||
public serializePreviousItem(item: ChangePreviousItem): string {
|
||||
return JSON.stringify(item);
|
||||
}
|
||||
|
||||
private async countByUser(userId: string): Promise<number> {
|
||||
const r: any = await this.db(this.tableName).where('owner_id', userId).count('id', { as: 'total' }).first();
|
||||
return r.total;
|
||||
public unserializePreviousItem(item: string): ChangePreviousItem {
|
||||
if (!item) return null;
|
||||
return JSON.parse(item);
|
||||
}
|
||||
|
||||
public changeUrl(): string {
|
||||
return `${this.baseUrl}/changes`;
|
||||
}
|
||||
|
||||
public async allWithPagination(pagination: Pagination): Promise<PaginatedChanges> {
|
||||
const results = await paginateDbQuery(this.db(this.tableName).select(...this.defaultFields).where('owner_id', '=', this.userId), pagination);
|
||||
const changeWithItems = await this.loadChangeItems(results.items);
|
||||
return {
|
||||
...results,
|
||||
items: changeWithItems,
|
||||
page_count: Math.ceil(await this.countByUser(this.userId) / pagination.limit),
|
||||
};
|
||||
public async allFromId(id: string): Promise<Change[]> {
|
||||
const startChange: Change = id ? await this.load(id) : null;
|
||||
const query = this.db(this.tableName).select(...this.defaultFields);
|
||||
if (startChange) void query.where('counter', '>', startChange.counter);
|
||||
void query.limit(1000);
|
||||
let results = await query;
|
||||
results = await this.removeDeletedItems(results);
|
||||
results = await this.compressChanges(results);
|
||||
return results;
|
||||
}
|
||||
|
||||
// Note: doesn't currently support checking for changes recursively but this
|
||||
// is not needed for Joplin synchronisation.
|
||||
public async byDirectoryId(dirId: string, pagination: ChangePagination = null): Promise<PaginatedChanges> {
|
||||
public async allForUser(userId: Uuid, pagination: ChangePagination = null, options: AllForUserOptions = null): Promise<PaginatedChanges> {
|
||||
options = {
|
||||
compressChanges: true,
|
||||
...options,
|
||||
};
|
||||
|
||||
pagination = {
|
||||
...defaultChangePagination(),
|
||||
...pagination,
|
||||
@@ -82,37 +89,49 @@ export default class ChangeModel extends BaseModel<Change> {
|
||||
if (!changeAtCursor) throw new ErrorResyncRequired();
|
||||
}
|
||||
|
||||
// Load the directory object to check that it exists and that we have
|
||||
// the right permissions (loading will check permissions)
|
||||
const fileModel = this.models().file({ userId: this.userId });
|
||||
const directory = await fileModel.load(dirId);
|
||||
if (!directory.is_directory) throw new ErrorUnprocessableEntity(`Item with id "${dirId}" is not a directory.`);
|
||||
// When need to get:
|
||||
//
|
||||
// - All the CREATE and DELETE changes associated with the user
|
||||
// - All the UPDATE changes that applies to items associated with the
|
||||
// user.
|
||||
//
|
||||
// UPDATE changes do not have the user_id set because they are specific
|
||||
// to the item, not to a particular user.
|
||||
|
||||
// Rather than query the changes, then use JS to compress them, it might
|
||||
// be possible to do both in one query.
|
||||
// https://stackoverflow.com/questions/65348794
|
||||
const query = this.db(this.tableName)
|
||||
const query = this
|
||||
.db('changes')
|
||||
.select([
|
||||
'counter',
|
||||
'id',
|
||||
'item_id',
|
||||
'item_name',
|
||||
'type',
|
||||
'updated_time',
|
||||
])
|
||||
.where('parent_id', dirId)
|
||||
.orderBy('counter', 'asc')
|
||||
.limit(pagination.limit);
|
||||
.where(function() {
|
||||
void this.whereRaw('((type = ? OR type = ?) AND user_id = ?)', [ChangeType.Create, ChangeType.Delete, userId])
|
||||
// Need to use a RAW query here because Knex has a "not a
|
||||
// bug" bug that makes it go into infinite loop in some
|
||||
// contexts, possibly only when running inside Jest (didn't
|
||||
// test outside).
|
||||
// https://github.com/knex/knex/issues/1851
|
||||
.orWhereRaw('type = ? AND item_id IN (SELECT item_id FROM user_items WHERE user_id = ?)', [ChangeType.Update, userId]);
|
||||
});
|
||||
|
||||
// If a cursor was provided, apply it to both queries.
|
||||
if (changeAtCursor) {
|
||||
void query.where('counter', '>', changeAtCursor.counter);
|
||||
}
|
||||
|
||||
const changes: Change[] = await query;
|
||||
const compressedChanges = this.compressChanges(changes);
|
||||
const changeWithItems = await this.loadChangeItems(compressedChanges);
|
||||
void query
|
||||
.orderBy('counter', 'asc')
|
||||
.limit(pagination.limit) as any[];
|
||||
|
||||
const changes = await query;
|
||||
|
||||
const finalChanges = options.compressChanges ? await this.removeDeletedItems(this.compressChanges(changes)) : changes;
|
||||
|
||||
return {
|
||||
items: changeWithItems,
|
||||
items: finalChanges,
|
||||
// If we have changes, we return the ID of the latest changes from which delta sync can resume.
|
||||
// If there's no change, we return the previous cursor.
|
||||
cursor: changes.length ? changes[changes.length - 1].id : pagination.cursor,
|
||||
@@ -120,15 +139,19 @@ export default class ChangeModel extends BaseModel<Change> {
|
||||
};
|
||||
}
|
||||
|
||||
private async loadChangeItems(changes: Change[]): Promise<ChangeWithItem[]> {
|
||||
private async removeDeletedItems(changes: Change[]): Promise<Change[]> {
|
||||
const itemIds = changes.map(c => c.item_id);
|
||||
const fileModel = this.models().file({ userId: this.userId });
|
||||
const items: File[] = await fileModel.loadByIds(itemIds);
|
||||
|
||||
const output: ChangeWithItem[] = [];
|
||||
// We skip permission check here because, when an item is shared, we need
|
||||
// to fetch files that don't belong to the current user. This check
|
||||
// would not be needed anyway because the change items are generated in
|
||||
// a context where permissions have already been checked.
|
||||
const items: Item[] = await this.db('items').select('id').whereIn('items.id', itemIds);
|
||||
|
||||
const output: Change[] = [];
|
||||
|
||||
for (const change of changes) {
|
||||
let item = items.find(f => f.id === change.item_id);
|
||||
const item = items.find(f => f.id === change.item_id);
|
||||
|
||||
// If the item associated with this change has been deleted, we have
|
||||
// two cases:
|
||||
@@ -136,63 +159,78 @@ export default class ChangeModel extends BaseModel<Change> {
|
||||
// - If it's anything else, skip it. The "delete" change will be
|
||||
// sent on one of the next pages.
|
||||
|
||||
if (!item) {
|
||||
if (change.type === ChangeType.Delete) {
|
||||
item = {
|
||||
id: change.item_id,
|
||||
name: change.item_name,
|
||||
};
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
if (!item && change.type !== ChangeType.Delete) {
|
||||
continue;
|
||||
}
|
||||
|
||||
output.push({
|
||||
type: change.type,
|
||||
updated_time: change.updated_time,
|
||||
item: item,
|
||||
});
|
||||
output.push(change);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// Compresses the changes so that, for example, multiple updates on the same
|
||||
// item are reduced down to one, because calling code usually only needs to
|
||||
// know that the item has changed at least once. The reduction is basically:
|
||||
//
|
||||
// create - update => create
|
||||
// create - delete => NOOP
|
||||
// update - update => update
|
||||
// update - delete => delete
|
||||
//
|
||||
// There's one exception for changes that include a "previous_item". This is
|
||||
// used to save specific properties about the previous state of the item,
|
||||
// such as "jop_parent_id" or "name", which is used by the share mechanism
|
||||
// to know if an item has been moved from one folder to another. In that
|
||||
// case, we need to know about each individual change, so they are not
|
||||
// compressed.
|
||||
private compressChanges(changes: Change[]): Change[] {
|
||||
const itemChanges: Record<Uuid, Change> = {};
|
||||
|
||||
const uniqueUpdateChanges: Record<Uuid, Record<string, Change>> = {};
|
||||
|
||||
for (const change of changes) {
|
||||
const previous = itemChanges[change.item_id];
|
||||
const itemId = change.item_id;
|
||||
const previous = itemChanges[itemId];
|
||||
|
||||
if (change.type === ChangeType.Update) {
|
||||
const key = md5(itemId + change.previous_item);
|
||||
if (!uniqueUpdateChanges[itemId]) uniqueUpdateChanges[itemId] = {};
|
||||
uniqueUpdateChanges[itemId][key] = change;
|
||||
}
|
||||
|
||||
if (previous) {
|
||||
// create - update => create
|
||||
// create - delete => NOOP
|
||||
// update - update => update
|
||||
// update - delete => delete
|
||||
|
||||
if (previous.type === ChangeType.Create && change.type === ChangeType.Update) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (previous.type === ChangeType.Create && change.type === ChangeType.Delete) {
|
||||
delete itemChanges[change.item_id];
|
||||
delete itemChanges[itemId];
|
||||
}
|
||||
|
||||
if (previous.type === ChangeType.Update && change.type === ChangeType.Update) {
|
||||
itemChanges[change.item_id] = change;
|
||||
itemChanges[itemId] = change;
|
||||
}
|
||||
|
||||
if (previous.type === ChangeType.Update && change.type === ChangeType.Delete) {
|
||||
itemChanges[change.item_id] = change;
|
||||
itemChanges[itemId] = change;
|
||||
}
|
||||
} else {
|
||||
itemChanges[change.item_id] = change;
|
||||
itemChanges[itemId] = change;
|
||||
}
|
||||
}
|
||||
|
||||
const output = [];
|
||||
const output: Change[] = [];
|
||||
|
||||
for (const itemId in itemChanges) {
|
||||
output.push(itemChanges[itemId]);
|
||||
const change = itemChanges[itemId];
|
||||
if (change.type === ChangeType.Update) {
|
||||
for (const key of Object.keys(uniqueUpdateChanges[itemId])) {
|
||||
output.push(uniqueUpdateChanges[itemId][key]);
|
||||
}
|
||||
} else {
|
||||
output.push(change);
|
||||
}
|
||||
}
|
||||
|
||||
output.sort((a: Change, b: Change) => a.counter < b.counter ? -1 : +1);
|
||||
@@ -200,4 +238,10 @@ export default class ChangeModel extends BaseModel<Change> {
|
||||
return output;
|
||||
}
|
||||
|
||||
public async save(change: Change, options: SaveOptions = {}): Promise<Change> {
|
||||
const savedChange = await super.save(change, options);
|
||||
ChangeModel.eventEmitter.emit('saved');
|
||||
return savedChange;
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -1,93 +0,0 @@
|
||||
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, createFileTree } from '../utils/testing/testUtils';
|
||||
import { File } from '../db';
|
||||
|
||||
describe('FileModel', function() {
|
||||
|
||||
beforeAll(async () => {
|
||||
await beforeAllDb('FileModel');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await afterAllTests();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await beforeEachDb();
|
||||
});
|
||||
|
||||
test('should compute item full path', async function() {
|
||||
const { user } = await createUserAndSession(1, true);
|
||||
const fileModel = models().file({ userId: user.id });
|
||||
const rootId = await fileModel.userRootFileId();
|
||||
|
||||
const tree: any = {
|
||||
folder1: {},
|
||||
folder2: {
|
||||
file2_1: null,
|
||||
file2_2: null,
|
||||
},
|
||||
folder3: {
|
||||
file3_1: null,
|
||||
},
|
||||
file1: null,
|
||||
file2: null,
|
||||
file3: null,
|
||||
};
|
||||
|
||||
await createFileTree(fileModel, rootId, tree);
|
||||
|
||||
const testCases = Object.keys(tree)
|
||||
.concat(Object.keys(tree.folder2))
|
||||
.concat(Object.keys(tree.folder3));
|
||||
|
||||
for (const t of testCases) {
|
||||
const file: File = await fileModel.loadByName(t);
|
||||
const path = await fileModel.itemFullPath(file);
|
||||
const fileBackId: string = await fileModel.pathToFileId(path);
|
||||
expect(file.id).toBe(fileBackId);
|
||||
}
|
||||
|
||||
const rootPath = await fileModel.itemFullPath(await fileModel.userRootFile());
|
||||
expect(rootPath).toBe('root');
|
||||
const fileBackId: string = await fileModel.pathToFileId(rootPath);
|
||||
expect(fileBackId).toBe(rootId);
|
||||
});
|
||||
|
||||
test('should resolve file paths', async function() {
|
||||
const testCases = [
|
||||
[
|
||||
['root', '.resource', 'test'],
|
||||
'root:/.resource/test:',
|
||||
],
|
||||
[
|
||||
['root:/.resource:', 'test'],
|
||||
'root:/.resource/test:',
|
||||
],
|
||||
[
|
||||
['root:/.resource:', ''],
|
||||
'root:/.resource:',
|
||||
],
|
||||
[
|
||||
['root:/.resource:'],
|
||||
'root:/.resource:',
|
||||
],
|
||||
[
|
||||
['root:/.resource:'],
|
||||
'root:/.resource:',
|
||||
],
|
||||
[
|
||||
['root'],
|
||||
'root',
|
||||
],
|
||||
];
|
||||
|
||||
const fileModel = models().file();
|
||||
|
||||
for (const t of testCases) {
|
||||
const [input, expected] = t;
|
||||
const actual = fileModel.resolve(...input);
|
||||
expect(actual).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
@@ -1,498 +0,0 @@
|
||||
import BaseModel, { ValidateOptions, SaveOptions, DeleteOptions } from './BaseModel';
|
||||
import { File, ItemType, databaseSchema, Uuid } from '../db';
|
||||
import { ErrorForbidden, ErrorUnprocessableEntity, ErrorNotFound, ErrorBadRequest, ErrorConflict } from '../utils/errors';
|
||||
import uuidgen from '../utils/uuidgen';
|
||||
import { splitItemPath, filePathInfo } from '../utils/routeUtils';
|
||||
import { paginateDbQuery, PaginatedResults, Pagination } from './utils/pagination';
|
||||
import { setQueryParameters } from '../utils/urlUtils';
|
||||
|
||||
const mimeUtils = require('@joplin/lib/mime-utils.js').mime;
|
||||
|
||||
const nodeEnv = process.env.NODE_ENV || 'development';
|
||||
|
||||
const removeTrailingColonsRegex = /^(:|)(.*?)(:|)$/;
|
||||
|
||||
export interface PaginatedFiles extends PaginatedResults {
|
||||
items: File[];
|
||||
}
|
||||
|
||||
export interface PathToFileOptions {
|
||||
mustExist?: boolean;
|
||||
returnFullEntity?: boolean;
|
||||
}
|
||||
|
||||
export interface LoadOptions {
|
||||
skipPermissionCheck?: boolean;
|
||||
}
|
||||
|
||||
export default class FileModel extends BaseModel<File> {
|
||||
|
||||
private readonly reservedCharacters = ['/', '\\', '*', '<', '>', '?', ':', '|', '#', '%'];
|
||||
|
||||
protected get tableName(): string {
|
||||
return 'files';
|
||||
}
|
||||
|
||||
protected get trackChanges(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected get itemType(): ItemType {
|
||||
return ItemType.File;
|
||||
}
|
||||
|
||||
protected get hasParentId(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
public async userRootFile(): Promise<File> {
|
||||
const file: File = await this.db<File>(this.tableName).select(...this.defaultFields).from(this.tableName).where({
|
||||
'owner_id': this.userId,
|
||||
'is_root': 1,
|
||||
}).first();
|
||||
if (file) await this.checkCanReadPermissions(file);
|
||||
return file;
|
||||
}
|
||||
|
||||
public async userRootFileId(): Promise<string> {
|
||||
const r = await this.userRootFile();
|
||||
return r ? r.id : '';
|
||||
}
|
||||
|
||||
private isSpecialDir(dirname: string): boolean {
|
||||
return dirname === 'root';
|
||||
}
|
||||
|
||||
private async specialDirId(dirname: string): Promise<string> {
|
||||
if (dirname === 'root') return this.userRootFileId();
|
||||
return null; // Not a special dir
|
||||
}
|
||||
|
||||
public async itemFullPaths(items: File[]): Promise<Record<string, string>> {
|
||||
const output: Record<string, string> = {};
|
||||
|
||||
const itemCache: Record<string, File> = {};
|
||||
|
||||
await this.withTransaction(async () => {
|
||||
for (const item of items) {
|
||||
const segments: string[] = [];
|
||||
let current: File = item;
|
||||
|
||||
while (current) {
|
||||
if (current.is_root) break;
|
||||
segments.splice(0, 0, current.name);
|
||||
|
||||
if (current.parent_id) {
|
||||
const id = current.parent_id;
|
||||
current = itemCache[id] ? itemCache[id] : await this.load(id);
|
||||
itemCache[id] = current;
|
||||
} else {
|
||||
current = null;
|
||||
}
|
||||
}
|
||||
|
||||
output[item.id] = segments.length ? (`root:/${segments.join('/')}:`) : 'root';
|
||||
}
|
||||
}, 'FileModel::itemFullPaths');
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
public async itemDisplayPath(item: File, loadOptions: LoadOptions = {}): Promise<string> {
|
||||
const path = await this.itemFullPath(item, loadOptions);
|
||||
return this.removeTrailingColons(path.replace(/root:\//, ''));
|
||||
}
|
||||
|
||||
public async itemFullPath(item: File, loadOptions: LoadOptions = {}): Promise<string> {
|
||||
const segments: string[] = [];
|
||||
while (item) {
|
||||
if (item.is_root) break;
|
||||
segments.splice(0, 0, item.name);
|
||||
item = item.parent_id ? await this.load(item.parent_id, loadOptions) : null;
|
||||
}
|
||||
|
||||
return segments.length ? (`root:/${segments.join('/')}:`) : 'root';
|
||||
}
|
||||
|
||||
// Remove first and last colon from a path element
|
||||
private removeTrailingColons(path: string): string {
|
||||
return path.replace(removeTrailingColonsRegex, '$2');
|
||||
}
|
||||
|
||||
public resolve(...paths: string[]): string {
|
||||
if (!paths.length) throw new Error('Path is empty');
|
||||
|
||||
let pathElements: string[] = [];
|
||||
|
||||
for (const p of paths) {
|
||||
pathElements = pathElements.concat(p.split('/').map(s => this.removeTrailingColons(s)));
|
||||
}
|
||||
|
||||
pathElements = pathElements.filter(p => !!p);
|
||||
|
||||
if (!this.isSpecialDir(pathElements[0])) throw new Error(`Path must start with a special dir: ${pathElements.join('/')}`);
|
||||
|
||||
if (pathElements.length === 1) return pathElements[0];
|
||||
|
||||
// If the output is just "root", we return that only. Otherwise we build the path, eg. `root:/.resource/file:'`
|
||||
const specialDir = pathElements.splice(0, 1)[0];
|
||||
|
||||
return `${specialDir}:/${pathElements.join('/')}:`;
|
||||
}
|
||||
|
||||
// Same as `pathToFile` but returns the ID only.
|
||||
public async pathToFileId(idOrPath: string, options: PathToFileOptions = {}): Promise<Uuid> {
|
||||
const file = await this.pathToFile(idOrPath, {
|
||||
...options,
|
||||
returnFullEntity: false,
|
||||
});
|
||||
return file ? file.id : null;
|
||||
}
|
||||
|
||||
// Converts an ID such as "Ps2YtQ8Udi4eCYm1A5bLFDGhHCWWCR43" or a path such
|
||||
// as "root:/path/to/file.txt:" to the actual file.
|
||||
public async pathToFile(idOrPath: string, options: PathToFileOptions = {}): Promise<File> {
|
||||
if (!idOrPath) throw new Error('ID cannot be null');
|
||||
|
||||
options = {
|
||||
mustExist: true,
|
||||
returnFullEntity: true,
|
||||
...options,
|
||||
};
|
||||
|
||||
const specialDirId = await this.specialDirId(idOrPath);
|
||||
|
||||
if (specialDirId) {
|
||||
return options.returnFullEntity ? this.load(specialDirId) : { id: specialDirId };
|
||||
} else if (idOrPath.indexOf(':') < 0) {
|
||||
if (options.mustExist) {
|
||||
const file = await this.load(idOrPath);
|
||||
if (!file) throw new ErrorNotFound(`file not found: ${idOrPath}`);
|
||||
return options.returnFullEntity ? file : { id: file.id };
|
||||
} else {
|
||||
return options.returnFullEntity ? this.load(idOrPath) : { id: idOrPath };
|
||||
}
|
||||
} else {
|
||||
// When this input is a path, there can be two cases:
|
||||
// - A path to an existing file - in which case we return the file
|
||||
// - A path to a file that needs to be created - in which case we
|
||||
// return a file with all the relevant properties populated. This
|
||||
// file might then be created by the caller.
|
||||
// The caller can check file.id to see if it's a new or existing file.
|
||||
// In both cases the directories before the filename must exist.
|
||||
|
||||
const fileInfo = filePathInfo(idOrPath);
|
||||
const parentFiles = await this.pathToFiles(fileInfo.dirname);
|
||||
const parentId = parentFiles[parentFiles.length - 1].id;
|
||||
|
||||
if (!fileInfo.basename) {
|
||||
const specialDirId = await this.specialDirId(fileInfo.dirname);
|
||||
// The path simply refers to a special directory. Can happen
|
||||
// for example with a path like `root:/:` (which is the same
|
||||
// as just `root`).
|
||||
if (specialDirId) return { id: specialDirId };
|
||||
}
|
||||
|
||||
// This is an existing file
|
||||
const existingFile = await this.fileByName(parentId, fileInfo.basename);
|
||||
if (existingFile) return options.returnFullEntity ? existingFile : { id: existingFile.id };
|
||||
|
||||
if (options.mustExist) throw new ErrorNotFound(`file not found: ${idOrPath}`);
|
||||
|
||||
// This is a potentially new file
|
||||
return {
|
||||
name: fileInfo.basename,
|
||||
parent_id: parentId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
protected get defaultFields(): string[] {
|
||||
return Object.keys(databaseSchema[this.tableName]).filter(f => f !== 'content');
|
||||
}
|
||||
|
||||
public async fileByName(parentId: string, name: string, options: LoadOptions = {}): Promise<File> {
|
||||
const file = await this.db<File>(this.tableName).select(...this.defaultFields).where({
|
||||
parent_id: parentId,
|
||||
name: name,
|
||||
}).first();
|
||||
|
||||
if (!file) return null;
|
||||
|
||||
if (!options.skipPermissionCheck) await this.checkCanReadPermissions(file);
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
protected async validate(object: File, options: ValidateOptions = {}): Promise<File> {
|
||||
const file: File = object;
|
||||
|
||||
const mustBeFile = options.rules.mustBeFile === true;
|
||||
|
||||
if (options.isNew) {
|
||||
if (!file.is_root && !file.name) throw new ErrorUnprocessableEntity('name cannot be empty');
|
||||
if (file.is_directory && mustBeFile) throw new ErrorUnprocessableEntity('item must not be a directory');
|
||||
} else {
|
||||
if ('name' in file && !file.name) throw new ErrorUnprocessableEntity('name cannot be empty');
|
||||
if ('is_directory' in file) throw new ErrorUnprocessableEntity('cannot turn a file into a directory or vice-versa');
|
||||
|
||||
if (mustBeFile && !('is_directory' in file)) {
|
||||
const existingFile = await this.load(file.id);
|
||||
if (existingFile.is_directory) throw new ErrorUnprocessableEntity('item must not be a directory');
|
||||
} else {
|
||||
if (file.is_directory) throw new ErrorUnprocessableEntity('item must not be a directory');
|
||||
}
|
||||
}
|
||||
|
||||
let parentId = file.parent_id;
|
||||
if (!parentId) parentId = await this.userRootFileId();
|
||||
|
||||
if ('parent_id' in file && !file.is_root) {
|
||||
const invalidParentError = function(extraInfo: string) {
|
||||
let msg = `Invalid parent ID or no permission to write to it: ${parentId}`;
|
||||
if (nodeEnv !== 'production') msg += ` (${extraInfo})`;
|
||||
return new ErrorForbidden(msg);
|
||||
};
|
||||
|
||||
if (!parentId) throw invalidParentError('No parent ID');
|
||||
|
||||
try {
|
||||
const parentFile: File = await this.load(parentId);
|
||||
if (!parentFile) throw invalidParentError('Cannot load parent file');
|
||||
if (!parentFile.is_directory) throw invalidParentError('Specified parent is not a directory');
|
||||
await this.checkCanWritePermission(parentFile);
|
||||
} catch (error) {
|
||||
if (error.message.indexOf('Invalid parent') === 0) throw error;
|
||||
throw invalidParentError(`Unknown: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if ('name' in file && !file.is_root) {
|
||||
const existingFile = await this.fileByName(parentId, file.name);
|
||||
if (existingFile && options.isNew) throw new ErrorConflict(`Already a file with name "${file.name}"`);
|
||||
if (existingFile && file.id === existingFile.id) throw new ErrorConflict(`Already a file with name "${file.name}"`);
|
||||
}
|
||||
|
||||
if ('name' in file) {
|
||||
if (this.includesReservedCharacter(file.name)) throw new ErrorUnprocessableEntity(`File name may not contain any of these characters: ${this.reservedCharacters.join('')}: ${file.name}`);
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
public fromApiInput(object: File): File {
|
||||
const file: File = {};
|
||||
|
||||
if ('id' in object) file.id = object.id;
|
||||
if ('name' in object) file.name = object.name;
|
||||
if ('parent_id' in object) file.parent_id = object.parent_id;
|
||||
if ('mime_type' in object) file.mime_type = object.mime_type;
|
||||
if ('is_directory' in object) file.is_directory = object.is_directory;
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
public toApiOutput(object: any): any {
|
||||
if (Array.isArray(object)) {
|
||||
return object.map(f => this.toApiOutput(f));
|
||||
} else {
|
||||
const output: File = { ...object };
|
||||
delete output.content;
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
public async createRootFile(): Promise<File> {
|
||||
const existingRootFile = await this.userRootFile();
|
||||
if (existingRootFile) throw new Error(`User ${this.userId} has already a root file`);
|
||||
|
||||
const id = uuidgen();
|
||||
|
||||
return this.save({
|
||||
id: id,
|
||||
is_directory: 1,
|
||||
is_root: 1,
|
||||
name: id, // Name must be unique so we set it to the ID
|
||||
}, {
|
||||
isNew: true,
|
||||
trackChanges: false, // Root file always exist and never changes so we don't want any change event being generated
|
||||
});
|
||||
}
|
||||
|
||||
private async checkCanReadOrWritePermissions(methodName: 'canRead' | 'canWrite', file: File | File[]): Promise<void> {
|
||||
const files = Array.isArray(file) ? file : [file];
|
||||
|
||||
if (!files.length || !files[0]) throw new ErrorNotFound();
|
||||
|
||||
const fileIds = files.map(f => f.id);
|
||||
|
||||
const permissionModel = this.models().permission();
|
||||
const permissionGrantedMap = await permissionModel[methodName](fileIds, this.userId);
|
||||
|
||||
for (const file of files) {
|
||||
if (file.owner_id === this.userId) permissionGrantedMap[file.id] = true;
|
||||
}
|
||||
|
||||
for (const fileId in permissionGrantedMap) {
|
||||
if (!permissionGrantedMap[fileId]) throw new ErrorForbidden(`No read access to: ${fileId}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async checkCanReadPermissions(file: File | File[]): Promise<void> {
|
||||
await this.checkCanReadOrWritePermissions('canRead', file);
|
||||
}
|
||||
|
||||
private async checkCanWritePermission(file: File): Promise<void> {
|
||||
await this.checkCanReadOrWritePermissions('canWrite', file);
|
||||
}
|
||||
|
||||
private includesReservedCharacter(path: string): boolean {
|
||||
return this.reservedCharacters.some(c => path.indexOf(c) >= 0);
|
||||
}
|
||||
|
||||
public async fileUrl(idOrPath: string, query: any = null): Promise<string> {
|
||||
const file: File = await this.pathToFile(idOrPath);
|
||||
const contentSuffix = !file.is_directory ? '/content' : '';
|
||||
return setQueryParameters(`${this.baseUrl}/files/${await this.itemFullPath(file)}${contentSuffix}`, query);
|
||||
}
|
||||
|
||||
private async pathToFiles(path: string, mustExist: boolean = true): Promise<File[]> {
|
||||
const filenames = splitItemPath(path);
|
||||
const output: File[] = [];
|
||||
let parent: File = null;
|
||||
|
||||
for (let i = 0; i < filenames.length; i++) {
|
||||
const filename = filenames[i];
|
||||
let file: File = null;
|
||||
if (i === 0) {
|
||||
// For now we only support "root" as a root component, but potentially it could
|
||||
// be any special directory like "documents", "pictures", etc.
|
||||
if (filename !== 'root') throw new ErrorBadRequest(`unknown path root component: ${filename}`);
|
||||
file = await this.userRootFile();
|
||||
} else {
|
||||
file = await this.fileByName(parent.id, filename);
|
||||
}
|
||||
|
||||
if (!file && mustExist) throw new ErrorNotFound(`file not found: "${filename}" on parent "${parent ? parent.name : ''}"`);
|
||||
|
||||
output.push(file);
|
||||
parent = { ...file };
|
||||
}
|
||||
|
||||
if (!output.length && mustExist) throw new ErrorBadRequest(`path without a base directory: ${path}`);
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// Mostly makes sense for testing/debugging because the filename would
|
||||
// have to globally unique, which is not a requirement.
|
||||
public async loadByName(name: string, options: LoadOptions = {}): Promise<File> {
|
||||
const file: File = await this.db(this.tableName)
|
||||
.select(this.defaultFields)
|
||||
.where({ name: name })
|
||||
.andWhere({ owner_id: this.userId })
|
||||
.first();
|
||||
if (!file) throw new ErrorNotFound(`No such file: ${name}`);
|
||||
if (!options.skipPermissionCheck) await this.checkCanReadPermissions(file);
|
||||
return file;
|
||||
}
|
||||
|
||||
public async loadWithContent(id: string, options: LoadOptions = {}): Promise<any> {
|
||||
const file: File = await this.db<File>(this.tableName).select('*').where({ id: id }).first();
|
||||
if (!file) return null;
|
||||
if (!options.skipPermissionCheck) await this.checkCanReadPermissions(file);
|
||||
return file;
|
||||
}
|
||||
|
||||
public async loadByIds(ids: string[], options: LoadOptions = {}): Promise<File[]> {
|
||||
const files: File[] = await super.loadByIds(ids);
|
||||
if (!files.length) return [];
|
||||
if (!options.skipPermissionCheck) await this.checkCanReadPermissions(files);
|
||||
return files;
|
||||
}
|
||||
|
||||
public async load(id: string, options: LoadOptions = {}): Promise<File> {
|
||||
const file: File = await super.load(id);
|
||||
if (!file) return null;
|
||||
if (!options.skipPermissionCheck) await this.checkCanReadPermissions(file);
|
||||
return file;
|
||||
}
|
||||
|
||||
public async save(object: File, options: SaveOptions = {}): Promise<File> {
|
||||
const isNew = await this.isNew(object, options);
|
||||
|
||||
const file: File = { ... object };
|
||||
|
||||
if ('content' in file) file.size = file.content ? file.content.byteLength : 0;
|
||||
|
||||
if (isNew) {
|
||||
if (!file.parent_id && !file.is_root) file.parent_id = await this.userRootFileId();
|
||||
|
||||
// Even if there's no content, set the mime type based on the extension
|
||||
if (!file.is_directory) file.mime_type = mimeUtils.fromFilename(file.name);
|
||||
|
||||
// Make sure it's not NULL, which is not allowed
|
||||
if (!file.mime_type) file.mime_type = '';
|
||||
|
||||
file.owner_id = this.userId;
|
||||
}
|
||||
|
||||
return super.save(file, options);
|
||||
}
|
||||
|
||||
public async childrenCount(id: string): Promise<number> {
|
||||
const parent = await this.load(id);
|
||||
await this.checkCanReadPermissions(parent);
|
||||
const r: any = await this.db(this.tableName).where('parent_id', id).count('id', { as: 'total' }).first();
|
||||
return r.total;
|
||||
}
|
||||
|
||||
public async childrens(id: string, pagination: Pagination): Promise<PaginatedFiles> {
|
||||
const parent = await this.load(id);
|
||||
await this.checkCanReadPermissions(parent);
|
||||
return paginateDbQuery(this.db(this.tableName).select(...this.defaultFields).where('parent_id', id), pagination);
|
||||
}
|
||||
|
||||
private async childrenIds(id: string): Promise<string[]> {
|
||||
const output = await this.db(this.tableName).select('id').where('parent_id', id);
|
||||
return output.map(r => r.id);
|
||||
}
|
||||
|
||||
public async deleteChildren(id: string): Promise<void> {
|
||||
const file: File = await this.load(id);
|
||||
if (!file) return;
|
||||
await this.checkCanWritePermission(file);
|
||||
if (!file.is_directory) throw new ErrorBadRequest(`Not a directory: ${id}`);
|
||||
|
||||
await this.withTransaction(async () => {
|
||||
const childrenIds = await this.childrenIds(file.id);
|
||||
for (const childId of childrenIds) {
|
||||
await this.delete(childId);
|
||||
}
|
||||
}, 'FileModel::deleteChildren');
|
||||
}
|
||||
|
||||
public async delete(id: string, options: DeleteOptions = {}): Promise<void> {
|
||||
const file: File = await this.load(id);
|
||||
if (!file) return;
|
||||
await this.checkCanWritePermission(file);
|
||||
|
||||
const canDeleteRoot = !!options.validationRules && !!options.validationRules.canDeleteRoot;
|
||||
|
||||
if (id === await this.userRootFileId() && !canDeleteRoot) throw new ErrorForbidden('the root directory may not be deleted');
|
||||
|
||||
await this.withTransaction(async () => {
|
||||
await this.models().permission().deleteByFileId(id);
|
||||
|
||||
if (file.is_directory) {
|
||||
const childrenIds = await this.childrenIds(file.id);
|
||||
for (const childId of childrenIds) {
|
||||
await this.delete(childId);
|
||||
}
|
||||
}
|
||||
|
||||
await super.delete(id);
|
||||
}, 'FileModel::delete');
|
||||
}
|
||||
|
||||
}
|
133
packages/server/src/models/ItemModel.test.ts
Normal file
133
packages/server/src/models/ItemModel.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, createItem, createItemTree, createResource, createNote, createFolder } from '../utils/testing/testUtils';
|
||||
import { shareFolderWithUser } from '../utils/testing/shareApiUtils';
|
||||
import { resourceBlobPath } from '../utils/joplinUtils';
|
||||
|
||||
describe('ItemModel', function() {
|
||||
|
||||
beforeAll(async () => {
|
||||
await beforeAllDb('ItemModel');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await afterAllTests();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await beforeEachDb();
|
||||
});
|
||||
|
||||
test('should find exclusively owned items 1', async function() {
|
||||
const { user: user1 } = await createUserAndSession(1, true);
|
||||
const { session: session2, user: user2 } = await createUserAndSession(2);
|
||||
|
||||
const tree: any = {
|
||||
'000000000000000000000000000000F1': {
|
||||
'00000000000000000000000000000001': null,
|
||||
},
|
||||
};
|
||||
|
||||
await createItemTree(user1.id, '', tree);
|
||||
await createItem(session2.id, 'root:/test.txt:', 'testing');
|
||||
|
||||
{
|
||||
const itemIds = await models().item().exclusivelyOwnedItemIds(user1.id);
|
||||
expect(itemIds.length).toBe(2);
|
||||
|
||||
const item1 = await models().item().load(itemIds[0]);
|
||||
const item2 = await models().item().load(itemIds[1]);
|
||||
|
||||
expect([item1.jop_id, item2.jop_id].sort()).toEqual(['000000000000000000000000000000F1', '00000000000000000000000000000001'].sort());
|
||||
}
|
||||
|
||||
{
|
||||
const itemIds = await models().item().exclusivelyOwnedItemIds(user2.id);
|
||||
expect(itemIds.length).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
test('should find exclusively owned items 2', async function() {
|
||||
const { session: session1, user: user1 } = await createUserAndSession(1, true);
|
||||
const { session: session2, user: user2 } = await createUserAndSession(2);
|
||||
|
||||
await shareFolderWithUser(session1.id, session2.id, '000000000000000000000000000000F1', {
|
||||
'000000000000000000000000000000F1': {
|
||||
'00000000000000000000000000000001': null,
|
||||
},
|
||||
});
|
||||
|
||||
await createFolder(session2.id, { id: '000000000000000000000000000000F2' });
|
||||
|
||||
{
|
||||
const itemIds = await models().item().exclusivelyOwnedItemIds(user1.id);
|
||||
expect(itemIds.length).toBe(0);
|
||||
}
|
||||
|
||||
{
|
||||
const itemIds = await models().item().exclusivelyOwnedItemIds(user2.id);
|
||||
expect(itemIds.length).toBe(1);
|
||||
}
|
||||
|
||||
await models().user().delete(user2.id);
|
||||
|
||||
{
|
||||
const itemIds = await models().item().exclusivelyOwnedItemIds(user1.id);
|
||||
expect(itemIds.length).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
test('should find all items within a shared folder', async function() {
|
||||
const { user: user1, session: session1 } = await createUserAndSession(1);
|
||||
const { session: session2 } = await createUserAndSession(2);
|
||||
|
||||
const resourceItem1 = await createResource(session1.id, { id: '000000000000000000000000000000E1' }, 'testing1');
|
||||
const resourceItem2 = await createResource(session1.id, { id: '000000000000000000000000000000E2' }, 'testing2');
|
||||
|
||||
const { share } = await shareFolderWithUser(session1.id, session2.id, '000000000000000000000000000000F1', [
|
||||
{
|
||||
id: '000000000000000000000000000000F1',
|
||||
children: [
|
||||
{
|
||||
id: '00000000000000000000000000000001',
|
||||
title: 'note test 1',
|
||||
body: `[testing 1](:/${resourceItem1.jop_id}) [testing 2](:/${resourceItem2.jop_id})`,
|
||||
},
|
||||
{
|
||||
id: '00000000000000000000000000000002',
|
||||
title: 'note test 2',
|
||||
body: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '000000000000000000000000000000F2',
|
||||
children: [],
|
||||
},
|
||||
]);
|
||||
|
||||
await createNote(session2.id, { id: '00000000000000000000000000000003', parent_id: '000000000000000000000000000000F1' });
|
||||
|
||||
{
|
||||
const shareUserIds = await models().share().allShareUserIds(share);
|
||||
const children = await models().item().sharedFolderChildrenItems(shareUserIds, '000000000000000000000000000000F1');
|
||||
|
||||
expect(children.filter(c => !!c.jop_id).map(c => c.jop_id).sort()).toEqual([
|
||||
'00000000000000000000000000000001',
|
||||
'00000000000000000000000000000002',
|
||||
'00000000000000000000000000000003',
|
||||
'000000000000000000000000000000E1',
|
||||
'000000000000000000000000000000E2',
|
||||
].sort());
|
||||
|
||||
expect(children.filter(c => !c.jop_id).map(c => c.name).sort()).toEqual([
|
||||
resourceBlobPath('000000000000000000000000000000E1'),
|
||||
resourceBlobPath('000000000000000000000000000000E2'),
|
||||
].sort());
|
||||
}
|
||||
|
||||
{
|
||||
const children = await models().item().sharedFolderChildrenItems([user1.id], '000000000000000000000000000000F2');
|
||||
expect(children.map(c => c.jop_id).sort()).toEqual([].sort());
|
||||
}
|
||||
});
|
||||
|
||||
});
|
556
packages/server/src/models/ItemModel.ts
Normal file
556
packages/server/src/models/ItemModel.ts
Normal file
@@ -0,0 +1,556 @@
|
||||
import BaseModel, { SaveOptions, LoadOptions, DeleteOptions, ValidateOptions, AclAction } from './BaseModel';
|
||||
import { ItemType, databaseSchema, Uuid, Item, ShareType, Share, ChangeType, User } from '../db';
|
||||
import { defaultPagination, paginateDbQuery, PaginatedResults, Pagination } from './utils/pagination';
|
||||
import { isJoplinItemName, isJoplinResourceBlobPath, linkedResourceIds, serializeJoplinItem, unserializeJoplinItem } from '../utils/joplinUtils';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import { ApiError, ErrorForbidden, ErrorNotFound, ErrorUnprocessableEntity } from '../utils/errors';
|
||||
import { Knex } from 'knex';
|
||||
import { ChangePreviousItem } from './ChangeModel';
|
||||
|
||||
const mimeUtils = require('@joplin/lib/mime-utils.js').mime;
|
||||
|
||||
// Converts "root:/myfile.txt:" to "myfile.txt"
|
||||
const extractNameRegex = /^root:\/(.*):$/;
|
||||
|
||||
export interface PaginatedItems extends PaginatedResults {
|
||||
items: Item[];
|
||||
}
|
||||
|
||||
export interface SharedRootInfo {
|
||||
item: Item;
|
||||
share: Share;
|
||||
}
|
||||
|
||||
export interface ItemSaveOption extends SaveOptions {
|
||||
shareId?: Uuid;
|
||||
}
|
||||
|
||||
export default class ItemModel extends BaseModel<Item> {
|
||||
|
||||
protected get tableName(): string {
|
||||
return 'items';
|
||||
}
|
||||
|
||||
protected get itemType(): ItemType {
|
||||
return ItemType.Item;
|
||||
}
|
||||
|
||||
protected get hasParentId(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
protected get defaultFields(): string[] {
|
||||
return Object.keys(databaseSchema[this.tableName]).filter(f => f !== 'content');
|
||||
}
|
||||
|
||||
public async checkIfAllowed(user: User, action: AclAction, resource: Item = null): Promise<void> {
|
||||
if (action === AclAction.Create) {
|
||||
if (!(await this.models().shareUser().isShareParticipant(resource.jop_share_id, user.id))) throw new ErrorForbidden('user has no access to this share');
|
||||
}
|
||||
|
||||
// if (action === AclAction.Delete) {
|
||||
// const share = await this.models().share().byItemId(resource.id);
|
||||
// if (share && share.type === ShareType.JoplinRootFolder) {
|
||||
// if (user.id !== share.owner_id) throw new ErrorForbidden('only the owner of the shared notebook can delete it');
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
public fromApiInput(item: Item): Item {
|
||||
const output: Item = {};
|
||||
|
||||
if ('id' in item) item.id = output.id;
|
||||
if ('name' in item) item.name = output.name;
|
||||
if ('mime_type' in item) item.mime_type = output.mime_type;
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
protected objectToApiOutput(object: Item): Item {
|
||||
const output: Item = {};
|
||||
const propNames = ['id', 'name', 'updated_time', 'created_time'];
|
||||
for (const k of Object.keys(object)) {
|
||||
if (propNames.includes(k)) (output as any)[k] = (object as any)[k];
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
public async userHasItem(userId: Uuid, itemId: Uuid): Promise<boolean> {
|
||||
const r = await this
|
||||
.db('user_items')
|
||||
.select('user_items.id')
|
||||
.where('user_items.user_id', '=', userId)
|
||||
.where('user_items.item_id', '=', itemId)
|
||||
.first();
|
||||
return !!r;
|
||||
}
|
||||
|
||||
// Remove first and last colon from a path element
|
||||
public pathToName(path: string): string {
|
||||
if (path === 'root') return '';
|
||||
return path.replace(extractNameRegex, '$1');
|
||||
}
|
||||
|
||||
public async byShareId(shareId: Uuid, options: LoadOptions = {}): Promise<Item[]> {
|
||||
return this
|
||||
.db('items')
|
||||
.select(this.selectFields(options, null, 'items'))
|
||||
.where('jop_share_id', '=', shareId);
|
||||
}
|
||||
|
||||
public async loadByJopIds(userId: Uuid | Uuid[], jopIds: string[], options: LoadOptions = {}): Promise<Item[]> {
|
||||
if (!jopIds.length) return [];
|
||||
|
||||
const userIds = Array.isArray(userId) ? userId : [userId];
|
||||
if (!userIds.length) return [];
|
||||
|
||||
return this
|
||||
.db('user_items')
|
||||
.leftJoin('items', 'items.id', 'user_items.item_id')
|
||||
.distinct(this.selectFields(options, null, 'items'))
|
||||
.whereIn('user_items.user_id', userIds)
|
||||
.whereIn('jop_id', jopIds);
|
||||
}
|
||||
|
||||
public async loadByJopId(userId: Uuid, jopId: string, options: LoadOptions = {}): Promise<Item> {
|
||||
const items = await this.loadByJopIds(userId, [jopId], options);
|
||||
return items.length ? items[0] : null;
|
||||
}
|
||||
|
||||
public async loadByNames(userId: Uuid | Uuid[], names: string[], options: LoadOptions = {}): Promise<Item[]> {
|
||||
if (!names.length) return [];
|
||||
|
||||
const userIds = Array.isArray(userId) ? userId : [userId];
|
||||
|
||||
return this
|
||||
.db('user_items')
|
||||
.leftJoin('items', 'items.id', 'user_items.item_id')
|
||||
.distinct(this.selectFields(options, null, 'items'))
|
||||
.whereIn('user_items.user_id', userIds)
|
||||
.whereIn('name', names);
|
||||
}
|
||||
|
||||
public async loadByName(userId: Uuid, name: string, options: LoadOptions = {}): Promise<Item> {
|
||||
const items = await this.loadByNames(userId, [name], options);
|
||||
return items.length ? items[0] : null;
|
||||
}
|
||||
|
||||
public async loadWithContent(id: Uuid, options: LoadOptions = {}): Promise<Item> {
|
||||
return this
|
||||
.db('user_items')
|
||||
.leftJoin('items', 'items.id', 'user_items.item_id')
|
||||
.select(this.selectFields(options, ['*'], 'items'))
|
||||
.where('items.id', '=', id)
|
||||
.first();
|
||||
}
|
||||
|
||||
public async loadAsSerializedJoplinItem(id: Uuid): Promise<string> {
|
||||
return serializeJoplinItem(await this.loadAsJoplinItem(id));
|
||||
}
|
||||
|
||||
public async serializedContent(item: Item | Uuid): Promise<Buffer> {
|
||||
item = typeof item === 'string' ? await this.loadWithContent(item) : item;
|
||||
|
||||
if (item.jop_type > 0) {
|
||||
return Buffer.from(await serializeJoplinItem(this.itemToJoplinItem(item)));
|
||||
} else {
|
||||
return item.content;
|
||||
}
|
||||
}
|
||||
|
||||
public async sharedFolderChildrenItems(shareUserIds: Uuid[], folderId: string, includeResources: boolean = true): Promise<Item[]> {
|
||||
if (!shareUserIds.length) throw new Error('User IDs must be specified');
|
||||
|
||||
let output: Item[] = [];
|
||||
|
||||
const folderAndNotes: Item[] = await this
|
||||
.db('user_items')
|
||||
.leftJoin('items', 'items.id', 'user_items.item_id')
|
||||
.distinct('items.id', 'items.jop_id', 'items.jop_type')
|
||||
.where('items.jop_parent_id', '=', folderId)
|
||||
.whereIn('user_items.user_id', shareUserIds)
|
||||
.whereIn('jop_type', [ModelType.Folder, ModelType.Note]);
|
||||
|
||||
for (const item of folderAndNotes) {
|
||||
output.push(item);
|
||||
|
||||
if (item.jop_type === ModelType.Folder) {
|
||||
const children = await this.sharedFolderChildrenItems(shareUserIds, item.jop_id, false);
|
||||
output = output.concat(children);
|
||||
}
|
||||
}
|
||||
|
||||
if (includeResources) {
|
||||
const noteItemIds = output.filter(i => i.jop_type === ModelType.Note).map(i => i.id);
|
||||
|
||||
const itemResourceIds = await this.models().itemResource().byItemIds(noteItemIds);
|
||||
|
||||
for (const itemId in itemResourceIds) {
|
||||
// TODO: should be resources with that path, that belong to any of the share users
|
||||
const resourceItems = await this.models().item().loadByJopIds(shareUserIds, itemResourceIds[itemId]);
|
||||
|
||||
for (const resourceItem of resourceItems) {
|
||||
output.push({
|
||||
id: resourceItem.id,
|
||||
jop_id: resourceItem.jop_id,
|
||||
jop_type: ModelType.Resource,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let allResourceIds: string[] = [];
|
||||
for (const itemId in itemResourceIds) {
|
||||
allResourceIds = allResourceIds.concat(itemResourceIds[itemId]);
|
||||
}
|
||||
// TODO: should be resources with that path, that belong to any of the share users
|
||||
const blobItems = await this.models().itemResource().blobItemsByResourceIds(shareUserIds, allResourceIds);
|
||||
for (const blobItem of blobItems) {
|
||||
output.push({
|
||||
id: blobItem.id,
|
||||
name: blobItem.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// private async folderChildrenItems(userId: Uuid, folderId: string): Promise<Item[]> {
|
||||
// let output: Item[] = [];
|
||||
|
||||
// const rows: Item[] = await this
|
||||
// .db('user_items')
|
||||
// .leftJoin('items', 'items.id', 'user_items.item_id')
|
||||
// .select('items.id', 'items.jop_id', 'items.jop_type')
|
||||
// .where('items.jop_parent_id', '=', folderId)
|
||||
// .where('user_items.user_id', '=', userId);
|
||||
|
||||
// for (const row of rows) {
|
||||
// output.push(row);
|
||||
|
||||
// if (row.jop_type === ModelType.Folder) {
|
||||
// const children = await this.folderChildrenItems(userId, row.jop_id);
|
||||
// output = output.concat(children);
|
||||
// }
|
||||
// }
|
||||
|
||||
// return output;
|
||||
// }
|
||||
|
||||
// public async shareJoplinFolderAndContent(shareId: Uuid, fromUserId: Uuid, toUserId: Uuid, folderId: string) {
|
||||
// const folderItem = await this.loadByJopId(fromUserId, folderId, { fields: ['id'] });
|
||||
// if (!folderItem) throw new ErrorNotFound(`Could not find folder "${folderId}" for share "${shareId}"`);
|
||||
|
||||
// const items = [folderItem].concat(await this.folderChildrenItems(fromUserId, folderId));
|
||||
|
||||
// const alreadySharedItemIds: string[] = await this
|
||||
// .db('user_items')
|
||||
// .pluck('item_id')
|
||||
// .whereIn('item_id', items.map(i => i.id))
|
||||
// .where('user_id', '=', toUserId)
|
||||
// // .where('share_id', '!=', '');
|
||||
|
||||
// await this.withTransaction(async () => {
|
||||
// for (const item of items) {
|
||||
// if (alreadySharedItemIds.includes(item.id)) continue;
|
||||
// await this.models().userItem().add(toUserId, item.id, shareId);
|
||||
|
||||
// if (item.jop_type === ModelType.Note) {
|
||||
// const resourceIds = await this.models().itemResource().byItemId(item.id);
|
||||
// await this.models().share().updateResourceShareStatus(true, shareId, fromUserId, toUserId, resourceIds);
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
|
||||
public itemToJoplinItem(itemRow: Item): any {
|
||||
if (itemRow.jop_type <= 0) throw new Error(`Not a Joplin item: ${itemRow.id}`);
|
||||
if (!itemRow.content) throw new Error('Item content is missing');
|
||||
const item = JSON.parse(itemRow.content.toString());
|
||||
|
||||
item.id = itemRow.jop_id;
|
||||
item.parent_id = itemRow.jop_parent_id;
|
||||
item.share_id = itemRow.jop_share_id;
|
||||
item.type_ = itemRow.jop_type;
|
||||
item.encryption_applied = itemRow.jop_encryption_applied;
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
public async loadAsJoplinItem(id: Uuid): Promise<any> {
|
||||
const raw = await this.loadWithContent(id);
|
||||
return this.itemToJoplinItem(raw);
|
||||
}
|
||||
|
||||
public async saveFromRawContent(userId: Uuid, name: string, buffer: Buffer, options: ItemSaveOption = null): Promise<Item> {
|
||||
options = options || {};
|
||||
|
||||
const existingItem = await this.loadByName(userId, name);
|
||||
|
||||
const isJoplinItem = isJoplinItemName(name);
|
||||
let isNote = false;
|
||||
|
||||
const item: Item = {
|
||||
name,
|
||||
};
|
||||
|
||||
let resourceIds: string[] = [];
|
||||
|
||||
if (isJoplinItem) {
|
||||
const joplinItem = await unserializeJoplinItem(buffer.toString());
|
||||
isNote = joplinItem.type_ === ModelType.Note;
|
||||
resourceIds = isNote ? linkedResourceIds(joplinItem.body) : [];
|
||||
|
||||
item.jop_id = joplinItem.id;
|
||||
item.jop_parent_id = joplinItem.parent_id || '';
|
||||
item.jop_type = joplinItem.type_;
|
||||
item.jop_encryption_applied = joplinItem.encryption_applied || 0;
|
||||
item.jop_share_id = joplinItem.share_id || '';
|
||||
|
||||
delete joplinItem.id;
|
||||
delete joplinItem.parent_id;
|
||||
delete joplinItem.share_id;
|
||||
delete joplinItem.type_;
|
||||
delete joplinItem.encryption_applied;
|
||||
|
||||
item.content = Buffer.from(JSON.stringify(joplinItem));
|
||||
} else {
|
||||
item.content = buffer;
|
||||
}
|
||||
|
||||
if (existingItem) item.id = existingItem.id;
|
||||
|
||||
if (options.shareId) item.jop_share_id = options.shareId;
|
||||
|
||||
return this.withTransaction<Item>(async () => {
|
||||
const savedItem = await this.saveForUser(userId, item);
|
||||
|
||||
if (isNote) {
|
||||
await this.models().itemResource().deleteByItemId(savedItem.id);
|
||||
await this.models().itemResource().addResourceIds(savedItem.id, resourceIds);
|
||||
}
|
||||
|
||||
return savedItem;
|
||||
});
|
||||
}
|
||||
|
||||
protected async validate(item: Item, options: ValidateOptions = {}): Promise<Item> {
|
||||
if (options.isNew) {
|
||||
if (!item.name) throw new ErrorUnprocessableEntity('name cannot be empty');
|
||||
} else {
|
||||
if ('name' in item && !item.name) throw new ErrorUnprocessableEntity('name cannot be empty');
|
||||
}
|
||||
|
||||
return super.validate(item, options);
|
||||
}
|
||||
|
||||
|
||||
private childrenQuery(userId: Uuid, pathQuery: string = '', options: LoadOptions = {}): Knex.QueryBuilder {
|
||||
const query = this
|
||||
.db('user_items')
|
||||
.leftJoin('items', 'user_items.item_id', 'items.id')
|
||||
.select(this.selectFields(options, ['id', 'name', 'updated_time'], 'items'))
|
||||
.where('user_items.user_id', '=', userId);
|
||||
|
||||
if (pathQuery) {
|
||||
// We support /* as a prefix only. Anywhere else would have
|
||||
// performance issue or requires a revert index.
|
||||
const sqlLike = pathQuery.replace(/\/\*$/g, '/%');
|
||||
void query.where('name', 'like', sqlLike);
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
public itemUrl(): string {
|
||||
return `${this.baseUrl}/items`;
|
||||
}
|
||||
|
||||
public itemContentUrl(itemId: Uuid): string {
|
||||
return `${this.baseUrl}/items/${itemId}/content`;
|
||||
}
|
||||
|
||||
public async children(userId: Uuid, pathQuery: string = '', pagination: Pagination = null, options: LoadOptions = {}): Promise<PaginatedItems> {
|
||||
pagination = pagination || defaultPagination();
|
||||
const query = this.childrenQuery(userId, pathQuery, options);
|
||||
return paginateDbQuery(query, pagination, 'items');
|
||||
}
|
||||
|
||||
public async childrenCount(userId: Uuid, pathQuery: string = ''): Promise<number> {
|
||||
const query = this.childrenQuery(userId, pathQuery);
|
||||
return query.count();
|
||||
}
|
||||
|
||||
private async joplinItemPath(jopId: string): Promise<Item[]> {
|
||||
// Use Recursive Common Table Expression to find path to given item
|
||||
// https://www.sqlite.org/lang_with.html#recursivecte
|
||||
|
||||
// with recursive paths(id, jop_id, jop_parent_id) as (
|
||||
// select id, jop_id, jop_parent_id from items where jop_id = '000000000000000000000000000000F1'
|
||||
// union
|
||||
// select items.id, items.jop_id, items.jop_parent_id
|
||||
// from items join paths where items.jop_id = paths.jop_parent_id
|
||||
// )
|
||||
// select id, jop_id, jop_parent_id from paths;
|
||||
|
||||
return this.db.withRecursive('paths', (qb: Knex.QueryBuilder) => {
|
||||
void qb.select('id', 'jop_id', 'jop_parent_id')
|
||||
.from('items')
|
||||
.where('jop_id', '=', jopId)
|
||||
.whereIn('jop_type', [ModelType.Note, ModelType.Folder])
|
||||
|
||||
.union((qb: Knex.QueryBuilder) => {
|
||||
void qb
|
||||
.select('items.id', 'items.jop_id', 'items.jop_parent_id')
|
||||
.from('items')
|
||||
.join('paths', 'items.jop_id', 'paths.jop_parent_id')
|
||||
.whereIn('jop_type', [ModelType.Note, ModelType.Folder]);
|
||||
});
|
||||
}).select('id', 'jop_id', 'jop_parent_id').from('paths');
|
||||
}
|
||||
|
||||
// If the note or folder is within a shared folder, this function returns
|
||||
// that shared folder. It returns null otherwise.
|
||||
public async joplinItemSharedRootInfo(jopId: string): Promise<SharedRootInfo | null> {
|
||||
const path = await this.joplinItemPath(jopId);
|
||||
if (!path.length) throw new ApiError(`Cannot retrieve path for item: ${jopId}`, null, 'noPathForItem');
|
||||
const rootFolderItem = path[path.length - 1];
|
||||
const share = await this.models().share().itemShare(ShareType.JoplinRootFolder, rootFolderItem.id);
|
||||
if (!share) return null;
|
||||
|
||||
return {
|
||||
item: await this.load(rootFolderItem.id),
|
||||
share,
|
||||
};
|
||||
}
|
||||
|
||||
public async allForDebug(): Promise<any[]> {
|
||||
const items = await this.all({ fields: ['*'] });
|
||||
return items.map(i => {
|
||||
if (!i.content) return i;
|
||||
i.content = i.content.toString() as any;
|
||||
return i;
|
||||
});
|
||||
}
|
||||
|
||||
public shouldRecordChange(itemName: string): boolean {
|
||||
if (isJoplinItemName(itemName)) return true;
|
||||
if (isJoplinResourceBlobPath(itemName)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
public isRootSharedFolder(item: Item): boolean {
|
||||
return item.jop_type === ModelType.Folder && item.jop_parent_id === '' && !!item.jop_share_id;
|
||||
}
|
||||
|
||||
// Returns the item IDs that are owned only by the given user. In other
|
||||
// words, the items that are not shared with anyone else. Such items
|
||||
// can be safely deleted when the user is deleted.
|
||||
public async exclusivelyOwnedItemIds(userId: Uuid): Promise<Uuid[]> {
|
||||
const query = this
|
||||
.db('items')
|
||||
.select(this.db.raw('items.id, count(user_items.item_id) as user_item_count'))
|
||||
.leftJoin('user_items', 'user_items.item_id', 'items.id')
|
||||
.whereIn('items.id', this.db('user_items').select('user_items.item_id').where('user_id', '=', userId))
|
||||
.groupBy('items.id');
|
||||
|
||||
const rows: any[] = await query;
|
||||
return rows.filter(r => r.user_item_count === 1).map(r => r.id);
|
||||
}
|
||||
|
||||
public async deleteExclusivelyOwnedItems(userId: Uuid) {
|
||||
const itemIds = await this.exclusivelyOwnedItemIds(userId);
|
||||
await this.delete(itemIds);
|
||||
}
|
||||
|
||||
public async deleteAll(userId: Uuid): Promise<void> {
|
||||
while (true) {
|
||||
const page = await this.children(userId, '', { ...defaultPagination(), limit: 1000 });
|
||||
await this.delete(page.items.map(c => c.id));
|
||||
if (!page.has_more) break;
|
||||
}
|
||||
}
|
||||
|
||||
public async delete(id: string | string[], options: DeleteOptions = {}): Promise<void> {
|
||||
const ids = typeof id === 'string' ? [id] : id;
|
||||
if (!ids.length) return;
|
||||
|
||||
const shares = await this.models().share().byItemIds(ids);
|
||||
|
||||
await this.withTransaction(async () => {
|
||||
await this.models().share().delete(shares.map(s => s.id));
|
||||
await this.models().userItem().deleteByItemIds(ids);
|
||||
await this.models().itemResource().deleteByItemIds(ids);
|
||||
|
||||
await super.delete(ids, options);
|
||||
}, 'ItemModel::delete');
|
||||
}
|
||||
|
||||
public async deleteForUser(userId: Uuid, item: Item): Promise<void> {
|
||||
if (this.isRootSharedFolder(item)) {
|
||||
const share = await this.models().share().byItemId(item.id);
|
||||
if (!share) throw new ErrorNotFound(`Cannot find share associated with item ${item.id}`);
|
||||
const userShare = await this.models().shareUser().byShareAndUserId(share.id, userId);
|
||||
if (!userShare) return;
|
||||
await this.models().shareUser().delete(userShare.id);
|
||||
} else {
|
||||
await this.delete(item.id);
|
||||
}
|
||||
}
|
||||
|
||||
public async saveForUser(userId: Uuid, item: Item, options: SaveOptions = {}): Promise<Item> {
|
||||
if (!userId) throw new Error('userId is required');
|
||||
|
||||
item = { ... item };
|
||||
const isNew = await this.isNew(item, options);
|
||||
|
||||
if (item.content) {
|
||||
item.content_size = item.content.byteLength;
|
||||
}
|
||||
|
||||
let previousItem: ChangePreviousItem = null;
|
||||
|
||||
if (isNew) {
|
||||
if (!item.mime_type) item.mime_type = mimeUtils.fromFilename(item.name) || '';
|
||||
} else {
|
||||
const beforeSaveItem = (await this.load(item.id, { fields: ['name', 'jop_type', 'jop_parent_id', 'jop_share_id'] }));
|
||||
const resourceIds = beforeSaveItem.jop_type === ModelType.Note ? await this.models().itemResource().byItemId(item.id) : [];
|
||||
|
||||
previousItem = {
|
||||
jop_parent_id: beforeSaveItem.jop_parent_id,
|
||||
name: beforeSaveItem.name,
|
||||
jop_resource_ids: resourceIds,
|
||||
jop_share_id: beforeSaveItem.jop_share_id,
|
||||
};
|
||||
}
|
||||
|
||||
return this.withTransaction(async () => {
|
||||
item = await super.save(item, options);
|
||||
|
||||
if (isNew) await this.models().userItem().add(userId, item.id);
|
||||
|
||||
// We only record updates. This because Create and Update events are
|
||||
// per user, whenever a user_item is created or deleted.
|
||||
const changeItemName = item.name || previousItem.name;
|
||||
|
||||
if (!isNew && this.shouldRecordChange(changeItemName)) {
|
||||
await this.models().change().save({
|
||||
item_type: this.itemType,
|
||||
item_id: item.id,
|
||||
item_name: changeItemName,
|
||||
type: isNew ? ChangeType.Create : ChangeType.Update,
|
||||
previous_item: previousItem ? this.models().change().serializePreviousItem(previousItem) : '',
|
||||
user_id: userId,
|
||||
});
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
public async save(_item: Item, _options: SaveOptions = {}): Promise<Item> {
|
||||
throw new Error('Use saveForUser()');
|
||||
// return this.saveForUser('', item, options);
|
||||
}
|
||||
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user