You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-12-23 23:33:01 +02:00
Compare commits
145 Commits
v2.12.10
...
new_note_l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba0e060bb0 | ||
|
|
92e1bf34bd | ||
|
|
72ead09d91 | ||
|
|
46b23bb1c6 | ||
|
|
ec09f712b3 | ||
|
|
e96ad7ccfa | ||
|
|
ce436f920a | ||
|
|
817ef7bbed | ||
|
|
5bd0c9b3a0 | ||
|
|
46d9cd34a8 | ||
|
|
c3e08237fd | ||
|
|
b406f05241 | ||
|
|
f9f2f3dd90 | ||
|
|
8f89c48302 | ||
|
|
9e6da89fec | ||
|
|
f126c44998 | ||
|
|
b68bfc2c9e | ||
|
|
2cbee6d8af | ||
|
|
8b3bd15c15 | ||
|
|
3636488ee0 | ||
|
|
86f98761ed | ||
|
|
40a1e33971 | ||
|
|
c859ad48c1 | ||
|
|
e69a5d482d | ||
|
|
1141b1c2a1 | ||
|
|
39c118be90 | ||
|
|
f9ac4e112b | ||
|
|
87e51aa8e6 | ||
|
|
41fdc0d44d | ||
|
|
a754a8d772 | ||
|
|
41d0363fd0 | ||
|
|
2a4c7a334e | ||
|
|
df1b0a96f4 | ||
|
|
0030681cb4 | ||
|
|
e7014492c5 | ||
|
|
4804c1c0c3 | ||
|
|
270d96ad07 | ||
|
|
01610be053 | ||
|
|
c1d7ef1957 | ||
|
|
144d19839c | ||
|
|
5ed3d94faa | ||
|
|
d0e943630d | ||
|
|
406e933407 | ||
|
|
7108a4243d | ||
|
|
135e2e4a21 | ||
|
|
c68c0bf501 | ||
|
|
cf3d86698d | ||
|
|
3251c4c40e | ||
|
|
e4a3de205a | ||
|
|
ada5977afa | ||
|
|
1503415ca2 | ||
|
|
bd5e0fd42a | ||
|
|
7d0b7122f0 | ||
|
|
c50052ac04 | ||
|
|
357c23b588 | ||
|
|
eca1afb6d5 | ||
|
|
85eddbfe22 | ||
|
|
c6c2733726 | ||
|
|
8c3227cacc | ||
|
|
460604d88a | ||
|
|
800a81ac23 | ||
|
|
7f8f5133ee | ||
|
|
d9a96da625 | ||
|
|
7e76c681f0 | ||
|
|
5d87b4ca3e | ||
|
|
47ecb47189 | ||
|
|
c1c6fb203a | ||
|
|
9ef11ca076 | ||
|
|
99507ed38f | ||
|
|
c78977cf11 | ||
|
|
b7540afb97 | ||
|
|
bf8fc5718c | ||
|
|
801b955ac8 | ||
|
|
89f550ca48 | ||
|
|
9e55d90736 | ||
|
|
8d0d9b58de | ||
|
|
ac5e484d4e | ||
|
|
bce18a1614 | ||
|
|
950b16370f | ||
|
|
4337e2b79a | ||
|
|
90d75ce80e | ||
|
|
72d34788dc | ||
|
|
6a2e6173ab | ||
|
|
65bb9fa3c4 | ||
|
|
90e1502e73 | ||
|
|
3cc990e1a2 | ||
|
|
10fd8454f7 | ||
|
|
56d7030222 | ||
|
|
8696052e27 | ||
|
|
5f7e130ff9 | ||
|
|
434c890686 | ||
|
|
5ab1b0bfd0 | ||
|
|
b9f632b634 | ||
|
|
ae8f32e6b4 | ||
|
|
ce8470ee7c | ||
|
|
3b1a726a23 | ||
|
|
3d4740203f | ||
|
|
7a74271e6a | ||
|
|
2cfae43fce | ||
|
|
63bfbf39e3 | ||
|
|
a143b980ee | ||
|
|
329c385358 | ||
|
|
5f645fc540 | ||
|
|
1543c651aa | ||
|
|
2518aa18f2 | ||
|
|
9cdaff4f1e | ||
|
|
8e3247221a | ||
|
|
5b5948413c | ||
|
|
ad975a473f | ||
|
|
9a8c0e9813 | ||
|
|
9d16dd22be | ||
|
|
c0501fc4e0 | ||
|
|
1acbb5dc9a | ||
|
|
2f6e146841 | ||
|
|
ba2bfa5b5c | ||
|
|
95ca89bf5b | ||
|
|
16d8a78d8a | ||
|
|
f116504e88 | ||
|
|
4d0b6ae382 | ||
|
|
13c4eba3af | ||
|
|
9756f64c11 | ||
|
|
807384cfac | ||
|
|
2329e321ec | ||
|
|
6c2e0d9262 | ||
|
|
887c271853 | ||
|
|
ea7c7f6447 | ||
|
|
29931c05ad | ||
|
|
17c227024e | ||
|
|
406a1496db | ||
|
|
c8180b91e0 | ||
|
|
1978929114 | ||
|
|
3e7debbcc5 | ||
|
|
a138b92b1b | ||
|
|
28619f1786 | ||
|
|
e8f30b708b | ||
|
|
39bc7ed397 | ||
|
|
59852e252b | ||
|
|
51764cc933 | ||
|
|
cb754604f1 | ||
|
|
2633bcef69 | ||
|
|
772c8abdcf | ||
|
|
ac098759ea | ||
|
|
349dda30b8 | ||
|
|
cbbd9b9a61 | ||
|
|
14ef0d725b |
@@ -133,6 +133,7 @@ packages/app-desktop/gui/ClipperConfigScreen.js
|
||||
packages/app-desktop/gui/ConfigScreen/ButtonBar.js
|
||||
packages/app-desktop/gui/ConfigScreen/ConfigScreen.js
|
||||
packages/app-desktop/gui/ConfigScreen/Sidebar.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.js
|
||||
@@ -259,6 +260,7 @@ packages/app-desktop/gui/NoteEditor/utils/types.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useDropHandler.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useEffectiveNoteId.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useFolder.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useFormNote.test.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useFormNote.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useMarkupToHtml.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useMessageHandler.js
|
||||
@@ -267,13 +269,36 @@ packages/app-desktop/gui/NoteEditor/utils/usePluginServiceRegistration.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useSearchMarkers.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useWindowCommandHandler.js
|
||||
packages/app-desktop/gui/NoteList/NoteList.js
|
||||
packages/app-desktop/gui/NoteList/NoteList2.js
|
||||
packages/app-desktop/gui/NoteList/NoteListSource.js
|
||||
packages/app-desktop/gui/NoteList/commands/focusElementNoteList.js
|
||||
packages/app-desktop/gui/NoteList/commands/index.js
|
||||
packages/app-desktop/gui/NoteList/types.js
|
||||
packages/app-desktop/gui/NoteList/utils/canManuallySortNotes.js
|
||||
packages/app-desktop/gui/NoteList/utils/defaultLeftToRightListRenderer.js
|
||||
packages/app-desktop/gui/NoteList/utils/defaultListRenderer.js
|
||||
packages/app-desktop/gui/NoteList/utils/getNoteTitleHtml.js
|
||||
packages/app-desktop/gui/NoteList/utils/prepareViewProps.js
|
||||
packages/app-desktop/gui/NoteList/utils/types.js
|
||||
packages/app-desktop/gui/NoteList/utils/useDragAndDrop.js
|
||||
packages/app-desktop/gui/NoteList/utils/useFocusNote.js
|
||||
packages/app-desktop/gui/NoteList/utils/useItemCss.js
|
||||
packages/app-desktop/gui/NoteList/utils/useMoveNote.js
|
||||
packages/app-desktop/gui/NoteList/utils/useOnKeyDown.js
|
||||
packages/app-desktop/gui/NoteList/utils/useOnNoteClick.js
|
||||
packages/app-desktop/gui/NoteList/utils/useRenderedNotes.js
|
||||
packages/app-desktop/gui/NoteList/utils/useScroll.js
|
||||
packages/app-desktop/gui/NoteList/utils/useVisibleRange.test.js
|
||||
packages/app-desktop/gui/NoteList/utils/useVisibleRange.js
|
||||
packages/app-desktop/gui/NoteListControls/NoteListControls.js
|
||||
packages/app-desktop/gui/NoteListControls/commands/focusSearch.js
|
||||
packages/app-desktop/gui/NoteListControls/commands/index.js
|
||||
packages/app-desktop/gui/NoteListItem.js
|
||||
packages/app-desktop/gui/NoteListItem/NoteListItem.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/types.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useItemElement.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useItemEventHandlers.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useOnContextMenu.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useRootElement.js
|
||||
packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.js
|
||||
packages/app-desktop/gui/NotePropertiesDialog.js
|
||||
packages/app-desktop/gui/NoteRevisionViewer.js
|
||||
@@ -368,6 +393,7 @@ packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.js
|
||||
packages/app-desktop/tools/notarizeMacApp.js
|
||||
packages/app-desktop/utils/checkForUpdatesUtils.test.js
|
||||
packages/app-desktop/utils/checkForUpdatesUtils.js
|
||||
packages/app-desktop/utils/checkForUpdatesUtilsTestData.js
|
||||
packages/app-desktop/utils/markupLanguageUtils.js
|
||||
packages/app-mobile/PluginAssetsLoader.js
|
||||
packages/app-mobile/components/ActionButton.js
|
||||
@@ -408,6 +434,7 @@ packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.js
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.js
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.js
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.js
|
||||
packages/app-mobile/components/NoteEditor/NoteEditor.test.js
|
||||
packages/app-mobile/components/NoteEditor/NoteEditor.js
|
||||
packages/app-mobile/components/NoteEditor/SearchPanel.js
|
||||
packages/app-mobile/components/NoteEditor/SelectionFormatting.js
|
||||
@@ -514,6 +541,8 @@ packages/lib/commands/index.js
|
||||
packages/lib/commands/openMasterPasswordDialog.js
|
||||
packages/lib/commands/synchronize.js
|
||||
packages/lib/components/EncryptionConfigScreen/utils.js
|
||||
packages/lib/components/shared/config/shouldShowMissingPasswordWarning.test.js
|
||||
packages/lib/components/shared/config/shouldShowMissingPasswordWarning.js
|
||||
packages/lib/components/shared/note-screen-shared.js
|
||||
packages/lib/components/shared/reduxSharedMiddleware.js
|
||||
packages/lib/database-driver-better-sqlite.js
|
||||
@@ -541,6 +570,7 @@ packages/lib/htmlUtils2.test.js
|
||||
packages/lib/import-enex-md-gen.test.js
|
||||
packages/lib/import-enex-md-gen.js
|
||||
packages/lib/import-enex.js
|
||||
packages/lib/initLib.js
|
||||
packages/lib/locale.test.js
|
||||
packages/lib/locale.js
|
||||
packages/lib/markdownUtils.test.js
|
||||
|
||||
15
.github/scripts/run_ci.sh
vendored
15
.github/scripts/run_ci.sh
vendored
@@ -171,6 +171,21 @@ if [ "$IS_PULL_REQUEST" == "1" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Check that the website still builds
|
||||
# =============================================================================
|
||||
|
||||
if [ "$IS_PULL_REQUEST" == "1" ] || [ "$IS_DEV_BRANCH" = "1" ]; then
|
||||
echo "Step: Check that the website still builds..."
|
||||
|
||||
mkdir -p ../joplin-website/docs
|
||||
SKIP_SPONSOR_PROCESSING=1 yarn run buildWebsite
|
||||
testResult=$?
|
||||
if [ $testResult -ne 0 ]; then
|
||||
exit $testResult
|
||||
fi
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Find out if we should run the build or not. Electron-builder gets stuck when
|
||||
# building PRs so we disable it in this case. The Linux build should provide
|
||||
|
||||
5
.github/workflows/build-android.yml
vendored
5
.github/workflows/build-android.yml
vendored
@@ -25,17 +25,18 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update || true
|
||||
sudo apt-get install -y libsecret-1-dev
|
||||
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install Yarn
|
||||
run: |
|
||||
corepack enable
|
||||
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Install
|
||||
run: yarn install
|
||||
|
||||
|
||||
1
.github/workflows/build-macos-m1.yml
vendored
1
.github/workflows/build-macos-m1.yml
vendored
@@ -26,6 +26,7 @@ jobs:
|
||||
# We need to pin the version to 18.15, because 18.16+ fails with this error:
|
||||
# https://github.com/facebook/react-native/issues/36440
|
||||
node-version: '18.15.0'
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install Yarn
|
||||
run: |
|
||||
|
||||
2
.github/workflows/github-actions-main.yml
vendored
2
.github/workflows/github-actions-main.yml
vendored
@@ -75,6 +75,7 @@ jobs:
|
||||
# We need to pin the version to 18.15, because 18.16+ fails with this error:
|
||||
# https://github.com/facebook/react-native/issues/36440
|
||||
node-version: '18.15.0'
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install Yarn
|
||||
run: |
|
||||
@@ -160,6 +161,7 @@ jobs:
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install Yarn
|
||||
run: |
|
||||
|
||||
33
.gitignore
vendored
33
.gitignore
vendored
@@ -50,6 +50,7 @@ packages/tools/github_oauth_token.txt
|
||||
lerna-debug.log
|
||||
.env
|
||||
docs/**/*.mustache
|
||||
.idea
|
||||
|
||||
# Yarn stuff
|
||||
# https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored
|
||||
@@ -118,6 +119,7 @@ packages/app-desktop/gui/ClipperConfigScreen.js
|
||||
packages/app-desktop/gui/ConfigScreen/ButtonBar.js
|
||||
packages/app-desktop/gui/ConfigScreen/ConfigScreen.js
|
||||
packages/app-desktop/gui/ConfigScreen/Sidebar.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.js
|
||||
@@ -244,6 +246,7 @@ packages/app-desktop/gui/NoteEditor/utils/types.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useDropHandler.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useEffectiveNoteId.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useFolder.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useFormNote.test.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useFormNote.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useMarkupToHtml.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useMessageHandler.js
|
||||
@@ -252,13 +255,36 @@ packages/app-desktop/gui/NoteEditor/utils/usePluginServiceRegistration.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useSearchMarkers.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useWindowCommandHandler.js
|
||||
packages/app-desktop/gui/NoteList/NoteList.js
|
||||
packages/app-desktop/gui/NoteList/NoteList2.js
|
||||
packages/app-desktop/gui/NoteList/NoteListSource.js
|
||||
packages/app-desktop/gui/NoteList/commands/focusElementNoteList.js
|
||||
packages/app-desktop/gui/NoteList/commands/index.js
|
||||
packages/app-desktop/gui/NoteList/types.js
|
||||
packages/app-desktop/gui/NoteList/utils/canManuallySortNotes.js
|
||||
packages/app-desktop/gui/NoteList/utils/defaultLeftToRightListRenderer.js
|
||||
packages/app-desktop/gui/NoteList/utils/defaultListRenderer.js
|
||||
packages/app-desktop/gui/NoteList/utils/getNoteTitleHtml.js
|
||||
packages/app-desktop/gui/NoteList/utils/prepareViewProps.js
|
||||
packages/app-desktop/gui/NoteList/utils/types.js
|
||||
packages/app-desktop/gui/NoteList/utils/useDragAndDrop.js
|
||||
packages/app-desktop/gui/NoteList/utils/useFocusNote.js
|
||||
packages/app-desktop/gui/NoteList/utils/useItemCss.js
|
||||
packages/app-desktop/gui/NoteList/utils/useMoveNote.js
|
||||
packages/app-desktop/gui/NoteList/utils/useOnKeyDown.js
|
||||
packages/app-desktop/gui/NoteList/utils/useOnNoteClick.js
|
||||
packages/app-desktop/gui/NoteList/utils/useRenderedNotes.js
|
||||
packages/app-desktop/gui/NoteList/utils/useScroll.js
|
||||
packages/app-desktop/gui/NoteList/utils/useVisibleRange.test.js
|
||||
packages/app-desktop/gui/NoteList/utils/useVisibleRange.js
|
||||
packages/app-desktop/gui/NoteListControls/NoteListControls.js
|
||||
packages/app-desktop/gui/NoteListControls/commands/focusSearch.js
|
||||
packages/app-desktop/gui/NoteListControls/commands/index.js
|
||||
packages/app-desktop/gui/NoteListItem.js
|
||||
packages/app-desktop/gui/NoteListItem/NoteListItem.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/types.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useItemElement.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useItemEventHandlers.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useOnContextMenu.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useRootElement.js
|
||||
packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.js
|
||||
packages/app-desktop/gui/NotePropertiesDialog.js
|
||||
packages/app-desktop/gui/NoteRevisionViewer.js
|
||||
@@ -353,6 +379,7 @@ packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.js
|
||||
packages/app-desktop/tools/notarizeMacApp.js
|
||||
packages/app-desktop/utils/checkForUpdatesUtils.test.js
|
||||
packages/app-desktop/utils/checkForUpdatesUtils.js
|
||||
packages/app-desktop/utils/checkForUpdatesUtilsTestData.js
|
||||
packages/app-desktop/utils/markupLanguageUtils.js
|
||||
packages/app-mobile/PluginAssetsLoader.js
|
||||
packages/app-mobile/components/ActionButton.js
|
||||
@@ -393,6 +420,7 @@ packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.js
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.js
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.js
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.js
|
||||
packages/app-mobile/components/NoteEditor/NoteEditor.test.js
|
||||
packages/app-mobile/components/NoteEditor/NoteEditor.js
|
||||
packages/app-mobile/components/NoteEditor/SearchPanel.js
|
||||
packages/app-mobile/components/NoteEditor/SelectionFormatting.js
|
||||
@@ -499,6 +527,8 @@ packages/lib/commands/index.js
|
||||
packages/lib/commands/openMasterPasswordDialog.js
|
||||
packages/lib/commands/synchronize.js
|
||||
packages/lib/components/EncryptionConfigScreen/utils.js
|
||||
packages/lib/components/shared/config/shouldShowMissingPasswordWarning.test.js
|
||||
packages/lib/components/shared/config/shouldShowMissingPasswordWarning.js
|
||||
packages/lib/components/shared/note-screen-shared.js
|
||||
packages/lib/components/shared/reduxSharedMiddleware.js
|
||||
packages/lib/database-driver-better-sqlite.js
|
||||
@@ -526,6 +556,7 @@ packages/lib/htmlUtils2.test.js
|
||||
packages/lib/import-enex-md-gen.test.js
|
||||
packages/lib/import-enex-md-gen.js
|
||||
packages/lib/import-enex.js
|
||||
packages/lib/initLib.js
|
||||
packages/lib/locale.test.js
|
||||
packages/lib/locale.js
|
||||
packages/lib/markdownUtils.test.js
|
||||
|
||||
BIN
Assets/Aide.png
Normal file
BIN
Assets/Aide.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 83 KiB |
@@ -112,7 +112,7 @@
|
||||
}).then(async function(result) {
|
||||
if (!result.ok) {
|
||||
console.error('Could not create Stripe checkout session', await result.text());
|
||||
alert('The checkout session could not be created. Please contact support@joplincloud.com for support.');
|
||||
alert('The checkout session could not be created. Please contact us on the forum for support.');
|
||||
} else {
|
||||
return result.json();
|
||||
}
|
||||
|
||||
84
README.md
84
README.md
@@ -75,11 +75,10 @@ A community maintained list of these distributions can be found here: [Unofficia
|
||||
| | | | |
|
||||
| :---: | :---: | :---: | :---: |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/215668?s=96&v=4"/></br>[avanderberg](https://github.com/avanderberg) | <img width="50" src="https://avatars2.githubusercontent.com/u/67130?s=96&v=4"/></br>[chr15m](https://github.com/chr15m) | <img width="50" src="https://avatars2.githubusercontent.com/u/2793530?s=96&v=4"/></br>[CyberXZT](https://github.com/CyberXZT) | <img width="50" src="https://avatars2.githubusercontent.com/u/1307332?s=96&v=4"/></br>[dbrandonjohnson](https://github.com/dbrandonjohnson) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/14873877?s=96&v=4"/></br>[dchecks](https://github.com/dchecks) | <img width="50" src="https://avatars2.githubusercontent.com/u/56287?s=96&v=4"/></br>[fats](https://github.com/fats) | <img width="50" src="https://avatars2.githubusercontent.com/u/49439044?s=96&v=4"/></br>[fourstepper](https://github.com/fourstepper) | <img width="50" src="https://avatars2.githubusercontent.com/u/64712218?s=96&v=4"/></br>[Hegghammer](https://github.com/Hegghammer) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/3266447?s=96&v=4"/></br>[iamwillbar](https://github.com/iamwillbar) | <img width="50" src="https://avatars2.githubusercontent.com/u/1310474?s=96&v=4"/></br>[jknowles](https://github.com/jknowles) | <img width="50" src="https://avatars2.githubusercontent.com/u/11947658?s=96&v=4"/></br>[KentBrockman](https://github.com/KentBrockman) | <img width="50" src="https://avatars2.githubusercontent.com/u/5588131?s=96&v=4"/></br>[kianenigma](https://github.com/kianenigma) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/24908652?s=96&v=4"/></br>[konishi-t](https://github.com/konishi-t) | <img width="50" src="https://avatars2.githubusercontent.com/u/42319182?s=96&v=4"/></br>[marcdw1289](https://github.com/marcdw1289) | <img width="50" src="https://avatars2.githubusercontent.com/u/126279083?s=96&v=4"/></br>[matmoly](https://github.com/matmoly) | <img width="50" src="https://avatars2.githubusercontent.com/u/1788010?s=96&v=4"/></br>[maxtruxa](https://github.com/maxtruxa) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/29300939?s=96&v=4"/></br>[mcejp](https://github.com/mcejp) | <img width="50" src="https://avatars2.githubusercontent.com/u/31054972?s=96&v=4"/></br>[saarantras](https://github.com/saarantras) | <img width="50" src="https://avatars2.githubusercontent.com/u/327998?s=96&v=4"/></br>[sif](https://github.com/sif) | <img width="50" src="https://avatars2.githubusercontent.com/u/765564?s=96&v=4"/></br>[taskcruncher](https://github.com/taskcruncher) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/333944?s=96&v=4"/></br>[tateisu](https://github.com/tateisu) | | | |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/14873877?s=96&v=4"/></br>[dchecks](https://github.com/dchecks) | <img width="50" src="https://avatars2.githubusercontent.com/u/56287?s=96&v=4"/></br>[fats](https://github.com/fats) | <img width="50" src="https://avatars2.githubusercontent.com/u/8030470?s=96&v=4"/></br>[Galliver7](https://github.com/Galliver7) | <img width="50" src="https://avatars2.githubusercontent.com/u/64712218?s=96&v=4"/></br>[Hegghammer](https://github.com/Hegghammer) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/1310474?s=96&v=4"/></br>[jknowles](https://github.com/jknowles) | <img width="50" src="https://avatars2.githubusercontent.com/u/11947658?s=96&v=4"/></br>[KentBrockman](https://github.com/KentBrockman) | <img width="50" src="https://avatars2.githubusercontent.com/u/24908652?s=96&v=4"/></br>[konishi-t](https://github.com/konishi-t) | <img width="50" src="https://avatars2.githubusercontent.com/u/42319182?s=96&v=4"/></br>[marcdw1289](https://github.com/marcdw1289) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/126279083?s=96&v=4"/></br>[matmoly](https://github.com/matmoly) | <img width="50" src="https://avatars2.githubusercontent.com/u/1788010?s=96&v=4"/></br>[maxtruxa](https://github.com/maxtruxa) | <img width="50" src="https://avatars2.githubusercontent.com/u/31054972?s=96&v=4"/></br>[saarantras](https://github.com/saarantras) | <img width="50" src="https://avatars2.githubusercontent.com/u/327998?s=96&v=4"/></br>[sif](https://github.com/sif) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/765564?s=96&v=4"/></br>[taskcruncher](https://github.com/taskcruncher) | <img width="50" src="https://avatars2.githubusercontent.com/u/333944?s=96&v=4"/></br>[tateisu](https://github.com/tateisu) | | |
|
||||
<!-- SPONSORS-GITHUB -->
|
||||
|
||||
<!-- TOC -->
|
||||
@@ -159,6 +158,7 @@ A community maintained list of these distributions can be found here: [Unofficia
|
||||
- [Guiding principles](https://github.com/laurent22/joplin/blob/dev/readme/principles.md)
|
||||
- [Stats](https://github.com/laurent22/joplin/blob/dev/readme/stats.md)
|
||||
- [Brand guidelines](https://joplinapp.org/brand)
|
||||
- [Release cycle](https://github.com/laurent22/joplin/blob/dev/readme/release_cycle.md)
|
||||
- [Donate](https://github.com/laurent22/joplin/blob/dev/readme/donate.md)
|
||||
<!-- TOC -->
|
||||
|
||||
@@ -232,9 +232,9 @@ Joplin is also capable of exporting to a number of other formats including HTML
|
||||
|
||||
# Synchronisation
|
||||
|
||||
One of the goals of Joplin is to avoid being tied to any particular company or service, whether it is Evernote, Google or Microsoft. As such the synchronisation is designed without any hard dependency to any particular service. Most of the synchronisation process is done at an abstract level and access to external services, such as Nextcloud or Dropbox, is done via lightweight drivers. It is easy to support new services by creating simple drivers that provide a filesystem-like interface, i.e. the ability to read, write, delete and list items. It is also simple to switch from one service to another or to even sync to multiple services at once. Each note, notebook, tags, as well as the relation between items is transmitted as plain text files during synchronisation, which means the data can also be moved to a different application, can be easily backed up, inspected, etc.
|
||||
One of the goals of Joplin is to avoid being tied to any particular company or service, whether it is Evernote, Google or Microsoft. As such the synchronisation is designed without any hard dependency to any particular service. Most of the synchronisation process is done at an abstract level and access to external services, such as Nextcloud or Dropbox, is done via lightweight drivers. It is easy to support new services by creating simple drivers that provide a filesystem-like interface, i.e. the ability to read, write, delete and list items. It is also simple to switch from one service to another.
|
||||
|
||||
Currently, synchronisation is possible with Nextcloud, WebDAV, Dropbox, OneDrive or the local filesystem. To enable synchronisation please follow the instructions below. After that, the application will synchronise in the background whenever it is running, or you can click on "Synchronise" to start a synchronisation manually. Joplin will background sync automatically after any content change is made on the local application.
|
||||
Currently, synchronisation is possible with Joplin Cloud, Nextcloud, S3, WebDAV, Dropbox, OneDrive or the local filesystem. To enable synchronisation please follow the instructions below. After that, the application will synchronise in the background whenever it is running, or you can click on "Synchronise" to start a synchronisation manually. Joplin will background sync automatically after any content change is made on the local application.
|
||||
|
||||
If the **terminal client** has been installed, it is possible to also synchronise outside of the user interface by typing `joplin sync` from the terminal. This can be used to setup a cron script to synchronise at a regular interval. For example, this would do it every 30 minutes:
|
||||
|
||||
@@ -537,47 +537,47 @@ Current translations:
|
||||
<!-- LOCALE-TABLE-AUTO-GENERATED -->
|
||||
| Language | Po File | Last translator | Percent done
|
||||
---|---|---|---|---
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/arableague.png" width="16px"/> | Arabic | [ar](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ar.po) | [Whaell O](mailto:Whaell@protonmail.com) | 79%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/arableague.png" width="16px"/> | Arabic | [ar](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ar.po) | [Whaell O](mailto:Whaell@protonmail.com) | 77%
|
||||
<img src="https://joplinapp.org/images/flags/es/basque_country.png" width="16px"/> | Basque | [eu](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/eu.po) | juan.abasolo@ehu.eus | 22%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/ba.png" width="16px"/> | Bosnian (Bosna i Hercegovina) | [bs_BA](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/bs_BA.po) | [Derviš T.](mailto:dervis.t@pm.me) | 57%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/bg.png" width="16px"/> | Bulgarian (България) | [bg_BG](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/bg_BG.po) | | 45%
|
||||
<img src="https://joplinapp.org/images/flags/es/catalonia.png" width="16px"/> | Catalan | [ca](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ca.po) | [Xavi Ivars](mailto:xavi.ivars@gmail.com) | 88%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/hr.png" width="16px"/> | Croatian (Hrvatska) | [hr_HR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/hr_HR.po) | [Milo Ivir](mailto:mail@milotype.de) | 99%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/cz.png" width="16px"/> | Czech (Česká republika) | [cs_CZ](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/cs_CZ.po) | Fejby | 98%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/dk.png" width="16px"/> | Dansk (Danmark) | [da_DK](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/da_DK.po) | ERYpTION | 98%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/de.png" width="16px"/> | Deutsch (Deutschland) | [de_DE](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/de_DE.po) | [MrKanister](mailto:pueblos_spatulas@aleeas.com) | 98%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/ee.png" width="16px"/> | Eesti Keel (Eesti) | [et_EE](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/et_EE.po) | | 44%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/ba.png" width="16px"/> | Bosnian (Bosna i Hercegovina) | [bs_BA](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/bs_BA.po) | [Derviš T.](mailto:dervis.t@pm.me) | 56%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/bg.png" width="16px"/> | Bulgarian (България) | [bg_BG](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/bg_BG.po) | | 44%
|
||||
<img src="https://joplinapp.org/images/flags/es/catalonia.png" width="16px"/> | Catalan | [ca](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ca.po) | [Xavi Ivars](mailto:xavi.ivars@gmail.com) | 86%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/hr.png" width="16px"/> | Croatian (Hrvatska) | [hr_HR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/hr_HR.po) | [Milo Ivir](mailto:mail@milotype.de) | 98%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/cz.png" width="16px"/> | Czech (Česká republika) | [cs_CZ](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/cs_CZ.po) | Fejby | 96%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/dk.png" width="16px"/> | Dansk (Danmark) | [da_DK](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/da_DK.po) | ERYpTION | 96%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/de.png" width="16px"/> | Deutsch (Deutschland) | [de_DE](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/de_DE.po) | [Mr-Kanister](mailto:viger_gtrc@simplelogin.com) | 98%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/ee.png" width="16px"/> | Eesti Keel (Eesti) | [et_EE](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/et_EE.po) | | 43%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/gb.png" width="16px"/> | English (United Kingdom) | [en_GB](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/en_GB.po) | | 100%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/us.png" width="16px"/> | English (United States of America) | [en_US](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/en_US.po) | | 100%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/es.png" width="16px"/> | Español (España) | [es_ES](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/es_ES.po) | [Francisco Villaverde](mailto:teko.gr@gmail.com) | 97%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/es.png" width="16px"/> | Español (España) | [es_ES](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/es_ES.po) | [Francisco Villaverde](mailto:teko.gr@gmail.com) | 95%
|
||||
<img src="https://joplinapp.org/images/flags/esperanto.png" width="16px"/> | Esperanto | [eo](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/eo.po) | Marton Paulo | 25%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/fi.png" width="16px"/> | Finnish (Suomi) | [fi_FI](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fi_FI.po) | mrkaato0 | 98%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/fi.png" width="16px"/> | Finnish (Suomi) | [fi_FI](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fi_FI.po) | mrkaato0 | 96%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/fr.png" width="16px"/> | Français (France) | [fr_FR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fr_FR.po) | Laurent Cozic | 100%
|
||||
<img src="https://joplinapp.org/images/flags/es/galicia.png" width="16px"/> | Galician (España) | [gl_ES](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/gl_ES.po) | [Marcos Lans](mailto:marcoslansgarza@gmail.com) | 29%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/id.png" width="16px"/> | Indonesian (Indonesia) | [id_ID](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/id_ID.po) | [Wisnu Adi Santoso](mailto:waditos@gmail.com) | 88%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/it.png" width="16px"/> | Italiano (Italia) | [it_IT](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/it_IT.po) | [Manuel Tassi](mailto:mannivuwiki@gmail.com) | 80%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/hu.png" width="16px"/> | Magyar (Magyarország) | [hu_HU](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/hu_HU.po) | [Magyari Balázs](mailto:balmag@gmail.com) | 77%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/be.png" width="16px"/> | Nederlands (België, Belgique, Belgien) | [nl_BE](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nl_BE.po) | | 78%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/nl.png" width="16px"/> | Nederlands (Nederland) | [nl_NL](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nl_NL.po) | [MHolkamp](mailto:mholkamp@users.noreply.github.com) | 87%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/no.png" width="16px"/> | Norwegian (Norge, Noreg) | [nb_NO](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nb_NO.po) | [Mats Estensen](mailto:code@mxe.no) | 87%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/ir.png" width="16px"/> | Persian | [fa](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fa.po) | [Kourosh Firoozbakht](mailto:kourox@protonmail.com) | 54%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/pl.png" width="16px"/> | Polski (Polska) | [pl_PL](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pl_PL.po) | [X3NO](mailto:X3NO@disroot.org) | 89%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/br.png" width="16px"/> | Português (Brasil) | [pt_BR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pt_BR.po) | [Douglas Leão](mailto:djlsplays@gmail.com) | 87%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/pt.png" width="16px"/> | Português (Portugal) | [pt_PT](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pt_PT.po) | [Diogo Caveiro](mailto:dcaveiro@yahoo.com) | 72%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/ro.png" width="16px"/> | Română | [ro](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ro.po) | [Cristi Duluta](mailto:cristi.duluta@gmail.com) | 50%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/si.png" width="16px"/> | Slovenian (Slovenija) | [sl_SI](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/sl_SI.po) | [Martin Korelič](mailto:martin.korelic@protonmail.com) | 79%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/se.png" width="16px"/> | Svenska | [sv](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/sv.po) | [Jonatan Nyberg](mailto:jonatan@autistici.org) | 99%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/th.png" width="16px"/> | Thai (ประเทศไทย) | [th_TH](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/th_TH.po) | | 36%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/vn.png" width="16px"/> | Tiếng Việt | [vi](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/vi.po) | | 77%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/tr.png" width="16px"/> | Türkçe (Türkiye) | [tr_TR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/tr_TR.po) | [Arda Kılıçdağı](mailto:arda@kilicdagi.com) | 99%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/ua.png" width="16px"/> | Ukrainian (Україна) | [uk_UA](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/uk_UA.po) | [Vyacheslav Andreykiv](mailto:vandreykiv@gmail.com) | 71%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/gr.png" width="16px"/> | Ελληνικά (Ελλάδα) | [el_GR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/el_GR.po) | [Harris Arvanitis](mailto:xaris@tuta.io) | 87%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/ru.png" width="16px"/> | Русский (Россия) | [ru_RU](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ru_RU.po) | [Dmitriy K](mailto:dmitry@atsip.ru) | 99%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/rs.png" width="16px"/> | српски језик (Србија) | [sr_RS](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/sr_RS.po) | | 64%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/cn.png" width="16px"/> | 中文 (简体) | [zh_CN](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/zh_CN.po) | [wh201906](mailto:wh201906@yandex.com) | 96%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/tw.png" width="16px"/> | 中文 (繁體) | [zh_TW](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/zh_TW.po) | [Kevin Hsu](mailto:kevin.hsu.hws@gmail.com) | 88%
|
||||
<img src="https://joplinapp.org/images/flags/es/galicia.png" width="16px"/> | Galician (España) | [gl_ES](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/gl_ES.po) | [Marcos Lans](mailto:marcoslansgarza@gmail.com) | 28%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/id.png" width="16px"/> | Indonesian (Indonesia) | [id_ID](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/id_ID.po) | [Wisnu Adi Santoso](mailto:waditos@gmail.com) | 86%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/it.png" width="16px"/> | Italiano (Italia) | [it_IT](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/it_IT.po) | [Manuel Tassi](mailto:mannivuwiki@gmail.com) | 78%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/hu.png" width="16px"/> | Magyar (Magyarország) | [hu_HU](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/hu_HU.po) | [Magyari Balázs](mailto:balmag@gmail.com) | 75%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/be.png" width="16px"/> | Nederlands (België, Belgique, Belgien) | [nl_BE](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nl_BE.po) | | 76%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/nl.png" width="16px"/> | Nederlands (Nederland) | [nl_NL](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nl_NL.po) | [MHolkamp](mailto:mholkamp@users.noreply.github.com) | 86%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/no.png" width="16px"/> | Norwegian (Norge, Noreg) | [nb_NO](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nb_NO.po) | [Mats Estensen](mailto:code@mxe.no) | 85%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/ir.png" width="16px"/> | Persian | [fa](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fa.po) | [Kourosh Firoozbakht](mailto:kourox@protonmail.com) | 53%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/pl.png" width="16px"/> | Polski (Polska) | [pl_PL](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pl_PL.po) | [X3NO](mailto:X3NO@disroot.org) | 88%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/br.png" width="16px"/> | Português (Brasil) | [pt_BR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pt_BR.po) | [Douglas Leão](mailto:djlsplays@gmail.com) | 85%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/pt.png" width="16px"/> | Português (Portugal) | [pt_PT](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pt_PT.po) | [Diogo Caveiro](mailto:dcaveiro@yahoo.com) | 70%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/ro.png" width="16px"/> | Română | [ro](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ro.po) | [Cristi Duluta](mailto:cristi.duluta@gmail.com) | 49%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/si.png" width="16px"/> | Slovenian (Slovenija) | [sl_SI](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/sl_SI.po) | [Martin Korelič](mailto:martin.korelic@protonmail.com) | 78%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/se.png" width="16px"/> | Svenska | [sv](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/sv.po) | [Jonatan Nyberg](mailto:jonatan@autistici.org) | 97%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/th.png" width="16px"/> | Thai (ประเทศไทย) | [th_TH](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/th_TH.po) | | 35%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/vn.png" width="16px"/> | Tiếng Việt | [vi](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/vi.po) | | 75%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/tr.png" width="16px"/> | Türkçe (Türkiye) | [tr_TR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/tr_TR.po) | [Arda Kılıçdağı](mailto:arda@kilicdagi.com) | 97%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/ua.png" width="16px"/> | Ukrainian (Україна) | [uk_UA](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/uk_UA.po) | [Vyacheslav Andreykiv](mailto:vandreykiv@gmail.com) | 98%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/gr.png" width="16px"/> | Ελληνικά (Ελλάδα) | [el_GR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/el_GR.po) | [Harris Arvanitis](mailto:xaris@tuta.io) | 97%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/ru.png" width="16px"/> | Русский (Россия) | [ru_RU](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ru_RU.po) | [Dmitriy K](mailto:dmitry@atsip.ru) | 97%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/rs.png" width="16px"/> | српски језик (Србија) | [sr_RS](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/sr_RS.po) | | 63%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/cn.png" width="16px"/> | 中文 (简体) | [zh_CN](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/zh_CN.po) | [qx100](mailto:ztymaxwell@gmail.com) | 98%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/tw.png" width="16px"/> | 中文 (繁體) | [zh_TW](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/zh_TW.po) | [Kevin Hsu](mailto:kevin.hsu.hws@gmail.com) | 86%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/jp.png" width="16px"/> | 日本語 (日本) | [ja_JP](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ja_JP.po) | [genneko](mailto:genneko217@gmail.com) | 88%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/kr.png" width="16px"/> | 한국어 | [ko](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ko.po) | [Ji-Hyeon Gim](mailto:potatogim@potatogim.net) | 88%
|
||||
<img src="https://joplinapp.org/images/flags/country-4x3/kr.png" width="16px"/> | 한국어 | [ko](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ko.po) | [Ji-Hyeon Gim](mailto:potatogim@potatogim.net) | 86%
|
||||
<!-- LOCALE-TABLE-AUTO-GENERATED -->
|
||||
|
||||
# Contributors
|
||||
|
||||
19
package.json
19
package.json
@@ -64,27 +64,27 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@joplin/utils": "~2.11",
|
||||
"@joplin/utils": "~2.12",
|
||||
"@seiyab/eslint-plugin-react-hooks": "4.5.1-beta.0",
|
||||
"@typescript-eslint/eslint-plugin": "5.59.0",
|
||||
"@typescript-eslint/parser": "5.59.0",
|
||||
"@typescript-eslint/eslint-plugin": "5.60.0",
|
||||
"@typescript-eslint/parser": "5.60.0",
|
||||
"cspell": "5.21.2",
|
||||
"eslint": "8.39.0",
|
||||
"eslint-interactive": "10.7.0",
|
||||
"eslint": "8.43.0",
|
||||
"eslint-interactive": "10.8.0",
|
||||
"eslint-plugin-import": "2.27.5",
|
||||
"eslint-plugin-jest": "27.2.1",
|
||||
"eslint-plugin-jest": "27.2.2",
|
||||
"eslint-plugin-promise": "6.1.1",
|
||||
"eslint-plugin-react": "7.32.2",
|
||||
"execa": "5.1.1",
|
||||
"fs-extra": "11.1.1",
|
||||
"glob": "10.2.7",
|
||||
"glob": "10.3.3",
|
||||
"gulp": "4.0.2",
|
||||
"husky": "3.1.0",
|
||||
"lerna": "3.22.1",
|
||||
"lint-staged": "13.2.3",
|
||||
"madge": "6.1.0",
|
||||
"npm-package-json-lint": "6.4.0",
|
||||
"typescript": "5.0.2"
|
||||
"typescript": "5.1.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/fs-extra": "11.0.1",
|
||||
@@ -93,11 +93,12 @@
|
||||
"node-gyp": "9.4.0",
|
||||
"nodemon": "2.0.22"
|
||||
},
|
||||
"packageManager": "yarn@3.5.0",
|
||||
"packageManager": "yarn@3.6.0",
|
||||
"resolutions": {
|
||||
"react-native-camera@4.2.1": "patch:react-native-camera@npm%3A4.2.1#./.yarn/patches/react-native-camera-npm-4.2.1-24b2600a7e.patch",
|
||||
"react-native-vosk@0.1.12": "patch:react-native-vosk@npm%3A0.1.12#./.yarn/patches/react-native-vosk-npm-0.1.12-76b1caaae8.patch",
|
||||
"eslint@8.39.0": "patch:eslint@npm%3A8.39.0#./.yarn/patches/eslint-npm-8.39.0-d92bace04d.patch",
|
||||
"eslint@8.43.0": "patch:eslint@npm%3A8.39.0#./.yarn/patches/eslint-npm-8.39.0-d92bace04d.patch",
|
||||
"eslint@^8.13.0": "patch:eslint@npm%3A8.39.0#./.yarn/patches/eslint-npm-8.39.0-d92bace04d.patch",
|
||||
"app-builder-lib@24.4.0": "patch:app-builder-lib@npm%3A24.4.0#./.yarn/patches/app-builder-lib-npm-24.4.0-05322ff057.patch",
|
||||
"react-native@0.71.10": "patch:react-native@npm%3A0.71.10#./.yarn/patches/react-native-animation-fix/react-native-npm-0.71.10-f9c32562d8.patch",
|
||||
|
||||
@@ -452,6 +452,8 @@ class Application extends BaseApplication {
|
||||
type: 'FOLDER_SELECT',
|
||||
id: Setting.value('activeFolderId'),
|
||||
});
|
||||
|
||||
this.startRotatingLogMaintenance(Setting.value('profileDir'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ const { FileApiDriverLocal } = require('@joplin/lib/file-api-driver-local');
|
||||
const EncryptionService = require('@joplin/lib/services/e2ee/EncryptionService').default;
|
||||
const envFromArgs = require('@joplin/lib/envFromArgs');
|
||||
const nodeSqlite = require('sqlite3');
|
||||
const initLib = require('@joplin/lib/initLib').default;
|
||||
|
||||
const env = envFromArgs(process.argv);
|
||||
|
||||
@@ -67,6 +68,10 @@ function appVersion() {
|
||||
|
||||
shimInit({ sharp, keytar, appVersion, nodeSqlite });
|
||||
|
||||
const logger = new Logger();
|
||||
Logger.initializeGlobalLogger(logger);
|
||||
initLib(logger);
|
||||
|
||||
const application = app();
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
"proper-lockfile": "4.1.2",
|
||||
"read-chunk": "2.1.0",
|
||||
"server-destroy": "1.0.1",
|
||||
"sharp": "0.32.1",
|
||||
"sharp": "0.32.4",
|
||||
"sprintf-js": "1.1.2",
|
||||
"sqlite3": "5.1.6",
|
||||
"string-padding": "1.0.2",
|
||||
@@ -66,18 +66,18 @@
|
||||
"terminal-kit": "3.0.0",
|
||||
"tkwidgets": "0.5.27",
|
||||
"url-parse": "1.5.10",
|
||||
"word-wrap": "1.2.3",
|
||||
"word-wrap": "1.2.5",
|
||||
"yargs-parser": "21.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@joplin/tools": "~2.12",
|
||||
"@types/fs-extra": "11.0.1",
|
||||
"@types/jest": "29.5.1",
|
||||
"@types/node": "18.15.13",
|
||||
"@types/jest": "29.5.3",
|
||||
"@types/node": "18.16.18",
|
||||
"@types/proper-lockfile": "^4.1.2",
|
||||
"gulp": "4.0.2",
|
||||
"jest": "29.5.0",
|
||||
"temp": "0.9.4",
|
||||
"typescript": "5.0.2"
|
||||
"typescript": "5.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ export default class ElectronAppWrapper {
|
||||
// so that it can tell us if we can really close the app or not.
|
||||
// Search for "appClose" event for closing logic on renderer side.
|
||||
event.preventDefault();
|
||||
this.win_.webContents.send('appClose');
|
||||
if (this.win_) this.win_.webContents.send('appClose');
|
||||
} else {
|
||||
// If the renderer process has responded, check if we can close or not
|
||||
if (this.rendererProcessQuitReply_.canClose) {
|
||||
|
||||
@@ -566,6 +566,8 @@ class Application extends BaseApplication {
|
||||
|
||||
await SpellCheckerService.instance().initialize(new SpellCheckerServiceDriverNative());
|
||||
|
||||
this.startRotatingLogMaintenance(Setting.value('profileDir'));
|
||||
|
||||
// await populateDatabase(reg.db(), {
|
||||
// clearDatabase: true,
|
||||
// folderCount: 1000,
|
||||
|
||||
@@ -23,8 +23,8 @@ function onCheckEnded() {
|
||||
isCheckingForUpdate_ = false;
|
||||
}
|
||||
|
||||
async function fetchLatestRelease() {
|
||||
const response = await shim.fetch('https://api.github.com/repos/laurent22/joplin/releases');
|
||||
async function fetchLatestReleases() {
|
||||
const response = await shim.fetch('https://objects.joplinusercontent.com/r/releases');
|
||||
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
@@ -76,8 +76,8 @@ export default async function checkForUpdates(inBackground: boolean, parentWindo
|
||||
logger.info(`Checking with options ${JSON.stringify(options)}`);
|
||||
|
||||
try {
|
||||
const releases = await fetchLatestRelease();
|
||||
const release = extractVersionInfo(releases, process.platform, options);
|
||||
const releases = await fetchLatestReleases();
|
||||
const release = extractVersionInfo(releases, process.platform, process.arch, shim.isPortable(), options);
|
||||
|
||||
logger.info(`Current version: ${packageInfo.version}`);
|
||||
logger.info(`Latest version: ${release.version}`);
|
||||
|
||||
@@ -2,7 +2,6 @@ import CommandService, { CommandRuntime, CommandDeclaration } from '@joplin/lib/
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { stateUtils } from '@joplin/lib/reducer';
|
||||
import { DesktopCommandContext } from '../services/commands/types';
|
||||
import { enabledCondition } from '../gui/NoteEditor/editorCommandDeclarations';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'toggleExternalEditing',
|
||||
@@ -23,7 +22,7 @@ export const runtime = (): CommandRuntime => {
|
||||
void CommandService.instance().execute('startExternalEditing', noteId);
|
||||
}
|
||||
},
|
||||
enabledCondition: enabledCondition(declaration.name),
|
||||
enabledCondition: 'oneNoteSelected && !noteIsReadOnly && (!modalDialogVisible || gotoAnythingVisible)',
|
||||
mapStateToTitle: (state: any) => {
|
||||
const noteId = stateUtils.selectedNoteId(state);
|
||||
return state.watchedNoteFiles.includes(noteId) ? _('Stop') : '';
|
||||
|
||||
@@ -12,13 +12,17 @@ const { connect } = require('react-redux');
|
||||
const { themeStyle } = require('@joplin/lib/theme');
|
||||
const pathUtils = require('@joplin/lib/path-utils');
|
||||
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
|
||||
const shared = require('@joplin/lib/components/shared/config-shared.js');
|
||||
const shared = require('@joplin/lib/components/shared/config/config-shared.js');
|
||||
import ClipperConfigScreen from '../ClipperConfigScreen';
|
||||
import restart from '../../services/restart';
|
||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||
import { getDefaultPluginsInstallState, updateDefaultPluginsInstallState } from '@joplin/lib/services/plugins/defaultPlugins/defaultPluginsUtils';
|
||||
import getDefaultPluginsInfo from '@joplin/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo';
|
||||
import JoplinCloudConfigScreen from '../JoplinCloudConfigScreen';
|
||||
import ToggleAdvancedSettingsButton from './controls/ToggleAdvancedSettingsButton';
|
||||
import shouldShowMissingPasswordWarning from '@joplin/lib/components/shared/config/shouldShowMissingPasswordWarning';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import StyledLink from '../style/StyledLink';
|
||||
const { KeymapConfigScreen } = require('../KeymapConfig/KeymapConfigScreen');
|
||||
|
||||
const settingKeyToControl: any = {
|
||||
@@ -180,6 +184,34 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
if (section.name === 'sync') {
|
||||
const syncTargetMd = SyncTargetRegistry.idToMetadata(settings['sync.target']);
|
||||
const statusStyle = { ...theme.textStyle, marginTop: 10 };
|
||||
const warningStyle = { ...theme.textStyle, color: theme.colorWarn };
|
||||
|
||||
// Don't show the missing password warning if the user just changed the sync target (but hasn't
|
||||
// saved yet).
|
||||
const matchesSavedTarget = settings['sync.target'] === this.props.settings['sync.target'];
|
||||
if (matchesSavedTarget && shouldShowMissingPasswordWarning(settings['sync.target'], settings)) {
|
||||
const openMissingPasswordFAQ = () =>
|
||||
bridge().openExternal('https://joplinapp.org/faq#why-did-my-sync-and-encryption-passwords-disappear-after-updating-joplin');
|
||||
|
||||
const macInfoLink = (
|
||||
<StyledLink href="#"
|
||||
onClick={openMissingPasswordFAQ}
|
||||
style={theme.linkStyle}
|
||||
>
|
||||
{_('Help')}
|
||||
</StyledLink>
|
||||
);
|
||||
|
||||
// The FAQ section related to missing passwords is specific to MacOS/ARM -- only show it
|
||||
// in that case.
|
||||
const showMacInfoLink = shim.isMac() && process.arch === 'arm64';
|
||||
|
||||
settingComps.push(
|
||||
<p key='missing-password-warning' style={warningStyle}>
|
||||
{_('Warning: Missing password.')}{' '}{showMacInfoLink ? macInfoLink : null}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (syncTargetMd.supportsConfigCheck) {
|
||||
const messages = shared.checkSyncConfigMessages(this);
|
||||
@@ -208,17 +240,11 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
const advancedSettingsSectionStyle = { display: 'none' };
|
||||
|
||||
if (advancedSettingComps.length) {
|
||||
const iconName = this.state.showAdvancedSettings ? 'fa fa-angle-down' : 'fa fa-angle-right';
|
||||
// const advancedSettingsButtonStyle = { ...theme.buttonStyle, marginBottom: 10 };
|
||||
advancedSettingsButton = (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<Button
|
||||
level={ButtonLevel.Secondary}
|
||||
onClick={() => shared.advancedSettingsButton_click(this)}
|
||||
iconName={iconName}
|
||||
title={_('Show Advanced Settings')}
|
||||
/>
|
||||
</div>
|
||||
<ToggleAdvancedSettingsButton
|
||||
onClick={() => shared.advancedSettingsButton_click(this)}
|
||||
advancedSettingsVisible={this.state.showAdvancedSettings}
|
||||
/>
|
||||
);
|
||||
advancedSettingsSectionStyle.display = this.state.showAdvancedSettings ? 'block' : 'none';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import Button, { ButtonLevel } from '../../Button/Button';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
interface Props {
|
||||
onClick: ()=> void;
|
||||
advancedSettingsVisible: boolean;
|
||||
}
|
||||
|
||||
const ToggleAdvancedSettingsButton: React.FunctionComponent<Props> = props => {
|
||||
const iconName = props.advancedSettingsVisible ? 'fa fa-angle-down' : 'fa fa-angle-right';
|
||||
return (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<Button
|
||||
level={ButtonLevel.Secondary}
|
||||
onClick={props.onClick}
|
||||
iconName={iconName}
|
||||
title={_('Show Advanced Settings')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default ToggleAdvancedSettingsButton;
|
||||
@@ -10,12 +10,13 @@ import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
|
||||
import { getEncryptionEnabled, masterKeyEnabled, SyncInfo } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
||||
import { getDefaultMasterKey, getMasterPasswordStatusMessage, masterPasswordIsValid, toggleAndSetupEncryption } from '@joplin/lib/services/e2ee/utils';
|
||||
import Button, { ButtonLevel } from '../Button/Button';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { AppState } from '../../app.reducer';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import { PublicPrivateKeyPair } from '@joplin/lib/services/e2ee/ppk';
|
||||
import ToggleAdvancedSettingsButton from '../ConfigScreen/controls/ToggleAdvancedSettingsButton';
|
||||
|
||||
interface Props {
|
||||
themeId: any;
|
||||
@@ -83,34 +84,6 @@ const EncryptionConfigScreen = (props: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderReencryptData = () => {
|
||||
if (!shim.isElectron()) return null;
|
||||
if (!props.shouldReencrypt) return null;
|
||||
|
||||
const theme = themeStyle(props.themeId);
|
||||
const buttonLabel = _('Re-encrypt data');
|
||||
|
||||
const intro = props.shouldReencrypt ? _('The default encryption method has been changed to a more secure one and it is recommended that you apply it to your data.') : _('You may use the tool below to re-encrypt your data, for example if you know that some of your notes are encrypted with an obsolete encryption method.');
|
||||
|
||||
let t = `${intro}\n\n${_('In order to do so, your entire data set will have to be encrypted and synchronised, so it is best to run it overnight.\n\nTo start, please follow these instructions:\n\n1. Synchronise all your devices.\n2. Click "%s".\n3. Let it run to completion. While it runs, avoid changing any note on your other devices, to avoid conflicts.\n4. Once sync is done on this device, sync all your other devices and let it run to completion.\n\nImportant: you only need to run this ONCE on one device.', buttonLabel)}`;
|
||||
|
||||
t = t.replace(/\n\n/g, '</p><p>');
|
||||
t = t.replace(/\n/g, '<br>');
|
||||
t = `<p>${t}</p>`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>{_('Re-encryption')}</h2>
|
||||
<p style={theme.textStyle} dangerouslySetInnerHTML={{ __html: t }}></p>
|
||||
<span style={{ marginRight: 10 }}>
|
||||
<button onClick={() => void reencryptData()} style={theme.buttonStyle}>{buttonLabel}</button>
|
||||
</span>
|
||||
|
||||
{ !props.shouldReencrypt ? null : <button onClick={() => dontReencryptData()} style={theme.buttonStyle}>{_('Ignore')}</button> }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMasterKey = (mk: MasterKeyEntity) => {
|
||||
const theme = themeStyle(props.themeId);
|
||||
|
||||
@@ -121,6 +94,12 @@ const EncryptionConfigScreen = (props: Props) => {
|
||||
borderColor: theme.dividerColor,
|
||||
};
|
||||
|
||||
const missingPasswordCellStyle = {
|
||||
...theme.textStyle,
|
||||
border: '3px solid',
|
||||
borderColor: theme.colorError,
|
||||
};
|
||||
|
||||
const password = inputPasswords[mk.id] ? inputPasswords[mk.id] : '';
|
||||
const isActive = props.activeMasterKeyId === mk.id;
|
||||
const activeIcon = isActive ? '✔' : '';
|
||||
@@ -135,8 +114,15 @@ const EncryptionConfigScreen = (props: Props) => {
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<td style={theme.textStyle}>
|
||||
<input type="password" style={passwordStyle} value={password} onChange={event => onInputPasswordChange(mk, event.target.value)} />{' '}
|
||||
<td style={missingPasswordCellStyle}>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={_('Enter password')}
|
||||
style={passwordStyle}
|
||||
value={password}
|
||||
onChange={event => onInputPasswordChange(mk, event.target.value)}
|
||||
/>
|
||||
{' '}
|
||||
<button style={theme.buttonStyle} onClick={() => onSavePasswordClick(mk, { ...props.passwords, ...inputPasswords })}>
|
||||
{_('Save')}
|
||||
</button>
|
||||
@@ -239,7 +225,6 @@ const EncryptionConfigScreen = (props: Props) => {
|
||||
/>
|
||||
);
|
||||
const needUpgradeSection = renderNeedUpgradeSection();
|
||||
const reencryptDataSection = renderReencryptData();
|
||||
|
||||
return (
|
||||
<div className="section">
|
||||
@@ -254,7 +239,6 @@ const EncryptionConfigScreen = (props: Props) => {
|
||||
{decryptedItemsInfo}
|
||||
{toggleButton}
|
||||
{needUpgradeSection}
|
||||
{props.shouldReencrypt ? reencryptDataSection : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -338,6 +322,56 @@ const EncryptionConfigScreen = (props: Props) => {
|
||||
return nonExistingMasterKeySection;
|
||||
};
|
||||
|
||||
const renderReencryptData = () => {
|
||||
if (!shim.isElectron()) return null;
|
||||
if (!props.encryptionEnabled) return null;
|
||||
|
||||
const theme = themeStyle(props.themeId);
|
||||
const buttonLabel = _('Re-encrypt data');
|
||||
|
||||
const intro = props.shouldReencrypt ? _('The default encryption method has been changed to a more secure one and it is recommended that you apply it to your data.') : _('You may use the tool below to re-encrypt your data, for example if you know that some of your notes are encrypted with an obsolete encryption method.');
|
||||
|
||||
let t = `${intro}\n\n${_('In order to do so, your entire data set will have to be encrypted and synchronised, so it is best to run it overnight.\n\nTo start, please follow these instructions:\n\n1. Synchronise all your devices.\n2. Click "%s".\n3. Let it run to completion. While it runs, avoid changing any note on your other devices, to avoid conflicts.\n4. Once sync is done on this device, sync all your other devices and let it run to completion.\n\nImportant: you only need to run this ONCE on one device.', buttonLabel)}`;
|
||||
|
||||
t = t.replace(/\n\n/g, '</p><p>');
|
||||
t = t.replace(/\n/g, '<br>');
|
||||
t = `<p>${t}</p>`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>{_('Re-encryption')}</h2>
|
||||
<p style={theme.textStyle} dangerouslySetInnerHTML={{ __html: t }}></p>
|
||||
<span style={{ marginRight: 10 }}>
|
||||
<button onClick={() => void reencryptData()} style={theme.buttonStyle}>{buttonLabel}</button>
|
||||
</span>
|
||||
|
||||
{ !props.shouldReencrypt ? null : <button onClick={() => dontReencryptData()} style={theme.buttonStyle}>{_('Ignore')}</button> }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// If the user should re-encrypt, ensure that the section is visible initially.
|
||||
const [showAdvanced, setShowAdvanced] = useState<boolean>(props.shouldReencrypt);
|
||||
const toggleAdvanced = useCallback(() => {
|
||||
setShowAdvanced(!showAdvanced);
|
||||
}, [showAdvanced]);
|
||||
|
||||
const renderAdvancedSection = () => {
|
||||
const reEncryptSection = renderReencryptData();
|
||||
|
||||
if (!reEncryptSection) return null;
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ToggleAdvancedSettingsButton
|
||||
onClick={toggleAdvanced}
|
||||
advancedSettingsVisible={showAdvanced}/>
|
||||
{ showAdvanced ? reEncryptSection : null }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="config-screen-content">
|
||||
{renderDebugSection()}
|
||||
@@ -346,6 +380,7 @@ const EncryptionConfigScreen = (props: Props) => {
|
||||
{renderMasterKeySection(props.masterKeys.filter(mk => masterKeyEnabled(mk)), true)}
|
||||
{renderMasterKeySection(props.masterKeys.filter(mk => !masterKeyEnabled(mk)), false)}
|
||||
{renderNonExistingMasterKeysSection()}
|
||||
{renderAdvancedSection()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ interface State {
|
||||
|
||||
interface Props {
|
||||
message?: string;
|
||||
children: any;
|
||||
}
|
||||
|
||||
export default class ErrorBoundary extends React.Component<Props, State> {
|
||||
|
||||
@@ -20,6 +20,7 @@ import NoteListWrapper from '../NoteListWrapper/NoteListWrapper';
|
||||
import { AppState } from '../../app.reducer';
|
||||
import { saveLayout, loadLayout } from '../ResizableLayout/utils/persist';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import shouldShowMissingPasswordWarning from '@joplin/lib/components/shared/config/shouldShowMissingPasswordWarning';
|
||||
import produce from 'immer';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import bridge from '../../services/bridge';
|
||||
@@ -67,6 +68,7 @@ interface Props {
|
||||
shouldUpgradeSyncTarget: boolean;
|
||||
hasDisabledSyncItems: boolean;
|
||||
hasDisabledEncryptionItems: boolean;
|
||||
hasMissingSyncCredentials: boolean;
|
||||
showMissingMasterKeyMessage: boolean;
|
||||
showNeedUpgradingMasterKeyMessage: boolean;
|
||||
showShouldReencryptMessage: boolean;
|
||||
@@ -561,6 +563,16 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
});
|
||||
};
|
||||
|
||||
const onViewSyncSettingsScreen = () => {
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Config',
|
||||
props: {
|
||||
defaultSection: 'sync',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onViewPluginScreen = () => {
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
@@ -598,6 +610,12 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
_('Disable safe mode and restart'),
|
||||
onDisableSafeModeAndRestart
|
||||
);
|
||||
} else if (this.props.hasMissingSyncCredentials) {
|
||||
msg = this.renderNotificationMessage(
|
||||
_('The synchronisation password is missing.'),
|
||||
_('Set the password'),
|
||||
onViewSyncSettingsScreen
|
||||
);
|
||||
} else if (this.props.shouldUpgradeSyncTarget) {
|
||||
msg = this.renderNotificationMessage(
|
||||
_('The sync target needs to be upgraded before Joplin can sync. The operation may take a few minutes to complete and the app needs to be restarted. To proceed please click on the link.'),
|
||||
@@ -662,7 +680,7 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
|
||||
public 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 || this.showShareInvitationNotification(props) || this.props.needApiAuth || this.props.showInstallTemplatesPlugin;
|
||||
return props.hasDisabledSyncItems || props.showMissingMasterKeyMessage || props.hasMissingSyncCredentials || props.showNeedUpgradingMasterKeyMessage || props.showShouldReencryptMessage || props.hasDisabledEncryptionItems || this.props.shouldUpgradeSyncTarget || props.isSafeMode || this.showShareInvitationNotification(props) || this.props.needApiAuth || this.props.showInstallTemplatesPlugin;
|
||||
}
|
||||
|
||||
public registerCommands() {
|
||||
@@ -875,6 +893,7 @@ const mapStateToProps = (state: AppState) => {
|
||||
showNeedUpgradingMasterKeyMessage: showNeedUpgradingEnabledMasterKeyMessage,
|
||||
showShouldReencryptMessage: state.settings['encryption.shouldReencrypt'] >= Setting.SHOULD_REENCRYPT_YES,
|
||||
shouldUpgradeSyncTarget: state.settings['sync.upgradeState'] === Setting.SYNC_UPGRADE_STATE_SHOULD_DO,
|
||||
hasMissingSyncCredentials: shouldShowMissingPasswordWarning(state.settings['sync.target'], state.settings),
|
||||
pluginsLegacy: state.pluginsLegacy,
|
||||
plugins: state.pluginService.plugins,
|
||||
pluginHtmlContents: state.pluginService.pluginHtmlContents,
|
||||
|
||||
@@ -38,6 +38,7 @@ export const runtime = (): CommandRuntime => {
|
||||
menuItem.click();
|
||||
});
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import * as React from 'react';
|
||||
import NoteListUtils from './utils/NoteListUtils';
|
||||
import { Dispatch } from 'redux';
|
||||
|
||||
const { buildStyle } = require('@joplin/lib/theme');
|
||||
const bridge = require('@electron/remote').require('./bridge').default;
|
||||
@@ -9,8 +10,7 @@ interface MultiNoteActionsProps {
|
||||
themeId: number;
|
||||
selectedNoteIds: string[];
|
||||
notes: any[];
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
dispatch: Function;
|
||||
dispatch: Dispatch;
|
||||
watchedNoteFiles: string[];
|
||||
plugins: PluginStates;
|
||||
inConflictFolder: boolean;
|
||||
|
||||
@@ -99,7 +99,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const addListItem = useCallback((string1, defaultText = '') => {
|
||||
const addListItem = useCallback((string1: string, defaultText = '') => {
|
||||
if (editorRef.current) {
|
||||
if (editorRef.current.somethingSelected()) {
|
||||
editorRef.current.wrapSelectionsByLine(string1);
|
||||
|
||||
@@ -141,7 +141,7 @@ export default function useScrollHandler(editorRef: any, webviewRef: any, onScro
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, []);
|
||||
|
||||
const editor_resize = useCallback((cm) => {
|
||||
const editor_resize = useCallback((cm: any) => {
|
||||
if (isCodeMirrorReady(cm)) {
|
||||
// This handler is called when resized and refreshed.
|
||||
// Only when resized, the scroll position is restored.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import TinyMCE from './NoteBody/TinyMCE/TinyMCE';
|
||||
import CodeMirror from './NoteBody/CodeMirror/CodeMirror';
|
||||
import { connect } from 'react-redux';
|
||||
@@ -40,7 +40,7 @@ import Note from '@joplin/lib/models/Note';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
const bridge = require('@electron/remote').require('./bridge').default;
|
||||
import NoteRevisionViewer from '../NoteRevisionViewer';
|
||||
import { readFromSettings } from '@joplin/lib/services/share/reducer';
|
||||
import { parseShareCache } from '@joplin/lib/services/share/reducer';
|
||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import BaseItem from '@joplin/lib/models/BaseItem';
|
||||
@@ -78,6 +78,7 @@ function NoteEditor(props: NoteEditorProps) {
|
||||
|
||||
const { formNote, setFormNote, isNewNote, resourceInfos } = useFormNote({
|
||||
syncStarted: props.syncStarted,
|
||||
decryptionStarted: props.decryptionStarted,
|
||||
noteId: effectiveNoteId,
|
||||
isProvisional: props.isProvisional,
|
||||
titleInputRef: titleInputRef,
|
||||
@@ -286,11 +287,15 @@ function NoteEditor(props: NoteEditorProps) {
|
||||
// }
|
||||
// }, [props.dispatch]);
|
||||
|
||||
const shareCache = useMemo(() => {
|
||||
return parseShareCache(props.shareCacheSetting);
|
||||
}, [props.shareCacheSetting]);
|
||||
|
||||
useAsyncEffect(async event => {
|
||||
if (!formNote.id) return;
|
||||
|
||||
try {
|
||||
const result = await itemIsReadOnly(BaseItem, ModelType.Note, ItemChange.SOURCE_UNSPECIFIED, formNote.id, props.syncUserId, props.shareCache);
|
||||
const result = await itemIsReadOnly(BaseItem, ModelType.Note, ItemChange.SOURCE_UNSPECIFIED, formNote.id, props.syncUserId, shareCache);
|
||||
if (event.cancelled) return;
|
||||
setIsReadOnly(result);
|
||||
} catch (error) {
|
||||
@@ -301,7 +306,7 @@ function NoteEditor(props: NoteEditorProps) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}, [formNote.id, props.syncUserId, props.shareCache]);
|
||||
}, [formNote.id, props.syncUserId, shareCache]);
|
||||
|
||||
const onBodyWillChange = useCallback((event: any) => {
|
||||
handleProvisionalFlag();
|
||||
@@ -324,7 +329,7 @@ function NoteEditor(props: NoteEditorProps) {
|
||||
|
||||
const onMessage = useMessageHandler(scrollWhenReady, setScrollWhenReady, editorRef, setLocalSearchResultCount, props.dispatch, formNote);
|
||||
|
||||
const externalEditWatcher_noteChange = useCallback((event) => {
|
||||
const externalEditWatcher_noteChange = useCallback((event: any) => {
|
||||
if (event.id === formNote.id) {
|
||||
const newFormNote = {
|
||||
...formNote,
|
||||
@@ -337,7 +342,7 @@ function NoteEditor(props: NoteEditorProps) {
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [formNote]);
|
||||
|
||||
const onNotePropertyChange = useCallback((event) => {
|
||||
const onNotePropertyChange = useCallback((event: any) => {
|
||||
setFormNote(formNote => {
|
||||
if (formNote.id !== event.note.id) return formNote;
|
||||
|
||||
@@ -629,6 +634,7 @@ const mapStateToProps = (state: AppState) => {
|
||||
isProvisional: state.provisionalNoteIds.includes(noteId),
|
||||
editorNoteStatuses: state.editorNoteStatuses,
|
||||
syncStarted: state.syncStarted,
|
||||
decryptionStarted: state.decryptionWorker?.state !== 'idle',
|
||||
themeId: state.settings.theme,
|
||||
richTextBannerDismissed: state.settings.richTextBannerDismissed,
|
||||
watchedNoteFiles: state.watchedNoteFiles,
|
||||
@@ -656,7 +662,7 @@ const mapStateToProps = (state: AppState) => {
|
||||
isSafeMode: state.settings.isSafeMode,
|
||||
useCustomPdfViewer: false,
|
||||
syncUserId: state.settings['sync.userId'],
|
||||
shareCache: readFromSettings(state),
|
||||
shareCacheSetting: state.settings['sync.shareCache'],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ export async function commandAttachFileToBody(body: string, filePaths: string[]
|
||||
logger.info(`Attaching ${filePath}`);
|
||||
const newBody = await shim.attachFileToNoteBody(body, filePath, options.position, {
|
||||
createFileURL: options.createFileURL,
|
||||
resizeLargeImages: 'ask',
|
||||
resizeLargeImages: Setting.value('imageResizing'),
|
||||
});
|
||||
|
||||
if (!newBody) {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import AsyncActionQueue from '@joplin/lib/AsyncActionQueue';
|
||||
import { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import { State as ShareState } from '@joplin/lib/services/share/reducer';
|
||||
import { MarkupLanguage } from '@joplin/renderer';
|
||||
import { RenderResult, RenderResultPluginAsset } from '@joplin/renderer/MarkupToHtml';
|
||||
import { MarkupToHtmlOptions } from './useMarkupToHtml';
|
||||
import { Dispatch } from 'redux';
|
||||
|
||||
export interface AllAssetsOptions {
|
||||
contentMaxWidthTarget?: string;
|
||||
@@ -16,11 +15,9 @@ export interface ToolbarButtonInfos {
|
||||
}
|
||||
|
||||
export interface NoteEditorProps {
|
||||
// style: any;
|
||||
noteId: string;
|
||||
themeId: number;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
dispatch: Function;
|
||||
dispatch: Dispatch;
|
||||
selectedNoteIds: string[];
|
||||
selectedFolderId: string;
|
||||
notes: any[];
|
||||
@@ -28,6 +25,7 @@ export interface NoteEditorProps {
|
||||
isProvisional: boolean;
|
||||
editorNoteStatuses: any;
|
||||
syncStarted: boolean;
|
||||
decryptionStarted: boolean;
|
||||
bodyEditor: string;
|
||||
notesParentType: string;
|
||||
selectedNoteTags: any[];
|
||||
@@ -46,7 +44,7 @@ export interface NoteEditorProps {
|
||||
contentMaxWidth: number;
|
||||
isSafeMode: boolean;
|
||||
useCustomPdfViewer: boolean;
|
||||
shareCache: ShareState;
|
||||
shareCacheSetting: string;
|
||||
syncUserId: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import useFormNote, { HookDependencies } from './useFormNote';
|
||||
|
||||
|
||||
describe('useFormNote', () => {
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
});
|
||||
|
||||
it('should update note when decryption completes', async () => {
|
||||
const testNote = await Note.save({ title: 'Test Note!' });
|
||||
|
||||
const makeFormNoteProps = (syncStarted: boolean, decryptionStarted: boolean): HookDependencies => {
|
||||
return {
|
||||
syncStarted,
|
||||
decryptionStarted,
|
||||
noteId: testNote.id,
|
||||
isProvisional: false,
|
||||
titleInputRef: null,
|
||||
editorRef: null,
|
||||
onBeforeLoad: ()=>{},
|
||||
onAfterLoad: ()=>{},
|
||||
};
|
||||
};
|
||||
|
||||
const formNote = renderHook(props => useFormNote(props), {
|
||||
initialProps: makeFormNoteProps(true, false),
|
||||
});
|
||||
await formNote.waitFor(() => {
|
||||
expect(formNote.result.current.formNote).toMatchObject({
|
||||
encryption_applied: 0,
|
||||
title: testNote.title,
|
||||
});
|
||||
});
|
||||
|
||||
await Note.save({
|
||||
id: testNote.id,
|
||||
encryption_cipher_text: 'cipher_text',
|
||||
encryption_applied: 1,
|
||||
});
|
||||
|
||||
// Sync starting should cause a re-render
|
||||
formNote.rerender(makeFormNoteProps(false, false));
|
||||
|
||||
await formNote.waitFor(() => {
|
||||
expect(formNote.result.current.formNote).toMatchObject({
|
||||
encryption_applied: 1,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
formNote.rerender(makeFormNoteProps(false, true));
|
||||
|
||||
await Note.save({
|
||||
id: testNote.id,
|
||||
encryption_applied: 0,
|
||||
title: 'Test Note!',
|
||||
});
|
||||
|
||||
// Ending decryption should also cause a re-render
|
||||
formNote.rerender(makeFormNoteProps(false, false));
|
||||
|
||||
await formNote.waitFor(() => {
|
||||
expect(formNote.result.current.formNote).toMatchObject({
|
||||
encryption_applied: 0,
|
||||
title: 'Test Note!',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,8 +18,9 @@ export interface OnLoadEvent {
|
||||
formNote: FormNote;
|
||||
}
|
||||
|
||||
interface HookDependencies {
|
||||
export interface HookDependencies {
|
||||
syncStarted: boolean;
|
||||
decryptionStarted: boolean;
|
||||
noteId: string;
|
||||
isProvisional: boolean;
|
||||
titleInputRef: any;
|
||||
@@ -61,14 +62,21 @@ function resourceInfosChanged(a: ResourceInfos, b: ResourceInfos): boolean {
|
||||
}
|
||||
|
||||
export default function useFormNote(dependencies: HookDependencies) {
|
||||
const { syncStarted, noteId, isProvisional, titleInputRef, editorRef, onBeforeLoad, onAfterLoad } = dependencies;
|
||||
const {
|
||||
syncStarted, decryptionStarted, noteId, isProvisional, titleInputRef, editorRef, onBeforeLoad, onAfterLoad,
|
||||
} = dependencies;
|
||||
|
||||
const [formNote, setFormNote] = useState<FormNote>(defaultFormNote());
|
||||
const [isNewNote, setIsNewNote] = useState(false);
|
||||
const prevSyncStarted = usePrevious(syncStarted);
|
||||
const prevDecryptionStarted = usePrevious(decryptionStarted);
|
||||
const previousNoteId = usePrevious(formNote.id);
|
||||
const [resourceInfos, setResourceInfos] = useState<ResourceInfos>({});
|
||||
|
||||
// Increasing the value of this counter cancels any ongoing note refreshes and starts
|
||||
// a new refresh.
|
||||
const [formNoteRefeshScheduled, setFormNoteRefreshScheduled] = useState<number>(0);
|
||||
|
||||
async function initNoteState(n: any) {
|
||||
let originalCss = '';
|
||||
|
||||
@@ -106,14 +114,7 @@ export default function useFormNote(dependencies: HookDependencies) {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Check that synchronisation has just finished - and
|
||||
// if the note has never been changed, we reload it.
|
||||
// If the note has already been changed, it's a conflict
|
||||
// that's already been handled by the synchronizer.
|
||||
|
||||
if (!prevSyncStarted) return () => {};
|
||||
if (syncStarted) return () => {};
|
||||
if (formNote.hasChanged) return () => {};
|
||||
if (formNoteRefeshScheduled <= 0) return () => {};
|
||||
|
||||
reg.logger().info('Sync has finished and note has never been changed - reloading it');
|
||||
|
||||
@@ -132,6 +133,7 @@ export default function useFormNote(dependencies: HookDependencies) {
|
||||
}
|
||||
|
||||
await initNoteState(n);
|
||||
setFormNoteRefreshScheduled(0);
|
||||
};
|
||||
|
||||
void loadNote();
|
||||
@@ -139,8 +141,34 @@ export default function useFormNote(dependencies: HookDependencies) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [prevSyncStarted, syncStarted, formNote]);
|
||||
}, [formNoteRefeshScheduled, noteId]);
|
||||
|
||||
const refreshFormNote = useCallback(() => {
|
||||
// Increase the counter to cancel any ongoing refresh attempts
|
||||
// and start a new one.
|
||||
setFormNoteRefreshScheduled(formNoteRefeshScheduled + 1);
|
||||
}, [formNoteRefeshScheduled]);
|
||||
|
||||
useEffect(() => {
|
||||
// Check that synchronisation has just finished - and
|
||||
// if the note has never been changed, we reload it.
|
||||
// If the note has already been changed, it's a conflict
|
||||
// that's already been handled by the synchronizer.
|
||||
const decryptionJustEnded = prevDecryptionStarted && !decryptionStarted;
|
||||
const syncJustEnded = prevSyncStarted && !syncStarted;
|
||||
|
||||
if (!decryptionJustEnded && !syncJustEnded) return;
|
||||
if (formNote.hasChanged) return;
|
||||
|
||||
// Refresh the form note.
|
||||
// This is kept separate from the above logic so that when prevSyncStarted is changed
|
||||
// from true to false, it doesn't cancel the note from loading.
|
||||
refreshFormNote();
|
||||
}, [
|
||||
prevSyncStarted, syncStarted,
|
||||
prevDecryptionStarted, decryptionStarted,
|
||||
formNote.hasChanged, refreshFormNote,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!noteId) {
|
||||
|
||||
@@ -17,7 +17,7 @@ import ItemList from '../ItemList';
|
||||
const { connect } = require('react-redux');
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import { Props } from './types';
|
||||
import { Props } from './utils/types';
|
||||
import usePrevious from '../hooks/usePrevious';
|
||||
import { itemIsReadOnlySync, ItemSlice } from '@joplin/lib/models/utils/readOnly';
|
||||
import { FolderEntity } from '@joplin/lib/services/database/types';
|
||||
|
||||
304
packages/app-desktop/gui/NoteList/NoteList2.tsx
Normal file
304
packages/app-desktop/gui/NoteList/NoteList2.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import * as React from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { useMemo, useRef, useEffect } from 'react';
|
||||
import { AppState } from '../../app.reducer';
|
||||
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
|
||||
import { ItemFlow, Props } from './utils/types';
|
||||
import { itemIsReadOnlySync, ItemSlice } from '@joplin/lib/models/utils/readOnly';
|
||||
import { FolderEntity } from '@joplin/lib/services/database/types';
|
||||
import ItemChange from '@joplin/lib/models/ItemChange';
|
||||
import { Size } from '@joplin/utils/types';
|
||||
import NoteListItem from '../NoteListItem/NoteListItem';
|
||||
import useRenderedNotes from './utils/useRenderedNotes';
|
||||
import useItemCss from './utils/useItemCss';
|
||||
import useOnContextMenu from '../NoteListItem/utils/useOnContextMenu';
|
||||
import useVisibleRange from './utils/useVisibleRange';
|
||||
import useScroll from './utils/useScroll';
|
||||
import useFocusNote from './utils/useFocusNote';
|
||||
import useOnNoteClick from './utils/useOnNoteClick';
|
||||
import useMoveNote from './utils/useMoveNote';
|
||||
import useOnKeyDown from './utils/useOnKeyDown';
|
||||
import * as focusElementNoteList from './commands/focusElementNoteList';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import useDragAndDrop from './utils/useDragAndDrop';
|
||||
import usePrevious from '../hooks/usePrevious';
|
||||
// import defaultLeftToRightItemRenderer from './utils/defaultLeftToRightListRenderer';
|
||||
import defaultListRenderer from './utils/defaultListRenderer';
|
||||
const { connect } = require('react-redux');
|
||||
|
||||
const commands = {
|
||||
focusElementNoteList,
|
||||
};
|
||||
|
||||
const NoteList = (props: Props) => {
|
||||
const listRef = useRef(null);
|
||||
const itemRefs = useRef<Record<string, HTMLDivElement>>({});
|
||||
// const listRenderer = defaultLeftToRightItemRenderer;
|
||||
const listRenderer = defaultListRenderer;
|
||||
|
||||
const itemSize: Size = useMemo(() => {
|
||||
return {
|
||||
width: listRenderer.itemSize.width ? listRenderer.itemSize.width : props.size.width,
|
||||
height: listRenderer.itemSize.height,
|
||||
};
|
||||
}, [listRenderer.itemSize, props.size.width]);
|
||||
|
||||
const itemsPerLine = useMemo(() => {
|
||||
if (listRenderer.flow === ItemFlow.TopToBottom) {
|
||||
return 1;
|
||||
} else {
|
||||
return Math.max(1, Math.floor(props.size.width / itemSize.width));
|
||||
}
|
||||
}, [listRenderer.flow, props.size.width, itemSize.width]);
|
||||
|
||||
const { scrollTop, onScroll, makeItemIndexVisible } = useScroll(
|
||||
itemsPerLine,
|
||||
props.notes.length,
|
||||
itemSize,
|
||||
props.size,
|
||||
listRef
|
||||
);
|
||||
|
||||
const [startNoteIndex, endNoteIndex, startLineIndex, endLineIndex, totalLineCount, visibleItemCount] = useVisibleRange(
|
||||
itemsPerLine,
|
||||
scrollTop,
|
||||
props.size,
|
||||
itemSize,
|
||||
props.notes.length
|
||||
);
|
||||
|
||||
const focusNote = useFocusNote(itemRefs);
|
||||
|
||||
const moveNote = useMoveNote(
|
||||
props.notesParentType,
|
||||
props.noteSortOrder,
|
||||
props.selectedNoteIds,
|
||||
props.selectedFolderId,
|
||||
props.uncompletedTodosOnTop,
|
||||
props.showCompletedTodos,
|
||||
props.notes
|
||||
);
|
||||
|
||||
const renderedNotes = useRenderedNotes(
|
||||
startNoteIndex,
|
||||
endNoteIndex,
|
||||
props.notes,
|
||||
props.selectedNoteIds,
|
||||
listRenderer,
|
||||
props.highlightedWords,
|
||||
props.watchedNoteFiles
|
||||
);
|
||||
|
||||
const noteItemStyle = useMemo(() => {
|
||||
return {
|
||||
width: 'auto',
|
||||
height: itemSize.height,
|
||||
};
|
||||
}, [itemSize.height]);
|
||||
|
||||
const noteListStyle = useMemo(() => {
|
||||
return {
|
||||
width: props.size.width,
|
||||
height: props.size.height,
|
||||
};
|
||||
}, [props.size]);
|
||||
|
||||
const onNoteClick = useOnNoteClick(props.dispatch, focusNote);
|
||||
|
||||
const onKeyDown = useOnKeyDown(
|
||||
props.selectedNoteIds,
|
||||
moveNote,
|
||||
makeItemIndexVisible,
|
||||
focusNote,
|
||||
props.notes,
|
||||
props.dispatch,
|
||||
visibleItemCount,
|
||||
props.notes.length,
|
||||
listRenderer.flow,
|
||||
itemsPerLine
|
||||
);
|
||||
|
||||
useItemCss(listRenderer.itemCss);
|
||||
|
||||
useEffect(() => {
|
||||
CommandService.instance().registerRuntime(commands.focusElementNoteList.declaration.name, commands.focusElementNoteList.runtime(focusNote));
|
||||
return () => {
|
||||
CommandService.instance().unregisterRuntime(commands.focusElementNoteList.declaration.name);
|
||||
};
|
||||
}, [focusNote]);
|
||||
|
||||
const onItemContextMenu = useOnContextMenu(
|
||||
props.selectedNoteIds,
|
||||
props.selectedFolderId,
|
||||
props.notes,
|
||||
props.dispatch,
|
||||
props.watchedNoteFiles,
|
||||
props.plugins,
|
||||
props.customCss
|
||||
);
|
||||
|
||||
const { onDragStart, onDragOver, onDrop, dragOverTargetNoteIndex } = useDragAndDrop(props.parentFolderIsReadOnly,
|
||||
props.selectedNoteIds,
|
||||
props.selectedFolderId,
|
||||
listRef,
|
||||
scrollTop,
|
||||
itemSize,
|
||||
props.notesParentType,
|
||||
props.noteSortOrder,
|
||||
props.uncompletedTodosOnTop,
|
||||
props.showCompletedTodos,
|
||||
listRenderer.flow,
|
||||
itemsPerLine
|
||||
);
|
||||
|
||||
const previousSelectedNoteIds = usePrevious(props.selectedNoteIds, []);
|
||||
const previousNoteCount = usePrevious(props.notes.length, 0);
|
||||
const previousVisible = usePrevious(props.visible, false);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousSelectedNoteIds !== props.selectedNoteIds && props.selectedNoteIds.length === 1) {
|
||||
const id = props.selectedNoteIds[0];
|
||||
const doRefocus = props.notes.length < previousNoteCount && !props.focusedField;
|
||||
|
||||
for (let i = 0; i < props.notes.length; i++) {
|
||||
if (props.notes[i].id === id) {
|
||||
makeItemIndexVisible(i);
|
||||
if (doRefocus) {
|
||||
const ref = itemRefs.current[id];
|
||||
if (ref) ref.focus();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [makeItemIndexVisible, previousSelectedNoteIds, previousNoteCount, previousVisible, props.selectedNoteIds, props.notes, props.focusedField, props.visible]);
|
||||
|
||||
const highlightedWords = useMemo(() => {
|
||||
if (props.notesParentType === 'Search') {
|
||||
const query = BaseModel.byId(props.searches, props.selectedSearchId);
|
||||
if (query) return props.highlightedWords;
|
||||
}
|
||||
return [];
|
||||
}, [props.notesParentType, props.searches, props.selectedSearchId, props.highlightedWords]);
|
||||
|
||||
const renderEmptyList = () => {
|
||||
if (props.notes.length) return null;
|
||||
return <div className="emptylist">{props.folders.length ? _('No notes in here. Create one by clicking on "New note".') : _('There is currently no notebook. Create one by clicking on "New notebook".')}</div>;
|
||||
};
|
||||
|
||||
const renderFiller = (key: string, style: React.CSSProperties) => {
|
||||
if (!props.notes.length) return null;
|
||||
if (style.height as number <= 0) return null;
|
||||
return <div key={key} style={style}></div>;
|
||||
};
|
||||
|
||||
const renderNotes = () => {
|
||||
if (!props.notes.length) return null;
|
||||
|
||||
const output: JSX.Element[] = [];
|
||||
|
||||
for (let i = startNoteIndex; i <= endNoteIndex; i++) {
|
||||
const note = props.notes[i];
|
||||
const renderedNote = renderedNotes[note.id];
|
||||
|
||||
output.push(
|
||||
<NoteListItem
|
||||
key={note.id}
|
||||
ref={el => itemRefs.current[note.id] = el}
|
||||
index={i}
|
||||
dragIndex={dragOverTargetNoteIndex}
|
||||
noteCount={props.notes.length}
|
||||
itemSize={itemSize}
|
||||
noteHtml={renderedNote ? renderedNote.html : ''}
|
||||
noteId={note.id}
|
||||
onChange={listRenderer.onChange}
|
||||
onClick={onNoteClick}
|
||||
onContextMenu={onItemContextMenu}
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
style={noteItemStyle}
|
||||
highlightedWords={highlightedWords}
|
||||
isProvisional={props.provisionalNoteIds.includes(note.id)}
|
||||
flow={listRenderer.flow}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
const topFillerHeight = startLineIndex * itemSize.height;
|
||||
const bottomFillerHeight = (totalLineCount - endLineIndex - 1) * itemSize.height;
|
||||
|
||||
const fillerBaseStyle = useMemo(() => {
|
||||
// return { width: 'auto', border: '1px solid red', backgroundColor: 'green' };
|
||||
return { width: 'auto' };
|
||||
}, []);
|
||||
|
||||
const topFillerStyle = useMemo(() => {
|
||||
return { ...fillerBaseStyle, height: topFillerHeight };
|
||||
}, [fillerBaseStyle, topFillerHeight]);
|
||||
|
||||
const bottomFillerStyle = useMemo(() => {
|
||||
return { ...fillerBaseStyle, height: bottomFillerHeight };
|
||||
}, [fillerBaseStyle, bottomFillerHeight]);
|
||||
|
||||
const notesStyle = useMemo(() => {
|
||||
const output: React.CSSProperties = {};
|
||||
|
||||
if (listRenderer.flow === ItemFlow.LeftToRight) {
|
||||
output.flexFlow = 'row wrap';
|
||||
} else {
|
||||
output.flexDirection = 'column';
|
||||
}
|
||||
|
||||
return output;
|
||||
}, [listRenderer.flow]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="note-list"
|
||||
style={noteListStyle}
|
||||
ref={listRef}
|
||||
onScroll={onScroll}
|
||||
onKeyDown={onKeyDown}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
{renderEmptyList()}
|
||||
{renderFiller('top', topFillerStyle)}
|
||||
<div className="notes" style={notesStyle}>
|
||||
{renderNotes()}
|
||||
</div>
|
||||
{renderFiller('bottom', bottomFillerStyle)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
const selectedFolder: FolderEntity = state.notesParentType === 'Folder' ? BaseModel.byId(state.folders, state.selectedFolderId) : null;
|
||||
const userId = state.settings['sync.userId'];
|
||||
|
||||
return {
|
||||
notes: state.notes,
|
||||
folders: state.folders,
|
||||
selectedNoteIds: state.selectedNoteIds,
|
||||
selectedFolderId: state.selectedFolderId,
|
||||
themeId: state.settings.theme,
|
||||
notesParentType: state.notesParentType,
|
||||
searches: state.searches,
|
||||
selectedSearchId: state.selectedSearchId,
|
||||
watchedNoteFiles: state.watchedNoteFiles,
|
||||
provisionalNoteIds: state.provisionalNoteIds,
|
||||
isInsertingNotes: state.isInsertingNotes,
|
||||
noteSortOrder: state.settings['notes.sortOrder.field'],
|
||||
uncompletedTodosOnTop: state.settings.uncompletedTodosOnTop,
|
||||
showCompletedTodos: state.settings.showCompletedTodos,
|
||||
highlightedWords: state.highlightedWords,
|
||||
plugins: state.pluginService.plugins,
|
||||
customCss: state.customCss,
|
||||
focusedField: state.focusedField,
|
||||
parentFolderIsReadOnly: state.notesParentType === 'Folder' && selectedFolder ? itemIsReadOnlySync(ModelType.Folder, ItemChange.SOURCE_UNSPECIFIED, selectedFolder as ItemSlice, userId, state.shareService) : false,
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(NoteList);
|
||||
113
packages/app-desktop/gui/NoteList/NoteListSource.tsx
Normal file
113
packages/app-desktop/gui/NoteList/NoteListSource.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import * as React from 'react';
|
||||
import { useMemo, useState, useRef, useCallback } from 'react';
|
||||
import { AppState } from '../../app.reducer';
|
||||
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
|
||||
import NoteListItem from '../NoteListItem';
|
||||
import styled from 'styled-components';
|
||||
import ItemList from '../ItemList';
|
||||
const { connect } = require('react-redux');
|
||||
import { Props } from './utils/types';
|
||||
import { itemIsReadOnlySync, ItemSlice } from '@joplin/lib/models/utils/readOnly';
|
||||
import { FolderEntity } from '@joplin/lib/services/database/types';
|
||||
import ItemChange from '@joplin/lib/models/ItemChange';
|
||||
|
||||
const StyledRoot = styled.div``;
|
||||
|
||||
const NoteListComponent = (props: Props) => {
|
||||
const [width] = useState(0);
|
||||
|
||||
const itemHeight = 34;
|
||||
|
||||
const noteListRef = useRef(null);
|
||||
const itemListRef = useRef(null);
|
||||
|
||||
const style = useMemo(() => {
|
||||
return {};
|
||||
}, []);
|
||||
|
||||
const renderItem = useCallback((item: any, index: number) => {
|
||||
return <NoteListItem
|
||||
key={item.id}
|
||||
style={style}
|
||||
item={item}
|
||||
index={index}
|
||||
themeId={props.themeId}
|
||||
width={width}
|
||||
height={itemHeight}
|
||||
dragItemIndex={0}
|
||||
highlightedWords={[]}
|
||||
isProvisional={props.provisionalNoteIds.includes(item.id)}
|
||||
isSelected={props.selectedNoteIds.indexOf(item.id) >= 0}
|
||||
isWatched={props.watchedNoteFiles.indexOf(item.id) < 0}
|
||||
itemCount={props.notes.length}
|
||||
onCheckboxClick={() => {}}
|
||||
onDragStart={()=>{}}
|
||||
onNoteDragOver={()=>{}}
|
||||
onTitleClick={() => {}}
|
||||
onContextMenu={() => {}}
|
||||
draggable={!props.parentFolderIsReadOnly}
|
||||
/>;
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [style, props.themeId, width, itemHeight, props.provisionalNoteIds, props.selectedNoteIds, props.watchedNoteFiles,
|
||||
props.notes,
|
||||
props.notesParentType,
|
||||
props.searches,
|
||||
props.selectedSearchId,
|
||||
props.highlightedWords,
|
||||
props.parentFolderIsReadOnly,
|
||||
]);
|
||||
const renderItemList = () => {
|
||||
if (!props.notes.length) return null;
|
||||
|
||||
return (
|
||||
<ItemList
|
||||
ref={itemListRef}
|
||||
disabled={props.isInsertingNotes}
|
||||
itemHeight={32}
|
||||
className={'note-list'}
|
||||
items={props.notes}
|
||||
style={props.size}
|
||||
itemRenderer={renderItem}
|
||||
onKeyDown={() => {}}
|
||||
onNoteDrop={()=>{}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
if (!props.size) throw new Error('props.size is required');
|
||||
|
||||
return (
|
||||
<StyledRoot ref={noteListRef}>
|
||||
{renderItemList()}
|
||||
</StyledRoot>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
const selectedFolder: FolderEntity = state.notesParentType === 'Folder' ? BaseModel.byId(state.folders, state.selectedFolderId) : null;
|
||||
const userId = state.settings['sync.userId'];
|
||||
|
||||
return {
|
||||
notes: state.notes,
|
||||
folders: state.folders,
|
||||
selectedNoteIds: state.selectedNoteIds,
|
||||
selectedFolderId: state.selectedFolderId,
|
||||
themeId: state.settings.theme,
|
||||
notesParentType: state.notesParentType,
|
||||
searches: state.searches,
|
||||
selectedSearchId: state.selectedSearchId,
|
||||
watchedNoteFiles: state.watchedNoteFiles,
|
||||
provisionalNoteIds: state.provisionalNoteIds,
|
||||
isInsertingNotes: state.isInsertingNotes,
|
||||
noteSortOrder: state.settings['notes.sortOrder.field'],
|
||||
uncompletedTodosOnTop: state.settings.uncompletedTodosOnTop,
|
||||
showCompletedTodos: state.settings.showCompletedTodos,
|
||||
highlightedWords: state.highlightedWords,
|
||||
plugins: state.pluginService.plugins,
|
||||
customCss: state.customCss,
|
||||
focusedField: state.focusedField,
|
||||
parentFolderIsReadOnly: state.notesParentType === 'Folder' && selectedFolder ? itemIsReadOnlySync(ModelType.Folder, ItemChange.SOURCE_UNSPECIFIED, selectedFolder as ItemSlice, userId, state.shareService) : false,
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(NoteListComponent);
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { stateUtils } from '@joplin/lib/reducer';
|
||||
import { itemAnchorRef } from '../NoteList';
|
||||
import { FocusNote } from '../utils/useFocusNote';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'focusElementNoteList',
|
||||
@@ -9,15 +9,11 @@ export const declaration: CommandDeclaration = {
|
||||
parentLabel: () => _('Focus'),
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
export const runtime = (focusNote: FocusNote): CommandRuntime => {
|
||||
return {
|
||||
execute: async (context: CommandContext, noteId: string = null) => {
|
||||
noteId = noteId || stateUtils.selectedNoteId(context.state);
|
||||
|
||||
if (noteId) {
|
||||
const ref = itemAnchorRef(noteId);
|
||||
if (ref) ref.focus();
|
||||
}
|
||||
focusNote(noteId);
|
||||
},
|
||||
enabledCondition: 'noteListHasNotes',
|
||||
};
|
||||
|
||||
43
packages/app-desktop/gui/NoteList/style.scss
Normal file
43
packages/app-desktop/gui/NoteList/style.scss
Normal file
@@ -0,0 +1,43 @@
|
||||
.note-list {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--joplin-background-color3);
|
||||
border-right: 1px solid var(--joplin-divider-color);
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
|
||||
> .notes {
|
||||
display: flex;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
> .emptylist {
|
||||
padding: 10px;
|
||||
font-size: var(--joplin-font-size);
|
||||
color: var(--joplin-color);
|
||||
background-color: var(--joplin-background-color);
|
||||
font-family: var(--joplin-font-family);
|
||||
}
|
||||
}
|
||||
|
||||
.note-list-item {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.note-list-item-wrapper {
|
||||
border-color: var(--joplin-color);
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
|
||||
> .dragcursor {
|
||||
background-color: var(--joplin-color);
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.note-list-item-wrapper.-provisional {
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { FolderEntity, NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
|
||||
export interface Props {
|
||||
themeId: any;
|
||||
selectedNoteIds: string[];
|
||||
notes: NoteEntity[];
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
dispatch: Function;
|
||||
watchedNoteFiles: any[];
|
||||
plugins: PluginStates;
|
||||
selectedFolderId: string;
|
||||
customCss: string;
|
||||
notesParentType: string;
|
||||
noteSortOrder: string;
|
||||
uncompletedTodosOnTop: boolean;
|
||||
showCompletedTodos: boolean;
|
||||
resizableLayoutEventEmitter: any;
|
||||
isInsertingNotes: boolean;
|
||||
folders: FolderEntity[];
|
||||
size: any;
|
||||
searches: any[];
|
||||
selectedSearchId: string;
|
||||
highlightedWords: string[];
|
||||
provisionalNoteIds: string[];
|
||||
visible: boolean;
|
||||
focusedField: string;
|
||||
parentFolderIsReadOnly: boolean;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import bridge from '../../../services/bridge';
|
||||
|
||||
const canManuallySortNotes = (notesParentType: string, noteSortOrder: string) => {
|
||||
if (notesParentType !== 'Folder') return false;
|
||||
|
||||
if (noteSortOrder !== 'order') {
|
||||
const doIt = bridge().showConfirmMessageBox(_('To manually sort the notes, the sort order must be changed to "%s" in the menu "%s" > "%s"', _('Custom order'), _('View'), _('Sort notes by')), {
|
||||
buttons: [_('Do it now'), _('Cancel')],
|
||||
});
|
||||
if (!doIt) return false;
|
||||
|
||||
Setting.setValue('notes.sortOrder.field', 'order');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export default canManuallySortNotes;
|
||||
@@ -0,0 +1,165 @@
|
||||
import { MarkupLanguage, MarkupToHtml } from '@joplin/renderer';
|
||||
import { ItemFlow, ListRenderer } from './types';
|
||||
|
||||
interface Props {
|
||||
note: {
|
||||
id: string;
|
||||
title: string;
|
||||
is_todo: number;
|
||||
todo_completed: number;
|
||||
body: string;
|
||||
};
|
||||
item: {
|
||||
size: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
selected: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const defaultLeftToRightItemRenderer: ListRenderer = {
|
||||
flow: ItemFlow.LeftToRight,
|
||||
|
||||
itemSize: {
|
||||
width: 150,
|
||||
height: 150,
|
||||
},
|
||||
|
||||
dependencies: [
|
||||
'item.selected',
|
||||
'item.size.width',
|
||||
'item.size.height',
|
||||
'note.body',
|
||||
'note.id',
|
||||
'note.is_shared',
|
||||
'note.is_todo',
|
||||
'note.isWatched',
|
||||
'note.titleHtml',
|
||||
'note.todo_completed',
|
||||
],
|
||||
|
||||
itemCss: // css
|
||||
`
|
||||
&:before {
|
||||
content: '';
|
||||
border-bottom: 1px solid var(--joplin-divider-color);
|
||||
width: 90%;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 5%;
|
||||
}
|
||||
|
||||
> .content.-selected {
|
||||
background-color: var(--joplin-selected-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--joplin-background-color-hover3);
|
||||
}
|
||||
|
||||
> .content {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
align-items: flex-start;
|
||||
overflow-y: hidden;
|
||||
flex-direction: column;
|
||||
user-select: none;
|
||||
|
||||
> .checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
> input {
|
||||
margin: 0px 10px 1px 0px;
|
||||
}
|
||||
}
|
||||
|
||||
> .title {
|
||||
font-family: var(--joplin-font-family);
|
||||
font-size: var(--joplin-font-size);
|
||||
color: var(--joplin-color);
|
||||
cursor: default;
|
||||
flex: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 8px;
|
||||
|
||||
> .checkbox {
|
||||
margin: 0 6px 0 0;
|
||||
}
|
||||
|
||||
> .watchedicon {
|
||||
display: none;
|
||||
padding-right: 4px;
|
||||
color: var(--joplin-color);
|
||||
}
|
||||
|
||||
> .titlecontent {
|
||||
word-break: break-all;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
> .preview {
|
||||
overflow-y: hidden;
|
||||
font-family: var(--joplin-font-family);
|
||||
font-size: var(--joplin-font-size);
|
||||
color: var(--joplin-color);
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
> .content.-shared {
|
||||
> .title {
|
||||
color: var(--joplin-color-warn3);
|
||||
}
|
||||
}
|
||||
|
||||
> .content.-completed {
|
||||
> .title {
|
||||
opacity: 0.5;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
> .content.-watched {
|
||||
> .title {
|
||||
> .watchedicon {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
itemTemplate: // html
|
||||
`
|
||||
<div class="content {{#item.selected}}-selected{{/item.selected}} {{#note.is_shared}}-shared{{/note.is_shared}} {{#note.todo_completed}}-completed{{/note.todo_completed}} {{#note.isWatched}}-watched{{/note.isWatched}}">
|
||||
<div style="width: {{titleWidth}}px;" class="title" data-id="{{note.id}}">
|
||||
{{#note.is_todo}}
|
||||
<input class="checkbox" data-id="todo-checkbox" type="checkbox" {{#note.todo_completed}}checked="checked"{{/note.todo_completed}}>
|
||||
{{/note.is_todo}}
|
||||
<i class="watchedicon fa fa-share-square"></i>
|
||||
<div class="titlecontent">{{{note.titleHtml}}}</div>
|
||||
</div>
|
||||
<div class="preview">{{notePreview}}</div>
|
||||
</div>
|
||||
`,
|
||||
|
||||
onRenderNote: async (props: Props) => {
|
||||
const markupToHtml_ = new MarkupToHtml();
|
||||
|
||||
return {
|
||||
...props,
|
||||
notePreview: markupToHtml_.stripMarkup(MarkupLanguage.Markdown, props.note.body).substring(0, 200),
|
||||
titleWidth: props.item.size.width - 32,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default defaultLeftToRightItemRenderer;
|
||||
134
packages/app-desktop/gui/NoteList/utils/defaultListRenderer.ts
Normal file
134
packages/app-desktop/gui/NoteList/utils/defaultListRenderer.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { ItemFlow, ListRenderer } from './types';
|
||||
|
||||
interface Props {
|
||||
note: {
|
||||
id: string;
|
||||
title: string;
|
||||
is_todo: number;
|
||||
todo_completed: number;
|
||||
};
|
||||
item: {
|
||||
size: {
|
||||
height: number;
|
||||
};
|
||||
selected: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const defaultItemRenderer: ListRenderer = {
|
||||
flow: ItemFlow.TopToBottom,
|
||||
|
||||
itemSize: {
|
||||
width: 0,
|
||||
height: 34,
|
||||
},
|
||||
|
||||
dependencies: [
|
||||
'item.selected',
|
||||
'item.size.height',
|
||||
'note.id',
|
||||
'note.is_shared',
|
||||
'note.is_todo',
|
||||
'note.isWatched',
|
||||
'note.titleHtml',
|
||||
'note.todo_completed',
|
||||
],
|
||||
|
||||
itemCss: // css
|
||||
`
|
||||
&:before {
|
||||
content: '';
|
||||
border-bottom: 1px solid var(--joplin-divider-color);
|
||||
width: 90%;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 5%;
|
||||
}
|
||||
|
||||
> .content.-selected {
|
||||
background-color: var(--joplin-selected-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--joplin-background-color-hover3);
|
||||
}
|
||||
|
||||
> .content {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-left: 16px;
|
||||
|
||||
> .checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
> input {
|
||||
margin: 0px 10px 1px 0px;
|
||||
}
|
||||
}
|
||||
|
||||
> .title {
|
||||
font-family: var(--joplin-font-family);
|
||||
font-size: var(--joplin-font-size);
|
||||
text-decoration: none;
|
||||
color: var(--joplin-color);
|
||||
cursor: default;
|
||||
white-space: nowrap;
|
||||
flex: 1 1 0%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
|
||||
> .watchedicon {
|
||||
display: none;
|
||||
padding-right: 4px;
|
||||
color: var(--joplin-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .content.-shared {
|
||||
> .title {
|
||||
color: var(--joplin-color-warn3);
|
||||
}
|
||||
}
|
||||
|
||||
> .content.-completed {
|
||||
> .title {
|
||||
opacity: 0.5;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
> .content.-watched {
|
||||
> .title {
|
||||
> .watchedicon {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
itemTemplate: // html
|
||||
`
|
||||
<div class="content {{#item.selected}}-selected{{/item.selected}} {{#note.is_shared}}-shared{{/note.is_shared}} {{#note.todo_completed}}-completed{{/note.todo_completed}} {{#note.isWatched}}-watched{{/note.isWatched}}">
|
||||
{{#note.is_todo}}
|
||||
<div class="checkbox">
|
||||
<input data-id="todo-checkbox" type="checkbox" {{#note.todo_completed}}checked="checked"{{/note.todo_completed}}>
|
||||
</div>
|
||||
{{/note.is_todo}}
|
||||
<div class="title" data-id="{{note.id}}">
|
||||
<i class="watchedicon fa fa-share-square"></i>
|
||||
<span>{{{note.titleHtml}}}</span>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
|
||||
onRenderNote: async (props: Props) => {
|
||||
return props;
|
||||
},
|
||||
};
|
||||
|
||||
export default defaultItemRenderer;
|
||||
45
packages/app-desktop/gui/NoteList/utils/getNoteTitleHtml.ts
Normal file
45
packages/app-desktop/gui/NoteList/utils/getNoteTitleHtml.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { htmlentities } from '@joplin/utils/html';
|
||||
const Mark = require('mark.js/dist/mark.min.js');
|
||||
const markJsUtils = require('@joplin/lib/markJsUtils');
|
||||
const { replaceRegexDiacritics, pregQuote } = require('@joplin/lib/string-utils');
|
||||
|
||||
const getNoteTitleHtml = (highlightedWords: string[], displayTitle: string) => {
|
||||
if (highlightedWords.length) {
|
||||
const titleElement = document.createElement('span');
|
||||
titleElement.textContent = displayTitle;
|
||||
const mark = new Mark(titleElement, {
|
||||
exclude: ['img'],
|
||||
acrossElements: true,
|
||||
});
|
||||
|
||||
mark.unmark();
|
||||
|
||||
try {
|
||||
for (const wordToBeHighlighted of highlightedWords) {
|
||||
markJsUtils.markKeyword(mark, wordToBeHighlighted, {
|
||||
pregQuote: pregQuote,
|
||||
replaceRegexDiacritics: replaceRegexDiacritics,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name !== 'SyntaxError') {
|
||||
throw error;
|
||||
}
|
||||
// An error of 'Regular expression too large' might occour in the markJs library
|
||||
// when the input is really big, this catch is here to avoid the application crashing
|
||||
// https://github.com/laurent22/joplin/issues/7634
|
||||
// console.error('Error while trying to highlight words from search: ', error);
|
||||
}
|
||||
|
||||
// Note: in this case it is safe to use dangerouslySetInnerHTML because titleElement
|
||||
// is a span tag that we created and that contains data that's been inserted as plain text
|
||||
// with `textContent` so it cannot contain any XSS attacks. We use this feature because
|
||||
// mark.js can only deal with DOM elements.
|
||||
// https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
|
||||
return titleElement.outerHTML;
|
||||
} else {
|
||||
return htmlentities(displayTitle);
|
||||
}
|
||||
};
|
||||
|
||||
export default getNoteTitleHtml;
|
||||
51
packages/app-desktop/gui/NoteList/utils/prepareViewProps.ts
Normal file
51
packages/app-desktop/gui/NoteList/utils/prepareViewProps.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { ListRendererDepependency } from './types';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import { Size } from '@joplin/utils/types';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
|
||||
const prepareViewProps = async (dependencies: ListRendererDepependency[], note: NoteEntity, itemSize: Size, selected: boolean, noteTitleHtml: string, noteIsWatched: boolean) => {
|
||||
const output: any = {};
|
||||
|
||||
for (const dep of dependencies) {
|
||||
|
||||
if (dep.startsWith('note.')) {
|
||||
const splitted = dep.split('.');
|
||||
if (splitted.length !== 2) throw new Error(`Invalid dependency name: ${dep}`);
|
||||
const propName = splitted.pop();
|
||||
if (!output.note) output.note = {};
|
||||
if (dep === 'note.titleHtml') {
|
||||
output.note.titleHtml = noteTitleHtml;
|
||||
} else if (dep === 'note.isWatched') {
|
||||
output.note.isWatched = noteIsWatched;
|
||||
} else {
|
||||
// The notes in the state only contain the properties defined in
|
||||
// Note.previewFields(). It means that if a view request a
|
||||
// property not present there, we need to load the full note.
|
||||
// One such missing property is the note body, which we don't
|
||||
// load by default.
|
||||
if (!(propName in note)) note = await Note.load(note.id);
|
||||
if (!(propName in note)) throw new Error(`Invalid dependency name: ${dep}`);
|
||||
output.note[propName] = (note as any)[propName];
|
||||
}
|
||||
}
|
||||
|
||||
if (dep.startsWith('item.size.')) {
|
||||
const splitted = dep.split('.');
|
||||
if (splitted.length !== 3) throw new Error(`Invalid dependency name: ${dep}`);
|
||||
const propName = splitted.pop();
|
||||
if (!output.item) output.item = {};
|
||||
if (!output.item.size) output.item.size = {};
|
||||
if (!(propName in itemSize)) throw new Error(`Invalid dependency name: ${dep}`);
|
||||
output.item.size[propName] = (itemSize as any)[propName];
|
||||
}
|
||||
|
||||
if (dep === 'item.selected') {
|
||||
if (!output.item) output.item = {};
|
||||
output.item.selected = selected;
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
export default prepareViewProps;
|
||||
64
packages/app-desktop/gui/NoteList/utils/types.ts
Normal file
64
packages/app-desktop/gui/NoteList/utils/types.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { FolderEntity, ItemRendererDatabaseDependency, NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import { Size } from '@joplin/utils/types';
|
||||
import { Dispatch } from 'redux';
|
||||
|
||||
export interface Props {
|
||||
themeId: any;
|
||||
selectedNoteIds: string[];
|
||||
notes: NoteEntity[];
|
||||
dispatch: Dispatch;
|
||||
watchedNoteFiles: string[];
|
||||
plugins: PluginStates;
|
||||
selectedFolderId: string;
|
||||
customCss: string;
|
||||
notesParentType: string;
|
||||
noteSortOrder: string;
|
||||
uncompletedTodosOnTop: boolean;
|
||||
showCompletedTodos: boolean;
|
||||
resizableLayoutEventEmitter: any;
|
||||
isInsertingNotes: boolean;
|
||||
folders: FolderEntity[];
|
||||
size: Size;
|
||||
searches: any[];
|
||||
selectedSearchId: string;
|
||||
highlightedWords: string[];
|
||||
provisionalNoteIds: string[];
|
||||
visible: boolean;
|
||||
focusedField: string;
|
||||
parentFolderIsReadOnly: boolean;
|
||||
}
|
||||
|
||||
export enum ItemFlow {
|
||||
TopToBottom = 'topToBottom',
|
||||
LeftToRight = 'leftToRight',
|
||||
}
|
||||
|
||||
export type RenderNoteView = Record<string, any>;
|
||||
|
||||
export interface OnChangeEvent {
|
||||
elementId: string;
|
||||
value: any;
|
||||
noteId: string;
|
||||
}
|
||||
|
||||
export type OnRenderNoteHandler = (props: any)=> Promise<RenderNoteView>;
|
||||
export type OnChangeHandler = (event: OnChangeEvent)=> Promise<void>;
|
||||
|
||||
export type ListRendererDepependency =
|
||||
ItemRendererDatabaseDependency |
|
||||
'item.size.width' |
|
||||
'item.size.height' |
|
||||
'item.selected' |
|
||||
'note.titleHtml' |
|
||||
'note.isWatched';
|
||||
|
||||
export interface ListRenderer {
|
||||
flow: ItemFlow;
|
||||
itemSize: Size;
|
||||
itemCss?: string;
|
||||
dependencies: ListRendererDepependency[];
|
||||
itemTemplate: string;
|
||||
onRenderNote: OnRenderNoteHandler;
|
||||
onChange?: OnChangeHandler;
|
||||
}
|
||||
102
packages/app-desktop/gui/NoteList/utils/useDragAndDrop.ts
Normal file
102
packages/app-desktop/gui/NoteList/utils/useDragAndDrop.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback, DragEventHandler, MutableRefObject, useState, useEffect } from 'react';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import canManuallySortNotes from './canManuallySortNotes';
|
||||
import { Size } from '@joplin/utils/types';
|
||||
import { ItemFlow } from './types';
|
||||
|
||||
const useDragAndDrop = (
|
||||
parentFolderIsReadOnly: boolean,
|
||||
selectedNoteIds: string[],
|
||||
selectedFolderId: string,
|
||||
listRef: MutableRefObject<HTMLDivElement>,
|
||||
scrollTop: number,
|
||||
itemSize: Size,
|
||||
notesParentType: string,
|
||||
noteSortOrder: string,
|
||||
uncompletedTodosOnTop: boolean,
|
||||
showCompletedTodos: boolean,
|
||||
flow: ItemFlow,
|
||||
itemsPerLine: number
|
||||
) => {
|
||||
const [dragOverTargetNoteIndex, setDragOverTargetNoteIndex] = useState(null);
|
||||
|
||||
const onGlobalDrop = useCallback(() => {
|
||||
setDragOverTargetNoteIndex(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('dragend', onGlobalDrop);
|
||||
return () => {
|
||||
document.removeEventListener('dragend', onGlobalDrop);
|
||||
};
|
||||
}, [onGlobalDrop]);
|
||||
|
||||
const onDragStart: DragEventHandler = useCallback(event => {
|
||||
if (parentFolderIsReadOnly) return false;
|
||||
|
||||
let noteIds = [];
|
||||
|
||||
// Here there is two cases:
|
||||
// - If multiple notes are selected, we drag the group
|
||||
// - If only one note is selected, we drag the note that was clicked on
|
||||
// (which might be different from the currently selected note)
|
||||
if (selectedNoteIds.length >= 2) {
|
||||
noteIds = selectedNoteIds;
|
||||
} else {
|
||||
const clickedNoteId = event.currentTarget.getAttribute('data-id');
|
||||
if (clickedNoteId) noteIds.push(clickedNoteId);
|
||||
}
|
||||
|
||||
if (!noteIds.length) return false;
|
||||
|
||||
event.dataTransfer.setDragImage(new Image(), 1, 1);
|
||||
event.dataTransfer.clearData();
|
||||
event.dataTransfer.setData('text/x-jop-note-ids', JSON.stringify(noteIds));
|
||||
return true;
|
||||
}, [parentFolderIsReadOnly, selectedNoteIds]);
|
||||
|
||||
|
||||
const dragTargetNoteIndex = useCallback((event: React.DragEvent) => {
|
||||
const rect = listRef.current.getBoundingClientRect();
|
||||
const lineIndexFloat = (event.clientY - rect.top + scrollTop) / itemSize.height;
|
||||
if (flow === ItemFlow.TopToBottom) {
|
||||
return Math.abs(Math.round(lineIndexFloat));
|
||||
} else {
|
||||
const lineIndex = Math.floor(lineIndexFloat);
|
||||
const rowIndexFloat = (event.clientX - rect.left) / itemSize.width;
|
||||
const rowIndex = Math.round(rowIndexFloat);
|
||||
return lineIndex * itemsPerLine + rowIndex;
|
||||
}
|
||||
}, [listRef, itemSize, scrollTop, flow, itemsPerLine]);
|
||||
|
||||
const onDragOver: DragEventHandler = useCallback(event => {
|
||||
if (notesParentType !== 'Folder') return;
|
||||
|
||||
const dt = event.dataTransfer;
|
||||
|
||||
if (dt.types.indexOf('text/x-jop-note-ids') >= 0) {
|
||||
event.preventDefault();
|
||||
const newIndex = dragTargetNoteIndex(event);
|
||||
if (dragOverTargetNoteIndex === newIndex) return;
|
||||
setDragOverTargetNoteIndex(newIndex);
|
||||
}
|
||||
}, [notesParentType, dragTargetNoteIndex, dragOverTargetNoteIndex]);
|
||||
|
||||
const onDrop: DragEventHandler = useCallback(async (event: any) => {
|
||||
// TODO: check that parent type is folder
|
||||
if (!canManuallySortNotes(notesParentType, noteSortOrder)) return;
|
||||
|
||||
const dt = event.dataTransfer;
|
||||
setDragOverTargetNoteIndex(null);
|
||||
|
||||
const targetNoteIndex = dragTargetNoteIndex(event);
|
||||
const noteIds: string[] = JSON.parse(dt.getData('text/x-jop-note-ids'));
|
||||
|
||||
await Note.insertNotesAt(selectedFolderId, noteIds, targetNoteIndex, uncompletedTodosOnTop, showCompletedTodos);
|
||||
}, [notesParentType, dragTargetNoteIndex, noteSortOrder, selectedFolderId, uncompletedTodosOnTop, showCompletedTodos]);
|
||||
|
||||
return { onDragStart, onDragOver, onDrop, dragOverTargetNoteIndex };
|
||||
};
|
||||
|
||||
export default useDragAndDrop;
|
||||
33
packages/app-desktop/gui/NoteList/utils/useFocusNote.ts
Normal file
33
packages/app-desktop/gui/NoteList/utils/useFocusNote.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { useRef, useCallback, MutableRefObject } from 'react';
|
||||
|
||||
export type FocusNote = (noteId: string)=> void;
|
||||
|
||||
const useFocusNote = (itemRefs: MutableRefObject<Record<string, HTMLDivElement>>) => {
|
||||
const focusItemIID = useRef(null);
|
||||
|
||||
const focusNote: FocusNote = useCallback((noteId: string) => {
|
||||
// - We need to focus the item manually otherwise focus might be lost when the
|
||||
// list is scrolled and items within it are being rebuilt.
|
||||
// - We need to use an interval because when leaving the arrow pressed, the rendering
|
||||
// of items might lag behind and so the ref is not yet available at this point.
|
||||
|
||||
if (!itemRefs.current[noteId]) {
|
||||
if (focusItemIID.current) shim.clearInterval(focusItemIID.current);
|
||||
focusItemIID.current = shim.setInterval(() => {
|
||||
if (itemRefs.current[noteId]) {
|
||||
itemRefs.current[noteId].focus();
|
||||
shim.clearInterval(focusItemIID.current);
|
||||
focusItemIID.current = null;
|
||||
}
|
||||
}, 10);
|
||||
} else {
|
||||
if (focusItemIID.current) shim.clearInterval(focusItemIID.current);
|
||||
itemRefs.current[noteId].focus();
|
||||
}
|
||||
}, [itemRefs]);
|
||||
|
||||
return focusNote;
|
||||
};
|
||||
|
||||
export default useFocusNote;
|
||||
19
packages/app-desktop/gui/NoteList/utils/useItemCss.ts
Normal file
19
packages/app-desktop/gui/NoteList/utils/useItemCss.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const useItemCss = (itemCss: string) => {
|
||||
useEffect(() => {
|
||||
const element = document.createElement('style');
|
||||
element.setAttribute('type', 'text/css');
|
||||
element.appendChild(document.createTextNode(`
|
||||
.note-list-item {
|
||||
${itemCss};
|
||||
}
|
||||
`));
|
||||
document.head.appendChild(element);
|
||||
return () => {
|
||||
element.remove();
|
||||
};
|
||||
}, [itemCss]);
|
||||
};
|
||||
|
||||
export default useItemCss;
|
||||
25
packages/app-desktop/gui/NoteList/utils/useMoveNote.ts
Normal file
25
packages/app-desktop/gui/NoteList/utils/useMoveNote.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import BaseModel from '@joplin/lib/BaseModel';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import { useCallback } from 'react';
|
||||
import canManuallySortNotes from './canManuallySortNotes';
|
||||
|
||||
const useMoveNote = (notesParentType: string, noteSortOrder: string, selectedNoteIds: string[], selectedFolderId: string, uncompletedTodosOnTop: boolean, showCompletedTodos: boolean, notes: NoteEntity[]) => {
|
||||
const moveNote = useCallback((direction: number, inc: number) => {
|
||||
if (!canManuallySortNotes(notesParentType, noteSortOrder)) return;
|
||||
|
||||
const noteId = selectedNoteIds[0];
|
||||
let targetNoteIndex = BaseModel.modelIndexById(notes, noteId);
|
||||
if ((direction === 1)) {
|
||||
targetNoteIndex += inc + 1;
|
||||
}
|
||||
if ((direction === -1)) {
|
||||
targetNoteIndex -= inc;
|
||||
}
|
||||
void Note.insertNotesAt(selectedFolderId, selectedNoteIds, targetNoteIndex, uncompletedTodosOnTop, showCompletedTodos);
|
||||
}, [selectedFolderId, noteSortOrder, notes, notesParentType, selectedNoteIds, uncompletedTodosOnTop, showCompletedTodos]);
|
||||
|
||||
return moveNote;
|
||||
};
|
||||
|
||||
export default useMoveNote;
|
||||
150
packages/app-desktop/gui/NoteList/utils/useOnKeyDown.ts
Normal file
150
packages/app-desktop/gui/NoteList/utils/useOnKeyDown.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import * as React from 'react';
|
||||
import BaseModel from '@joplin/lib/BaseModel';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import { useCallback } from 'react';
|
||||
import { Dispatch } from 'redux';
|
||||
import { FocusNote } from './useFocusNote';
|
||||
import { ItemFlow } from './types';
|
||||
import { KeyboardEventKey } from '@joplin/lib/dom';
|
||||
|
||||
const useOnKeyDown = (
|
||||
selectedNoteIds: string[],
|
||||
moveNote: (direction: number, inc: number)=> void,
|
||||
makeItemIndexVisible: (itemIndex: number)=> void,
|
||||
focusNote: FocusNote,
|
||||
notes: NoteEntity[],
|
||||
dispatch: Dispatch,
|
||||
visibleItemCount: number,
|
||||
noteCount: number,
|
||||
flow: ItemFlow,
|
||||
itemsPerLine: number
|
||||
) => {
|
||||
const scrollNoteIndex = useCallback((visibleItemCount: number, key: KeyboardEventKey, ctrlKey: boolean, metaKey: boolean, noteIndex: number) => {
|
||||
if (flow === ItemFlow.TopToBottom) {
|
||||
if (key === 'PageUp') {
|
||||
noteIndex -= (visibleItemCount - 1);
|
||||
} else if (key === 'PageDown') {
|
||||
noteIndex += (visibleItemCount - 1);
|
||||
} else if ((key === 'End' && ctrlKey) || (key === 'ArrowDown' && metaKey)) {
|
||||
noteIndex = noteCount - 1;
|
||||
} else if ((key === 'Home' && ctrlKey) || (key === 'ArrowUp' && metaKey)) {
|
||||
noteIndex = 0;
|
||||
} else if (key === 'ArrowUp' && !metaKey) {
|
||||
noteIndex -= 1;
|
||||
} else if (key === 'ArrowDown' && !metaKey) {
|
||||
noteIndex += 1;
|
||||
}
|
||||
if (noteIndex < 0) noteIndex = 0;
|
||||
if (noteIndex > noteCount - 1) noteIndex = noteCount - 1;
|
||||
}
|
||||
|
||||
if (flow === ItemFlow.LeftToRight) {
|
||||
if (key === 'PageUp') {
|
||||
noteIndex -= (visibleItemCount - itemsPerLine);
|
||||
} else if (key === 'PageDown') {
|
||||
noteIndex += (visibleItemCount - itemsPerLine);
|
||||
} else if ((key === 'End' && ctrlKey) || (key === 'ArrowDown' && metaKey)) {
|
||||
noteIndex = noteCount - 1;
|
||||
} else if ((key === 'Home' && ctrlKey) || (key === 'ArrowUp' && metaKey)) {
|
||||
noteIndex = 0;
|
||||
} else if (key === 'ArrowUp' && !metaKey) {
|
||||
noteIndex -= itemsPerLine;
|
||||
} else if (key === 'ArrowDown' && !metaKey) {
|
||||
noteIndex += itemsPerLine;
|
||||
} else if (key === 'ArrowLeft' && !metaKey) {
|
||||
noteIndex -= 1;
|
||||
} else if (key === 'ArrowRight' && !metaKey) {
|
||||
noteIndex += 1;
|
||||
}
|
||||
if (noteIndex < 0) noteIndex = 0;
|
||||
if (noteIndex > noteCount - 1) noteIndex = noteCount - 1;
|
||||
}
|
||||
|
||||
return noteIndex;
|
||||
}, [noteCount, flow, itemsPerLine]);
|
||||
|
||||
const onKeyDown: React.KeyboardEventHandler<HTMLDivElement> = useCallback(async (event) => {
|
||||
const noteIds = selectedNoteIds;
|
||||
const key = event.key as KeyboardEventKey;
|
||||
|
||||
if (['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'].includes(key) && event.altKey) {
|
||||
if (flow === ItemFlow.TopToBottom) {
|
||||
await moveNote(key === 'ArrowDown' ? 1 : -1, 1);
|
||||
} else {
|
||||
if (key === 'ArrowRight') {
|
||||
await moveNote(1, 1);
|
||||
} else if (key === 'ArrowLeft') {
|
||||
await moveNote(-1, 1);
|
||||
} else if (key === 'ArrowUp') {
|
||||
await moveNote(-1, itemsPerLine);
|
||||
} else if (key === 'ArrowDown') {
|
||||
await moveNote(1, itemsPerLine);
|
||||
}
|
||||
}
|
||||
event.preventDefault();
|
||||
} else if (noteIds.length > 0 && (key === 'ArrowDown' || key === 'ArrowUp' || key === 'ArrowLeft' || key === 'ArrowRight' || key === 'PageDown' || key === 'PageUp' || key === 'End' || key === 'Home')) {
|
||||
const noteId = noteIds[0];
|
||||
let noteIndex = BaseModel.modelIndexById(notes, noteId);
|
||||
|
||||
noteIndex = scrollNoteIndex(visibleItemCount, key, event.ctrlKey, event.metaKey, noteIndex);
|
||||
|
||||
const newSelectedNote = notes[noteIndex];
|
||||
|
||||
dispatch({
|
||||
type: 'NOTE_SELECT',
|
||||
id: newSelectedNote.id,
|
||||
});
|
||||
|
||||
makeItemIndexVisible(noteIndex);
|
||||
|
||||
focusNote(newSelectedNote.id);
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (noteIds.length && (key === 'Delete' || (key === 'Backspace' && event.metaKey))) {
|
||||
event.preventDefault();
|
||||
void CommandService.instance().execute('deleteNote', noteIds);
|
||||
}
|
||||
|
||||
if (noteIds.length && key === ' ') {
|
||||
event.preventDefault();
|
||||
|
||||
const selectedNotes = BaseModel.modelsByIds(notes, noteIds);
|
||||
const todos = selectedNotes.filter((n: any) => !!n.is_todo);
|
||||
if (!todos.length) return;
|
||||
|
||||
for (let i = 0; i < todos.length; i++) {
|
||||
const toggledTodo = Note.toggleTodoCompleted(todos[i]);
|
||||
await Note.save(toggledTodo);
|
||||
}
|
||||
|
||||
focusNote(todos[0].id);
|
||||
}
|
||||
|
||||
if (key === 'Tab') {
|
||||
event.preventDefault();
|
||||
|
||||
if (event.shiftKey) {
|
||||
void CommandService.instance().execute('focusElement', 'sideBar');
|
||||
} else {
|
||||
void CommandService.instance().execute('focusElement', 'noteTitle');
|
||||
}
|
||||
}
|
||||
|
||||
if (key.toUpperCase() === 'A' && (event.ctrlKey || event.metaKey)) {
|
||||
event.preventDefault();
|
||||
|
||||
dispatch({
|
||||
type: 'NOTE_SELECT_ALL',
|
||||
});
|
||||
}
|
||||
}, [moveNote, focusNote, visibleItemCount, scrollNoteIndex, makeItemIndexVisible, notes, selectedNoteIds, dispatch, flow, itemsPerLine]);
|
||||
|
||||
|
||||
return onKeyDown;
|
||||
};
|
||||
|
||||
export default useOnKeyDown;
|
||||
41
packages/app-desktop/gui/NoteList/utils/useOnNoteClick.ts
Normal file
41
packages/app-desktop/gui/NoteList/utils/useOnNoteClick.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { Dispatch } from 'redux';
|
||||
import { FocusNote } from './useFocusNote';
|
||||
|
||||
const useOnNoteClick = (dispatch: Dispatch, focusNote: FocusNote) => {
|
||||
const onNoteClick = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
||||
const noteId = event.currentTarget.getAttribute('data-id');
|
||||
|
||||
const targetTagName = event.target ? (event.target as any).tagName : '';
|
||||
|
||||
// If we are for example on a checkbox, don't process the click since it
|
||||
// should be handled by the checkbox onChange handler.
|
||||
if (['INPUT'].includes(targetTagName)) return;
|
||||
|
||||
focusNote(noteId);
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
dispatch({
|
||||
type: 'NOTE_SELECT_TOGGLE',
|
||||
id: noteId,
|
||||
});
|
||||
} else if (event.shiftKey) {
|
||||
event.preventDefault();
|
||||
dispatch({
|
||||
type: 'NOTE_SELECT_EXTEND',
|
||||
id: noteId,
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: 'NOTE_SELECT',
|
||||
id: noteId,
|
||||
});
|
||||
}
|
||||
}, [dispatch, focusNote]);
|
||||
|
||||
return onNoteClick;
|
||||
};
|
||||
|
||||
export default useOnNoteClick;
|
||||
82
packages/app-desktop/gui/NoteList/utils/useRenderedNotes.ts
Normal file
82
packages/app-desktop/gui/NoteList/utils/useRenderedNotes.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useState } from 'react';
|
||||
import { ListRenderer } from './types';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import * as Mustache from 'mustache';
|
||||
import { createHash } from 'crypto';
|
||||
import getNoteTitleHtml from './getNoteTitleHtml';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import prepareViewProps from './prepareViewProps';
|
||||
|
||||
interface RenderedNote {
|
||||
id: string;
|
||||
hash: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
const hashContent = (content: any) => {
|
||||
return createHash('sha1').update(JSON.stringify(content)).digest('hex');
|
||||
};
|
||||
|
||||
const useRenderedNotes = (startNoteIndex: number, endNoteIndex: number, notes: NoteEntity[], selectedNoteIds: string[], listRenderer: ListRenderer, highlightedWords: string[], watchedNoteFiles: string[]) => {
|
||||
const [renderedNotes, setRenderedNotes] = useState<Record<string, RenderedNote>>({});
|
||||
|
||||
useAsyncEffect(async (event) => {
|
||||
if (event.cancelled) return;
|
||||
|
||||
const renderNote = async (note: NoteEntity): Promise<void> => {
|
||||
const isSelected = selectedNoteIds.includes(note.id);
|
||||
const isWatched = watchedNoteFiles.includes(note.id);
|
||||
|
||||
// Note: with this hash we're assuming that the list renderer
|
||||
// properties never changes. It means that later if we support
|
||||
// dynamic list renderers, we should include these into the hash.
|
||||
const viewHash = hashContent([
|
||||
note.updated_time,
|
||||
isSelected,
|
||||
isWatched,
|
||||
highlightedWords,
|
||||
]);
|
||||
|
||||
if (renderedNotes[note.id] && renderedNotes[note.id].hash === viewHash) return null;
|
||||
|
||||
const titleHtml = getNoteTitleHtml(highlightedWords, Note.displayTitle(note));
|
||||
const viewProps = await prepareViewProps(
|
||||
listRenderer.dependencies,
|
||||
note,
|
||||
listRenderer.itemSize,
|
||||
isSelected,
|
||||
titleHtml,
|
||||
isWatched
|
||||
);
|
||||
const view = await listRenderer.onRenderNote(viewProps);
|
||||
|
||||
if (event.cancelled) return null;
|
||||
|
||||
setRenderedNotes(prev => {
|
||||
if (prev[note.id] && prev[note.id].hash === viewHash) return prev;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[note.id]: {
|
||||
id: note.id,
|
||||
hash: viewHash,
|
||||
html: Mustache.render(listRenderer.itemTemplate, view),
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
for (let i = startNoteIndex; i <= endNoteIndex; i++) {
|
||||
promises.push(renderNote(notes[i]));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
}, [startNoteIndex, endNoteIndex, notes, selectedNoteIds, listRenderer, renderedNotes, watchedNoteFiles]);
|
||||
|
||||
return renderedNotes;
|
||||
};
|
||||
|
||||
export default useRenderedNotes;
|
||||
99
packages/app-desktop/gui/NoteList/utils/useScroll.ts
Normal file
99
packages/app-desktop/gui/NoteList/utils/useScroll.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import * as React from 'react';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { Size } from '@joplin/utils/types';
|
||||
import { useCallback, useState, useRef, useEffect, useMemo } from 'react';
|
||||
|
||||
const useScroll = (itemsPerLine: number, noteCount: number, itemSize: Size, listSize: Size, listRef: React.MutableRefObject<HTMLDivElement>) => {
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const lastScrollSetTime = useRef(0);
|
||||
|
||||
const maxScrollTop = useMemo(() => {
|
||||
return Math.max(0, itemSize.height * noteCount - listSize.height);
|
||||
}, [itemSize.height, noteCount, listSize.height]);
|
||||
|
||||
// This ugly hack is necessary because setting scrollTop at a high
|
||||
// frequency, while scrolling with the keyboard, is unreliable - the
|
||||
// property will appear to be set (reading it back gives the correct value),
|
||||
// but the scrollbar will not be at the expected position. That can be
|
||||
// verified by moving the scrollbar a little and reading the event value -
|
||||
// it will be different from what was set, and what was read.
|
||||
//
|
||||
// As a result, since we can't rely on setting or reading that value (to
|
||||
// check if it's correct), we forcefully set it multiple times over the next
|
||||
// few milliseconds, hoping that maybe one of these attempts will stick.
|
||||
//
|
||||
// This is most likely a race condition in either Chromimum or Electron
|
||||
// although I couldn't find an upstream issue.
|
||||
//
|
||||
// Setting the value only once after a short time, for example 10ms, helps
|
||||
// but still fails now and then. Setting it after 500ms would probably work
|
||||
// reliably but it's too slow so it makes sense to do it in an interval.
|
||||
|
||||
const setScrollTopLikeYouMeanItTimer = useRef(null);
|
||||
const setScrollTopLikeYouMeanItStartTime = useRef(0);
|
||||
const setScrollTopLikeYouMeanIt = useCallback((newScrollTop: number) => {
|
||||
if (setScrollTopLikeYouMeanItTimer.current) shim.clearInterval(setScrollTopLikeYouMeanItTimer.current);
|
||||
setScrollTopLikeYouMeanItStartTime.current = Date.now();
|
||||
|
||||
setScrollTopLikeYouMeanItTimer.current = shim.setInterval(() => {
|
||||
if (!listRef.current) {
|
||||
shim.clearInterval(setScrollTopLikeYouMeanItTimer.current);
|
||||
setScrollTopLikeYouMeanItTimer.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
listRef.current.scrollTop = newScrollTop;
|
||||
lastScrollSetTime.current = Date.now();
|
||||
|
||||
if (Date.now() - setScrollTopLikeYouMeanItStartTime.current > 500) {
|
||||
shim.clearInterval(setScrollTopLikeYouMeanItTimer.current);
|
||||
setScrollTopLikeYouMeanItTimer.current = null;
|
||||
}
|
||||
}, 10);
|
||||
}, [listRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (setScrollTopLikeYouMeanItTimer.current) shim.clearInterval(setScrollTopLikeYouMeanItTimer.current);
|
||||
setScrollTopLikeYouMeanItTimer.current = null;
|
||||
}, []);
|
||||
|
||||
const makeItemIndexVisible = useCallback((itemIndex: number) => {
|
||||
const lineTopFloat = scrollTop / itemSize.height;
|
||||
const topFloat = lineTopFloat * itemsPerLine; // scrollTop / itemSize.height;
|
||||
const lineBottomFloat = (scrollTop + listSize.height - itemSize.height) / itemSize.height;
|
||||
const bottomFloat = lineBottomFloat * itemsPerLine; // (scrollTop + listSize.height - itemSize.height) / itemSize.height;
|
||||
const top = Math.min(noteCount - 1, Math.floor(topFloat) + 1);
|
||||
const bottom = Math.max(0, Math.floor(bottomFloat));
|
||||
|
||||
if (itemIndex >= top && itemIndex <= bottom) return;
|
||||
|
||||
const lineIndex = Math.floor(itemIndex / itemsPerLine);
|
||||
|
||||
let newScrollTop = 0;
|
||||
if (itemIndex < top) {
|
||||
newScrollTop = itemSize.height * lineIndex;
|
||||
} else {
|
||||
newScrollTop = itemSize.height * (lineIndex + 1) - listSize.height;
|
||||
}
|
||||
|
||||
if (newScrollTop < 0) newScrollTop = 0;
|
||||
if (newScrollTop > maxScrollTop) newScrollTop = maxScrollTop;
|
||||
|
||||
setScrollTop(newScrollTop);
|
||||
setScrollTopLikeYouMeanIt(newScrollTop);
|
||||
}, [itemsPerLine, noteCount, itemSize.height, scrollTop, listSize.height, maxScrollTop, setScrollTopLikeYouMeanIt]);
|
||||
|
||||
const onScroll = useCallback((event: any) => {
|
||||
// Ignore the scroll event if it has just been set programmatically.
|
||||
if (Date.now() - lastScrollSetTime.current < 100) return;
|
||||
setScrollTop(event.target.scrollTop);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
scrollTop,
|
||||
onScroll,
|
||||
makeItemIndexVisible,
|
||||
};
|
||||
};
|
||||
|
||||
export default useScroll;
|
||||
@@ -0,0 +1,61 @@
|
||||
import useVisibleRange from './useVisibleRange';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { Size } from '@joplin/utils/types';
|
||||
|
||||
describe('useVisibleRange', () => {
|
||||
|
||||
test('should calculate indexes', () => {
|
||||
|
||||
// IN: scrollTop, listSize, itemSize, noteCount, flow
|
||||
//
|
||||
// OUT: [itemsPerLine, startNoteIndex, endNoteIndex, startLineIndex, endLineIndex, totalLineCount, visibleItemCount]
|
||||
|
||||
const testCases: [number, number, Size, Size, number, ReturnType<typeof useVisibleRange>][] = [
|
||||
[
|
||||
1,
|
||||
150,
|
||||
{ width: 100, height: 400 },
|
||||
{ width: 100, height: 100 },
|
||||
8,
|
||||
[1, 5, 1, 5, 8, 5],
|
||||
],
|
||||
[
|
||||
2,
|
||||
100,
|
||||
{ width: 220, height: 380 },
|
||||
{ width: 100, height: 100 },
|
||||
12,
|
||||
[2, 9, 1, 4, 6, 8],
|
||||
],
|
||||
[
|
||||
2,
|
||||
50,
|
||||
{ width: 220, height: 300 },
|
||||
{ width: 100, height: 100 },
|
||||
9,
|
||||
[0, 7, 0, 3, 5, 8],
|
||||
],
|
||||
[
|
||||
4,
|
||||
0,
|
||||
{ width: 410, height: 450 },
|
||||
{ width: 100, height: 100 },
|
||||
30,
|
||||
[0, 19, 0, 4, 8, 20],
|
||||
],
|
||||
];
|
||||
|
||||
for (const [scrollTop, listSize, itemSize, noteCount, flow, expected] of testCases) {
|
||||
const { result } = renderHook(() => useVisibleRange(
|
||||
scrollTop,
|
||||
listSize,
|
||||
itemSize,
|
||||
noteCount,
|
||||
flow
|
||||
));
|
||||
|
||||
expect(result.current).toEqual(expected);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
57
packages/app-desktop/gui/NoteList/utils/useVisibleRange.ts
Normal file
57
packages/app-desktop/gui/NoteList/utils/useVisibleRange.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Size } from '@joplin/utils/types';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
const useVisibleRange = (itemsPerLine: number, scrollTop: number, listSize: Size, itemSize: Size, noteCount: number) => {
|
||||
const startLineIndexFloat = useMemo(() => {
|
||||
return scrollTop / itemSize.height;
|
||||
}, [scrollTop, itemSize.height]);
|
||||
|
||||
const endLineIndexFloat = useMemo(() => {
|
||||
return startLineIndexFloat + (listSize.height / itemSize.height);
|
||||
}, [startLineIndexFloat, listSize.height, itemSize.height]);
|
||||
|
||||
const startLineIndex = useMemo(() => {
|
||||
return Math.floor(startLineIndexFloat);
|
||||
}, [startLineIndexFloat]);
|
||||
|
||||
const endLineIndex = useMemo(() => {
|
||||
return Math.floor(endLineIndexFloat);
|
||||
}, [endLineIndexFloat]);
|
||||
|
||||
const visibleLineCount = useMemo(() => {
|
||||
return endLineIndex - startLineIndex + 1;
|
||||
}, [endLineIndex, startLineIndex]);
|
||||
|
||||
const visibleItemCount = useMemo(() => {
|
||||
return visibleLineCount * itemsPerLine;
|
||||
}, [visibleLineCount, itemsPerLine]);
|
||||
|
||||
const startNoteIndex = useMemo(() => {
|
||||
return itemsPerLine * startLineIndex;
|
||||
}, [itemsPerLine, startLineIndex]);
|
||||
|
||||
const endNoteIndex = useMemo(() => {
|
||||
let output = (endLineIndex + 1) * itemsPerLine - 1;
|
||||
if (output >= noteCount) output = noteCount - 1;
|
||||
return output;
|
||||
}, [endLineIndex, itemsPerLine, noteCount]);
|
||||
|
||||
const totalLineCount = useMemo(() => {
|
||||
return Math.ceil(noteCount / itemsPerLine);
|
||||
}, [noteCount, itemsPerLine]);
|
||||
|
||||
// console.info('itemsPerLine', itemsPerLine);
|
||||
// console.info('startLineIndexFloat', startLineIndexFloat);
|
||||
// console.info('endLineIndexFloat', endLineIndexFloat);
|
||||
// console.info('visibleLineCount', visibleLineCount);
|
||||
// console.info('startNoteIndex', startNoteIndex);
|
||||
// console.info('endNoteIndex', endNoteIndex);
|
||||
// console.info('startLineIndex', startLineIndex);
|
||||
// console.info('endLineIndex', endLineIndex);
|
||||
// console.info('totalLineCount', totalLineCount);
|
||||
// console.info('visibleItemCount', visibleItemCount);
|
||||
|
||||
return [startNoteIndex, endNoteIndex, startLineIndex, endLineIndex, totalLineCount, visibleItemCount];
|
||||
};
|
||||
|
||||
export default useVisibleRange;
|
||||
@@ -84,11 +84,11 @@ function NoteListItem(props: NoteListItemProps, ref: any) {
|
||||
dragItemPosition = 'bottom';
|
||||
}
|
||||
|
||||
const onTitleClick = useCallback((event) => {
|
||||
const onTitleClick = useCallback((event: any) => {
|
||||
props.onTitleClick(event, props.item);
|
||||
}, [props.onTitleClick, props.item]);
|
||||
|
||||
const onCheckboxClick = useCallback((event) => {
|
||||
const onCheckboxClick = useCallback((event: any) => {
|
||||
props.onCheckboxClick(event, props.item);
|
||||
}, [props.onCheckboxClick, props.item]);
|
||||
|
||||
|
||||
143
packages/app-desktop/gui/NoteListItem/NoteListItem.tsx
Normal file
143
packages/app-desktop/gui/NoteListItem/NoteListItem.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback, forwardRef, LegacyRef, ChangeEvent, CSSProperties, MouseEventHandler, DragEventHandler, useMemo, memo } from 'react';
|
||||
import { ItemFlow, OnChangeEvent, OnChangeHandler } from '../NoteList/utils/types';
|
||||
import { Size } from '@joplin/utils/types';
|
||||
import useRootElement from './utils/useRootElement';
|
||||
import useItemElement from './utils/useItemElement';
|
||||
import useItemEventHandlers from './utils/useItemEventHandlers';
|
||||
import { OnCheckboxChange } from './utils/types';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
|
||||
interface NoteItemProps {
|
||||
dragIndex: number;
|
||||
flow: ItemFlow;
|
||||
highlightedWords: string[];
|
||||
index: number;
|
||||
isProvisional: boolean;
|
||||
itemSize: Size;
|
||||
noteCount: number;
|
||||
noteHtml: string;
|
||||
noteId: string;
|
||||
onChange: OnChangeHandler;
|
||||
onClick: MouseEventHandler<HTMLDivElement>;
|
||||
onContextMenu: MouseEventHandler;
|
||||
onDragOver: DragEventHandler;
|
||||
onDragStart: DragEventHandler;
|
||||
style: CSSProperties;
|
||||
}
|
||||
|
||||
const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => {
|
||||
const elementId = `list-note-${props.noteId}`;
|
||||
|
||||
const onCheckboxChange: OnCheckboxChange = useCallback(async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const changeEvent: OnChangeEvent = {
|
||||
noteId: props.noteId,
|
||||
elementId: event.currentTarget.getAttribute('data-id'),
|
||||
value: event.currentTarget.checked,
|
||||
};
|
||||
|
||||
if (changeEvent.elementId === 'todo-checkbox') {
|
||||
await Note.save({
|
||||
id: changeEvent.noteId,
|
||||
todo_completed: changeEvent.value ? Date.now() : 0,
|
||||
}, { userSideValidation: true });
|
||||
} else {
|
||||
if (props.onChange) await props.onChange(changeEvent);
|
||||
}
|
||||
}, [props.onChange, props.noteId]);
|
||||
|
||||
const rootElement = useRootElement(elementId);
|
||||
|
||||
const itemElement = useItemElement(
|
||||
rootElement,
|
||||
props.noteId,
|
||||
props.noteHtml,
|
||||
props.style,
|
||||
props.itemSize,
|
||||
props.onClick,
|
||||
props.flow
|
||||
);
|
||||
|
||||
useItemEventHandlers(rootElement, itemElement, onCheckboxChange);
|
||||
|
||||
const className = useMemo(() => {
|
||||
return [
|
||||
'note-list-item-wrapper',
|
||||
|
||||
// This is not used by the app, but kept here because it may be used
|
||||
// by users for custom CSS.
|
||||
(props.index + 1) % 2 === 0 ? 'even' : 'odd',
|
||||
|
||||
props.isProvisional && '-provisional',
|
||||
].filter(e => !!e).join(' ');
|
||||
}, [props.index, props.isProvisional]);
|
||||
|
||||
const isActiveDragItem = props.dragIndex === props.index;
|
||||
const isLastActiveDragItem = props.index === props.noteCount - 1 && props.dragIndex >= props.noteCount;
|
||||
|
||||
const dragCursorStyle = useMemo(() => {
|
||||
if (props.flow === ItemFlow.TopToBottom) {
|
||||
let dragItemPosition = '';
|
||||
if (isActiveDragItem) {
|
||||
dragItemPosition = 'top';
|
||||
} else if (isLastActiveDragItem) {
|
||||
dragItemPosition = 'bottom';
|
||||
}
|
||||
|
||||
const output: React.CSSProperties = {
|
||||
width: props.itemSize.width,
|
||||
display: dragItemPosition ? 'block' : 'none',
|
||||
left: 0,
|
||||
};
|
||||
|
||||
if (dragItemPosition === 'top') {
|
||||
output.top = 0;
|
||||
} else {
|
||||
output.bottom = 0;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
if (props.flow === ItemFlow.LeftToRight) {
|
||||
let dragItemPosition = '';
|
||||
if (isActiveDragItem) {
|
||||
dragItemPosition = 'left';
|
||||
} else if (isLastActiveDragItem) {
|
||||
dragItemPosition = 'right';
|
||||
}
|
||||
|
||||
const output: React.CSSProperties = {
|
||||
height: props.itemSize.height,
|
||||
display: dragItemPosition ? 'block' : 'none',
|
||||
top: 0,
|
||||
};
|
||||
|
||||
if (dragItemPosition === 'left') {
|
||||
output.left = 0;
|
||||
} else {
|
||||
output.right = 0;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
throw new Error('Unreachable');
|
||||
}, [isActiveDragItem, isLastActiveDragItem, props.flow, props.itemSize]);
|
||||
|
||||
return <div
|
||||
id={elementId}
|
||||
ref={ref}
|
||||
draggable={true}
|
||||
tabIndex={0}
|
||||
className={className}
|
||||
data-id={props.noteId}
|
||||
onContextMenu={props.onContextMenu}
|
||||
onDragStart={props.onDragStart}
|
||||
onDragOver={props.onDragOver}
|
||||
>
|
||||
<div className="dragcursor" style={dragCursorStyle}></div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default memo(forwardRef(NoteListItem));
|
||||
3
packages/app-desktop/gui/NoteListItem/utils/types.ts
Normal file
3
packages/app-desktop/gui/NoteListItem/utils/types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import * as React from 'react';
|
||||
|
||||
export type OnCheckboxChange = (event: React.ChangeEvent<HTMLInputElement>)=> void;
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as React from 'react';
|
||||
import { Size } from '@joplin/utils/types';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ItemFlow } from '../../NoteList/utils/types';
|
||||
|
||||
const useItemElement = (rootElement: HTMLDivElement, noteId: string, noteHtml: string, style: any, itemSize: Size, onClick: React.MouseEventHandler<HTMLDivElement>, flow: ItemFlow) => {
|
||||
const [itemElement, setItemElement] = useState<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rootElement) return () => {};
|
||||
|
||||
const element = document.createElement('div');
|
||||
element.setAttribute('data-id', noteId);
|
||||
element.className = 'note-list-item';
|
||||
for (const [n, v] of Object.entries(style)) {
|
||||
(element.style as any)[n] = v;
|
||||
}
|
||||
if (flow === ItemFlow.LeftToRight) element.style.width = `${itemSize.width}px`;
|
||||
element.style.height = `${itemSize.height}px`;
|
||||
element.innerHTML = noteHtml;
|
||||
element.addEventListener('click', onClick as any);
|
||||
|
||||
rootElement.appendChild(element);
|
||||
|
||||
setItemElement(element);
|
||||
|
||||
return () => {
|
||||
element.remove();
|
||||
};
|
||||
}, [rootElement, itemSize, noteHtml, noteId, style, onClick, flow]);
|
||||
|
||||
return itemElement;
|
||||
};
|
||||
|
||||
export default useItemElement;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { OnCheckboxChange } from './types';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const useItemEventHandlers = (rootElement: HTMLDivElement, itemElement: HTMLDivElement, onCheckboxChange: OnCheckboxChange) => {
|
||||
useEffect(() => {
|
||||
if (!itemElement) return () => {};
|
||||
|
||||
const inputs = itemElement.getElementsByTagName('input');
|
||||
|
||||
const mods: HTMLInputElement[] = [];
|
||||
|
||||
for (const input of inputs) {
|
||||
if (input.type === 'checkbox') {
|
||||
input.addEventListener('change', onCheckboxChange as any);
|
||||
mods.push(input);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const input of mods) {
|
||||
input.removeEventListener('change', onCheckboxChange as any);
|
||||
}
|
||||
};
|
||||
}, [itemElement, rootElement, onCheckboxChange]);
|
||||
};
|
||||
|
||||
export default useItemEventHandlers;
|
||||
@@ -0,0 +1,44 @@
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import { useCallback } from 'react';
|
||||
import { Dispatch } from 'redux';
|
||||
import bridge from '../../../services/bridge';
|
||||
import NoteListUtils from '../../utils/NoteListUtils';
|
||||
|
||||
const useOnContextMenu = (
|
||||
selectedNoteIds: string[],
|
||||
selectedFolderId: string,
|
||||
notes: NoteEntity[],
|
||||
dispatch: Dispatch,
|
||||
watchedNoteFiles: string[],
|
||||
plugins: PluginStates,
|
||||
customCss: string
|
||||
) => {
|
||||
return useCallback((event: any) => {
|
||||
const currentNoteId = event.currentTarget.getAttribute('data-id');
|
||||
if (!currentNoteId) return;
|
||||
|
||||
let noteIds = [];
|
||||
if (selectedNoteIds.indexOf(currentNoteId) < 0) {
|
||||
noteIds = [currentNoteId];
|
||||
} else {
|
||||
noteIds = selectedNoteIds;
|
||||
}
|
||||
|
||||
if (!noteIds.length) return;
|
||||
|
||||
const menu = NoteListUtils.makeContextMenu(noteIds, {
|
||||
notes: notes,
|
||||
dispatch: dispatch,
|
||||
watchedNoteFiles: watchedNoteFiles,
|
||||
plugins: plugins,
|
||||
inConflictFolder: selectedFolderId === Folder.conflictFolderId(),
|
||||
customCss: customCss,
|
||||
});
|
||||
|
||||
menu.popup({ window: bridge().window() });
|
||||
}, [selectedNoteIds, notes, dispatch, watchedNoteFiles, plugins, selectedFolderId, customCss]);
|
||||
};
|
||||
|
||||
export default useOnContextMenu;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import { waitForElement } from '@joplin/lib/dom';
|
||||
|
||||
const useRootElement = (elementId: string) => {
|
||||
const [rootElement, setRootElement] = useState<HTMLDivElement>(null);
|
||||
|
||||
useAsyncEffect(async (event) => {
|
||||
const element = await waitForElement(document, elementId);
|
||||
if (event.cancelled) return;
|
||||
setRootElement(element);
|
||||
}, [document, elementId]);
|
||||
|
||||
return rootElement;
|
||||
};
|
||||
|
||||
export default useRootElement;
|
||||
@@ -1,7 +1,8 @@
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import * as React from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import NoteList from '../NoteList/NoteList';
|
||||
// import NoteList from '../NoteList/NoteList';
|
||||
import NoteList2 from '../NoteList/NoteList2';
|
||||
import NoteListControls from '../NoteListControls/NoteListControls';
|
||||
import { Size } from '../ResizableLayout/utils/types';
|
||||
import styled from 'styled-components';
|
||||
@@ -39,10 +40,12 @@ export default function NoteListWrapper(props: Props) {
|
||||
};
|
||||
}, [props.size, controlHeight]);
|
||||
|
||||
// <NoteList resizableLayoutEventEmitter={props.resizableLayoutEventEmitter} size={noteListSize} visible={props.visible}/>
|
||||
|
||||
return (
|
||||
<StyledRoot>
|
||||
<NoteListControls height={controlHeight} width={noteListSize.width} onContentHeightChange={onContentHeightChange}/>
|
||||
<NoteList resizableLayoutEventEmitter={props.resizableLayoutEventEmitter} size={noteListSize} visible={props.visible}/>
|
||||
<NoteList2 resizableLayoutEventEmitter={props.resizableLayoutEventEmitter} size={noteListSize} visible={props.visible}/>
|
||||
</StyledRoot>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -210,7 +210,7 @@ class NotePropertiesDialog extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
public async saveProperty() {
|
||||
if (!this.state.editedKey) return;
|
||||
if (!this.state.editedKey) return null;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
return new Promise((resolve: Function) => {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import themeToCss from '@joplin/lib/services/style/themeToCss';
|
||||
import { addExtraStyles, themeById } from '@joplin/lib/theme';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
|
||||
interface Props {
|
||||
themeId: any;
|
||||
@@ -21,7 +21,7 @@ export default function(props: Props): any {
|
||||
const [styleSheetContent, setStyleSheetContent] = useState('');
|
||||
|
||||
useAsyncEffect(async (event: AsyncEffectEvent) => {
|
||||
const theme = addExtraStyles(themeById(props.themeId));
|
||||
const theme = themeStyle(props.themeId);
|
||||
const themeCss = themeToCss(theme);
|
||||
if (event.cancelled) return;
|
||||
setStyleSheetContent(themeCss);
|
||||
|
||||
@@ -7,19 +7,19 @@ import InteropServiceHelper from '../../InteropServiceHelper';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types';
|
||||
import { getNoteCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||
|
||||
import bridge from '../../services/bridge';
|
||||
import BaseModel from '@joplin/lib/BaseModel';
|
||||
const bridge = require('@electron/remote').require('./bridge').default;
|
||||
const Menu = bridge().Menu;
|
||||
const MenuItem = bridge().MenuItem;
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
const { clipboard } = require('electron');
|
||||
import { Dispatch } from 'redux';
|
||||
|
||||
const Menu = bridge().Menu;
|
||||
const MenuItem = bridge().MenuItem;
|
||||
|
||||
interface ContextMenuProps {
|
||||
notes: any[];
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
dispatch: Function;
|
||||
dispatch: Dispatch;
|
||||
watchedNoteFiles: string[];
|
||||
plugins: PluginStates;
|
||||
inConflictFolder: boolean;
|
||||
@@ -45,26 +45,26 @@ export default class NoteListUtils {
|
||||
|
||||
if (!hasEncrypted) {
|
||||
menu.append(
|
||||
new MenuItem(menuUtils.commandToStatefulMenuItem('setTags', noteIds))
|
||||
new MenuItem(menuUtils.commandToStatefulMenuItem('setTags', noteIds) as any)
|
||||
);
|
||||
|
||||
menu.append(
|
||||
new MenuItem(menuUtils.commandToStatefulMenuItem('moveToFolder', noteIds))
|
||||
new MenuItem(menuUtils.commandToStatefulMenuItem('moveToFolder', noteIds) as any)
|
||||
);
|
||||
|
||||
menu.append(
|
||||
new MenuItem(menuUtils.commandToStatefulMenuItem('duplicateNote', noteIds))
|
||||
new MenuItem(menuUtils.commandToStatefulMenuItem('duplicateNote', noteIds) as any)
|
||||
);
|
||||
|
||||
if (singleNoteId) {
|
||||
const cmd = props.watchedNoteFiles.includes(singleNoteId) ? 'stopExternalEditing' : 'startExternalEditing';
|
||||
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem(cmd, singleNoteId)));
|
||||
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem(cmd, singleNoteId) as any));
|
||||
}
|
||||
|
||||
if (noteIds.length <= 1) {
|
||||
menu.append(
|
||||
new MenuItem(
|
||||
menuUtils.commandToStatefulMenuItem('toggleNoteType', noteIds)
|
||||
menuUtils.commandToStatefulMenuItem('toggleNoteType', noteIds) as any
|
||||
)
|
||||
);
|
||||
} else {
|
||||
@@ -125,7 +125,7 @@ export default class NoteListUtils {
|
||||
if ([9, 10].includes(Setting.value('sync.target'))) {
|
||||
menu.append(
|
||||
new MenuItem(
|
||||
menuUtils.commandToStatefulMenuItem('showShareNoteDialog', noteIds.slice())
|
||||
menuUtils.commandToStatefulMenuItem('showShareNoteDialog', noteIds.slice()) as any
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -156,7 +156,7 @@ export default class NoteListUtils {
|
||||
|
||||
exportMenu.append(
|
||||
new MenuItem(
|
||||
menuUtils.commandToStatefulMenuItem('exportPdf', noteIds)
|
||||
menuUtils.commandToStatefulMenuItem('exportPdf', noteIds) as any
|
||||
)
|
||||
);
|
||||
|
||||
@@ -167,7 +167,7 @@ export default class NoteListUtils {
|
||||
|
||||
menu.append(
|
||||
new MenuItem(
|
||||
menuUtils.commandToStatefulMenuItem('deleteNote', noteIds)
|
||||
menuUtils.commandToStatefulMenuItem('deleteNote', noteIds) as any
|
||||
)
|
||||
);
|
||||
|
||||
@@ -179,7 +179,7 @@ export default class NoteListUtils {
|
||||
|
||||
if (cmdService.isEnabled(info.view.commandName)) {
|
||||
menu.append(
|
||||
new MenuItem(menuUtils.commandToStatefulMenuItem(info.view.commandName, noteIds))
|
||||
new MenuItem(menuUtils.commandToStatefulMenuItem(info.view.commandName, noteIds) as any)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,37 @@
|
||||
/* eslint-disable jest/require-top-level-describe */
|
||||
|
||||
const { default: Logger, TargetType } = require('@joplin/utils/Logger');
|
||||
|
||||
// TODO: Some libraries required by test-utils.js seem to fail to import with the
|
||||
// jsdom environment.
|
||||
//
|
||||
// Thus, require('@joplin/lib/testing/test-utils.js') fails and some setup must be
|
||||
// copied.
|
||||
|
||||
const logger = new Logger();
|
||||
logger.addTarget(TargetType.Console);
|
||||
logger.setLevel(Logger.LEVEL_WARN);
|
||||
Logger.initializeGlobalLogger(logger);
|
||||
const { shimInit } = require('@joplin/lib/shim-init-node');
|
||||
const sqlite3 = require('sqlite3');
|
||||
const SyncTargetNone = require('@joplin/lib/SyncTargetNone').default;
|
||||
|
||||
// Mock the S3 sync target -- the @aws-s3 libraries depend on an old version
|
||||
// of uuid that doesn't work with jest without additional configuration.
|
||||
jest.doMock('@joplin/lib/SyncTargetAmazonS3', () => {
|
||||
return SyncTargetNone;
|
||||
});
|
||||
|
||||
// @electron/remote requires electron to be running. Mock it.
|
||||
jest.mock('@electron/remote', () => {
|
||||
return { require };
|
||||
return {
|
||||
require: () => {
|
||||
return {
|
||||
default: {},
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Import after mocking problematic libraries
|
||||
const { afterEachCleanUp, afterAllCleanUp } = require('@joplin/lib/testing/test-utils.js');
|
||||
|
||||
|
||||
shimInit({ nodeSqlite: sqlite3 });
|
||||
|
||||
afterEach(async () => {
|
||||
await afterEachCleanUp();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await afterAllCleanUp();
|
||||
});
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ const EncryptionService = require('@joplin/lib/services/e2ee/EncryptionService')
|
||||
const { FileApiDriverLocal } = require('@joplin/lib/file-api-driver-local');
|
||||
const React = require('react');
|
||||
const nodeSqlite = require('sqlite3');
|
||||
const initLib = require('@joplin/lib/initLib').default;
|
||||
|
||||
// Security: If we attempt to navigate away from the root HTML page, it's likely because
|
||||
// of an improperly sanitized link. Prevent this by closing the window before we can
|
||||
@@ -132,6 +133,10 @@ document.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
const logger = new Logger();
|
||||
Logger.initializeGlobalLogger(logger);
|
||||
initLib(logger);
|
||||
|
||||
app().start(bridge().processArgv()).then((result) => {
|
||||
if (!result || !result.action) {
|
||||
require('./gui/Root');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "2.12.10",
|
||||
"version": "2.12.11",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.js",
|
||||
"private": true,
|
||||
@@ -110,21 +110,21 @@
|
||||
"@electron/rebuild": "3.2.13",
|
||||
"@joplin/tools": "~2.12",
|
||||
"@testing-library/react-hooks": "8.0.1",
|
||||
"@types/jest": "29.5.1",
|
||||
"@types/node": "18.15.13",
|
||||
"@types/react": "16.14.41",
|
||||
"@types/jest": "29.5.3",
|
||||
"@types/node": "18.16.18",
|
||||
"@types/react": "18.0.24",
|
||||
"@types/react-redux": "7.1.25",
|
||||
"@types/styled-components": "5.1.26",
|
||||
"electron": "25.3.1",
|
||||
"electron-builder": "24.4.0",
|
||||
"glob": "10.2.7",
|
||||
"glob": "10.3.3",
|
||||
"gulp": "4.0.2",
|
||||
"jest": "29.5.0",
|
||||
"jest-environment-jsdom": "29.5.0",
|
||||
"js-sha512": "0.8.0",
|
||||
"nan": "2.17.0",
|
||||
"react-test-renderer": "18.2.0",
|
||||
"typescript": "5.0.2"
|
||||
"typescript": "5.1.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"7zip-bin-linux": "^1.0.1",
|
||||
@@ -139,6 +139,7 @@
|
||||
"@joplin/lib": "~2.12",
|
||||
"@joplin/renderer": "~2.12",
|
||||
"@joplin/utils": "~2.12",
|
||||
"@types/mustache": "4.2.2",
|
||||
"async-mutex": "0.4.0",
|
||||
"codemirror": "5.65.9",
|
||||
"color": "3.2.1",
|
||||
@@ -154,6 +155,7 @@
|
||||
"mark.js": "8.11.1",
|
||||
"md5": "2.3.0",
|
||||
"moment": "2.29.4",
|
||||
"mustache": "4.2.0",
|
||||
"node-fetch": "2.6.7",
|
||||
"node-notifier": "10.0.1",
|
||||
"node-rsa": "1.1.1",
|
||||
@@ -162,8 +164,8 @@
|
||||
"react": "18.2.0",
|
||||
"react-datetime": "3.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-redux": "8.1.1",
|
||||
"react-select": "5.7.3",
|
||||
"react-redux": "8.1.2",
|
||||
"react-select": "5.7.4",
|
||||
"react-toggle-button": "2.2.0",
|
||||
"react-tooltip": "4.5.1",
|
||||
"redux": "4.2.1",
|
||||
|
||||
@@ -5,4 +5,5 @@
|
||||
@use 'gui/JoplinCloudConfigScreen.scss' as joplin-cloud-config-screen;
|
||||
@use 'gui/Dropdown/style.scss' as dropdown-control;
|
||||
@use 'gui/ShareFolderDialog/style.scss' as share-folder-dialog;
|
||||
@use 'gui/NoteList/style.scss' as note-list;
|
||||
@use 'main.scss' as main;
|
||||
@@ -73,7 +73,7 @@ export default async (params: Params) => {
|
||||
teamId: process.env.APPLE_ASC_PROVIDER,
|
||||
|
||||
tool: 'notarytool',
|
||||
});
|
||||
} as any);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,23 @@
|
||||
import { fileExtension } from '@joplin/lib/path-utils';
|
||||
import shim from '@joplin/lib/shim';
|
||||
|
||||
export interface CheckForUpdateOptions {
|
||||
includePreReleases?: boolean;
|
||||
}
|
||||
|
||||
interface GitHubReleaseAsset {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
}
|
||||
|
||||
export interface GitHubRelease {
|
||||
tag_name: string;
|
||||
prerelease: boolean;
|
||||
body: string;
|
||||
assets: {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
}[];
|
||||
assets: GitHubReleaseAsset[];
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
interface Release {
|
||||
export interface Release {
|
||||
version: string;
|
||||
prerelease: boolean;
|
||||
downloadUrl: string;
|
||||
@@ -24,13 +25,17 @@ interface Release {
|
||||
pageUrl: string;
|
||||
}
|
||||
|
||||
export type Platform = typeof process.platform;
|
||||
|
||||
export type Architecture = typeof process.arch;
|
||||
|
||||
function getMajorMinorTagName(tagName: string) {
|
||||
const s = tagName.split('.');
|
||||
s.pop();
|
||||
return s.join('.');
|
||||
}
|
||||
|
||||
export const extractVersionInfo = (releases: GitHubRelease[], platform: typeof process.platform, options: CheckForUpdateOptions) => {
|
||||
export const extractVersionInfo = (releases: GitHubRelease[], platform: Platform, arch: Architecture, portable: boolean, options: CheckForUpdateOptions) => {
|
||||
options = { includePreReleases: false, ...options };
|
||||
|
||||
if (!releases.length) throw new Error('Cannot get latest release info (JSON)');
|
||||
@@ -67,28 +72,43 @@ export const extractVersionInfo = (releases: GitHubRelease[], platform: typeof p
|
||||
}
|
||||
}
|
||||
|
||||
let downloadUrl = null;
|
||||
for (let i = 0; i < release.assets.length; i++) {
|
||||
const asset = release.assets[i];
|
||||
let found = false;
|
||||
const ext = fileExtension(asset.name);
|
||||
if (platform === 'win32' && ext === 'exe') {
|
||||
if (shim.isPortable()) {
|
||||
found = asset.name === 'JoplinPortable.exe';
|
||||
} else {
|
||||
found = !!asset.name.match(/^Joplin-Setup-[\d.]+\.exe$/);
|
||||
}
|
||||
} else if (platform === 'darwin' && ext === 'dmg' && !asset.name.endsWith('arm64.dmg')) { // We don't return the arm64 version for now
|
||||
found = true;
|
||||
} else if (platform === 'linux' && ext === '.AppImage') {
|
||||
found = true;
|
||||
}
|
||||
let foundAsset: GitHubReleaseAsset = null;
|
||||
|
||||
if (found) {
|
||||
downloadUrl = asset.browser_download_url.replace('github.com/laurent22/joplin/releases/download', 'objects.joplinusercontent.com');
|
||||
downloadUrl.concat('?source=DesktopApp&type=Update');
|
||||
break;
|
||||
}
|
||||
if (platform === 'win32' && portable) {
|
||||
foundAsset = release.assets.find(asset => {
|
||||
return asset.name === 'JoplinPortable.exe';
|
||||
});
|
||||
}
|
||||
|
||||
if (!foundAsset && platform === 'win32') {
|
||||
foundAsset = release.assets.find(asset => {
|
||||
return !!asset.name.match(/^Joplin-Setup-[\d.]+\.exe$/);
|
||||
});
|
||||
}
|
||||
|
||||
if (platform === 'darwin' && arch === 'arm64') {
|
||||
foundAsset = release.assets.find(asset => {
|
||||
return asset.name.endsWith('arm64.dmg');
|
||||
});
|
||||
}
|
||||
|
||||
if (!foundAsset && platform === 'darwin') {
|
||||
foundAsset = release.assets.find(asset => {
|
||||
return fileExtension(asset.name) === 'dmg' && !asset.name.endsWith('arm64.dmg');
|
||||
});
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
foundAsset = release.assets.find(asset => {
|
||||
return fileExtension(asset.name) === 'AppImage';
|
||||
});
|
||||
}
|
||||
|
||||
let downloadUrl: string = null;
|
||||
|
||||
if (foundAsset) {
|
||||
downloadUrl = foundAsset.browser_download_url.replace('github.com/laurent22/joplin/releases/download', 'objects.joplinusercontent.com');
|
||||
downloadUrl.concat('?source=DesktopApp&type=Update');
|
||||
}
|
||||
|
||||
function cleanUpReleaseNotes(releaseNotes: string[]) {
|
||||
|
||||
5155
packages/app-desktop/utils/checkForUpdatesUtilsTestData.ts
Normal file
5155
packages/app-desktop/utils/checkForUpdatesUtilsTestData.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -110,8 +110,8 @@ android {
|
||||
applicationId "net.cozic.joplin"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2097717
|
||||
versionName "2.12.0"
|
||||
versionCode 2097718
|
||||
versionName "2.12.1"
|
||||
// ndk {
|
||||
// abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
|
||||
// }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const React = require('react');
|
||||
|
||||
import { ReactElement, useCallback, useState } from 'react';
|
||||
import { ReactElement, useCallback, useMemo, useState } from 'react';
|
||||
import { LayoutChangeEvent, ScrollView, View, ViewStyle } from 'react-native';
|
||||
import ToggleOverflowButton from './ToggleOverflowButton';
|
||||
import ToolbarButton, { buttonSize } from './ToolbarButton';
|
||||
@@ -18,19 +18,22 @@ const Toolbar = (props: ToolbarProps) => {
|
||||
const [overflowButtonsVisible, setOverflowPopupVisible] = useState(false);
|
||||
const [maxButtonsEachSide, setMaxButtonsEachSide] = useState(0);
|
||||
|
||||
const allButtonSpecs = props.buttons.reduce((accumulator: ButtonSpec[], current: ButtonGroup) => {
|
||||
const newItems: ButtonSpec[] = [];
|
||||
for (const item of current.items) {
|
||||
if (item.visible ?? true) {
|
||||
newItems.push(item);
|
||||
const allButtonSpecs = useMemo(() => {
|
||||
const buttons = props.buttons.reduce((accumulator: ButtonSpec[], current: ButtonGroup) => {
|
||||
const newItems: ButtonSpec[] = [];
|
||||
for (const item of current.items) {
|
||||
if (item.visible ?? true) {
|
||||
newItems.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return accumulator.concat(...newItems);
|
||||
}, []);
|
||||
return accumulator.concat(...newItems);
|
||||
}, []);
|
||||
|
||||
// Sort from highest priority to lowest
|
||||
allButtonSpecs.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
||||
// Sort from highest priority to lowest
|
||||
buttons.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
||||
return buttons;
|
||||
}, [props.buttons]);
|
||||
|
||||
const allButtonComponents: ReactElement[] = [];
|
||||
let key = 0;
|
||||
@@ -67,7 +70,9 @@ const Toolbar = (props: ToolbarProps) => {
|
||||
);
|
||||
|
||||
const mainButtons: ReactElement[] = [];
|
||||
if (maxButtonsEachSide < allButtonComponents.length) {
|
||||
if (maxButtonsEachSide >= allButtonComponents.length) {
|
||||
mainButtons.push(...allButtonComponents);
|
||||
} else if (maxButtonsEachSide > 0) {
|
||||
// We want the menu to look something like this:
|
||||
// B I (…) 🔍 ⌨
|
||||
// where (…) shows/hides overflow.
|
||||
@@ -77,7 +82,7 @@ const Toolbar = (props: ToolbarProps) => {
|
||||
mainButtons.push(toggleOverflowButton);
|
||||
mainButtons.push(...allButtonComponents.slice(-maxButtonsEachSide));
|
||||
} else {
|
||||
mainButtons.push(...allButtonComponents);
|
||||
mainButtons.push(toggleOverflowButton);
|
||||
}
|
||||
|
||||
const styles = props.styleSheet.styles;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { describe, it, expect, beforeEach } from '@jest/globals';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native';
|
||||
import '@testing-library/jest-native';
|
||||
|
||||
import NoteEditor from './NoteEditor';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { MenuProvider } from 'react-native-popup-menu';
|
||||
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
|
||||
describe('NoteEditor', () => {
|
||||
beforeEach(async () => {
|
||||
// Required to use ExtendedWebView
|
||||
await setupDatabaseAndSynchronizer(0);
|
||||
await switchClient(0);
|
||||
});
|
||||
|
||||
it('should hide the markdown toolbar when the window is small', async () => {
|
||||
const wrappedNoteEditor = render(
|
||||
<MenuProvider>
|
||||
<NoteEditor
|
||||
themeId={Setting.THEME_ARITIM_DARK}
|
||||
initialText='Testing...'
|
||||
style={{}}
|
||||
toolbarEnabled={true}
|
||||
readOnly={false}
|
||||
onChange={()=>{}}
|
||||
onSelectionChange={()=>{}}
|
||||
onUndoRedoDepthChange={()=>{}}
|
||||
onAttach={()=>{}}
|
||||
/>
|
||||
</MenuProvider>
|
||||
);
|
||||
|
||||
// Maps from screen height to whether the markdown toolbar should be visible.
|
||||
const testCases: [number, boolean][] = [
|
||||
[10, false],
|
||||
[1000, true],
|
||||
[100, false],
|
||||
[80, false],
|
||||
[600, true],
|
||||
];
|
||||
|
||||
const noteEditorRoot = await wrappedNoteEditor.findByTestId('note-editor-root');
|
||||
|
||||
const setRootHeight = (height: number) => {
|
||||
act(() => {
|
||||
// See https://stackoverflow.com/a/61774123
|
||||
fireEvent(noteEditorRoot, 'layout', {
|
||||
nativeEvent: {
|
||||
layout: { height },
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
for (const [height, visible] of testCases) {
|
||||
setRootHeight(height);
|
||||
|
||||
await waitFor(async () => {
|
||||
const showMoreButton = await screen.queryByLabelText(_('Show more actions'));
|
||||
if (visible) {
|
||||
expect(showMoreButton).not.toBeNull();
|
||||
} else {
|
||||
expect(showMoreButton).toBeNull();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import ExtendedWebView from '../ExtendedWebView';
|
||||
const React = require('react');
|
||||
import { forwardRef, RefObject, useImperativeHandle } from 'react';
|
||||
import { useEffect, useMemo, useState, useCallback, useRef } from 'react';
|
||||
import { View, ViewStyle } from 'react-native';
|
||||
import { LayoutChangeEvent, View, ViewStyle } from 'react-native';
|
||||
const { editorFont } = require('../global-style');
|
||||
|
||||
import SelectionFormatting from './SelectionFormatting';
|
||||
@@ -368,6 +368,19 @@ function NoteEditor(props: Props, ref: any) {
|
||||
console.error('NoteEditor: webview error');
|
||||
}, []);
|
||||
|
||||
const [hasSpaceForToolbar, setHasSpaceForToolbar] = useState(true);
|
||||
const toolbarEnabled = props.toolbarEnabled && hasSpaceForToolbar;
|
||||
|
||||
const onContainerLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
const containerHeight = event.nativeEvent.layout.height;
|
||||
|
||||
if (containerHeight < 140) {
|
||||
setHasSpaceForToolbar(false);
|
||||
} else {
|
||||
setHasSpaceForToolbar(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toolbar = <MarkdownToolbar
|
||||
style={{
|
||||
// Don't show the markdown toolbar if there isn't enough space
|
||||
@@ -385,10 +398,14 @@ function NoteEditor(props: Props, ref: any) {
|
||||
// - `scrollEnabled` prevents iOS from scrolling the document (has no effect on Android)
|
||||
// when an editable region (e.g. a the full-screen NoteEditor) is focused.
|
||||
return (
|
||||
<View style={{
|
||||
...props.style,
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
<View
|
||||
testID='note-editor-root'
|
||||
onLayout={onContainerLayout}
|
||||
style={{
|
||||
...props.style,
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<EditLinkDialog
|
||||
visible={linkDialogVisible}
|
||||
themeId={props.themeId}
|
||||
@@ -419,7 +436,7 @@ function NoteEditor(props: Props, ref: any) {
|
||||
searchState={searchState}
|
||||
/>
|
||||
|
||||
{props.toolbarEnabled ? toolbar : null}
|
||||
{toolbarEnabled ? toolbar : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ const { _ } = require('@joplin/lib/locale');
|
||||
const { BaseScreenComponent } = require('../../base-screen.js');
|
||||
const { Dropdown } = require('../../Dropdown');
|
||||
const { themeStyle } = require('../../global-style.js');
|
||||
const shared = require('@joplin/lib/components/shared/config-shared.js');
|
||||
const shared = require('@joplin/lib/components/shared/config/config-shared.js');
|
||||
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
|
||||
import { openDocumentTree } from '@joplin/react-native-saf-x';
|
||||
import biometricAuthenticate from '../../biometrics/biometricAuthenticate';
|
||||
|
||||
@@ -36,7 +36,7 @@ const { BaseScreenComponent } = require('../base-screen.js');
|
||||
const { themeStyle, editorFont } = require('../global-style.js');
|
||||
const { dialogs } = require('../../utils/dialogs.js');
|
||||
const DialogBox = require('react-native-dialogbox').default;
|
||||
const ImageResizer = require('react-native-image-resizer').default;
|
||||
import ImageResizer from '@bam.tech/react-native-image-resizer';
|
||||
import shared from '@joplin/lib/components/shared/note-screen-shared';
|
||||
import { ImagePickerResponse, launchImageLibrary } from 'react-native-image-picker';
|
||||
import SelectDateTimeDialog from '../SelectDateTimeDialog';
|
||||
@@ -572,34 +572,30 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
|
||||
public async resizeImage(localFilePath: string, targetPath: string, mimeType: string) {
|
||||
const maxSize = Resource.IMAGE_MAX_DIMENSION;
|
||||
|
||||
const dimensions: any = await this.imageDimensions(localFilePath);
|
||||
|
||||
reg.logger().info('Original dimensions ', dimensions);
|
||||
|
||||
let mustResize = dimensions.width > maxSize || dimensions.height > maxSize;
|
||||
|
||||
if (mustResize) {
|
||||
const buttonId = await dialogs.pop(this, _('You are about to attach a large image (%dx%d pixels). Would you like to resize it down to %d pixels before attaching it?', dimensions.width, dimensions.height, maxSize), [
|
||||
{ text: _('Yes'), id: 'yes' },
|
||||
{ text: _('No'), id: 'no' },
|
||||
{ text: _('Cancel'), id: 'cancel' },
|
||||
]);
|
||||
|
||||
if (buttonId === 'cancel') return false;
|
||||
|
||||
mustResize = buttonId === 'yes';
|
||||
}
|
||||
|
||||
if (mustResize) {
|
||||
const saveOriginalImage = async () => {
|
||||
await shim.fsDriver().copy(localFilePath, targetPath);
|
||||
return true;
|
||||
};
|
||||
const saveResizedImage = async () => {
|
||||
dimensions.width = maxSize;
|
||||
dimensions.height = maxSize;
|
||||
|
||||
reg.logger().info('New dimensions ', dimensions);
|
||||
|
||||
const format = mimeType === 'image/png' ? 'PNG' : 'JPEG';
|
||||
reg.logger().info(`Resizing image ${localFilePath}`);
|
||||
const resizedImage = await ImageResizer.createResizedImage(localFilePath, dimensions.width, dimensions.height, format, 85); // , 0, targetPath);
|
||||
const resizedImage = await ImageResizer.createResizedImage(
|
||||
localFilePath,
|
||||
dimensions.width,
|
||||
dimensions.height,
|
||||
format,
|
||||
85, // quality
|
||||
undefined, // rotation
|
||||
undefined, // outputPath
|
||||
true // keep metadata
|
||||
);
|
||||
|
||||
const resizedImagePath = resizedImage.uri;
|
||||
reg.logger().info('Resized image ', resizedImagePath);
|
||||
@@ -612,11 +608,27 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
} catch (error) {
|
||||
reg.logger().warn('Error when unlinking cached file: ', error);
|
||||
}
|
||||
} else {
|
||||
await shim.fsDriver().copy(localFilePath, targetPath);
|
||||
return true;
|
||||
};
|
||||
|
||||
const canResize = dimensions.width > maxSize || dimensions.height > maxSize;
|
||||
if (canResize) {
|
||||
const resizeLargeImages = Setting.value('imageResizing');
|
||||
if (resizeLargeImages === 'alwaysAsk') {
|
||||
const userAnswer = await dialogs.pop(this, `${_('You are about to attach a large image (%dx%d pixels). Would you like to resize it down to %d pixels before attaching it?', dimensions.width, dimensions.height, maxSize)}\n\n${_('(You may disable this prompt in the options)')}`, [
|
||||
{ text: _('Yes'), id: 'yes' },
|
||||
{ text: _('No'), id: 'no' },
|
||||
{ text: _('Cancel'), id: 'cancel' },
|
||||
]);
|
||||
if (userAnswer === 'yes') return await saveResizedImage();
|
||||
if (userAnswer === 'no') return await saveOriginalImage();
|
||||
if (userAnswer === 'cancel') return false;
|
||||
} else if (resizeLargeImages === 'alwaysResize') {
|
||||
return await saveResizedImage();
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
return await saveOriginalImage();
|
||||
}
|
||||
|
||||
public async attachFile(pickerResponse: any, fileType: string) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Banner, ActivityIndicator, Modal } from 'react-native-paper';
|
||||
import { Banner, ActivityIndicator } from 'react-native-paper';
|
||||
import { _, languageName } from '@joplin/lib/locale';
|
||||
import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import { getVosk, Recorder, startRecording, Vosk } from '../../services/voiceTyping/vosk';
|
||||
@@ -107,18 +107,16 @@ export default (props: Props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={true} style={{ display: 'flex', flexDirection: 'column', justifyContent: 'flex-end' }}>
|
||||
<Banner
|
||||
visible={true}
|
||||
icon={renderIcon()}
|
||||
actions={[
|
||||
{
|
||||
label: _('Done'),
|
||||
onPress: onDismiss,
|
||||
},
|
||||
]}>
|
||||
{`${_('Voice typing...')}\n${renderContent()}`}
|
||||
</Banner>
|
||||
</Modal>
|
||||
<Banner
|
||||
visible={true}
|
||||
icon={renderIcon()}
|
||||
actions={[
|
||||
{
|
||||
label: _('Done'),
|
||||
onPress: onDismiss,
|
||||
},
|
||||
]}>
|
||||
{`${_('Voice typing...')}\n${renderContent()}`}
|
||||
</Banner>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -343,7 +343,7 @@ PODS:
|
||||
- React-Core
|
||||
- react-native-camera/RN (4.2.1):
|
||||
- React-Core
|
||||
- react-native-document-picker (8.2.1):
|
||||
- react-native-document-picker (9.0.1):
|
||||
- React-Core
|
||||
- react-native-fingerprint-scanner (6.0.0):
|
||||
- React
|
||||
@@ -351,17 +351,17 @@ PODS:
|
||||
- React-Core
|
||||
- react-native-get-random-values (1.9.0):
|
||||
- React-Core
|
||||
- react-native-image-picker (5.4.2):
|
||||
- react-native-image-picker (5.6.0):
|
||||
- React-Core
|
||||
- react-native-image-resizer (1.4.5):
|
||||
- react-native-image-resizer (3.0.5):
|
||||
- React-Core
|
||||
- react-native-netinfo (9.3.11):
|
||||
- react-native-netinfo (9.4.1):
|
||||
- React-Core
|
||||
- react-native-rsa-native (2.0.5):
|
||||
- React
|
||||
- react-native-saf-x (2.12.0):
|
||||
- React-Core
|
||||
- react-native-safe-area-context (4.5.5):
|
||||
- react-native-safe-area-context (4.6.4):
|
||||
- RCT-Folly
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
@@ -465,9 +465,9 @@ PODS:
|
||||
- React-Core
|
||||
- RNCPushNotificationIOS (1.11.0):
|
||||
- React-Core
|
||||
- RNDateTimePicker (7.1.0):
|
||||
- RNDateTimePicker (7.4.1):
|
||||
- React-Core
|
||||
- RNDeviceInfo (10.6.1):
|
||||
- RNDeviceInfo (10.7.0):
|
||||
- React-Core
|
||||
- RNExitApp (1.1.0):
|
||||
- React
|
||||
@@ -584,7 +584,7 @@ DEPENDENCIES:
|
||||
- "react-native-geolocation (from `../node_modules/@react-native-community/geolocation`)"
|
||||
- react-native-get-random-values (from `../node_modules/react-native-get-random-values`)
|
||||
- react-native-image-picker (from `../node_modules/react-native-image-picker`)
|
||||
- react-native-image-resizer (from `../node_modules/react-native-image-resizer`)
|
||||
- "react-native-image-resizer (from `../node_modules/@bam.tech/react-native-image-resizer`)"
|
||||
- "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)"
|
||||
- react-native-rsa-native (from `../node_modules/react-native-rsa-native`)
|
||||
- "react-native-saf-x (from `../node_modules/@joplin/react-native-saf-x`)"
|
||||
@@ -703,7 +703,7 @@ EXTERNAL SOURCES:
|
||||
react-native-image-picker:
|
||||
:path: "../node_modules/react-native-image-picker"
|
||||
react-native-image-resizer:
|
||||
:path: "../node_modules/react-native-image-resizer"
|
||||
:path: "../node_modules/@bam.tech/react-native-image-resizer"
|
||||
react-native-netinfo:
|
||||
:path: "../node_modules/@react-native-community/netinfo"
|
||||
react-native-rsa-native:
|
||||
@@ -819,16 +819,16 @@ SPEC CHECKSUMS:
|
||||
React-logger: ef2269b3afa6ba868da90496c3e17a4ec4f4cee0
|
||||
react-native-alarm-notification: 0732f97be04975a23ba60e675bdb961a0aaf6aa6
|
||||
react-native-camera: 3eae183c1d111103963f3dd913b65d01aef8110f
|
||||
react-native-document-picker: 69ca2094d8780cfc1e7e613894d15290fdc54bba
|
||||
react-native-document-picker: 2b8f18667caee73a96708a82b284a4f40b30a156
|
||||
react-native-fingerprint-scanner: ac6656f18c8e45a7459302b84da41a44ad96dbbe
|
||||
react-native-geolocation: 0f7fe8a4c2de477e278b0365cce27d089a8c5903
|
||||
react-native-get-random-values: dee677497c6a740b71e5612e8dbd83e7539ed5bb
|
||||
react-native-image-picker: 77f552291e993f3fdcdf48cc3c280ef7f11789c8
|
||||
react-native-image-resizer: d9fb629a867335bdc13230ac2a58702bb8c8828f
|
||||
react-native-netinfo: 3a48f51c18dbd9253440621955e11de71bc51b32
|
||||
react-native-image-picker: db60857e03d63721f19b6f4027de20429ddd9cba
|
||||
react-native-image-resizer: 00ceb0e05586c7aadf061eea676957a6c2ec60fa
|
||||
react-native-netinfo: fefd4e98d75cbdd6e85fc530f7111a8afdf2b0c5
|
||||
react-native-rsa-native: 12132eb627797529fdb1f0d22fd0f8f9678df64a
|
||||
react-native-saf-x: 129cd2ddf120a1f6164c724b2846d172666b33de
|
||||
react-native-safe-area-context: 33e71d7408dffe148b08968d97a524009634ccc8
|
||||
react-native-safe-area-context: 68b07eabfb0d14547d36f6929c0e98d818064f02
|
||||
react-native-slider: 33b8d190b59d4f67a541061bb91775d53d617d9d
|
||||
react-native-sqlite-storage: f6d515e1c446d1e6d026aa5352908a25d4de3261
|
||||
react-native-version-info: a106f23009ac0db4ee00de39574eb546682579b9
|
||||
@@ -849,8 +849,8 @@ SPEC CHECKSUMS:
|
||||
rn-fetch-blob: f065bb7ab7fb48dd002629f8bdcb0336602d3cba
|
||||
RNCClipboard: 41d8d918092ae8e676f18adada19104fa3e68495
|
||||
RNCPushNotificationIOS: 64218f3c776c03d7408284a819b2abfda1834bc8
|
||||
RNDateTimePicker: 7ecd54a97fc3749f38c3c89a171f6cbd52f3c142
|
||||
RNDeviceInfo: ab292735ad4fccc5f2aec0c773f7a7f03c7073ae
|
||||
RNDateTimePicker: 9b4091348e53f540180abdc54984d839a556f593
|
||||
RNDeviceInfo: 25d818c85db769cc0e7083d39efaa01a6f450df3
|
||||
RNExitApp: c4e052df2568b43bec8a37c7cd61194d4cfee2c3
|
||||
RNFileViewer: ce7ca3ac370e18554d35d6355cffd7c30437c592
|
||||
RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
|
||||
|
||||
@@ -33,6 +33,21 @@ document.createRange = () => {
|
||||
|
||||
shimInit({ nodeSqlite: sqlite3 });
|
||||
|
||||
// This library has the following error when running within Jest:
|
||||
// Invariant Violation: `new NativeEventEmitter()` requires a non-null argument.
|
||||
jest.mock('react-native-device-info', () => {
|
||||
return {
|
||||
hasNotch: () => false,
|
||||
};
|
||||
});
|
||||
|
||||
// react-native-webview expects native iOS/Android code so needs to be mocked.
|
||||
jest.mock('react-native-webview', () => {
|
||||
const { View } = require('react-native');
|
||||
return {
|
||||
WebView: View,
|
||||
};
|
||||
});
|
||||
|
||||
// react-native-fs's CachesDirectoryPath export doesn't work in a testing environment.
|
||||
// Use a temporary folder instead.
|
||||
|
||||
@@ -18,15 +18,16 @@
|
||||
"postinstall": "jetify && yarn run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bam.tech/react-native-image-resizer": "3.0.5",
|
||||
"@joplin/lib": "~2.12",
|
||||
"@joplin/react-native-alarm-notification": "~2.12",
|
||||
"@joplin/react-native-saf-x": "~2.12",
|
||||
"@joplin/renderer": "~2.12",
|
||||
"@joplin/utils": "~2.12",
|
||||
"@react-native-community/clipboard": "1.5.1",
|
||||
"@react-native-community/datetimepicker": "7.1.0",
|
||||
"@react-native-community/datetimepicker": "7.4.1",
|
||||
"@react-native-community/geolocation": "3.0.6",
|
||||
"@react-native-community/netinfo": "9.3.11",
|
||||
"@react-native-community/netinfo": "9.4.1",
|
||||
"@react-native-community/push-notification-ios": "1.11.0",
|
||||
"@react-native-community/slider": "4.4.2",
|
||||
"assert-browserify": "2.0.0",
|
||||
@@ -43,29 +44,27 @@
|
||||
"punycode": "2.3.0",
|
||||
"react": "18.2.0",
|
||||
"react-native": "0.71.10",
|
||||
"react-native-action-button": "2.8.5",
|
||||
"react-native-camera": "4.2.1",
|
||||
"react-native-device-info": "10.6.1",
|
||||
"react-native-device-info": "10.7.0",
|
||||
"react-native-dialogbox": "0.6.10",
|
||||
"react-native-document-picker": "8.2.1",
|
||||
"react-native-document-picker": "9.0.1",
|
||||
"react-native-drawer-layout": "3.2.1",
|
||||
"react-native-dropdownalert": "4.5.1",
|
||||
"react-native-exit-app": "1.1.0",
|
||||
"react-native-file-viewer": "2.1.5",
|
||||
"react-native-fingerprint-scanner": "6.0.0",
|
||||
"react-native-fs": "2.20.0",
|
||||
"react-native-gesture-handler": "2.12.0",
|
||||
"react-native-gesture-handler": "2.12.1",
|
||||
"react-native-get-random-values": "1.9.0",
|
||||
"react-native-image-picker": "5.4.2",
|
||||
"react-native-image-resizer": "1.4.5",
|
||||
"react-native-image-picker": "5.6.0",
|
||||
"react-native-localize": "3.0.2",
|
||||
"react-native-modal-datetime-picker": "15.0.1",
|
||||
"react-native-paper": "5.8.0",
|
||||
"react-native-paper": "5.9.1",
|
||||
"react-native-popup-menu": "0.16.1",
|
||||
"react-native-quick-actions": "0.3.13",
|
||||
"react-native-reanimated": "3.3.0",
|
||||
"react-native-rsa-native": "2.0.5",
|
||||
"react-native-safe-area-context": "4.5.5",
|
||||
"react-native-safe-area-context": "4.7.1",
|
||||
"react-native-securerandom": "1.0.1",
|
||||
"react-native-share": "8.2.2",
|
||||
"react-native-sqlite-storage": "6.0.1",
|
||||
@@ -75,7 +74,7 @@
|
||||
"react-native-vosk": "0.1.12",
|
||||
"react-native-webview": "12.4.0",
|
||||
"react-native-zip-archive": "6.0.9",
|
||||
"react-redux": "8.1.1",
|
||||
"react-redux": "8.1.2",
|
||||
"redux": "4.2.1",
|
||||
"rn-fetch-blob": "0.12.0",
|
||||
"stream": "0.0.2",
|
||||
@@ -105,15 +104,15 @@
|
||||
"@joplin/tools": "~2.12",
|
||||
"@lezer/highlight": "1.1.4",
|
||||
"@testing-library/jest-native": "5.4.2",
|
||||
"@testing-library/react-native": "12.1.2",
|
||||
"@testing-library/react-native": "12.1.3",
|
||||
"@tsconfig/react-native": "2.0.2",
|
||||
"@types/fs-extra": "11.0.1",
|
||||
"@types/jest": "29.5.1",
|
||||
"@types/jest": "29.5.3",
|
||||
"@types/react": "18.0.24",
|
||||
"@types/react-native": "0.70.6",
|
||||
"@types/react-redux": "7.1.25",
|
||||
"@types/tar-stream": "2.2.2",
|
||||
"babel-jest": "29.2.1",
|
||||
"babel-jest": "29.5.0",
|
||||
"babel-plugin-module-resolver": "4.1.0",
|
||||
"execa": "4.1.0",
|
||||
"fs-extra": "11.1.1",
|
||||
@@ -127,10 +126,10 @@
|
||||
"nodemon": "2.0.22",
|
||||
"react-test-renderer": "18.2.0",
|
||||
"sqlite3": "5.1.6",
|
||||
"ts-jest": "29.1.0",
|
||||
"ts-jest": "29.1.1",
|
||||
"ts-loader": "9.4.4",
|
||||
"ts-node": "10.9.1",
|
||||
"typescript": "5.0.2",
|
||||
"typescript": "5.1.3",
|
||||
"uglify-js": "3.17.4",
|
||||
"webpack": "5.74.0"
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ const SyncTargetWebDAV = require('@joplin/lib/SyncTargetWebDAV.js');
|
||||
const SyncTargetDropbox = require('@joplin/lib/SyncTargetDropbox.js');
|
||||
const SyncTargetAmazonS3 = require('@joplin/lib/SyncTargetAmazonS3.js');
|
||||
import BiometricPopup from './components/biometrics/BiometricPopup';
|
||||
import initLib from '@joplin/lib/initLib';
|
||||
|
||||
SyncTargetRegistry.addClass(SyncTargetNone);
|
||||
SyncTargetRegistry.addClass(SyncTargetOneDrive);
|
||||
@@ -474,6 +475,7 @@ async function initialize(dispatch: Function) {
|
||||
}
|
||||
|
||||
Logger.initializeGlobalLogger(mainLogger);
|
||||
initLib(mainLogger);
|
||||
|
||||
reg.setLogger(mainLogger);
|
||||
reg.setShowErrorMessageBoxHandler((message: string) => { alert(message); });
|
||||
|
||||
@@ -351,7 +351,7 @@ export default class FsDriverRN extends FsDriverBase {
|
||||
} else {
|
||||
// the result is an array
|
||||
if (multiple) {
|
||||
result = await DocumentPicker.pickMultiple();
|
||||
result = await DocumentPicker.pick({ allowMultiSelection: true });
|
||||
} else {
|
||||
result = [await DocumentPicker.pick()];
|
||||
}
|
||||
|
||||
@@ -45,16 +45,16 @@
|
||||
"entities": "2.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.1",
|
||||
"@types/node": "18.15.13",
|
||||
"@typescript-eslint/eslint-plugin": "5.59.0",
|
||||
"@typescript-eslint/parser": "5.59.0",
|
||||
"@types/jest": "29.5.3",
|
||||
"@types/node": "18.16.18",
|
||||
"@typescript-eslint/eslint-plugin": "5.60.0",
|
||||
"@typescript-eslint/parser": "5.60.0",
|
||||
"coveralls": "3.1.1",
|
||||
"eslint": "8.39.0",
|
||||
"eslint": "8.43.0",
|
||||
"jest": "29.5.0",
|
||||
"prettier": "2.8.8",
|
||||
"ts-jest": "29.1.0",
|
||||
"typescript": "5.0.2"
|
||||
"ts-jest": "29.1.1",
|
||||
"typescript": "5.1.3"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "ts-jest",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
],
|
||||
"devDependencies": {
|
||||
"standard": "17.1.0",
|
||||
"tap": "16.3.7"
|
||||
"tap": "16.3.8"
|
||||
},
|
||||
"gitHead": "eb4b0e64eab40a51b0895d3a40a9d8c3cb7b1b14"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "generate-plugin-doc",
|
||||
"packageManager": "yarn@3.3.1",
|
||||
"packageManager": "yarn@3.6.0",
|
||||
"scripts": {
|
||||
"buildPluginDoc_": "typedoc --name 'Joplin Plugin API Documentation' --mode file -theme '../../Assets/PluginDocTheme/' --readme '../../Assets/PluginDocTheme/index.md' --excludeNotExported --excludeExternals --excludePrivate --excludeProtected --out ../../../joplin-website/docs/api/references/plugin_api ../lib/services/plugins/api/"
|
||||
},
|
||||
|
||||
@@ -59,7 +59,6 @@ import Resource from './models/Resource';
|
||||
import { ProfileConfig } from './services/profileConfig/types';
|
||||
import initProfile from './services/profileConfig/initProfile';
|
||||
import { parseShareCache } from './services/share/reducer';
|
||||
|
||||
import RotatingLogs from './RotatingLogs';
|
||||
|
||||
const appLogger: LoggerWrapper = Logger.create('App');
|
||||
@@ -69,6 +68,7 @@ const appLogger: LoggerWrapper = Logger.create('App');
|
||||
|
||||
interface StartOptions {
|
||||
keychainEnabled?: boolean;
|
||||
setupGlobalLogger?: boolean;
|
||||
}
|
||||
|
||||
export default class BaseApplication {
|
||||
@@ -547,7 +547,7 @@ export default class BaseApplication {
|
||||
const newState = store.getState();
|
||||
|
||||
if (this.hasGui() && ['NOTE_UPDATE_ONE', 'NOTE_DELETE', 'FOLDER_UPDATE_ONE', 'FOLDER_DELETE'].indexOf(action.type) >= 0) {
|
||||
if (!(await reg.syncTarget().syncStarted())) void reg.scheduleSync(1000, { syncSteps: ['update_remote', 'delete_remote'] });
|
||||
if (!(await reg.syncTarget().syncStarted())) void reg.scheduleSync(15 * 1000, { syncSteps: ['update_remote', 'delete_remote'] });
|
||||
SearchEngine.instance().scheduleSyncTables();
|
||||
}
|
||||
|
||||
@@ -735,9 +735,24 @@ export default class BaseApplication {
|
||||
return toSystemSlashes(output, 'linux');
|
||||
}
|
||||
|
||||
protected startRotatingLogMaintenance(profileDir: string) {
|
||||
this.rotatingLogs = new RotatingLogs(profileDir);
|
||||
const processLogs = async () => {
|
||||
try {
|
||||
await this.rotatingLogs.cleanActiveLogFile();
|
||||
await this.rotatingLogs.deleteNonActiveLogFiles();
|
||||
} catch (error) {
|
||||
appLogger.error(error);
|
||||
}
|
||||
};
|
||||
shim.setTimeout(() => { void processLogs(); }, 60000);
|
||||
shim.setInterval(() => { void processLogs(); }, 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
public async start(argv: string[], options: StartOptions = null): Promise<any> {
|
||||
options = {
|
||||
keychainEnabled: true,
|
||||
setupGlobalLogger: true,
|
||||
...options,
|
||||
};
|
||||
|
||||
@@ -800,18 +815,15 @@ export default class BaseApplication {
|
||||
const extraFlags = await this.readFlagsFromFile(`${profileDir}/flags.txt`);
|
||||
initArgs = { ...initArgs, ...extraFlags };
|
||||
|
||||
const globalLogger = Logger.globalLogger;
|
||||
|
||||
|
||||
|
||||
const globalLogger = new Logger();
|
||||
globalLogger.addTarget(TargetType.File, { path: `${profileDir}/log.txt` });
|
||||
if (Setting.value('appType') === 'desktop') {
|
||||
globalLogger.addTarget(TargetType.Console);
|
||||
if (options.setupGlobalLogger) {
|
||||
globalLogger.addTarget(TargetType.File, { path: `${profileDir}/log.txt` });
|
||||
if (Setting.value('appType') === 'desktop') {
|
||||
globalLogger.addTarget(TargetType.Console);
|
||||
}
|
||||
globalLogger.setLevel(initArgs.logLevel);
|
||||
}
|
||||
globalLogger.setLevel(initArgs.logLevel);
|
||||
Logger.initializeGlobalLogger(globalLogger);
|
||||
|
||||
|
||||
|
||||
reg.setLogger(Logger.create('') as Logger);
|
||||
// reg.dispatch = () => {};
|
||||
@@ -934,18 +946,6 @@ export default class BaseApplication {
|
||||
|
||||
await MigrationService.instance().run();
|
||||
|
||||
this.rotatingLogs = new RotatingLogs(profileDir);
|
||||
const processLogs = async () => {
|
||||
try {
|
||||
await this.rotatingLogs.cleanActiveLogFile();
|
||||
await this.rotatingLogs.deleteNonActiveLogFiles();
|
||||
} catch (error) {
|
||||
appLogger.error(error);
|
||||
}
|
||||
};
|
||||
shim.setTimeout(() => { void processLogs(); }, 60000);
|
||||
shim.setInterval(() => { void processLogs(); }, 24 * 60 * 60 * 1000);
|
||||
|
||||
return argv;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,12 @@ export default class BaseSyncTarget {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Returns true if the sync target expects a non-empty sync.{id}.password
|
||||
// setting.
|
||||
public static requiresPassword() {
|
||||
return false;
|
||||
}
|
||||
|
||||
public static description(): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ export default class ClipperServer {
|
||||
} catch (error) {
|
||||
this.setStartState(StartState.Idle);
|
||||
this.logger().error(error);
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
this.server_ = require('http').createServer();
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
"use strict";
|
||||
var __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 shim_1 = require("./shim");
|
||||
class RotatingLogs {
|
||||
constructor(logFilesDir, maxFileSize = null, inactiveMaxAge = null) {
|
||||
this.maxFileSize = 1024 * 1024 * 100;
|
||||
this.inactiveMaxAge = 90 * 24 * 60 * 60 * 1000;
|
||||
this.logFilesDir = logFilesDir;
|
||||
if (maxFileSize)
|
||||
this.maxFileSize = maxFileSize;
|
||||
if (inactiveMaxAge)
|
||||
this.inactiveMaxAge = inactiveMaxAge;
|
||||
}
|
||||
cleanActiveLogFile() {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
const stats = yield this.fsDriver().stat(this.logFileFullpath());
|
||||
if (stats.size >= this.maxFileSize) {
|
||||
const newLogFile = this.logFileFullpath(this.getNameToNonActiveLogFile());
|
||||
yield this.fsDriver().move(this.logFileFullpath(), newLogFile);
|
||||
}
|
||||
});
|
||||
}
|
||||
getNameToNonActiveLogFile() {
|
||||
return `log-${Date.now()}.txt`;
|
||||
}
|
||||
deleteNonActiveLogFiles() {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
const files = yield this.fsDriver().readDirStats(this.logFilesDir);
|
||||
for (const file of files) {
|
||||
if (!file.path.match(/^log-[0-9]+.txt$/gi))
|
||||
continue;
|
||||
const ageOfTheFile = Date.now() - file.birthtime;
|
||||
if (ageOfTheFile >= this.inactiveMaxAge) {
|
||||
yield this.fsDriver().remove(this.logFileFullpath(file.path));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
logFileFullpath(fileName = 'log.txt') {
|
||||
return `${this.logFilesDir}/${fileName}`;
|
||||
}
|
||||
fsDriver() {
|
||||
return shim_1.default.fsDriver();
|
||||
}
|
||||
}
|
||||
exports.default = RotatingLogs;
|
||||
//# sourceMappingURL=RotatingLogs.js.map
|
||||
@@ -1,56 +0,0 @@
|
||||
"use strict";
|
||||
var __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 fs_extra_1 = require("fs-extra");
|
||||
const test_utils_1 = require("./testing/test-utils");
|
||||
const RotatingLogs_1 = require("./RotatingLogs");
|
||||
const createTestLogFile = (dir) => __awaiter(void 0, void 0, void 0, function* () {
|
||||
yield (0, fs_extra_1.writeFile)(`${dir}/log.txt`, 'some content');
|
||||
});
|
||||
describe('RotatingLogs', () => {
|
||||
test('should rename log.txt to log-TIMESTAMP.txt', () => __awaiter(void 0, void 0, void 0, function* () {
|
||||
let dir;
|
||||
try {
|
||||
dir = yield (0, test_utils_1.createTempDir)();
|
||||
yield createTestLogFile(dir);
|
||||
let files = yield (0, fs_extra_1.readdir)(dir);
|
||||
expect(files.find(file => file.match(/^log.txt$/gi))).toBeTruthy();
|
||||
expect(files.length).toBe(1);
|
||||
const rotatingLogs = new RotatingLogs_1.default(dir, 1, 1);
|
||||
yield rotatingLogs.cleanActiveLogFile();
|
||||
files = yield (0, fs_extra_1.readdir)(dir);
|
||||
expect(files.find(file => file.match(/^log.txt$/gi))).toBeFalsy();
|
||||
expect(files.find(file => file.match(/^log-[0-9]+.txt$/gi))).toBeTruthy();
|
||||
expect(files.length).toBe(1);
|
||||
}
|
||||
finally {
|
||||
yield (0, fs_extra_1.remove)(dir);
|
||||
}
|
||||
}));
|
||||
test('should delete inative log file after 1ms', () => __awaiter(void 0, void 0, void 0, function* () {
|
||||
let dir;
|
||||
try {
|
||||
dir = yield (0, test_utils_1.createTempDir)();
|
||||
yield createTestLogFile(dir);
|
||||
const rotatingLogs = new RotatingLogs_1.default(dir, 1, 1);
|
||||
yield rotatingLogs.cleanActiveLogFile();
|
||||
yield (0, test_utils_1.msleep)(1);
|
||||
yield rotatingLogs.deleteNonActiveLogFiles();
|
||||
const files = yield (0, fs_extra_1.readdir)(dir);
|
||||
expect(files.find(file => file.match(/^log-[0-9]+.txt$/gi))).toBeFalsy();
|
||||
expect(files.length).toBe(0);
|
||||
}
|
||||
finally {
|
||||
yield (0, fs_extra_1.remove)(dir);
|
||||
}
|
||||
}));
|
||||
});
|
||||
//# sourceMappingURL=RotatingLogs.test.js.map
|
||||
@@ -12,7 +12,7 @@ describe('RotatingLogs', () => {
|
||||
try {
|
||||
dir = await createTempDir();
|
||||
await createTestLogFile(dir);
|
||||
let files: string[] = await readdir(dir);
|
||||
let files = await readdir(dir);
|
||||
expect(files.find(file => file.match(/^log.txt$/gi))).toBeTruthy();
|
||||
expect(files.length).toBe(1);
|
||||
const rotatingLogs: RotatingLogs = new RotatingLogs(dir, 1, 1);
|
||||
@@ -26,7 +26,7 @@ describe('RotatingLogs', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('should delete inative log file after 1ms', async () => {
|
||||
test('should delete inactive log file after 1ms', async () => {
|
||||
let dir: string;
|
||||
try {
|
||||
dir = await createTempDir();
|
||||
@@ -42,4 +42,21 @@ describe('RotatingLogs', () => {
|
||||
await remove(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('should not delete the log-timestamp.txt right after its be created', async () => {
|
||||
let dir: string;
|
||||
try {
|
||||
dir = await createTempDir();
|
||||
await createTestLogFile(dir);
|
||||
await msleep(100);
|
||||
const rotatingLogs: RotatingLogs = new RotatingLogs(dir, 1, 100);
|
||||
await rotatingLogs.cleanActiveLogFile();
|
||||
await rotatingLogs.deleteNonActiveLogFiles();
|
||||
const files = await readdir(dir);
|
||||
expect(files.find(file => file.match(/^log-[0-9]+.txt$/gi))).toBeTruthy();
|
||||
expect(files.length).toBe(1);
|
||||
} finally {
|
||||
await remove(dir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,7 +29,8 @@ export default class RotatingLogs {
|
||||
const files: Stat[] = await this.fsDriver().readDirStats(this.logFilesDir);
|
||||
for (const file of files) {
|
||||
if (!file.path.match(/^log-[0-9]+.txt$/gi)) continue;
|
||||
const ageOfTheFile: number = Date.now() - file.birthtime;
|
||||
const timestamp: number = parseInt(file.path.match(/[0-9]+/g)[0], 10);
|
||||
const ageOfTheFile: number = Date.now() - timestamp;
|
||||
if (ageOfTheFile >= this.inactiveMaxAge) {
|
||||
await this.fsDriver().remove(this.logFileFullpath(file.path));
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ class SyncTargetAmazonS3 extends BaseSyncTarget {
|
||||
return true;
|
||||
}
|
||||
|
||||
static requiresPassword() {
|
||||
return true;
|
||||
}
|
||||
|
||||
static s3BucketName() {
|
||||
return Setting.value('sync.8.path');
|
||||
}
|
||||
|
||||
@@ -49,6 +49,10 @@ export default class SyncTargetJoplinCloud extends BaseSyncTarget {
|
||||
return true;
|
||||
}
|
||||
|
||||
public static requiresPassword() {
|
||||
return true;
|
||||
}
|
||||
|
||||
public async fileApi(): Promise<FileApi> {
|
||||
return super.fileApi();
|
||||
}
|
||||
|
||||
@@ -65,6 +65,10 @@ export default class SyncTargetJoplinServer extends BaseSyncTarget {
|
||||
return true;
|
||||
}
|
||||
|
||||
public static requiresPassword() {
|
||||
return true;
|
||||
}
|
||||
|
||||
public async fileApi(): Promise<FileApi> {
|
||||
return super.fileApi();
|
||||
}
|
||||
|
||||
@@ -33,6 +33,10 @@ class SyncTargetNextcloud extends BaseSyncTarget {
|
||||
return true;
|
||||
}
|
||||
|
||||
static requiresPassword() {
|
||||
return true;
|
||||
}
|
||||
|
||||
static async checkConfig(options) {
|
||||
return SyncTargetWebDAV.checkConfig(options);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,10 @@ class SyncTargetWebDAV extends BaseSyncTarget {
|
||||
return true;
|
||||
}
|
||||
|
||||
static requiresPassword() {
|
||||
return true;
|
||||
}
|
||||
|
||||
static async newFileApi_(syncTargetId, options) {
|
||||
const apiOptions = {
|
||||
baseUrl: () => options.path(),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user