1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-12-08 23:07:32 +02:00

Compare commits

..

88 Commits

Author SHA1 Message Date
Self Not Found
819a591cc0 Desktop: Add CJK characters counter in statistics panel (#13840) 2025-12-08 18:52:09 +00:00
mrjo118
421b82c86d Mobile: Add the ability to rename and delete tags (#13731) 2025-12-08 10:15:34 +00:00
Henry Heino
16169b2780 Server: Periodically delete old backups for archived accounts (#13741) 2025-12-08 10:15:18 +00:00
Charlie Arehart
49ed4ae920 Docs: add links to clarify "plugins" references (#13763)
Co-authored-by: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-12-08 10:11:26 +00:00
mrjo118
13777d261c Desktop: Replace the edit profile config menu option with a gui to manage profiles (#13771) 2025-12-08 10:08:58 +00:00
Henry Heino
1c7b0e6266 Mac: Fixes #13214: Markdown editor: Don't open links on ctrl-click (#13792) 2025-12-08 10:07:41 +00:00
Henry Heino
4589670126 Chore: Server: Debug: Add debug benchmarkDeltaPerformance API (#13801) 2025-12-08 10:07:25 +00:00
marph91
b6ab6e0b46 Desktop: Use the "--no-sandbox" flag for Tuxedo OS (#13810) 2025-12-08 10:06:42 +00:00
mrjo118
9b28b618bb Desktop, Mobile: Do no re-use the 'Restored Notes' folder if it is trashed (#13813) 2025-12-08 10:06:25 +00:00
Bartolomeo
bf7cc6be03 Desktop: Resolves: #13804: Change search Resources feature to case insensitive (#13824) 2025-12-08 10:04:31 +00:00
mrjo118
e5e5b342a7 Mobile: Fixes #13825: Fix incompatible plugins cannot be uninstalled (#13828) 2025-12-08 10:03:23 +00:00
Henry Heino
9709721a73 Desktop: OneNote importer: Fix missing content in imported notebooks, improve math formula import (#13829) 2025-12-08 09:59:58 +00:00
Henry Heino
a34010ef62 Chore: Tests: Make renderBlockImages.test.ts less likely to fail in CI (#13835) 2025-12-08 09:59:50 +00:00
Henry Heino
9a6043e6a6 Chore: Desktop: Migrate the "Share note" dialog to RSCSS (#13842) 2025-12-08 09:53:15 +00:00
Henry Heino
992bf683c4 Chore: Server: Create more realistic test data (#13843) 2025-12-08 09:53:05 +00:00
Henry Heino
b40c2b8a41 Desktop: Fixes #13844: OneNote importer: Fix wrong page version imported (#13850) 2025-12-08 09:52:51 +00:00
Henry Heino
8dcd08e21d Server: Ensure that shared items are processed in the correct order (#13858) 2025-12-08 09:52:39 +00:00
mrjo118
cb2b32520d Mobile: Prevent opening the edit / delete dialog when long pressing the conflicts notebook (#13860) 2025-12-08 09:51:12 +00:00
Henry Heino
315b1d8275 Desktop: Markdown Editor: Collapse selection to a single cursor when pressing "escape" (#13864) 2025-12-08 09:49:50 +00:00
renovate[bot]
8018f1269a Update dependency react-native-share to v12.2.0 (#13861)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-07 10:17:21 +00:00
renovate[bot]
c2d186188b Update dependency react-native-safe-area-context to v5.6.1 (#13868)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-06 11:05:56 +00:00
renovate[bot]
d5798e558b Update dependency react-native-safe-area-context to v5.6.0 (#13865)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-06 09:37:17 +00:00
renovate[bot]
224bcd54f1 Update dependency fs-extra to v11.3.1 (#13849)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-03 23:45:31 +00:00
Laurent Cozic
1a3d572498 Server v3.5.1 2025-12-03 11:56:50 +00:00
Laurent Cozic
848a2c986a Chore: Remove the need for yarn when bumping version number
Since "yarn version patch" also performs "yarn install" which is usually unnecessary
2025-12-03 11:56:04 +00:00
renovate[bot]
fc61a2bc6a Update dependency raw-body to v3.0.1 (#13846)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-03 06:55:14 +00:00
Henry Heino
f9d58742c0 Desktop: Support converting multiple notes from HTML to Markdown at once (#13802) 2025-12-01 18:52:01 +00:00
renovate[bot]
5ba8cefe7c Update dependency nodejs to v24.4.1 (#13833)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-01 18:51:35 +00:00
renovate[bot]
74484f194e Update Node.js to v24 (#13834)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-01 18:51:30 +00:00
renovate[bot]
eae569aff8 Update dependency lint-staged to v16.1.6 (#13831)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-01 13:58:57 +00:00
renovate[bot]
8734bc8467 Update dependency nodejs to v24 (#13832)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-01 13:58:50 +00:00
renovate[bot]
612d09d16f Update dependency lint-staged to v16.1.2 (#13822)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-01 12:07:32 +00:00
Joplin Bot
eb2e9419b9 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-12-01 02:04:54 +00:00
renovate[bot]
17935458e6 Update dependency react-native-web to v0.21.1 (#13823)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-01 01:24:20 +00:00
renovate[bot]
a69a5d98ee Update dependency react-native-web to v0.21.0 (#13819)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-30 19:10:02 +00:00
renovate[bot]
48c9c1112c Update dependency lint-staged to v16 (#13820)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-30 19:09:41 +00:00
Kachelkaiser
a6585a67d0 All: Translation: Update de_DE.po (#13815) 2025-11-30 13:16:12 -05:00
renovate[bot]
959e1522d4 Update dependency babel-plugin-react-native-web to v0.21.1 (#13818)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-30 18:12:52 +00:00
renovate[bot]
8605e5aad5 Update dependency ts-loader to v9.5.4 (#13808)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-30 16:02:04 +00:00
renovate[bot]
88af5208f5 Update dependency babel-plugin-react-native-web to v0.21.0 (#13811)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-30 16:01:48 +00:00
renovate[bot]
bef73dbbf5 Update dependency rate-limiter-flexible to v7.2.0 (#13817)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-30 16:01:41 +00:00
renovate[bot]
b23c50cc7d Update dependency node-gyp to v11.3.0 (#13812)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-30 12:41:42 +00:00
Joplin Bot
3e90a9392d Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-11-29 18:37:14 +00:00
renovate[bot]
e2a32c5993 Update dependency git to v2.50.1 (#13807)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-29 16:59:17 +00:00
renovate[bot]
759761086d Update dependency dayjs to v1.11.18 (#13806)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-29 16:57:30 +00:00
Laurent Cozic
ca29ed94cc Chore: Updated CLA signatures and consent record 2025-11-29 14:07:08 +00:00
Laurent Cozic
f815933ad0 Chore: Improved saveClaConsentRecords script to display any mistake in data 2025-11-29 14:06:46 +00:00
Laurent Cozic
67af879d38 Chore: Updated signatures.json 2025-11-29 13:51:23 +00:00
github-actions[bot]
3caf41984f @carehart has signed the CLA in laurent22/joplin#13763 2025-11-22 05:01:45 +00:00
github-actions[bot]
865d39d657 @Wohlstand has signed the CLA in laurent22/joplin#13727 2025-11-18 15:23:06 +00:00
github-actions[bot]
d701b9b1bd @Kallemakela has signed the CLA in laurent22/joplin#13675 2025-11-11 22:09:54 +00:00
github-actions[bot]
f8fe143809 @saturneric has signed the CLA in laurent22/joplin#13673 2025-11-11 21:06:32 +00:00
github-actions[bot]
9e0491ef2f @jasonblewis has signed the CLA in laurent22/joplin#13666 2025-11-11 01:09:38 +00:00
github-actions[bot]
1f77357c7d @Bappoz has signed the CLA in laurent22/joplin#13588 2025-11-06 20:55:57 +00:00
github-actions[bot]
c53d18e068 @horvatkm has signed the CLA in laurent22/joplin#13613 2025-11-03 17:08:09 +00:00
github-actions[bot]
fe8ad1fa74 @mariadenis has signed the CLA in laurent22/joplin#13593 2025-10-31 22:47:47 +00:00
github-actions[bot]
dfc0a96567 @HarmonicSoldier has signed the CLA in laurent22/joplin#13592 2025-10-31 21:57:08 +00:00
github-actions[bot]
2eb70be937 @ffes has signed the CLA in laurent22/joplin#13510 2025-10-22 12:11:13 +00:00
github-actions[bot]
3ef138c9fe @Asagat has signed the CLA in laurent22/joplin#13507 2025-10-22 04:28:53 +00:00
github-actions[bot]
4e21643bbe @bhorbowicz has signed the CLA in laurent22/joplin#13503 2025-10-21 18:06:20 +00:00
github-actions[bot]
d9d9946faf @greg-at-moderne has signed the CLA in laurent22/joplin#13489 2025-10-20 08:59:01 +00:00
github-actions[bot]
032dfa949d @k-santos has signed the CLA in laurent22/joplin#13448 2025-10-16 01:40:36 +00:00
github-actions[bot]
7e703ed405 @WhiskerLogic has signed the CLA in laurent22/joplin#13429 2025-10-12 08:10:48 +00:00
github-actions[bot]
3b0cc08e6b @shania-codes has signed the CLA in laurent22/joplin#13418 2025-10-11 11:39:38 +00:00
github-actions[bot]
8961a4a10d @manuerwin has signed the CLA in laurent22/joplin#13383 2025-10-05 21:48:55 +00:00
github-actions[bot]
fed580ae18 @GordonRamsay-689 has signed the CLA in laurent22/joplin#13381 2025-10-05 17:34:50 +00:00
github-actions[bot]
f036869f53 @filbert-wijaya has signed the CLA in laurent22/joplin#13339 2025-10-01 01:38:50 +00:00
github-actions[bot]
3a1b36d594 @Om7035 has signed the CLA in laurent22/joplin#13287 2025-09-29 14:59:37 +00:00
github-actions[bot]
b9ba747327 @yingli-lab has signed the CLA in laurent22/joplin#13311 2025-09-26 22:50:39 +00:00
github-actions[bot]
5631e1d57b @Sid0004 has signed the CLA in laurent22/joplin#13307 2025-09-26 17:46:01 +00:00
github-actions[bot]
740a5628dd @carica has signed the CLA in laurent22/joplin#13306 2025-09-26 17:13:02 +00:00
github-actions[bot]
0a758561f3 @trap000d has signed the CLA in laurent22/joplin#13299 2025-09-25 21:51:03 +00:00
github-actions[bot]
4986b1f084 @chadcrum has signed the CLA in laurent22/joplin#13286 2025-09-24 17:55:16 +00:00
github-actions[bot]
7aaad4e7f3 @bsavant has signed the CLA in laurent22/joplin#13281 2025-09-23 18:40:43 +00:00
github-actions[bot]
b0497bfa07 @kimar has signed the CLA in laurent22/joplin#13275 2025-09-23 07:22:52 +00:00
github-actions[bot]
2d0f02cb8a @maggie897 has signed the CLA in laurent22/joplin#13190 2025-09-11 22:00:38 +00:00
github-actions[bot]
1ae72235fc @pplulee has signed the CLA in laurent22/joplin#13137 2025-09-06 11:15:42 +00:00
github-actions[bot]
86f2a3a7d0 @VortexP has signed the CLA in laurent22/joplin#12971 2025-08-15 19:29:31 +00:00
github-actions[bot]
5b106d4827 @yuudi has signed the CLA in laurent22/joplin#12948 2025-08-13 20:45:52 +00:00
github-actions[bot]
3bf2eb0399 @prashant1177 has signed the CLA in laurent22/joplin#12940 2025-08-13 11:25:48 +00:00
github-actions[bot]
8302afda19 @miguelammatos has signed the CLA in laurent22/joplin#12718 2025-08-10 23:26:18 +00:00
github-actions[bot]
ba970ac7a5 @laurent22 has signed the CLA in laurent22/joplin#12902 2025-08-06 12:04:36 +00:00
github-actions[bot]
89018e497f @klaas0 has signed the CLA in laurent22/joplin#12895 2025-08-05 21:46:31 +00:00
github-actions[bot]
53a05eb781 @JZou-Code has signed the CLA in laurent22/joplin#12868 2025-08-04 11:57:37 +00:00
github-actions[bot]
7637915bed @PanWor has signed the CLA in laurent22/joplin#12857 2025-08-02 20:09:21 +00:00
github-actions[bot]
d5dd55a813 @w568w has signed the CLA in laurent22/joplin#12839 2025-08-01 15:31:56 +00:00
github-actions[bot]
e80a0c39f8 @laurent22 has signed the CLA in laurent22/joplin#12798 2025-07-27 14:57:46 +00:00
github-actions[bot]
357199658f @bwat47 has signed the CLA in laurent22/joplin#12805 2025-07-27 13:42:23 +00:00
154 changed files with 19133 additions and 888 deletions

View File

@@ -165,8 +165,6 @@ packages/app-desktop/app.reducer.js
packages/app-desktop/app.js
packages/app-desktop/bridge.js
packages/app-desktop/checkForUpdates.js
packages/app-desktop/commands/convertNoteToMarkdown.test.js
packages/app-desktop/commands/convertNoteToMarkdown.js
packages/app-desktop/commands/copyDevCommand.js
packages/app-desktop/commands/copyToClipboard.js
packages/app-desktop/commands/editProfileConfig.js
@@ -183,6 +181,7 @@ packages/app-desktop/commands/openProfileDirectory.js
packages/app-desktop/commands/openSecondaryAppInstance.js
packages/app-desktop/commands/replaceMisspelling.js
packages/app-desktop/commands/restoreNoteRevision.js
packages/app-desktop/commands/showProfileEditor.js
packages/app-desktop/commands/startExternalEditing.js
packages/app-desktop/commands/stopExternalEditing.js
packages/app-desktop/commands/switchProfile.js
@@ -207,7 +206,6 @@ 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
packages/app-desktop/gui/ConversionNotification/ConversionNotification.js
packages/app-desktop/gui/Dialog.js
packages/app-desktop/gui/DialogButtonRow.js
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js
@@ -393,6 +391,7 @@ packages/app-desktop/gui/PopupNotification/NotificationItem.js
packages/app-desktop/gui/PopupNotification/PopupNotificationList.js
packages/app-desktop/gui/PopupNotification/PopupNotificationProvider.js
packages/app-desktop/gui/PopupNotification/types.js
packages/app-desktop/gui/ProfileEditor.js
packages/app-desktop/gui/PromptDialog.js
packages/app-desktop/gui/ResizableLayout/LayoutItemContainer.js
packages/app-desktop/gui/ResizableLayout/MoveButtons.js
@@ -1031,6 +1030,7 @@ packages/editor/CodeMirror/editorCommands/supportsCommand.js
packages/editor/CodeMirror/extensions/biDirectionalTextExtension.js
packages/editor/CodeMirror/extensions/ctrlClickActionExtension.js
packages/editor/CodeMirror/extensions/ctrlClickCheckboxExtension.js
packages/editor/CodeMirror/extensions/editorSettingsExtension.js
packages/editor/CodeMirror/extensions/highlightActiveLineExtension.js
packages/editor/CodeMirror/extensions/keyUpHandlerExtension.js
packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.js
@@ -1245,6 +1245,8 @@ packages/lib/callbackUrlUtils.js
packages/lib/clipperUtils.js
packages/lib/commands/convertHtmlToMarkdown.test.js
packages/lib/commands/convertHtmlToMarkdown.js
packages/lib/commands/convertNoteToMarkdown.test.js
packages/lib/commands/convertNoteToMarkdown.js
packages/lib/commands/deleteNote.js
packages/lib/commands/historyBackward.js
packages/lib/commands/historyForward.js

8
.gitignore vendored
View File

@@ -137,8 +137,6 @@ packages/app-desktop/app.reducer.js
packages/app-desktop/app.js
packages/app-desktop/bridge.js
packages/app-desktop/checkForUpdates.js
packages/app-desktop/commands/convertNoteToMarkdown.test.js
packages/app-desktop/commands/convertNoteToMarkdown.js
packages/app-desktop/commands/copyDevCommand.js
packages/app-desktop/commands/copyToClipboard.js
packages/app-desktop/commands/editProfileConfig.js
@@ -155,6 +153,7 @@ packages/app-desktop/commands/openProfileDirectory.js
packages/app-desktop/commands/openSecondaryAppInstance.js
packages/app-desktop/commands/replaceMisspelling.js
packages/app-desktop/commands/restoreNoteRevision.js
packages/app-desktop/commands/showProfileEditor.js
packages/app-desktop/commands/startExternalEditing.js
packages/app-desktop/commands/stopExternalEditing.js
packages/app-desktop/commands/switchProfile.js
@@ -179,7 +178,6 @@ 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
packages/app-desktop/gui/ConversionNotification/ConversionNotification.js
packages/app-desktop/gui/Dialog.js
packages/app-desktop/gui/DialogButtonRow.js
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js
@@ -365,6 +363,7 @@ packages/app-desktop/gui/PopupNotification/NotificationItem.js
packages/app-desktop/gui/PopupNotification/PopupNotificationList.js
packages/app-desktop/gui/PopupNotification/PopupNotificationProvider.js
packages/app-desktop/gui/PopupNotification/types.js
packages/app-desktop/gui/ProfileEditor.js
packages/app-desktop/gui/PromptDialog.js
packages/app-desktop/gui/ResizableLayout/LayoutItemContainer.js
packages/app-desktop/gui/ResizableLayout/MoveButtons.js
@@ -1003,6 +1002,7 @@ packages/editor/CodeMirror/editorCommands/supportsCommand.js
packages/editor/CodeMirror/extensions/biDirectionalTextExtension.js
packages/editor/CodeMirror/extensions/ctrlClickActionExtension.js
packages/editor/CodeMirror/extensions/ctrlClickCheckboxExtension.js
packages/editor/CodeMirror/extensions/editorSettingsExtension.js
packages/editor/CodeMirror/extensions/highlightActiveLineExtension.js
packages/editor/CodeMirror/extensions/keyUpHandlerExtension.js
packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.js
@@ -1217,6 +1217,8 @@ packages/lib/callbackUrlUtils.js
packages/lib/clipperUtils.js
packages/lib/commands/convertHtmlToMarkdown.test.js
packages/lib/commands/convertHtmlToMarkdown.js
packages/lib/commands/convertNoteToMarkdown.test.js
packages/lib/commands/convertNoteToMarkdown.js
packages/lib/commands/deleteNote.js
packages/lib/commands/historyBackward.js
packages/lib/commands/historyForward.js

View File

@@ -1,4 +1,4 @@
FROM node:18-bullseye
FROM node:24-bullseye
RUN apt-get update \
&& apt-get install -y \

View File

@@ -275,7 +275,7 @@ if command -v lsb_release &> /dev/null; then
# without writing the AppImage to a non-user-writable location (without invalidating other security
# controls). See https://discourse.joplinapp.org/t/possible-future-requirement-for-no-sandbox-flag-for-ubuntu-23-10/.
HAS_USERNS_RESTRICTIONS=false
if [[ "$DISTVER" =~ ^Ubuntu && $DISTMAJOR -ge 23 ]]; then
if [[ "$DISTVER" =~ ^(Ubuntu|Tuxedo) && $DISTMAJOR -ge 23 ]]; then
HAS_USERNS_RESTRICTIONS=true
fi

View File

@@ -9,7 +9,7 @@
"vips.dev": {
"platforms": ["aarch64-darwin"],
},
"nodejs": "23.11.0",
"nodejs": "24.4.1",
"pkg-config": "latest",
"darwin.apple_sdk.frameworks.Foundation": { // satisfies missing CoreText/CoreText.h
// https://github.com/NixOS/nixpkgs/blob/master/pkgs/os-specific/darwin/apple-sdk/default.nix
@@ -22,7 +22,7 @@
"version": "latest",
"excluded_platforms": ["aarch64-darwin", "x86_64-darwin"],
},
"git": "2.50.0",
"git": "2.50.1",
},
"shell": {
"init_hook": [

View File

@@ -81,12 +81,12 @@
"eslint-plugin-promise": "6.6.0",
"eslint-plugin-react": "7.37.5",
"execa": "5.1.1",
"fs-extra": "11.2.0",
"fs-extra": "11.3.1",
"glob": "11.0.3",
"gulp": "4.0.2",
"husky": "9.1.7",
"lerna": "3.22.1",
"lint-staged": "15.5.2",
"lint-staged": "16.1.6",
"madge": "8.0.0",
"npm-package-json-lint": "8.0.0",
"typescript": "5.8.3"
@@ -95,7 +95,7 @@
"@types/fs-extra": "11.0.4",
"eslint-plugin-github": "4.10.2",
"http-server": "14.1.1",
"node-gyp": "11.2.0",
"node-gyp": "11.3.0",
"nodemon": "3.1.10"
},
"packageManager": "yarn@4.9.2",

View File

@@ -48,7 +48,7 @@
"chalk": "4.1.2",
"compare-version": "0.1.2",
"file-type": "16.5.4",
"fs-extra": "11.2.0",
"fs-extra": "11.3.1",
"html-entities": "1.4.0",
"keytar": "7.9.0",
"md5": "2.3.0",

Binary file not shown.

View File

@@ -1,52 +0,0 @@
import { _ } from '@joplin/lib/locale';
import Note from '@joplin/lib/models/Note';
import { stateUtils } from '@joplin/lib/reducer';
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { MarkupLanguage } from '@joplin/renderer';
import { runtime as convertHtmlToMarkdown } from '@joplin/lib/commands/convertHtmlToMarkdown';
import bridge from '../services/bridge';
export const declaration: CommandDeclaration = {
name: 'convertNoteToMarkdown',
label: () => _('Convert note to Markdown'),
};
export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext, noteId: string = null) => {
noteId = noteId || stateUtils.selectedNoteId(context.state);
const note = await Note.load(noteId);
if (!note) return;
try {
const markdownBody = await convertHtmlToMarkdown().execute(context, note.body);
const newNote = await Note.duplicate(note.id);
newNote.body = markdownBody;
newNote.markup_language = MarkupLanguage.Markdown;
await Note.save(newNote);
await Note.delete(note.id, { toTrash: true });
context.dispatch({
type: 'NOTE_HTML_TO_MARKDOWN_DONE',
value: note.id,
});
context.dispatch({
type: 'NOTE_SELECT',
id: newNote.id,
});
} catch (error) {
bridge().showErrorMessageBox(_('Could not convert note to Markdown: %s', error.message));
}
},
enabledCondition: 'oneNoteSelected && noteIsHtml && !noteIsReadOnly',
};
};

View File

@@ -1,5 +1,4 @@
// AUTO-GENERATED using `gulp buildScriptIndexes`
import * as convertNoteToMarkdown from './convertNoteToMarkdown';
import * as copyDevCommand from './copyDevCommand';
import * as copyToClipboard from './copyToClipboard';
import * as editProfileConfig from './editProfileConfig';
@@ -14,6 +13,7 @@ import * as openProfileDirectory from './openProfileDirectory';
import * as openSecondaryAppInstance from './openSecondaryAppInstance';
import * as replaceMisspelling from './replaceMisspelling';
import * as restoreNoteRevision from './restoreNoteRevision';
import * as showProfileEditor from './showProfileEditor';
import * as startExternalEditing from './startExternalEditing';
import * as stopExternalEditing from './stopExternalEditing';
import * as switchProfile from './switchProfile';
@@ -25,7 +25,6 @@ import * as toggleSafeMode from './toggleSafeMode';
import * as toggleTabMovesFocus from './toggleTabMovesFocus';
const index: any[] = [
convertNoteToMarkdown,
copyDevCommand,
copyToClipboard,
editProfileConfig,
@@ -40,6 +39,7 @@ const index: any[] = [
openSecondaryAppInstance,
replaceMisspelling,
restoreNoteRevision,
showProfileEditor,
startExternalEditing,
stopExternalEditing,
switchProfile,

View File

@@ -0,0 +1,20 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
export const declaration: CommandDeclaration = {
name: 'showProfileEditor',
label: () => _('Manage profiles'),
};
export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext) => {
context.dispatch({
type: 'NAV_GO',
routeName: 'ProfileEditor',
});
},
enabledCondition: 'hasMultiProfiles',
};
};

View File

@@ -1,28 +0,0 @@
import * as React from 'react';
import { useContext, useEffect } from 'react';
import { _ } from '@joplin/lib/locale';
import { Dispatch } from 'redux';
import { PopupNotificationContext } from '../PopupNotification/PopupNotificationProvider';
import { NotificationType } from '../PopupNotification/types';
interface Props {
noteId: string;
dispatch: Dispatch;
}
export default (props: Props) => {
const popupManager = useContext(PopupNotificationContext);
useEffect(() => {
if (!props.noteId || props.noteId === '') return;
props.dispatch({ type: 'NOTE_HTML_TO_MARKDOWN_DONE', value: '' });
const notification = popupManager.createPopup(() => (
<div>{_('The note has been converted to Markdown and the original note has been moved to the trash')}</div>
), { type: NotificationType.Success });
notification.scheduleDismiss();
}, [props.dispatch, popupManager, props.noteId]);
return <div style={{ display: 'none' }}/>;
};

View File

@@ -38,14 +38,12 @@ import restart from '../services/restart';
import { connect } from 'react-redux';
import { NoteListColumns } from '@joplin/lib/services/plugins/api/noteListType';
import validateColumns from './NoteListHeader/utils/validateColumns';
import ConversionNotification from './ConversionNotification/ConversionNotification';
import TrashNotification from './TrashNotification/TrashNotification';
import UpdateNotification from './UpdateNotification/UpdateNotification';
import NoteEditor from './NoteEditor/NoteEditor';
import PluginNotification from './PluginNotification/PluginNotification';
import { Toast } from '@joplin/lib/services/plugins/api/types';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import { Dispatch } from 'redux';
const ipcRenderer = require('electron').ipcRenderer;
@@ -86,7 +84,6 @@ interface Props {
showInvalidJoplinCloudCredential: boolean;
toast: Toast;
shouldSwitchToAppleSiliconVersion: boolean;
noteHtmlToMarkdownDone: string;
}
interface ShareFolderDialogOptions {
@@ -800,10 +797,6 @@ class MainScreenComponent extends React.Component<Props, State> {
return (
<div style={style}>
<ConversionNotification
noteId={this.props.noteHtmlToMarkdownDone}
dispatch={this.props.dispatch as Dispatch}
/>
<TrashNotification
lastDeletion={this.props.lastDeletion}
lastDeletionNotificationTime={this.props.lastDeletionNotificationTime}
@@ -860,7 +853,6 @@ const mapStateToProps = (state: AppState) => {
showInvalidJoplinCloudCredential: state.settings['sync.target'] === 10 && state.mustAuthenticate,
toast: state.toast,
shouldSwitchToAppleSiliconVersion: shim.isAppleSilicon() && process.arch !== 'arm64',
noteHtmlToMarkdownDone: state.noteHtmlToMarkdownDone,
};
};

View File

@@ -117,7 +117,7 @@ const useSwitchProfileMenuItems = (profileConfig: ProfileConfig, menuItemDic: an
switchProfileMenuItems.push({ type: 'separator' });
switchProfileMenuItems.push(menuItemDic.addProfile);
switchProfileMenuItems.push(menuItemDic.editProfileConfig);
switchProfileMenuItems.push(menuItemDic.showProfileEditor);
return switchProfileMenuItems;
}, [profileConfig, menuItemDic]);

View File

@@ -31,13 +31,15 @@ function markupToHtml() {
}
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
function countElements(text: string, wordSetter: Function, characterSetter: Function, characterNoSpaceSetter: Function, lineSetter: Function) {
function countElements(text: string, wordSetter: Function, characterSetter: Function, characterNoSpaceSetter: Function, cjkCharacterSetter: React.Dispatch<React.SetStateAction<number>>, lineSetter: Function) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
Countable.count(text, (counter: any) => {
wordSetter(counter.words);
characterSetter(counter.all);
characterNoSpaceSetter(counter.characters);
});
const cjkMatches = text.match(/[\p{Script=Han}\p{Script=Bopomofo}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/gu);
cjkCharacterSetter(cjkMatches ? cjkMatches.length : 0);
lineSetter(text === '' ? 0 : text.split('\n').length);
}
@@ -58,23 +60,25 @@ export default function NoteContentPropertiesDialog(props: NoteContentProperties
const [words, setWords] = useState<number>(0);
const [characters, setCharacters] = useState<number>(0);
const [charactersNoSpace, setCharactersNoSpace] = useState<number>(0);
const [cjkCharacters, setCjkCharacters] = useState<number>(0);
// For source with Markdown syntax stripped out
const [strippedLines, setStrippedLines] = useState<number>(0);
const [strippedWords, setStrippedWords] = useState<number>(0);
const [strippedCharacters, setStrippedCharacters] = useState<number>(0);
const [strippedCharactersNoSpace, setStrippedCharactersNoSpace] = useState<number>(0);
const [strippedCjkCharacters, setStrippedCjkCharacters] = useState<number>(0);
const [strippedReadTime, setStrippedReadTime] = useState<number>(0);
// This amount based on the following paper:
// https://www.researchgate.net/publication/332380784_How_many_words_do_we_read_per_minute_A_review_and_meta-analysis_of_reading_rate
const wordsPerMinute = 250;
useEffect(() => {
countElements(props.text, setWords, setCharacters, setCharactersNoSpace, setLines);
countElements(props.text, setWords, setCharacters, setCharactersNoSpace, setCjkCharacters, setLines);
}, [props.text]);
useEffect(() => {
const strippedText: string = markupToHtml().stripMarkup(props.markupLanguage, props.text);
countElements(strippedText, setStrippedWords, setStrippedCharacters, setStrippedCharactersNoSpace, setStrippedLines);
countElements(strippedText, setStrippedWords, setStrippedCharacters, setStrippedCharactersNoSpace, setStrippedCjkCharacters, setStrippedLines);
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.text]);
@@ -88,6 +92,7 @@ export default function NoteContentPropertiesDialog(props: NoteContentProperties
words: words,
characters: characters,
charactersNoSpace: charactersNoSpace,
cjkCharacters: cjkCharacters,
};
const strippedTextProperties: TextPropertiesMap = {
@@ -99,12 +104,14 @@ export default function NoteContentPropertiesDialog(props: NoteContentProperties
words: strippedWords,
characters: strippedCharacters,
charactersNoSpace: strippedCharactersNoSpace,
cjkCharacters: strippedCjkCharacters,
};
const keyToLabel: KeyToLabelMap = {
words: _('Words'),
characters: _('Characters'),
charactersNoSpace: _('Characters excluding spaces'),
cjkCharacters: _('Chinese/Japanese/Korean characters'),
lines: _('Lines'),
};
@@ -147,6 +154,7 @@ export default function NoteContentPropertiesDialog(props: NoteContentProperties
);
for (const key in textProperties) {
if (key === 'cjkCharacters' && textProperties[key] === 0 && strippedTextProperties[key] === 0) continue;
const comp = createTableBodyRow(key, textProperties[key], strippedTextProperties[key]);
tableBodyComps.push(comp);
}

View File

@@ -366,6 +366,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
ignoreModifiers: true,
spellcheckEnabled: Setting.value('editor.spellcheckBeta'),
keymap: keyboardMode,
preferMacShortcuts: shim.isMac(),
indentWithTabs: true,
tabMovesFocus: props.tabMovesFocus,
editorLabel: _('Markdown editor'),

View File

@@ -1,7 +1,8 @@
import * as React from 'react';
import { createContext, useMemo, useRef, useState } from 'react';
import { createContext, useEffect, useMemo, useRef, useState } from 'react';
import { NotificationType, PopupHandle, PopupControl as PopupManager } from './types';
import { Hour, msleep } from '@joplin/utils/time';
import shim from '@joplin/lib/shim';
export const PopupNotificationContext = createContext<PopupManager|null>(null);
export const VisibleNotificationsContext = createContext<PopupSpec[]>([]);
@@ -112,6 +113,18 @@ const PopupNotificationProvider: React.FC<Props> = props => {
return manager;
}, []);
useEffect(() => {
const defaultShowToast = shim.showToast;
shim.showToast = async (message: string, options) => {
const popup = popupManager.createPopup(() => message, { type: options?.type ?? NotificationType.Info });
popup.scheduleDismiss();
};
return () => {
shim.showToast = defaultShowToast;
};
}, [popupManager]);
return <PopupNotificationContext.Provider value={popupManager}>
<VisibleNotificationsContext.Provider value={popupSpecs}>
{props.children}

View File

@@ -1,3 +1,4 @@
import { ToastType } from '@joplin/lib/shim';
import * as React from 'react';
export type PopupHandle = {
@@ -5,14 +6,13 @@ export type PopupHandle = {
scheduleDismiss(delay?: number): void;
};
export enum NotificationType {
Info = 'info',
Success = 'success',
Error = 'error',
}
export type NotificationContentCallback = ()=> React.ReactNode;
// NotificationType is an alias for ToastType
export type NotificationType = ToastType;
// eslint-disable-next-line no-redeclare -- export const is necessary for creating an alias, this is not a redeclaration.
export const NotificationType = ToastType;
export interface PopupOptions {
type?: NotificationType;
}

View File

@@ -0,0 +1,47 @@
.profile-management {
font-family: var(--joplin-font-family);
display: flex;
flex-direction: column;
> .tableContainer {
overflow-y: scroll;
padding: 20px;
box-sizing: border-box;
flex: 1 1 0%;
color: var(--joplin-color);
> .notification {
margin-bottom: 10px;
}
}
}
.profile-table {
width: 100%;
> thead > tr > .headerCell {
white-space: nowrap;
font-weight: bold;
width: 1px;
}
> tbody > tr {
> .nameCell {
text-overflow: ellipsis;
overflow-x: hidden;
max-width: 1px;
width: 100%;
white-space: nowrap;
}
> .dataCell {
white-space: nowrap;
width: 1px;
color: var(--joplin-color-faded);
}
> .profileActions > button {
margin-right: 10px;
}
}
}

View File

@@ -0,0 +1,193 @@
import * as React from 'react';
import { useState, useEffect, CSSProperties } from 'react';
import ButtonBar from './ConfigScreen/ButtonBar';
import { _ } from '@joplin/lib/locale';
import { connect } from 'react-redux';
import { themeStyle } from '@joplin/lib/theme';
import bridge from '../services/bridge';
import dialogs from './dialogs';
import { Profile, ProfileConfig } from '@joplin/lib/services/profileConfig/types';
import { deleteProfileById, saveProfileConfig } from '@joplin/lib/services/profileConfig';
import Setting from '@joplin/lib/models/Setting';
import shim from '@joplin/lib/shim';
import Logger from '@joplin/utils/Logger';
import { AppState } from '../app.reducer';
import { Dispatch } from 'redux';
const logger = Logger.create('ProfileEditor');
interface Props {
themeId: number;
dispatch: Dispatch;
style: CSSProperties;
profileConfig: ProfileConfig;
}
interface ProfileTableProps {
profiles: Profile[];
currentProfileId: string;
onProfileRename: (profile: Profile)=> void;
onProfileDelete: (profile: Profile)=> void;
themeId: number;
}
const ProfileTableComp: React.FC<ProfileTableProps> = props => {
const theme = themeStyle(props.themeId);
return (
<table className="profile-table">
<thead>
<tr>
<th className="headerCell">{_('Profile name')}</th>
<th className="headerCell">{_('ID')}</th>
<th className="headerCell">{_('Status')}</th>
<th className="headerCell">{_('Actions')}</th>
</tr>
</thead>
<tbody>
{props.profiles.map((profile: Profile, index: number) => {
const isCurrentProfile = profile.id === props.currentProfileId;
return (
<tr key={index}>
<td id={`name-${profile.id}`} className="nameCell">
<span style={{ fontWeight: isCurrentProfile ? 'bold' : 'normal' }}>
{profile.name || `(${_('Untitled')})`}
</span>
</td>
<td className="dataCell">{profile.id}</td>
<td className="dataCell">
{isCurrentProfile ? _('Active') : ''}
</td>
<td className="dataCell profileActions">
<button
id={`rename-${profile.id}`}
aria-labelledby={`rename-${profile.id} name-${profile.id}`}
style={theme.buttonStyle}
onClick={() => props.onProfileRename(profile)}
>
{_('Rename')}
</button>
{!isCurrentProfile && (
<button
id={`delete-${profile.id}`}
aria-labelledby={`delete-${profile.id} name-${profile.id}`}
style={theme.buttonStyle}
onClick={() => props.onProfileDelete(profile)}
>
{_('Delete')}
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
);
};
const ProfileEditorComponent: React.FC<Props> = props => {
const { profileConfig, themeId, dispatch } = props;
const theme = themeStyle(themeId);
const style = props.style;
const containerHeight = style.height;
const [profiles, setProfiles] = useState<Profile[]>(profileConfig.profiles);
useEffect(() => {
setProfiles(profileConfig.profiles);
}, [profileConfig]);
const saveNewProfileConfig = async (makeNewProfileConfig: ()=> ProfileConfig) => {
try {
const newProfileConfig = makeNewProfileConfig();
await saveProfileConfig(`${Setting.value('rootProfileDir')}/profiles.json`, newProfileConfig);
dispatch({
type: 'PROFILE_CONFIG_SET',
value: newProfileConfig,
});
} catch (error) {
logger.error(error);
bridge().showErrorMessageBox(error.message);
}
};
const onProfileRename = async (profile: Profile) => {
const newName = await dialogs.prompt(_('Profile name:'), '', profile.name);
if (newName === null || newName === undefined || newName === profile.name) return;
if (!newName.trim()) {
bridge().showErrorMessageBox(_('Profile name cannot be empty'));
return;
}
const makeNewProfileConfig = () => {
const newProfiles = profileConfig.profiles.map(p =>
p.id === profile.id ? { ...p, name: newName.trim() } : p,
);
const newProfileConfig = {
...profileConfig,
profiles: newProfiles,
};
return newProfileConfig;
};
await saveNewProfileConfig(makeNewProfileConfig);
};
const onProfileDelete = async (profile: Profile) => {
const isCurrentProfile = profile.id === profileConfig.currentProfileId;
if (isCurrentProfile) {
bridge().showErrorMessageBox(_('The active profile cannot be deleted. Switch to a different profile and try again.'));
return;
}
const ok = bridge().showConfirmMessageBox(_('Delete profile "%s"?\n\nAll data, including notes, notebooks and tags will be permanently deleted.', profile.name), {
buttons: [_('Delete'), _('Cancel')],
defaultId: 1,
});
if (!ok) return;
const rootDir = Setting.value('rootProfileDir');
const profileDir = `${rootDir}/profile-${profile.id}`;
try {
await shim.fsDriver().remove(profileDir);
logger.info('Deleted profile directory: ', profileDir);
} catch (error) {
logger.error('Error deleting profile directory: ', error);
bridge().showErrorMessageBox(error.message);
}
await saveNewProfileConfig(() => deleteProfileById(profileConfig, profile.id));
};
return (
<div className="profile-management" style={{ ...theme.containerStyle, height: containerHeight }}>
<div className="tableContainer">
<div className="notification" style={theme.notificationBox}>
{_('Manage your profiles. You can rename or delete profiles. The active profile cannot be deleted.')}
</div>
<ProfileTableComp
themeId={themeId}
profiles={profiles}
currentProfileId={profileConfig.currentProfileId}
onProfileRename={onProfileRename}
onProfileDelete={onProfileDelete}
/>
</div>
<ButtonBar
onCancelClick={() => dispatch({ type: 'NAV_BACK' })}
/>
</div>
);
};
const mapStateToProps = (state: AppState) => ({
themeId: state.settings.theme,
profileConfig: state.profileConfig,
});
export default connect(mapStateToProps)(ProfileEditorComponent);

View File

@@ -84,7 +84,13 @@ const ResourceTableComp = (props: ResourceTable) => {
};
const filteredResources = props.resources.filter(
(resource: InnerResource) => !props.filter || resource.title?.includes(props.filter) || resource.id.includes(props.filter),
(resource: InnerResource) => {
if (props.filter) {
const filterLowerCase = props.filter.toLowerCase();
return resource.title?.toLowerCase().includes(filterLowerCase) || resource.id.toLowerCase().includes(filterLowerCase);
}
return true;
},
);
const renderSortableHeader = (title: string, order: SortingOrder) => {
@@ -297,7 +303,7 @@ class ResourceScreenComponent extends React.Component<Props, State> {
<div style={{ ...theme.notificationBox, marginBottom: 10 }}>{
_('This is an advanced tool to show the attachments that are linked to your notes. Please be careful when deleting one of them as they cannot be restored afterwards.')
}</div>
<div style={{ float: 'right' }}>
<p style={{ float: 'left', paddingRight: 10 }}>
<input
style={theme.inputStyle}
type="search"
@@ -305,7 +311,7 @@ class ResourceScreenComponent extends React.Component<Props, State> {
onChange={this.onFilterUpdate}
placeholder={_('Search...')}
/>
</div>
</p>
{this.state.isLoading && <div>{_('Please wait...')}</div>}
{!this.state.isLoading && <div>
{!this.state.resources && <div>

View File

@@ -20,6 +20,7 @@ import Dialog from './Dialog';
import StyleSheetContainer from './StyleSheets/StyleSheetContainer';
import ImportScreen from './ImportScreen';
import ResourceScreen from './ResourceScreen';
import ProfileEditor from './ProfileEditor';
import Navigator from './Navigator';
import WelcomeUtils from '@joplin/lib/WelcomeUtils';
import JoplinCloudLoginScreen from './JoplinCloudLoginScreen';
@@ -165,6 +166,7 @@ class RootComponent extends React.Component<Props, any> {
Import: { screen: ImportScreen, title: () => _('Import') },
Config: { screen: ConfigScreen, title: () => _('Options') },
Resources: { screen: ResourceScreen, title: () => _('Note attachments') },
ProfileEditor: { screen: ProfileEditor, title: () => _('Manage profiles') },
Status: { screen: StatusScreen, title: () => _('Synchronisation Status') },
};

View File

@@ -3,7 +3,6 @@ import { useState, useEffect, useCallback, useMemo } from 'react';
import { _, _n } from '@joplin/lib/locale';
import Note from '@joplin/lib/models/Note';
import DialogButtonRow from './DialogButtonRow';
import { themeStyle, buildStyle } from '@joplin/lib/theme';
import Dialog from './Dialog';
import DialogTitle from './DialogTitle';
import ShareService from '@joplin/lib/services/share/ShareService';
@@ -29,47 +28,6 @@ interface Props {
syncTargetId: number;
}
function styles_(props: Props) {
return buildStyle('ShareNoteDialog', props.themeId, theme => {
return {
root: {
minWidth: 500,
},
noteList: {
marginBottom: 10,
},
note: {
flex: 1,
flexDirection: 'row',
display: 'flex',
alignItems: 'center',
border: '1px solid',
borderColor: theme.dividerColor,
padding: '0.5em',
marginBottom: 5,
},
noteTitle: {
...theme.textStyle,
flex: 1,
display: 'flex',
color: theme.color,
},
noteRemoveButton: {
background: 'none',
border: 'none',
},
noteRemoveButtonIcon: {
color: theme.color,
fontSize: '1.4em',
},
copyShareLinkButton: {
...theme.buttonStyle,
marginBottom: 10,
},
};
});
}
export function ShareNoteDialog(props: Props) {
const [notes, setNotes] = useState<NoteEntity[]>([]);
const [recursiveShare, setRecursiveShare] = useState<boolean>(false);
@@ -77,8 +35,6 @@ export function ShareNoteDialog(props: Props) {
const syncTargetInfo = useMemo(() => SyncTargetRegistry.infoById(props.syncTargetId), [props.syncTargetId]);
const noteCount = notes.length;
const theme = themeStyle(props.themeId);
const styles = styles_(props);
useEffect(() => {
void ShareService.instance().refreshShares();
@@ -117,8 +73,8 @@ export function ShareNoteDialog(props: Props) {
);
return (
<div key={note.id} style={styles.note}>
<span style={styles.noteTitle}>{note.title}</span>{unshareButton}
<div key={note.id} className='shared-note-list-item'>
<span className='title'>{note.title}</span>{unshareButton}
</div>
);
};
@@ -128,7 +84,7 @@ export function ShareNoteDialog(props: Props) {
for (const note of notes) {
noteComps.push(renderNote(note));
}
return <div style={styles.noteList}>{noteComps}</div>;
return <div className="notes">{noteComps}</div>;
};
const statusMessage = useShareStatusMessage({ sharesState, noteCount });
@@ -136,7 +92,7 @@ export function ShareNoteDialog(props: Props) {
function renderEncryptionWarningMessage() {
if (!encryptionWarning) return null;
return <div style={theme.textStyle}>{encryptionWarning}<hr/></div>;
return <div className="message">{encryptionWarning}<hr/></div>;
}
const onRecursiveShareChange = useCallback(() => {
@@ -155,12 +111,16 @@ export function ShareNoteDialog(props: Props) {
const renderContent = () => {
return (
<div style={styles.root} className="form">
<div className="form share-note-dialog">
<DialogTitle title={_('Publish Notes')}/>
{renderNoteList(notes)}
{renderRecursiveShareCheckbox()}
<button disabled={[SharingStatus.Creating, SharingStatus.Synchronizing].indexOf(sharesState) >= 0} style={styles.copyShareLinkButton} onClick={shareLinkButton_click}>{_n('Copy Shareable Link', 'Copy Shareable Links', noteCount)}</button>
<div style={theme.textStyle}>{statusMessage}</div>
<button
disabled={[SharingStatus.Creating, SharingStatus.Synchronizing].indexOf(sharesState) >= 0}
className="share"
onClick={shareLinkButton_click}
>{_n('Copy Shareable Link', 'Copy Shareable Links', noteCount)}</button>
<div className="message">{statusMessage}</div>
{renderEncryptionWarningMessage()}
<DialogButtonRow
themeId={props.themeId}

View File

@@ -73,7 +73,7 @@ export default function() {
'commandPalette',
'openMasterPasswordDialog',
'addProfile',
'editProfileConfig',
'showProfileEditor',
'switchProfile1',
'switchProfile2',
'switchProfile3',

View File

@@ -0,0 +1,16 @@
// Corresponds to theme.buttonStyle
.base-button {
border: 1px solid;
min-height: 26px;
min-width: 80px;
padding-left: 12px;
padding-right: 12px;
padding-top: 6px;
padding-bottom: 6px;
border-radius: 4px;
color: var(--joplin-color4);
background-color: var(--joplin-background-color4);
border-color: var(--joplin-border-color4);
user-select: none;
}

View File

@@ -0,0 +1,8 @@
// Corresponds to theme.textStyle
.base-text {
font-family: var(--joplin-font-family);
font-size: var(--joplin-font-size);
line-height: 1.6em;
color: var(--joplin-color);
}

View File

@@ -1,4 +1,6 @@
@use './base-text.scss';
@use './base-button.scss';
@use './dialog-modal-layer.scss';
@use './user-webview-dialog.scss';
@use './prompt-dialog.scss';
@@ -19,3 +21,5 @@
@use './popup-notification-list.scss';
@use './popup-notification-item.scss';
@use './multi-note-actions.scss';
@use './share-note-dialog.scss';
@use './shared-note-list-item.scss';

View File

@@ -0,0 +1,19 @@
@use "./base-button.scss";
@use "./base-text.scss";
.share-note-dialog {
min-width: 500px;
> .message {
@extend .base-text;
}
> .notes {
margin-bottom: 10px;
}
> .share {
@extend .base-button;
margin-bottom: 10px;
}
}

View File

@@ -0,0 +1,20 @@
@use "./base-text.scss";
.shared-note-list-item {
flex: 1;
flex-direction: row;
display: flex;
align-items: center;
border: 1px solid;
border-color: var(--joplin-divider-color);
padding: 0.5em;
margin-bottom: 5px;
> .title {
@extend .base-text;
flex: 1;
display: flex;
color: var(--joplin-color);
}
}

View File

@@ -13,6 +13,7 @@ import Setting from '@joplin/lib/models/Setting';
const { clipboard } = require('electron');
import { Dispatch } from 'redux';
import { NoteEntity } from '@joplin/lib/services/database/types';
import { MarkupLanguage } from '@joplin/renderer';
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
@@ -143,6 +144,16 @@ export default class NoteListUtils {
menu.append(new MenuItem({ type: 'separator' }));
const includesHtmlNotes = notes.some(n => n.markup_language === MarkupLanguage.Html);
if (includesHtmlNotes) {
menu.append(
new MenuItem(
menuUtils.commandToStatefulMenuItem('convertNoteToMarkdown', noteIds),
),
);
menu.append(new MenuItem({ type: 'separator' }));
}
if ([9, 10, 11].includes(Setting.value('sync.target'))) {
menu.append(
new MenuItem(
@@ -204,7 +215,6 @@ export default class NoteListUtils {
);
}
const pluginViewInfos = pluginUtils.viewInfosByType(props.plugins, 'menuItem');
for (const info of pluginViewInfos) {

View File

@@ -209,7 +209,7 @@
"dependencies": {
"@electron/remote": "2.1.3",
"@joplin/onenote-converter": "~3.5",
"fs-extra": "11.2.0",
"fs-extra": "11.3.1",
"keytar": "7.9.0",
"node-fetch": "2.6.7",
"sqlite3": "5.1.6"

View File

@@ -13,6 +13,7 @@
@use 'gui/Sidebar/style.scss' as sidebar-styles;
@use 'gui/NoteEditor/style.scss' as note-editor-styles;
@use 'gui/KeymapConfig/style.scss' as keymap-styles;
@use 'gui/ProfileEditor.scss' as profile-editor;
@use 'services/plugins/styles/index.scss' as plugins-styles;
@use 'gui/styles/index.scss' as gui-styles;
@use 'main.scss' as main;

View File

@@ -29,7 +29,7 @@ const useStyles = () => {
const TextInputDialog: React.FC<Props> = ({ dialog, containerStyle, themeId }) => {
const styles = useStyles();
const [text, setText] = useState('');
const [text, setText] = useState(dialog.initialValue ?? '');
const labelId = useId();
return (

View File

@@ -91,7 +91,7 @@ const useDialogControl = (setPromptDialogs: SetPromptDialogs) => {
});
});
},
promptForText: (message: string) => {
promptForText: (message: string, initialValue?: string) => {
return new Promise<string|null>((resolve) => {
const dismiss = () => {
onDismiss(dialog);
@@ -101,6 +101,7 @@ const useDialogControl = (setPromptDialogs: SetPromptDialogs) => {
type: DialogType.TextInput,
key: `prompt-dialog-${nextDialogIdRef.current++}`,
message,
initialValue,
onSubmit: (text) => {
resolve(text);
dismiss();

View File

@@ -24,7 +24,7 @@ export interface DialogControl {
info(message: string): Promise<void>;
error(message: string): Promise<void>;
prompt(title: string, message: string, buttons?: PromptButtonSpec[], options?: PromptOptions): void;
promptForText(message: string): Promise<string>;
promptForText(message: string, initialValue?: string): Promise<string>;
showMenu<IdType>(title: string, choices: MenuChoice<IdType>[]): Promise<IdType>;
}
@@ -47,6 +47,7 @@ export interface TextInputDialogData {
type: DialogType.TextInput;
key: string;
message: string;
initialValue?: string;
onSubmit: (text: string)=> void;
onDismiss: ()=> void;
}

View File

@@ -274,6 +274,7 @@ function NoteEditor(props: Props) {
highlightActiveLine,
keymap: EditorKeymap.Default,
preferMacShortcuts: shim.mobilePlatform() === 'ios',
automatchBraces: false,
ignoreModifiers: false,

View File

@@ -11,7 +11,7 @@ export enum InstallState {
interface Props {
onPress: ()=> void;
disabled: boolean;
disabled?: boolean;
children: React.ReactNode;
style?: ViewStyle;
testID?: string;

View File

@@ -70,7 +70,6 @@ const PluginBox: React.FC<Props> = props => {
style={styles.cardContainer}
onPress={props.onShowPluginInfo ? onPress : null}
testID='plugin-card'
disabled={!props.isCompatible}
>
<Card.Content style={styles.content}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>

View File

@@ -1,6 +1,6 @@
import * as React from 'react';
import { View, Text, FlatList, StyleSheet, TouchableOpacity } from 'react-native';
import { View, Text, FlatList, StyleSheet, AccessibilityRole } from 'react-native';
import { connect } from 'react-redux';
import Tag from '@joplin/lib/models/Tag';
import { themeStyle } from '../global-style';
@@ -8,10 +8,15 @@ import { ScreenHeader } from '../ScreenHeader';
import { _ } from '@joplin/lib/locale';
import { AppState } from '../../utils/types';
import { TagEntity } from '@joplin/lib/services/database/types';
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useMemo, useState, useContext } from 'react';
import { Dispatch } from 'redux';
import useQueuedAsyncEffect from '@joplin/lib/hooks/useQueuedAsyncEffect';
import { getCollator, getCollatorLocale } from '@joplin/lib/models/utils/getCollator';
import { DialogContext } from '../DialogManager';
import useOnLongPressProps from '../../utils/hooks/useOnLongPressProps';
import { substrWithEllipsis } from '@joplin/lib/string-utils';
import { PromptButtonSpec } from '../DialogManager/types';
import MultiTouchableOpacity from '../buttons/MultiTouchableOpacity';
import SearchBar from './SearchScreen/SearchBar';
import Logger from '@joplin/utils/Logger';
@@ -47,12 +52,47 @@ const useStyles = (themeId: number) => {
}, [themeId]);
};
interface TagItemProps {
tag: TagEntity;
themeId: number;
onPress: (id: string)=> void;
onLongPress: (tag: TagEntity)=> void;
}
const TagItem: React.FC<TagItemProps> = ({ tag, themeId, onPress, onLongPress }) => {
const styles = useStyles(themeId);
const onLongPressProps = useOnLongPressProps({ onLongPress: () => onLongPress(tag), actionDescription: _('Edit tag') });
const accessibilityRole: AccessibilityRole = 'button';
const pressableProps = {
accessibilityRole,
accessibilityHint: _('Shows notes for tag'),
...onLongPressProps,
};
return (
<MultiTouchableOpacity
{...pressableProps}
containerProps={{
style: {},
}}
onPress={() => onPress(tag.id)}
beforePressable={null}
>
<View style={styles.listItem}>
<Text style={styles.listItemText}>{tag.title}</Text>
</View>
</MultiTouchableOpacity>
);
};
const TagsScreenComponent: React.FC<Props> = props => {
const [tags, setTags] = useState<TagEntity[]>([]);
const [refreshTrigger, setRefreshTrigger] = useState(0);
const [searchQuery, setSearchQuery] = useState('');
const [showSearch, setShowSearch] = useState(false);
const styles = useStyles(props.themeId);
const dialogs = useContext(DialogContext);
const collatorLocale = getCollatorLocale();
const collator = useMemo(() => {
return getCollator(collatorLocale);
@@ -86,7 +126,7 @@ const TagsScreenComponent: React.FC<Props> = props => {
setTags([]);
}
}
}, [searchQuery, collator], { interval: 200 });
}, [searchQuery, collator, refreshTrigger], { interval: 200 });
const onSearchButtonPress = useCallback(() => {
setShowSearch(!showSearch);
@@ -111,20 +151,74 @@ const TagsScreenComponent: React.FC<Props> = props => {
});
}, [props.dispatch]);
const onTagItemLongPress = useCallback(async (tag: TagEntity) => {
const menuItems: PromptButtonSpec[] = [];
const generateTagDeletion = () => {
return () => {
dialogs.prompt('', _('Delete tag "%s"?\n\nAll notes associated with this tag will remain, but the tag will be removed from all notes.', substrWithEllipsis(tag.title, 0, 32)), [
{
text: _('OK'),
onPress: async () => {
await Tag.delete(tag.id, { sourceDescription: 'tags-screen (long-press)' });
setRefreshTrigger(prev => prev + 1);
},
},
{
text: _('Cancel'),
onPress: () => { },
style: 'cancel',
},
]);
};
};
menuItems.push({
text: _('Rename'),
onPress: async () => {
const newName = await dialogs.promptForText(_('Rename tag:'), tag.title);
if (newName && newName.trim() && newName.trim() !== tag.title) {
try {
const updatedTag = { ...tag, title: newName };
await Tag.save(updatedTag, { fields: ['title'], userSideValidation: true });
setRefreshTrigger(prev => prev + 1);
} catch (error) {
await dialogs.error(error instanceof Error ? error.message : String(error));
}
}
},
});
menuItems.push({
text: _('Delete'),
onPress: generateTagDeletion(),
style: 'destructive',
});
menuItems.push({
text: _('Cancel'),
onPress: () => {},
style: 'cancel',
});
dialogs.prompt(
'',
_('Tag: %s', tag.title),
menuItems,
);
}, [dialogs]);
type RenderItemEvent = { item: TagEntity };
const onRenderItem = useCallback(({ item }: RenderItemEvent) => {
return (
<TouchableOpacity
onPress={() => onTagItemPress({ id: item.id })}
accessibilityRole='button'
accessibilityHint={_('Shows notes for tag')}
>
<View style={styles.listItem}>
<Text style={styles.listItemText}>{item.title}</Text>
</View>
</TouchableOpacity>
<TagItem
tag={item}
themeId={props.themeId}
onPress={(id) => onTagItemPress({ id })}
onLongPress={onTagItemLongPress}
/>
);
}, [onTagItemPress, styles]);
}, [onTagItemPress, onTagItemLongPress, props.themeId]);
return (
<View style={styles.rootStyle}>

View File

@@ -26,6 +26,7 @@ import { StateDecryptionWorker, StateResourceFetcher } from '@joplin/lib/reducer
import useOnLongPressProps from '../utils/hooks/useOnLongPressProps';
import { TouchableRipple } from 'react-native-paper';
import shim from '@joplin/lib/shim';
import getConflictFolderId from '@joplin/lib/models/utils/getConflictFolderId';
const { substrWithEllipsis } = require('@joplin/lib/string-utils');
interface Props {
@@ -338,6 +339,8 @@ const SideMenuContentComponent = (props: Props) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const menuItems: any[] = [];
if (folder && folder.id === getConflictFolderId()) return;
if (folder && folder.id === getTrashFolderId()) {
menuItems.push({
text: _('Empty trash'),

View File

@@ -66,9 +66,9 @@
"react-native-quick-actions": "0.3.13",
"react-native-quick-crypto": "0.7.17",
"react-native-rsa-native": "2.0.5",
"react-native-safe-area-context": "5.5.2",
"react-native-safe-area-context": "5.6.1",
"react-native-securerandom": "1.0.1",
"react-native-share": "12.1.2",
"react-native-share": "12.2.0",
"react-native-sqlite-storage": "6.0.1",
"react-native-svg": "15.13.0",
"react-native-url-polyfill": "2.0.0",
@@ -114,10 +114,10 @@
"babel-jest": "29.7.0",
"babel-loader": "9.1.3",
"babel-plugin-module-resolver": "4.1.0",
"babel-plugin-react-native-web": "0.20.0",
"babel-plugin-react-native-web": "0.21.1",
"esbuild": "0.25.9",
"fast-deep-equal": "3.1.3",
"fs-extra": "11.2.0",
"fs-extra": "11.3.1",
"gulp": "4.0.2",
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
@@ -127,14 +127,14 @@
"nodemon": "3.1.10",
"punycode": "2.3.1",
"react-dom": "19.0.0",
"react-native-web": "0.20.0",
"react-native-web": "0.21.1",
"react-refresh": "0.17.0",
"react-test-renderer": "19.0.0",
"sharp": "0.34.3",
"sqlite3": "5.1.6",
"timers-browserify": "2.0.12",
"ts-jest": "29.4.1",
"ts-loader": "9.5.2",
"ts-loader": "9.5.4",
"ts-node": "10.9.2",
"typescript": "5.8.3",
"url-loader": "4.1.1",

View File

@@ -19,7 +19,7 @@
},
"dependencies": {
"@joplin/utils": "~3.5",
"fs-extra": "11.2.0",
"fs-extra": "11.3.1",
"yargs": "17.7.2"
}
}

View File

@@ -8,7 +8,7 @@ import {
EditorView, drawSelection, highlightSpecialChars, ViewUpdate, Command, rectangularSelection,
dropCursor,
} from '@codemirror/view';
import { history, undoDepth, redoDepth, standardKeymap, insertTab } from '@codemirror/commands';
import { history, undoDepth, redoDepth, standardKeymap, insertTab, simplifySelection } from '@codemirror/commands';
import { keymap, KeyBinding } from '@codemirror/view';
import { searchKeymap } from '@codemirror/search';
@@ -40,6 +40,7 @@ import ctrlKeyStateClassExtension from './extensions/modifierKeyCssExtension';
import ctrlClickLinksExtension from './extensions/links/ctrlClickLinksExtension';
import { RenderedContentContext } from './extensions/rendering/types';
import ctrlClickCheckboxExtension from './extensions/ctrlClickCheckboxExtension';
import editorSettingsExtension, { setEditorSettingsEffect } from './extensions/editorSettingsExtension';
// Newer versions of CodeMirror by default use Chrome's EditContext API.
// While this might be stable enough for desktop use, it causes significant
@@ -235,6 +236,11 @@ const createEditor = (
}, true),
...standardKeymap, ...historyKeymap, ...searchKeymap,
// The escape -> simplifySelection mapping is present in "defaultKeymap",
// which is disabled on desktop but enabled on mobile. Enable this mapping
// globally for consistency:
keyCommand('Escape', simplifySelection, true),
]));
const editor = new EditorView({
@@ -295,6 +301,7 @@ const createEditor = (
biDirectionalTextExtension,
overwriteModeExtension,
ctrlKeyStateClassExtension,
editorSettingsExtension(settings),
selectedNoteIdExtension,
@@ -340,9 +347,12 @@ const createEditor = (
onSettingsChange: (newSettings: EditorSettings) => {
settings = newSettings;
editor.dispatch({
effects: dynamicConfig.reconfigure(
configFromSettings(newSettings, context),
),
effects: [
dynamicConfig.reconfigure(
configFromSettings(newSettings, context),
),
setEditorSettingsEffect.of(newSettings),
],
});
},
onUndoRedo: () => {

View File

@@ -1,5 +1,6 @@
import { EditorView } from '@codemirror/view';
import { Prec } from '@codemirror/state';
import { editorSettingsFacet } from './editorSettingsExtension';
const hasMultipleCursors = (view: EditorView) => {
return view.state.selection.ranges.length > 1;
@@ -12,7 +13,9 @@ const ctrlClickActionExtension = (onCtrlClick: OnCtrlClick) => {
Prec.high([
EditorView.domEventHandlers({
mousedown: (event: MouseEvent, view: EditorView) => {
const hasModifier = event.ctrlKey || event.metaKey;
const editorSettings = view.state.facet(editorSettingsFacet);
const hasModifier = editorSettings.preferMacShortcuts ? event.metaKey : event.ctrlKey;
// The default CodeMirror action for ctrl-click is to add another cursor
// to the document. If the user already has multiple cursors, assume that
// the ctrl-click action is intended to add another.

View File

@@ -0,0 +1,25 @@
import { Facet, StateEffect, StateField } from '@codemirror/state';
import { EditorSettings } from '../../types';
export const setEditorSettingsEffect = StateEffect.define<EditorSettings>();
export const editorSettingsFacet = Facet.define<EditorSettings|null, EditorSettings|null>({
combine: (possibleValues) => {
return possibleValues.filter(value => !!value)[0] ?? null;
},
});
export default (initialSettings: EditorSettings) => [
StateField.define<EditorSettings|null>({
create: () => initialSettings,
update: (oldValue, transaction) => {
for (const e of transaction.effects) {
if (e.is(setEditorSettingsEffect)) {
return e.value;
}
}
return oldValue;
},
provide: (field) => editorSettingsFacet.from(field),
}),
];

View File

@@ -1,6 +1,9 @@
import { StateEffect, StateField, Transaction } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { editorSettingsFacet } from './editorSettingsExtension';
// On MacOS: Tracks the meta key
// On other platforms: Tracks the ctrl key.
const ctrlOrMetaChangedEffect = StateEffect.define<boolean>();
const ctrlOrMetaPressedField = StateField.define<boolean>({
@@ -18,11 +21,13 @@ const ctrlOrMetaPressedField = StateField.define<boolean>({
})),
...(() => {
const onEvent = (event: KeyboardEvent|MouseEvent, view: EditorView) => {
const ctrlOrCmdPressed = event.ctrlKey || event.metaKey;
if (ctrlOrCmdPressed !== view.state.field(ctrlOrMetaPressedField)) {
const editorSettings = view.state.facet(editorSettingsFacet);
const hasModifier = editorSettings.preferMacShortcuts ? event.metaKey : event.ctrlKey;
if (hasModifier !== view.state.field(ctrlOrMetaPressedField)) {
view.dispatch({
effects: [
ctrlOrMetaChangedEffect.of(ctrlOrCmdPressed),
ctrlOrMetaChangedEffect.of(hasModifier),
],
});
}

View File

@@ -9,12 +9,12 @@ const allowImageUrlsToBeFetched = async () => {
await Promise.resolve();
};
const createEditor = async (initialMarkdown: string, hasImage: boolean) => {
const createEditor = async (initialMarkdown: string, expectedTags: string[] = ['Image']) => {
const resolveImageSrc = jest.fn((src, counter) => Promise.resolve(`${src}?r=${counter}`));
const editor = await createTestEditor(
initialMarkdown,
EditorSelection.cursor(0),
hasImage ? ['Image'] : [],
expectedTags,
[renderBlockImages({ resolveImageSrc })],
);
await allowImageUrlsToBeFetched();
@@ -40,7 +40,7 @@ describe('renderBlockImages', () => {
{ spaceBefore: ' ', spaceAfter: ' ', alt: 'test' },
{ spaceBefore: '', spaceAfter: '', alt: '!!!!' },
])('should render images below their Markdown source (case %#)', async ({ spaceBefore, spaceAfter, alt }) => {
const editor = await createEditor(`${spaceBefore}![${alt}](:/0123456789abcdef0123456789abcdef)${spaceAfter}`, true);
const editor = await createEditor(`${spaceBefore}![${alt}](:/0123456789abcdef0123456789abcdef)${spaceAfter}`);
const images = findImages(editor);
expect(images).toHaveLength(1);
@@ -51,13 +51,13 @@ describe('renderBlockImages', () => {
// For now, only Joplin resources are rendered. This simplifies the implementation and avoids
// potentially-unwanted web requests when opening a note with only the editor open.
test('should not render web images', async () => {
const editor = await createEditor('![test](https://example.com/test.png)\n\n', true);
const editor = await createEditor('![test](https://example.com/test.png)\n\n');
const images = findImages(editor);
expect(images).toHaveLength(0);
});
test('should allow reloading specific images', async () => {
const editor = await createEditor('![test](:/a123456789abcdef0123456789abcdef)\n![test 2](:/b123456789abcdef0123456789abcde2)', true);
const editor = await createEditor('![test](:/a123456789abcdef0123456789abcdef)\n![test 2](:/b123456789abcdef0123456789abcde2)');
// Should have the expected original image URLs
expect(getImageUrls(editor)).toMatchObject([
@@ -98,7 +98,7 @@ describe('renderBlockImages', () => {
const widthAttr = width ? ` width="${width}"` : '';
const editor = await createEditor(
`${spaceBefore}<img src=":/0123456789abcdef0123456789abcdef" alt="${alt}"${widthAttr} />${spaceAfter}`,
false,
['HTMLTag'],
);
const images = findImages(editor);
@@ -117,7 +117,7 @@ describe('renderBlockImages', () => {
test('should render non-self-closing HTML img tags', async () => {
const editor = await createEditor(
'<img src=":/0123456789abcdef0123456789abcdef" alt="test" width="300">',
false,
['HTMLBlock'],
);
const images = findImages(editor);
@@ -128,7 +128,7 @@ describe('renderBlockImages', () => {
test('should not render HTML img tags with web URLs', async () => {
const editor = await createEditor(
'<img src="https://example.com/test.png" alt="test" />',
false,
['HTMLTag'],
);
const images = findImages(editor);
@@ -138,7 +138,7 @@ describe('renderBlockImages', () => {
test('should render both markdown and HTML images in same document', async () => {
const editor = await createEditor(
'![markdown](:/a123456789abcdef0123456789abcdef)\n\n<img src=":/b123456789abcdef0123456789abcde2" alt="html" width="400" />',
true,
['Image', 'HTMLTag'],
);
const images = findImages(editor);
@@ -151,7 +151,7 @@ describe('renderBlockImages', () => {
const editor = await createEditor(
// eslint-disable-next-line quotes
"<img src=':/0123456789abcdef0123456789abcdef' alt='test' width='250' />",
false,
['HTMLTag'],
);
const images = findImages(editor);

View File

@@ -17,6 +17,7 @@ const createEditorSettings = (themeId: number) => {
highlightActiveLine: false,
keymap: EditorKeymap.Default,
preferMacShortcuts: false,
language: EditorLanguageType.Markdown,
themeData,

View File

@@ -180,6 +180,7 @@ export interface EditorSettings {
language: EditorLanguageType;
keymap: EditorKeymap;
preferMacShortcuts: boolean;
tabMovesFocus: boolean;
markdownMarkEnabled: boolean;

View File

@@ -28,7 +28,7 @@
"@adobe/css-tools": "4.4.4",
"@joplin/fork-htmlparser2": "^4.1.60",
"datauri": "4.1.0",
"fs-extra": "11.2.0",
"fs-extra": "11.3.1",
"html-entities": "1.4.0"
},
"devDependencies": {

View File

@@ -1,18 +1,20 @@
import * as convertHtmlToMarkdown from './convertNoteToMarkdown';
import { AppState, createAppDefaultState } from '../app.reducer';
import Note from '@joplin/lib/models/Note';
import { defaultState, State } from '../reducer';
import Note from '../models/Note';
import { MarkupLanguage } from '@joplin/renderer';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import Folder from '@joplin/lib/models/Folder';
import { NoteEntity } from '@joplin/lib/services/database/types';
import { setupDatabaseAndSynchronizer, switchClient } from '../testing/test-utils';
import Folder from '../models/Folder';
import { NoteEntity } from '../services/database/types';
import shim from '../shim';
describe('convertNoteToMarkdown', () => {
let state: AppState = undefined;
let state: State = undefined;
beforeEach(async () => {
state = createAppDefaultState({});
state = defaultState;
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
shim.showToast = jest.fn();
});
it('should set the original note to be trashed', async () => {
@@ -29,13 +31,6 @@ describe('convertNoteToMarkdown', () => {
});
it('should recreate a new note that is a clone of the original', async () => {
let noteConvertedToMarkdownId = '';
const dispatchFn = jest.fn()
.mockImplementationOnce(() => {})
.mockImplementationOnce(action => {
noteConvertedToMarkdownId = action.id;
});
const folder = await Folder.save({ title: 'test_folder' });
const htmlNoteProperties = {
title: 'test',
@@ -49,10 +44,11 @@ describe('convertNoteToMarkdown', () => {
const htmlNote = await Note.save(htmlNoteProperties);
state.selectedNoteIds = [htmlNote.id];
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: dispatchFn });
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: jest.fn() });
expect(dispatchFn).toHaveBeenCalledTimes(2);
expect(noteConvertedToMarkdownId).not.toBe('');
const notes = await Note.previews(folder.id);
expect(notes).toHaveLength(1);
const noteConvertedToMarkdownId = notes[0].id;
const markdownNote = await Note.load(noteConvertedToMarkdownId);
@@ -63,15 +59,6 @@ describe('convertNoteToMarkdown', () => {
});
it('should generate action to trigger notification', async () => {
let originalHtmlNoteId = '';
let actionType = '';
const dispatchFn = jest.fn()
.mockImplementationOnce(action => {
originalHtmlNoteId = action.value;
actionType = action.type;
})
.mockImplementationOnce(() => {});
const folder = await Folder.save({ title: 'test_folder' });
const htmlNoteProperties = {
title: 'test',
@@ -85,12 +72,9 @@ describe('convertNoteToMarkdown', () => {
const htmlNote = await Note.save(htmlNoteProperties);
state.selectedNoteIds = [htmlNote.id];
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: dispatchFn });
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: jest.fn() });
expect(dispatchFn).toHaveBeenCalledTimes(2);
expect(originalHtmlNoteId).toBe(htmlNote.id);
expect(actionType).toBe('NOTE_HTML_TO_MARKDOWN_DONE');
expect(shim.showToast).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,75 @@
import { _, _n } from '../locale';
import Note from '../models/Note';
import { CommandRuntime, CommandDeclaration, CommandContext } from '../services/CommandService';
import { MarkupLanguage } from '@joplin/renderer';
import { runtime as convertHtmlToMarkdown } from './convertHtmlToMarkdown';
import shim, { ToastType } from '../shim';
import { NoteEntity } from '../services/database/types';
import { itemIsReadOnly } from '../models/utils/readOnly';
import { ModelType } from '../BaseModel';
import ItemChange from '../models/ItemChange';
import Setting from '../models/Setting';
import Logger from '@joplin/utils/Logger';
const logger = Logger.create('convertNoteToMarkdown');
export const declaration: CommandDeclaration = {
name: 'convertNoteToMarkdown',
label: () => _('Convert to Markdown'),
};
export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext, noteIds: string|string[] = []) => {
if (typeof noteIds === 'string') {
noteIds = [noteIds];
}
if (noteIds.length === 0) {
noteIds = context.state.selectedNoteIds;
}
const notes: NoteEntity[] = await Note.loadItemsByIdsOrFail(noteIds);
try {
let isFirst = true;
let processedCount = 0;
for (const note of notes) {
if (note.markup_language === MarkupLanguage.Markdown) {
logger.warn('Skipping item: Already Markdown.');
continue;
}
if (await itemIsReadOnly(Note, ModelType.Note, ItemChange.SOURCE_UNSPECIFIED, note.id, Setting.value('sync.userId'), context.state.shareService)) {
throw new Error(_('Cannot convert read-only item: "%s"', note.title));
}
const markdownBody = await convertHtmlToMarkdown().execute(context, note.body);
const newNote = await Note.duplicate(note.id);
newNote.body = markdownBody;
newNote.markup_language = MarkupLanguage.Markdown;
await Note.save(newNote);
await Note.delete(note.id, { toTrash: true });
processedCount ++;
if (isFirst) {
context.dispatch({
type: 'NOTE_SELECT',
id: newNote.id,
});
isFirst = false;
}
}
void shim.showToast(_n(
'The note has been converted to Markdown and the original note has been moved to the trash',
'The notes have been converted to Markdown and the original notes have been moved to the trash',
processedCount,
), { type: ToastType.Success });
} catch (error) {
await shim.showErrorDialog(_('Could not convert notes to Markdown: %s', error.message));
}
},
enabledCondition: 'selectionIncludesHtmlNotes && (multipleNotesSelected || !noteIsReadOnly)',
};
};

View File

@@ -1,5 +1,6 @@
// AUTO-GENERATED using `gulp buildScriptIndexes`
import * as convertHtmlToMarkdown from './convertHtmlToMarkdown';
import * as convertNoteToMarkdown from './convertNoteToMarkdown';
import * as deleteNote from './deleteNote';
import * as historyBackward from './historyBackward';
import * as historyForward from './historyForward';
@@ -14,6 +15,7 @@ import * as toggleEditorPlugin from './toggleEditorPlugin';
const index: any[] = [
convertHtmlToMarkdown,
convertNoteToMarkdown,
deleteNote,
historyBackward,
historyForward,

View File

@@ -66,7 +66,7 @@
"file-type": "16.5.4",
"follow-redirects": "1.15.11",
"form-data": "4.0.4",
"fs-extra": "11.2.0",
"fs-extra": "11.3.1",
"hpagent": "1.2.0",
"html-entities": "1.4.0",
"html-minifier": "4.0.0",

View File

@@ -271,7 +271,10 @@ export default class RevisionService extends BaseService {
}
public async restoreFolder() {
let folder = await Folder.loadByTitle(this.restoreFolderTitle());
let folder = await Folder.loadByFields({
title: this.restoreFolderTitle(),
deleted_time: 0,
});
if (!folder) {
folder = await Folder.save({ title: this.restoreFolderTitle() });
}

View File

@@ -8,6 +8,7 @@ import { itemIsReadOnlySync, ItemSlice } from '../../models/utils/readOnly';
import ItemChange from '../../models/ItemChange';
import { getTrashFolderId } from '../trash';
import getActivePluginEditorView from '../plugins/utils/getActivePluginEditorView';
import { MarkupLanguage } from '@joplin/renderer';
export interface WhenClauseContextOptions {
commandFolderId?: string;
@@ -18,6 +19,7 @@ export interface WhenClauseContextOptions {
export interface WhenClauseContext {
allSelectedNotesAreDeleted: boolean;
selectionIncludesHtmlNotes: boolean;
foldersAreDeleted: boolean;
foldersIncludeReadOnly: boolean;
folderIsDeleted: boolean;
@@ -88,6 +90,7 @@ export default function stateToWhenClauseContext(state: State, options: WhenClau
// Selected notes properties
allSelectedNotesAreDeleted: !selectedNotes.find(n => !n.deleted_time),
selectionIncludesHtmlNotes: selectedNotes.some(n => n.markup_language === MarkupLanguage.Html),
// Note history
historyhasBackwardNotes: windowState.backwardHistoryNotes && windowState.backwardHistoryNotes.length > 0,

View File

@@ -226,7 +226,7 @@ describe('InteropService_Importer_OneNote', () => {
const noteToTest = notes.find(n => n.title === 'Tips from a Pro Using Trees for Dramatic Landscape Photography');
expectWithInstructions(noteToTest).toBeTruthy();
expectWithInstructions(noteToTest.body.includes('<a href="onenote:https://d.docs.live.net/c8d3bbab7f1acf3a/Documents/Photography/风景.one#Tips%20from%20a%20Pro%20Using%20Trees%20for%20Dramatic%20Landscape%20Photography&section-id={262ADDFB-A4DC-4453-A239-0024D6769962}&page-id={88D803A5-4F43-48D4-9B16-4C024F5787DC}&end" style="">Tips from a Pro: Using Trees for Dramatic Landscape Photography</a>')).toBe(true);
expectWithInstructions(noteToTest.body).toContain('<a href="onenote:https://d.docs.live.net/c8d3bbab7f1acf3a/Documents/Photography/%E9%A3%8E%E6%99%AF.one#Tips%20from%20a%20Pro%20Using%20Trees%20for%20Dramatic%20Landscape%20Photography&section-id={262ADDFB-A4DC-4453-A239-0024D6769962}&page-id={88D803A5-4F43-48D4-9B16-4C024F5787DC}&end" style="">Tips from a Pro: Using Trees for Dramatic Landscape Photography</a>');
});
it('should render links properly by ignoring wrongly set indices when the first character is a hyperlink marker', async () => {
@@ -307,4 +307,14 @@ describe('InteropService_Importer_OneNote', () => {
expect(markdown).toMatchSnapshot('Test Todo: As Markdown');
});
it('should correctly import math formulas', async () => {
const notes = await importNote(`${supportDir}/onenote/Math.one`);
const importedNote = notes.find(n => n.title.startsWith('Math'));
const converter = new HtmlToMd();
const markdown = converter.parse(importedNote.body);
expect(markdown).toMatchSnapshot('Math');
});
});

View File

@@ -142,7 +142,7 @@ jeudi 23 octobre 2025
- [x] Rédiger carnet MS-OneNote jeu d'essai (case cochée)
- [ ] Transmettre aux dev Joplin (case non cochée)
- [x] Exporter le carnet en \\*.packageone (case cochée)
- [x] Exporter le carnet en \\*.onepkg (case cochée)
- [ ] Exporter des page en \\*.one (case non cochée)
&nbsp;
@@ -155,6 +155,44 @@ jeudi 23 octobre 2025
- [x] Documenter configuration synchro JBS saml pour un utilisateur (case cochée)"
`;
exports[`InteropService_Importer_OneNote should correctly import math formulas: Math 1`] = `
" Math
Math
Friday, November 28, 2025
2:47 PM
Cauchy's Integral Formula: $\\def\\∫#1#2#3{\\int_{#1}^{#2}{#3}}\\def\\parens#1{\\left( {#1} \\right)}𝑓\\parens{𝑥}=\\∫{𝛾}{}{\\frac{𝑓(𝑧)}{𝑧−𝑥}𝑑𝑧}$
Pythagorean Theorem: $\\def\\pow#1#2{{#1}^{#2}}\\pow{𝑎}{2}+\\pow{𝑏}{2}=\\pow{𝑐}{2}$
Law of Cosines: $\\def\\pow#1#2{{#1}^{#2}}\\def\\fnCall#1#2{{ \\rm #1 }\\ {#2}}\\pow{𝑎}{2}+\\pow{𝑏}{2}=\\pow{𝑐}{2}+2𝑎𝑏\\fnCall{cos}{𝐶}$
Euler's Formula: $\\def\\pow#1#2{{#1}^{#2}}\\def\\fnCall#1#2{{ \\rm #1 }\\ {#2}}\\pow{𝑒}{𝑖𝜃}=\\fnCall{cos}{𝜃}+𝑖\\fnCall{sin}{𝜃}$
Determinant of a 2x2 matrix: $\\def\\parens#1{\\left( {#1} \\right)}\\def\\unknown#1{\\textsf{Unknown}(#1)}\\parens{\\unknown{20}{𝑎}{𝑏}{𝑐}{𝑑}}=𝑎𝑑−𝑏𝑐$
Empty formula: $\\mathrm{Type equation here.}$
Fractions: $\\frac{𝑎}{𝑏}, \\frac{𝑎}{𝑏},\\mathrm{\\frac{𝑎}{𝑏}}, \\frac{𝑎}{𝑏}$.
Summation: $\\def\\∑#1#2#3{\\sum_{#1}^{#2}{#3}}\\def\\withSubscript#1#2{{#1}_{#2}}\\def\\fnCall#1#2{{ \\rm #1 }\\ {#2}}\\fnCall{\\withSubscript{lim}{𝑛→∞}}{\\∑{𝑘=0}{𝑛 }{\\frac{1}{𝑘}}}=∞$.
Definite integral: $\\def\\∫#1#2#3{\\int_{#1}^{#2}{#3}}\\∫{0}{1}{𝑥}ⅆ𝑥=\\frac{1}{2}$
&nbsp;
Integral identities (see https://en.wikipedia.org/wiki/Lists_of_integrals), below, $𝐶∈ℝ$:
- $\\def\\parens#1{\\left( {#1} \\right)}\\def\\fnCall#1#2{{ \\rm #1 }\\ {#2}}∫\\mathrm{\\frac{1}{𝑥}𝑑𝑥}=\\fnCall{ln}{\\parens{𝑥}}+𝐶$.
- $\\def\\pow#1#2{{#1}^{#2}}∫\\pow{𝑒}{𝑎𝑥}𝑑𝑥=\\frac{1}{𝑎}\\pow{𝑒}{𝑎𝑥}+𝐶$
- $\\def\\parens#1{\\left( {#1} \\right)}\\def\\fnCall#1#2{{ \\rm #1 }\\ {#2}}∫\\parens{\\fnCall{sin}{𝑥}}𝑑𝑥=−\\fnCall{cos}{𝑥}+𝐶$
&nbsp;"
`;
exports[`InteropService_Importer_OneNote should expect notes to be rendered the same: A page can have any width it wants 1`] = `
"<!DOCTYPE HTML>
<html lang="en"><head>
@@ -304,7 +342,7 @@ exports[`InteropService_Importer_OneNote should expect notes to be rendered the
</div></div><div class="container-outline" style="left: 48px; position: absolute; top: 115px; width: 624px;"><div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 10.5pt; padding-bottom: 7px; padding-top: 7px;">Suspendisse vitae odio nibh. Etiam fringilla mattis dapibus. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Fusce vel ultricies ligula. Sed a nunc ante. Praesent suscipit fermentum magna. Aliquam convallis porttitor lacus ac posuere. Vestibulum maximus leo vel tortor condimentum, et tristique leo maximus. Nulla elementum, augue eu sollicitudin tempus, arcu ex lacinia enim, ut posuere lectus libero non eros. Vestibulum a libero leo. Donec id leo commodo, ornare ante ac, molestie tellus. Aenean a neque quis turpis euismod porta. Quisque vulputate augue vitae orci accumsan, a lobortis leo luctus. Nunc sodales sapien vitae lacus faucibus hendrerit. In ac lacinia diam.</p></div>
<div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt; padding-bottom: 7px; padding-top: 7px;"><span style="font-family: Calibri; font-size: 14pt;">Nam tempor urna eget posuere mollis. Aliquam erat volutpat. Sed ipsum massa, dictum eget sagittis id, fermentum a justo. Vivamus in iaculis libero. Pellentesque malesuada felis dictum turpis placerat, at ultrices justo viverra. Praesent nisi lectus, tincidunt ut tellus in, convallis euismod urna. Phasellus molestie porttitor odio vitae efficitur. Curabitur vulputate congue tincidunt. Fusce mattis orci at porttitor fermentum. Cras eu placerat odio. Fusce eu tortor sit amet massa pretium efficitur. Nam consequat, mauris at blandit placerat, est sapien feugiat felis, quis imperdiet sapien neque in justo. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Phasellus vestibulum rhoncus dolor, ut ullamcorper purus scelerisque eu. Integer sem felis, pellentesque in rutrum id, porta a ante.</span><span style="font-family: Calibri; font-size: 18pt;">Vivamus finibus imperdiet massa, at interdum turpis rhoncus et. Phasellus leo nibh, mattis vel tortor at, gravida finibus felis. Donec bibendum enim euismod, dignissim ipsum eu, laoreet nisl. Ut auctor sollicitudin eros dictum gravida.</span><span style="font-family: Calibri; font-size: 14pt;"> Vestibulum pellentesque, ex quis vulputate efficitur, dolor metus efficitur nisl, id elementum mi nulla sit amet orci. Nam odio sem, bibendum at hendrerit finibus, vestibulum vitae dolor. In hac habitasse platea dictumst. Curabitur et ligula elit. Donec vulputate, diam non gravida efficitur, mi odio imperdiet ipsum, nec rhoncus mi nibh non magna.</span></p></div>
<div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 10.5pt; padding-bottom: 7px; padding-top: 7px;">Suspendisse varius enim vel odio congue sodales. Integer sit amet nisi sagittis, dapibus mi ut, tincidunt magna. Duis posuere est felis, et rhoncus magna volutpat a. Nullam tempor dignissim suscipit. Vestibulum cursus felis vitae libero pulvinar molestie. Donec at metus eget arcu blandit tincidunt. Donec purus felis, malesuada ac egestas eu, interdum sed erat. Praesent nec accumsan orci. Nunc bibendum rutrum erat, vel luctus odio. Pellentesque iaculis gravida arcu, eu consequat turpis congue sit amet. Interdum et malesuada fames ac ante ipsum primis in faucibus. Duis eget urna vel erat aliquet fringilla. Praesent vel luctus ligula, nec viverra nisl. Sed ac sem consectetur, sodales ante sodales, feugiat arcu.</p></div>
<div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt;"><span style="color: rgb(66,66,67); font-family: Calibri; font-size: 10pt;">It was a bright cold day in April, and the clocks were striking thirteen. Winston Smith, his chin nuzzled into his breast in an effort to escape the vile wind, slipped quickly through the glass doors of Victory Mansions, though not quickly enough to prevent a swirl of gritty dust from entering along with him. </span><span style="color: rgb(66,66,67); font-family: Calibri; font-size: 10pt;"><br></span><span style="color: rgb(66,66,67); font-family: Calibri; font-size: 10pt;"><br></span><span style="color: rgb(66,66,67); font-family: Calibri; font-size: 10pt;">The hallway smelt of boiled cabbage and old rag mats. At one end of it a coloured poster, too large for indoor display, had been tacked to the wall. It depicted simply an enormous face, more than a metre wide: the face of a man of about forty-five, with a heavy black moustache and ruggedly handsome features. Winston made for the stairs. It was no use trying the lift. Even at the best of times it was seldom working, and at present the electric current was cut off during daylight hours. It was part of the economy drive in preparation for Hate Week. The flat was seven flights up, and Winston, who was thirty-nine and had a varicose ulcer above his right ankle, went slowly, resting several times on the way. On each landing, opposite the lift-shaft, the poster with the enormous face gazed from the wall. It was one of those pictures which are so contrived that the eyes follow you about when you move. BIG BROTHER IS WATCHING YOU, the caption beneath it ran. </span><span style="color: rgb(66,66,67); font-family: Calibri; font-size: 10pt;"><br></span><span style="color: rgb(66,66,67); font-family: Calibri; font-size: 10pt;"><br></span><span style="color: rgb(66,66,67); font-family: Calibri; font-size: 10pt;">Inside the flat a fruity voice was reading out a list of figures which had something to do with the production of pig-iron. The voice came from an oblong metal plaque like a dulled mirror which formed part of the surface of the right-hand wall. Winston turned a switch and the voice sank somewhat, though the words were still distinguishable. The instrument (the telescreen, it was called) could be dimmed, but there was no way of shutting it off completely. He moved over to the window: a smallish, frail figure, the meagreness of his body merely emphasized by the blue overalls which were the uniform of the party. His hair was very fair, his face naturally sanguine, his skin roughened by coarse soap and blunt razor blades and the cold of the winter that had just ended. </span><span style="color: rgb(66,66,67); font-family: Calibri; font-size: 10pt;"><br></span><span style="color: rgb(66,66,67); font-family: Calibri; font-size: 10pt;"><br></span></p></div>
<div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt;"><span style="color: rgb(66,66,67); font-family: Calibri; font-size: 10pt;">It was a bright cold day in April, and the clocks were striking thirteen. Winston Smith, his chin nuzzled into his breast in an effort to escape the vile wind, slipped quickly through the glass doors of Victory Mansions, though not quickly enough to prevent a swirl of gritty dust from entering along with him. </span><span style="color: rgb(66,66,67); font-family: Calibri; font-size: 10pt;"><br></span><span style="color: rgb(66,66,67); font-family: Calibri; font-size: 10pt;"><br></span><span style="color: rgb(66,66,67); font-family: Calibri; font-size: 10pt;">The hallway smelt of boiled cabbage and old rag mats. At one end of it a coloured poster, too large for indoor display, had been tacked to the wall. It depicted simply an enormous face, more than a metre wide: the face of a man of about forty-five, with a heavy black moustache and ruggedly handsome features. Winston made for the stairs. It was no use trying the lift. Even at the best of times it was seldom working, and at present the electric current was cut off during daylight hours. It was part of the economy drive in preparation for Hate Week. The flat was seven flights up, and Winston, who was thirty-nine and had a varicose ulcer above his right ankle, went slowly, resting several times on the way. On each landing, opposite the lift-shaft, the poster with the enormous face gazed from the wall. It was one of those pictures which are so contrived that the eyes follow you about when you move. BIG BROTHER IS WATCHING YOU, the caption beneath it ran. </span><span style="color: rgb(66,66,67); font-family: Calibri; font-size: 10pt;"><br></span><span style="color: rgb(66,66,67); font-family: Calibri; font-size: 10pt;"><br></span><span style="color: rgb(66,66,67); font-family: Calibri; font-size: 10pt;">Inside the flat a fruity voice was reading out a list of figures which had something to do with the production of pig-iron. The voice came from an oblong metal plaque like a dulled mirror which formed part of the surface of the right-hand wall. Winston turned a switch and the voice sank somewhat, though the words were still distinguishable. The instrument (the telescreen, it was called) could be dimmed, but there was no way of shutting it off completely. He moved over to the window: a smallish, frail figure, the meagreness of his body merely emphasized by the blue overalls which were the uniform of the party. His hair was very fair, his face naturally sanguine, his skin roughened by coarse soap and blunt razor blades and the cold of the winter that had just ended. </span><span style="color: rgb(66,66,67); font-family: Calibri; font-size: 10pt;"><br></span><span style="color: rgb(66,66,67); font-family: Calibri; font-size: 10pt;"><br></span>Outside, even through the shut window-pane, the world looked cold. Down in the street little eddies of wind were whirling dust and torn paper into spirals, and though the sun was shining and the sky a harsh blue, there seemed to be no colour in anything, except the posters that were plastered everywhere. The blackmoustachio'd face gazed down from every commanding corner. There was one on the house-front immediately opposite. BIG BROTHER IS WATCHING YOU, the caption said, while the dark eyes looked deep into Winston's own. Down at streetlevel another poster, torn at one corner, flapped fitfully in the wind, alternately covering and uncovering the single word INGSOC. In the far distance a helicopter skimmed down between the roofs, hovered for an instant like a bluebottle, and darted away again with a curving flight. It was the police patrol, snooping into people's windows. The patrols did not matter, however. Only the Thought Police mattered. <br><br>Behind Winston's back the voice from the telescreen was still babbling away about pig-iron and the overfulfilment of the Ninth Three-Year Plan. The telescreen received and transmitted simultaneously. Any sound that Winston made, above the level of a very low whisper, would be picked up by it, moreover, so long as he remained within the field of vision which the metal plaque commanded, he could be seen as well as heard. There was of course no way of knowing whether you were being watched at any given moment. How often, or on what system, the Thought Police plugged in on any individual wire was guesswork. It was even conceivable that they watched everybody all the time. But at any rate they could plug in your wire whenever they wanted to. You had to live -- did live, from habit that became instinct -- in the assumption that every sound you made was overheard, and, except in darkness, every movement scrutinized. </p></div>
</div><img style="height: 6px; left: 49px; overflow: visible; position: absolute; top: 127px; width: 72px;" src=":/204"><img style="height: 16px; left: 465px; overflow: visible; position: absolute; top: 227px; width: 17px;" src=":/205"><img style="height: 7px; left: 49px; overflow: visible; position: absolute; top: 262px; width: 37px;" src=":/206"><img style="height: 9px; left: 146px; overflow: visible; position: absolute; top: 521px; width: 82px;" src=":/207"><img style="height: 17px; left: 175px; overflow: visible; position: absolute; top: 713px; width: 28px;" src=":/208"><img style="height: 6px; left: 46px; overflow: visible; position: absolute; top: 883px; width: 12px;" src=":/209"><img style="height: 12px; left: 157px; overflow: visible; position: absolute; top: 1518px; width: 17px;" src=":/210">
<script>
@@ -788,17 +826,17 @@ exports[`InteropService_Importer_OneNote should ignore broken characters at the
<div class="title" style="left: 48px; position: absolute; top: 24px;"><div class="container-outline" style="width: 624px;"><div class="outline-element" style="margin-left: 0px;"><span style="font-family: Calibri Light; font-size: 20pt;">Action research - Wikipedia</span></div>
</div><div class="container-outline"><div class="outline-element" style="margin-left: 0px;"><span style="color: rgb(128,128,128); font-family: Calibri; font-size: 10pt;">Monday, May 27, 2019</span></div>
<div class="outline-element" style="margin-left: 0px;"><span style="color: rgb(128,128,128); font-family: Calibri; font-size: 10pt;">12:13 PM</span></div>
</div></div><div class="container-outline" style="left: 48px; position: absolute; top: 120px; width: 624px;"><div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt; padding-bottom: 7px; padding-top: 7px;"><span style="font-family: Verdana; font-size: 12pt;">Clipped from: </span><a href="https://en.wikipedia.org/wiki/Action_research#Action_research_in_organization_development" style="">https://en.wikipedia.org/wiki/Action_research#Action_research_in_organization_development</a></p></div>
</div></div><div class="container-outline" style="left: 48px; position: absolute; top: 120px; width: 624px;"><div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt; padding-bottom: 7px; padding-top: 7px;"><span style="font-family: Verdana; font-size: 12pt;">Clipped from: </span><a href="https://en.wikipedia.org/wiki/Action_research#Action_research_in_organization_development" style="font-family: Verdana; font-size: 12pt;">https://en.wikipedia.org/wiki/Action_research#Action_research_in_organization_development</a></p></div>
<div class="outline-element" style="margin-left: 0px;"><img src=":/5" style="max-height: -48px; max-width: -48px;" /></div>
<div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt; padding-bottom: 16px; padding-top: 7px;"><span style="font-family: Verdana; font-size: 12pt;">569 revisions since </span><a href="https://en.wikipedia.org/wiki/Special%3APermaLink%2F2264241" style="">2003-05-19</a><span style="font-family: Verdana; font-size: 12pt;"> (</span><a href="https://en.wikipedia.org/wiki/Special%3ADiff%2F898227450" style="">+5 days</a><span style="font-family: Verdana; font-size: 12pt;">), 328 editors, 90 watchers, </span><a href="https://tools.wmflabs.org/pageviews?project=en.wikipedia.org&pages=Action%20research&range=latest-30" style="">18,937 pageviews</a><span style="font-family: Verdana; font-size: 12pt;"> (30 days), created by: </span><a href="https://en.wikipedia.org/wiki/User:Thseamon" style="">Thseamon</a><span style="font-family: Verdana; font-size: 12pt;"> (</span><a href="http://xtools.wmflabs.org/ec/en.wikipedia.org/Thseamon" style="">762</a><span style="font-family: Verdana; font-size: 12pt;">) · </span><a href="http://xtools.wmflabs.org/articleinfo/en.wikipedia.org/Action%20research" style="">See full page statistics</a><span style="font-family: Verdana; font-size: 12pt;"> </span></p></div>
<div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt; padding-bottom: 16px; padding-top: 7px;"><a href="https://en.wikipedia.org/wiki/Action_research#mw-head" style="">Jump to navigation</a><span style="font-family: Verdana; font-size: 12pt;"> </span><a href="https://en.wikipedia.org/wiki/Action_research#p-search" style="">Jump to search</a><span style="font-family: Verdana; font-size: 12pt;"> </span></p></div>
<div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt; padding-bottom: 16px; padding-top: 7px;"><span style="font-family: Verdana; font-size: 12pt;">For the British charity formerly named Action Research, see </span><a href="https://en.wikipedia.org/wiki/Action_Medical_Research" style="">Action Medical Research</a><span style="font-family: Verdana; font-size: 12pt;">. For the academic journal titled Action Research, see </span><a href="https://en.wikipedia.org/wiki/Action_Research_(journal)" style="">Action Research (journal)</a><span style="font-family: Verdana; font-size: 12pt;">.</span></p></div>
<div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt; padding-bottom: 16px; padding-top: 7px;"><span style="font-family: Verdana; font-size: 12pt;">569 revisions since </span><a href="https://en.wikipedia.org/wiki/Special%3APermaLink%2F2264241" style="font-family: Verdana; font-size: 12pt;">2003-05-19</a><span style="font-family: Verdana; font-size: 12pt;"> (</span><a href="https://en.wikipedia.org/wiki/Special%3ADiff%2F898227450" style="font-family: Verdana; font-size: 12pt;">+5 days</a><span style="font-family: Verdana; font-size: 12pt;">), 328 editors, 90 watchers, </span><a href="https://tools.wmflabs.org/pageviews?project=en.wikipedia.org&pages=Action%20research&range=latest-30" style="font-family: Verdana; font-size: 12pt;">18,937 pageviews</a><span style="font-family: Verdana; font-size: 12pt;"> (30 days), created by: </span><a href="https://en.wikipedia.org/wiki/User:Thseamon" style="font-family: Verdana; font-size: 12pt;">Thseamon</a><span style="font-family: Verdana; font-size: 12pt;"> (</span><a href="http://xtools.wmflabs.org/ec/en.wikipedia.org/Thseamon" style="font-family: Verdana; font-size: 12pt;">762</a><span style="font-family: Verdana; font-size: 12pt;">) · </span><a href="http://xtools.wmflabs.org/articleinfo/en.wikipedia.org/Action%20research" style="font-family: Verdana; font-size: 12pt;">See full page statistics</a><span style="font-family: Verdana; font-size: 12pt;"> </span></p></div>
<div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt; padding-bottom: 16px; padding-top: 7px;"><a href="https://en.wikipedia.org/wiki/Action_research#mw-head" style="font-family: Verdana; font-size: 12pt;">Jump to navigation</a><span style="font-family: Verdana; font-size: 12pt;"> </span><a href="https://en.wikipedia.org/wiki/Action_research#p-search" style="font-family: Verdana; font-size: 12pt;">Jump to search</a><span style="font-family: Verdana; font-size: 12pt;"> </span></p></div>
<div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt; padding-bottom: 16px; padding-top: 7px;"><span style="font-family: Verdana; font-size: 12pt;">For the British charity formerly named Action Research, see </span><a href="https://en.wikipedia.org/wiki/Action_Medical_Research" style="font-family: Verdana; font-size: 12pt;">Action Medical Research</a><span style="font-family: Verdana; font-size: 12pt;">. For the academic journal titled Action Research, see </span><a href="https://en.wikipedia.org/wiki/Action_Research_(journal)" style="font-family: Verdana; font-size: 12pt;">Action Research (journal)</a><span style="font-family: Verdana; font-size: 12pt;">.</span></p></div>
<div class="outline-element" style="margin-left: 0px;"><table cellpadding="0" cellspacing="0" style="border-collapse: collapse;"><tr><td style="min-width: 48px; padding: 2pt; vertical-align: top;"><div class="outline-element" style="margin-left: 0px;"><img src=":/6" style="max-height: 39px; max-width: 50px;" /></div>
</td><td style="min-width: 48px; padding: 2pt; vertical-align: top;"><div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt;"><span style="font-family: Verdana; font-size: 12pt;">This article </span><span style="font-family: Verdana; font-size: 12pt; font-weight: bold;">needs additional citations for </span><a href="https://en.wikipedia.org/wiki/Wikipedia:Verifiability" style="">verification</a><span style="font-family: Verdana; font-size: 12pt; font-weight: bold;">. Please help </span><a href="https://en.wikipedia.org/w/index.php?title=Action_research&action=edit" style="">improve this article</a><span style="font-family: Verdana; font-size: 12pt; font-weight: bold;"> by </span><a href="https://en.wikipedia.org/wiki/Help:Introduction_to_referencing_with_Wiki_Markup/1" style="">adding citations to reliable sources</a><span style="font-family: Verdana; font-size: 12pt; font-weight: bold;">. Unsourced material may be challenged and removed.</span><span style="font-family: Verdana; font-size: 12pt; font-weight: bold;"><br></span><span style="font-family: Verdana; font-size: 12pt; font-weight: bold;">Find sources:</span><span style="font-family: Verdana; font-size: 12pt;"> </span><a href="https://www.google.com/search?as_eq=wikipedia&q=%22Action+research%22" style="">"Action research"</a><span style="font-family: Verdana; font-size: 12pt;"> – </span><a href="https://www.google.com/search?tbm=nws&q=%22Action+research%22+-wikipedia" style="">news</a><span style="font-family: Verdana; font-size: 12pt;"> </span><span style="font-family: Verdana; font-size: 12pt; font-weight: bold;">·</span><span style="font-family: Verdana; font-size: 12pt;"> </span><a href="https://www.google.com/search?&q=%22Action+research%22+site:news.google.com/newspapers&source=newspapers" style="">newspapers</a><span style="font-family: Verdana; font-size: 12pt;"> </span><span style="font-family: Verdana; font-size: 12pt; font-weight: bold;">·</span><span style="font-family: Verdana; font-size: 12pt;"> </span><a href="https://www.google.com/search?tbs=bks:1&q=%22Action+research%22+-wikipedia" style="">books</a><span style="font-family: Verdana; font-size: 12pt;"> </span><span style="font-family: Verdana; font-size: 12pt; font-weight: bold;">·</span><span style="font-family: Verdana; font-size: 12pt;"> </span><a href="https://scholar.google.com/scholar?q=%22Action+research%22" style="">scholar</a><span style="font-family: Verdana; font-size: 12pt;"> </span><span style="font-family: Verdana; font-size: 12pt; font-weight: bold;">·</span><span style="font-family: Verdana; font-size: 12pt;"> </span><a href="https://www.jstor.org/action/doBasicSearch?Query=%22Action+research%22&acc=on&wc=on" style="">JSTOR</a><span style="font-family: Verdana; font-size: 12pt;"> </span><span style="font-family: Verdana; font-size: 12pt; font-style: italic;">(May 2019)</span><span style="font-family: Verdana; font-size: 12pt; font-style: italic;"> (</span><a href="https://en.wikipedia.org/wiki/Help:Maintenance_template_removal" style="">Learn how and when to remove this template message</a><span style="font-family: Verdana; font-size: 12pt; font-style: italic;">)</span></p></div>
</td><td style="min-width: 48px; padding: 2pt; vertical-align: top;"><div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt;"><span style="font-family: Verdana; font-size: 12pt;">This article </span><span style="font-family: Verdana; font-size: 12pt; font-weight: bold;">needs additional citations for </span><a href="https://en.wikipedia.org/wiki/Wikipedia:Verifiability" style="font-family: Verdana; font-size: 12pt; font-weight: bold;">verification</a><span style="font-family: Verdana; font-size: 12pt; font-weight: bold;">. Please help </span><a href="https://en.wikipedia.org/w/index.php?title=Action_research&action=edit" style="font-family: Verdana; font-size: 12pt; font-weight: bold;">improve this article</a><span style="font-family: Verdana; font-size: 12pt; font-weight: bold;"> by </span><a href="https://en.wikipedia.org/wiki/Help:Introduction_to_referencing_with_Wiki_Markup/1" style="font-family: Verdana; font-size: 12pt; font-weight: bold;">adding citations to reliable sources</a><span style="font-family: Verdana; font-size: 12pt; font-weight: bold;">. Unsourced material may be challenged and removed.</span><span style="font-family: Verdana; font-size: 12pt; font-weight: bold;"><br></span><span style="font-family: Verdana; font-size: 12pt; font-weight: bold;">Find sources:</span><span style="font-family: Verdana; font-size: 12pt;"> </span><a href="https://www.google.com/search?as_eq=wikipedia&q=%22Action+research%22" style="font-family: Verdana; font-size: 12pt;">&quot;Action research&quot;</a><span style="font-family: Verdana; font-size: 12pt;"> – </span><a href="https://www.google.com/search?tbm=nws&q=%22Action+research%22+-wikipedia" style="font-family: Verdana; font-size: 12pt;">news</a><span style="font-family: Verdana; font-size: 12pt;"> </span><span style="font-family: Verdana; font-size: 12pt; font-weight: bold;">·</span><span style="font-family: Verdana; font-size: 12pt;"> </span><a href="https://www.google.com/search?&q=%22Action+research%22+site:news.google.com/newspapers&source=newspapers" style="font-family: Verdana; font-size: 12pt;">newspapers</a><span style="font-family: Verdana; font-size: 12pt;"> </span><span style="font-family: Verdana; font-size: 12pt; font-weight: bold;">·</span><span style="font-family: Verdana; font-size: 12pt;"> </span><a href="https://www.google.com/search?tbs=bks:1&q=%22Action+research%22+-wikipedia" style="font-family: Verdana; font-size: 12pt;">books</a><span style="font-family: Verdana; font-size: 12pt;"> </span><span style="font-family: Verdana; font-size: 12pt; font-weight: bold;">·</span><span style="font-family: Verdana; font-size: 12pt;"> </span><a href="https://scholar.google.com/scholar?q=%22Action+research%22" style="font-family: Verdana; font-size: 12pt;">scholar</a><span style="font-family: Verdana; font-size: 12pt;"> </span><span style="font-family: Verdana; font-size: 12pt; font-weight: bold;">·</span><span style="font-family: Verdana; font-size: 12pt;"> </span><a href="https://www.jstor.org/action/doBasicSearch?Query=%22Action+research%22&acc=on&wc=on" style="font-family: Verdana; font-size: 12pt;">JSTOR</a><span style="font-family: Verdana; font-size: 12pt;"> </span><span style="font-family: Verdana; font-size: 12pt; font-style: italic;">(May 2019)</span><span style="font-family: Verdana; font-size: 12pt; font-style: italic;"> (</span><a href="https://en.wikipedia.org/wiki/Help:Maintenance_template_removal" style="font-family: Verdana; font-size: 12pt; font-style: italic;">Learn how and when to remove this template message</a><span style="font-family: Verdana; font-size: 12pt; font-style: italic;">)</span></p></div>
</td></tr></table></div>
<div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt; padding-bottom: 16px; padding-top: 7px;"><span style="font-family: Verdana; font-size: 12pt; font-weight: bold;"><br>Action research</span><span style="font-family: Verdana; font-size: 12pt;"> seeks transformative change through the simultaneous process of taking action and doing research, which are linked together by critical reflection.</span><a href="https://en.wikipedia.org/wiki/Kurt_Lewin" style="">Kurt Lewin</a><span style="font-family: Verdana; font-size: 12pt;">, then a professor at </span><a href="https://en.wikipedia.org/wiki/MIT" style="">MIT</a><span style="font-family: Verdana; font-size: 12pt;">, first coined the term "action research" in 1944. In his 1946 paper "Action Research and Minority Problems" he described action research as "a comparative research on the conditions and effects of various forms of social action and research leading to social action" that uses "a spiral of steps, each of which is composed of a circle of planning, action and fact-finding about the result of the action". </span></p></div>
<div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt; padding-bottom: 16px; padding-top: 7px;"><span style="font-family: Verdana; font-size: 12pt;">Action research practitioners reflect upon the consequences of their own questions, beliefs, assumptions, and practices with the goal of understanding, developing, and improving social practices.</span><a href="https://en.wikipedia.org/wiki/Action_research#cite_note-1" style="">[1]</a><span style="font-family: Verdana; font-size: 12pt; vertical-align: super;"> This action is designed to create three levels of change</span><a href="https://en.wikipedia.org/wiki/Action_research#cite_note-2" style="">[2]</a><span style="font-family: Verdana; font-size: 12pt; vertical-align: super;"> (1) self-change as the only subject of action research is the person who conducting the research. This person is seeking to be better understand the effects of their action in social settings and to engage in a process of living his or her's values. The second level is a collective process of understanding change in a classroom, office, community, organization or institution. Action research enlists others and works to create a democratic sharing of voice to achieve deeper understanding of collective actions</span><a href="https://en.wikipedia.org/wiki/Action_research#cite_note-3" style="">[3]</a><span style="font-family: Verdana; font-size: 12pt; vertical-align: super;">. Finally action research is process of sharing finding with the community of researchers. This can be done is many ways, in journals</span><a href="https://en.wikipedia.org/wiki/Action_research#cite_note-4" style="">[4]</a><span style="font-family: Verdana; font-size: 12pt; vertical-align: super;">, on websites, in books, videos or at conferences. The Social Publishers Foundation</span><a href="https://en.wikipedia.org/wiki/Action_research#cite_note-5" style="">[5]</a><span style="font-family: Verdana; font-size: 12pt; vertical-align: super;"> provides support for this action research process. </span></p></div>
<div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt; padding-bottom: 16px; padding-top: 7px;"><span style="font-family: Verdana; font-size: 12pt;"><br>Action research involves actively participating in a change situation, often via an existing organization, whilst simultaneously conducting research. Action research can also be undertaken by larger organizations or institutions, assisted or guided by professional researchers, with the aim of improving their strategies, practices and knowledge of the environments within which they practice. As designers and stakeholders, researchers work with others to propose a new course of action to help their community improve its work practices. Depending upon the nature of the people involved in the action research as well as the person(s) organizing it, there are different ways of describing action research</span><a href="https://en.wikipedia.org/wiki/Action_research#cite_note-6" style="">[6]</a></p></div><ul class="list-0" style="left: -10px; position: relative;"><li class="outline-element" style="margin-left: 36px;"><span style="font-family: Verdana; font-size: 12pt;">Collaborative Action Research</span></li>
<div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt; padding-bottom: 16px; padding-top: 7px;"><span style="font-family: Verdana; font-size: 12pt; font-weight: bold;">Action research</span><span style="font-family: Verdana; font-size: 12pt;"> seeks transformative change through the simultaneous process of taking action and doing research, which are linked together by critical reflection.</span><a href="https://en.wikipedia.org/wiki/Kurt_Lewin" style="font-family: Verdana; font-size: 12pt;">Kurt Lewin</a><span style="font-family: Verdana; font-size: 12pt;">, then a professor at </span><a href="https://en.wikipedia.org/wiki/MIT" style="font-family: Verdana; font-size: 12pt;">MIT</a><span style="font-family: Verdana; font-size: 12pt;">, first coined the term &quot;action research&quot; in 1944. In his 1946 paper &quot;Action Research and Minority Problems&quot; he described action research as &quot;a comparative research on the conditions and effects of various forms of social action and research leading to social action&quot; that uses &quot;a spiral of steps, each of which is composed of a circle of planning, action and fact-finding about the result of the action&quot;. </span></p></div>
<div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt; padding-bottom: 16px; padding-top: 7px;"><span style="font-family: Verdana; font-size: 12pt;">Action research practitioners reflect upon the consequences of their own questions, beliefs, assumptions, and practices with the goal of understanding, developing, and improving social practices.</span><a href="https://en.wikipedia.org/wiki/Action_research#cite_note-1" style="font-family: Verdana; font-size: 12pt; vertical-align: super;">[1]</a><span style="font-family: Verdana; font-size: 12pt; vertical-align: super;"> This action is designed to create three levels of change</span><a href="https://en.wikipedia.org/wiki/Action_research#cite_note-2" style="font-family: Verdana; font-size: 12pt; vertical-align: super;">[2]</a><span style="font-family: Verdana; font-size: 12pt; vertical-align: super;"> (1) self-change as the only subject of action research is the person who conducting the research. This person is seeking to be better understand the effects of their action in social settings and to engage in a process of living his or her&apos;s values. The second level is a collective process of understanding change in a classroom, office, community, organization or institution. Action research enlists others and works to create a democratic sharing of voice to achieve deeper understanding of collective actions</span><a href="https://en.wikipedia.org/wiki/Action_research#cite_note-3" style="font-family: Verdana; font-size: 12pt; vertical-align: super;">[3]</a><span style="font-family: Verdana; font-size: 12pt; vertical-align: super;">. Finally action research is process of sharing finding with the community of researchers. This can be done is many ways, in journals</span><a href="https://en.wikipedia.org/wiki/Action_research#cite_note-4" style="font-family: Verdana; font-size: 12pt; vertical-align: super;">[4]</a><span style="font-family: Verdana; font-size: 12pt; vertical-align: super;">, on websites, in books, videos or at conferences. The Social Publishers Foundation</span><a href="https://en.wikipedia.org/wiki/Action_research#cite_note-5" style="font-family: Verdana; font-size: 12pt; vertical-align: super;">[5]</a><span style="font-family: Verdana; font-size: 12pt; vertical-align: super;"> provides support for this action research process. </span></p></div>
<div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt; padding-bottom: 16px; padding-top: 7px;"><span style="font-family: Verdana; font-size: 12pt;">Action research involves actively participating in a change situation, often via an existing organization, whilst simultaneously conducting research. Action research can also be undertaken by larger organizations or institutions, assisted or guided by professional researchers, with the aim of improving their strategies, practices and knowledge of the environments within which they practice. As designers and stakeholders, researchers work with others to propose a new course of action to help their community improve its work practices. Depending upon the nature of the people involved in the action research as well as the person(s) organizing it, there are different ways of describing action research</span><a href="https://en.wikipedia.org/wiki/Action_research#cite_note-6" style="font-family: Verdana; font-size: 12pt; vertical-align: super;">[6]</a><span style="font-family: Verdana; font-size: 12pt; vertical-align: super;">. </span></p></div><ul class="list-0" style="left: -10px; position: relative;"><li class="outline-element" style="margin-left: 36px;"><span style="font-family: Verdana; font-size: 12pt;">Collaborative Action Research</span></li>
<li class="outline-element" style="margin-left: 36px;"><span style="font-family: Verdana; font-size: 12pt;">Participatory Action Research</span></li>
<li class="outline-element" style="margin-left: 36px;"><span style="font-family: Verdana; font-size: 12pt;">Community-Based Action Research</span></li>
<li class="outline-element" style="margin-left: 36px;"><span style="font-family: Verdana; font-size: 12pt;">Youth Action Research</span></li>
@@ -808,13 +846,13 @@ exports[`InteropService_Importer_OneNote should ignore broken characters at the
<li class="outline-element" style="margin-left: 36px;"><span style="font-family: Verdana; font-size: 12pt;">Action Science</span></li>
<li class="outline-element" style="margin-left: 36px;"><span style="font-family: Verdana; font-size: 12pt;">Living Theory Action Research</span></li>
</ul>
<div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt; padding-bottom: 16px; padding-top: 7px;"><span style="font-family: Verdana; font-size: 12pt;"><br>There are also a set of approaches that share some properties with action research but have some different practices</span><a href="https://en.wikipedia.org/wiki/Action_research#cite_note-7" style="">[7]</a></p></div><ul class="list-1" style="left: -10px; position: relative;"><li class="outline-element" style="margin-left: 36px;"><span style="font-family: Verdana; font-size: 12pt;">Appreciative Inquiry is a way of starting with what is working well and then using action research to improve it.</span></li>
<div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt; padding-bottom: 16px; padding-top: 7px;"><span style="font-family: Verdana; font-size: 12pt;">There are also a set of approaches that share some properties with action research but have some different practices</span><a href="https://en.wikipedia.org/wiki/Action_research#cite_note-7" style="font-family: Verdana; font-size: 12pt; vertical-align: super;">[7]</a><span style="font-family: Verdana; font-size: 12pt; vertical-align: super;">. These include: </span></p></div><ul class="list-1" style="left: -10px; position: relative;"><li class="outline-element" style="margin-left: 36px;"><span style="font-family: Verdana; font-size: 12pt;">Appreciative Inquiry is a way of starting with what is working well and then using action research to improve it.</span></li>
<li class="outline-element" style="margin-left: 36px;"><span style="font-family: Calibri; font-size: 11pt;"><span style="font-family: Verdana; font-size: 12pt; font-style: italic;">Lesson Study</span><span style="font-family: Verdana; font-size: 12pt;"> places the teaching of a shared lesson as the action and has a set of protocols for understanding the outcomes.</span></span></li>
<li class="outline-element" style="margin-left: 36px;"><span style="font-family: Calibri; font-size: 11pt;"><span style="font-family: Verdana; font-size: 12pt; font-style: italic;">Practitioner Research</span><span style="font-family: Verdana; font-size: 12pt;"> does not have to be action research, as practitioners can engage in any form of the many forms of research.</span></span></li>
<li class="outline-element" style="margin-left: 36px;"><span style="font-family: Calibri; font-size: 11pt;"><span style="font-family: Verdana; font-size: 12pt; font-style: italic;">Reflective Practice/Self Study</span><span style="font-family: Verdana; font-size: 12pt;"> is the first part of action research but does not require the practitioner to make the results public, to share the results of the learning with others.  Many of these approaches will be described in these resources.</span></span></li>
<li class="outline-element" style="margin-left: 36px;"><span style="font-family: Calibri; font-size: 11pt;"><span style="font-family: Verdana; font-size: 12pt; font-style: italic;">Teacher Research</span><span style="font-family: Verdana; font-size: 12pt;"> can be any form of research that teachers do, including action research, but not limited to it. At George Mason University, teacher research is described in a way that is very similar to what most authors understand as action research. And at some point, they suggest that action research can be a synonym of teacher research.  The description of action research posted on this site is more closely aligned to what we have called reflective practice.   This shows the variation in the way that people working in the field have of conceptualizing these terms.</span></span></li>
<li class="outline-element" style="margin-left: 36px;"><span style="font-family: Verdana; font-size: 12pt;">Action Inquiry draws on action research and recasts evaluation research to help navigate complexity when enacting collective leadership. Find out more about by reading this document from Scotland.</span></li>
<li class="outline-element" style="margin-left: 36px;"><span style="font-family: Verdana; font-size: 12pt;">Improvement Science is explicitly designed to accelerate learning-by-doing. It's a more user-centered and problem-centered approached to improving teaching and learning that is highly similar to action research supported by the Carnegie Foundation for the Advancement of Teaching.</span></li>
<li class="outline-element" style="margin-left: 36px;"><span style="font-family: Verdana; font-size: 12pt;">Improvement Science is explicitly designed to accelerate learning-by-doing. It&apos;s a more user-centered and problem-centered approached to improving teaching and learning that is highly similar to action research supported by the Carnegie Foundation for the Advancement of Teaching.</span></li>
</ul>
<div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt; padding-bottom: 16px; padding-top: 7px;"><br></p></div>
</div>
@@ -982,7 +1020,7 @@ exports[`InteropService_Importer_OneNote should remove hyperlink from title: Tip
<div class="title" style="left: 48px; position: absolute; top: 24px;"><div class="container-outline" style="width: 624px;"><div class="outline-element" style="margin-left: 0px;"><span style="font-family: Calibri Light; font-size: 20pt; line-height: 32px;">&nbsp;</span></div>
</div><div class="container-outline" style="width: 624px;"><div class="outline-element" style="margin-left: 0px;"><span style="color: rgb(118,118,118); font-family: Calibri; font-size: 10pt; line-height: 16px;">Saturday, February 11, 2023</span></div>
<div class="outline-element" style="margin-left: 0px;"><span style="color: rgb(118,118,118); font-family: Calibri; font-size: 10pt; line-height: 16px;">12:56 AM</span></div>
</div></div><div class="container-outline" style="left: 48px; position: absolute; top: 115px; width: 624px;"><div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 14pt; line-height: 22px;"><a href="onenote:https://d.docs.live.net/c8d3bbab7f1acf3a/Documents/Photography/风景.one#Tips%20from%20a%20Pro%20Using%20Trees%20for%20Dramatic%20Landscape%20Photography&section-id={262ADDFB-A4DC-4453-A239-0024D6769962}&page-id={88D803A5-4F43-48D4-9B16-4C024F5787DC}&end" style="">Tips from a Pro: Using Trees for Dramatic Landscape Photography</a></p></div>
</div></div><div class="container-outline" style="left: 48px; position: absolute; top: 115px; width: 624px;"><div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 14pt; line-height: 22px;"><a href="onenote:https://d.docs.live.net/c8d3bbab7f1acf3a/Documents/Photography/%E9%A3%8E%E6%99%AF.one#Tips%20from%20a%20Pro%20Using%20Trees%20for%20Dramatic%20Landscape%20Photography&section-id={262ADDFB-A4DC-4453-A239-0024D6769962}&page-id={88D803A5-4F43-48D4-9B16-4C024F5787DC}&end" style="">Tips from a Pro: Using Trees for Dramatic Landscape Photography</a></p></div>
</div>
<script>
@@ -1044,7 +1082,7 @@ exports[`InteropService_Importer_OneNote should remove hyperlink from title: 风
<div class="title" style="left: 48px; position: absolute; top: 24px;"><div class="container-outline" style="width: 624px;"><div class="outline-element" style="margin-left: 0px;"><span style="font-family: Calibri Light; font-size: 20pt;">&nbsp;</span></div>
</div><div class="container-outline" style="width: 624px;"><div class="outline-element" style="margin-left: 0px;"><span style="color: rgb(128,128,128); font-family: Calibri; font-size: 10pt;">Sunday, January 5, 2025</span></div>
<div class="outline-element" style="margin-left: 0px;"><span style="color: rgb(128,128,128); font-family: Calibri; font-size: 10pt;">10:13 PM</span></div>
</div></div><div class="container-outline" style="left: 48px; position: absolute; top: 115px; width: 624px;"><div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt;"><a href="onenote:#风景&section-id={75256889-9e75-4ec2-82ed-fc799557e1b9}&page-id={d099b6f3-7f5a-4c08-aed7-e8d42c59523f}&end" style="">风景</a><span style="font-family: Calibri; font-size: 11pt;"> (</span><a href="https://onedrive.live.com/edit.aspx?resid=193EE54E3252492D!s9b62db4219f740709f444bc0129de4e9&migratedtospo=true&wd=target%28Quick%20Notes.one%7C75256889-9e75-4ec2-82ed-fc799557e1b9%2F%E9%A3%8E%E6%99%AF%7Cd099b6f3-7f5a-4c08-aed7-e8d42c59523f%2F%29&wdorigin=703&wdpreservelink=1" style="">Web view</a><span style="font-family: Calibri; font-size: 11pt;">)</span></p></div>
</div></div><div class="container-outline" style="left: 48px; position: absolute; top: 115px; width: 624px;"><div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt;"><a href="onenote:#%E9%A3%8E%E6%99%AF&section-id={75256889-9e75-4ec2-82ed-fc799557e1b9}&page-id={d099b6f3-7f5a-4c08-aed7-e8d42c59523f}&end" style="font-family: Calibri; font-size: 11pt;">风景</a><span style="font-family: Calibri; font-size: 11pt;"> (</span><a href="https://onedrive.live.com/edit.aspx?resid=193EE54E3252492D!s9b62db4219f740709f444bc0129de4e9&migratedtospo=true&wd=target%28Quick%20Notes.one%7C75256889-9e75-4ec2-82ed-fc799557e1b9%2F%E9%A3%8E%E6%99%AF%7Cd099b6f3-7f5a-4c08-aed7-e8d42c59523f%2F%29&wdorigin=703&wdpreservelink=1" style="font-family: Calibri; font-size: 11pt;">Web view</a><span style="font-family: Calibri; font-size: 11pt;">)</span></p></div>
</div>
<script>
@@ -1381,7 +1419,7 @@ exports[`InteropService_Importer_OneNote should support importing .one files tha
</span>Rédiger carnet MS-OneNote jeu d'essai (case cochée)</span></li>
<li class="outline-element" style="margin-left: 0px;"><span style="font-family: Calibri; font-size: 11pt; line-height: 17px;"><span aria-checked="false" aria-disabled="true" class="note-tag-icon icon-1 -checkbox -large" role="checkbox"><img class="checkbox-icon" alt="Unchecked" src=":/id-here">
</span>Transmettre aux dev Joplin (case non cochée)</span><ul class="tagged-list"><li class="outline-element" style="margin-left: 36px;"><span style="font-family: Calibri; font-size: 11pt; line-height: 17px;"><span aria-checked="true" aria-disabled="true" class="note-tag-icon icon-2 -checkbox -large" role="checkbox"><img alt="Checked" src=":/id-here">
</span>Exporter le carnet en *.packageone (case cochée)</span></li>
</span>Exporter le carnet en *.onepkg (case cochée)</span></li>
<li class="outline-element" style="margin-left: 36px;"><span style="font-family: Calibri; font-size: 11pt; line-height: 17px;"><span aria-checked="false" aria-disabled="true" class="note-tag-icon icon-3 -checkbox -large" role="checkbox"><img class="checkbox-icon" alt="Unchecked" src=":/id-here">
</span>Exporter des page en *.one (case non cochée)</span></li>
</ul></li>
@@ -1459,7 +1497,7 @@ exports[`InteropService_Importer_OneNote should use default value for EntityGuid
<div class="title" style="left: 48px; position: absolute; top: 24px;"><div class="container-outline"><div class="outline-element" style="margin-left: 0px;"><span style="font-family: Calibri Light; font-size: 20pt; line-height: 32px;">Decrease support costs</span></div>
</div><div class="container-outline"><div class="outline-element" style="margin-left: 0px;"><span style="color: rgb(128,128,128); font-family: Calibri; font-size: 10pt; line-height: 16px;">Saturday, October 10, 2015</span></div>
<div class="outline-element" style="margin-left: 0px;"><span style="color: rgb(128,128,128); font-family: Calibri; font-size: 10pt; line-height: 16px;">11:15 PM</span></div>
</div></div><div class="container-outline" style="left: 48px; position: absolute; top: 88px; width: 624px;"><div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt; line-height: 17px;">One of the strategic goals of training <span style="font-style: italic; font-weight: bold;">must</span> be to decrease the cost of customer support. To do this training must teach customers to "<span style="font-weight: bold;">do</span>" not to "know." How many customers call asking to know something?</p></div>
</div></div><div class="container-outline" style="left: 48px; position: absolute; top: 88px; width: 624px;"><div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt; line-height: 17px;">One of the strategic goals of training <span style="font-style: italic; font-weight: bold;">must</span> be to decrease the cost of customer support. To do this training must teach customers to &quot;<span style="font-weight: bold;">do</span>&quot; not to &quot;know.&quot; How many customers call asking to know something?</p></div>
<div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt; line-height: 17px;">&nbsp;</p></div>
<div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt; line-height: 17px;">&nbsp;</p></div>
<div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt; line-height: 17px;">&nbsp;</p></div>

View File

@@ -54,6 +54,16 @@ export interface ShowMessageBoxOptions {
cancelId?: number;
}
export enum ToastType {
Info = 'info',
Error = 'error',
Success = 'success',
}
export interface ShowToastOptions {
type: ToastType;
}
export enum MobilePlatform {
None = '',
Android = 'android',
@@ -458,6 +468,11 @@ const shim = {
return await shim.showMessageBox(message, { type: MessageBoxType.Confirm }) === 0;
},
showToast: async (message: string, { type = ToastType.Info }: ShowToastOptions = null): Promise<void> => {
// Should usually be overridden by implementers
await shim.showMessageBox(message, { type: type === ToastType.Error ? MessageBoxType.Error : MessageBoxType.Info });
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
writeImageToFile: (_image: any, _format: any, _filePath: string): void => {
throw new Error('Not implemented');

View File

@@ -369,7 +369,6 @@ dependencies = [
"palette",
"parser-macros",
"parser-utils",
"percent-encoding",
"regex",
"sanitize-filename",
"uuid",
@@ -568,6 +567,7 @@ dependencies = [
"parser",
"parser-utils",
"paste",
"percent-encoding",
"regex",
"sanitize-filename",
"thiserror",

View File

@@ -13,7 +13,6 @@ color-eyre = "0.5"
log = "0.4.11"
mime_guess = "2.0.3"
palette = "0.5.0"
percent-encoding = "2.1.0"
regex = "1"
sanitize-filename = "0.3.0"
console_error_panic_hook = "0.1.7"

View File

@@ -67,5 +67,8 @@ pub mod property {
pub mod rich_text {
pub use crate::one::property::paragraph_alignment::ParagraphAlignment;
pub use crate::onenote::rich_text::ParagraphStyling;
pub use crate::onenote::text_region::Hyperlink;
pub use crate::onenote::text_region::MathExpression;
pub use crate::onenote::text_region::TextRegion;
}
}

View File

@@ -94,7 +94,12 @@ impl crate::onestore::object_space::ObjectSpace for ObjectSpace {
self.revision_list
.revisions
.iter()
.rev()
// TODO: It would make more sense to use the **last** revision, rather than
// the first to get the content root. However, doing so seems to return
// version history information, rather than the true content root.
// In the future, if there are issues related to importing the wrong versions
// of pages, look into this.
// .rev()
.find_map(|revision| revision.content_root())
}
@@ -102,7 +107,7 @@ impl crate::onestore::object_space::ObjectSpace for ObjectSpace {
self.revision_list
.revisions
.iter()
.rev()
// .rev() // TODO: Why does calling .rev() result in the wrong metadata being returned?
.find_map(|revision| revision.metadata_root())
}
}

View File

@@ -29,6 +29,7 @@ pub struct Revision {
root_objects: HashMap<RootRole, ExGuid>,
}
// See [MS-ONE 2.1.8](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-one/037e31c0-4484-4a14-819a-0ddece2cacbc)
#[derive(Eq, PartialEq, Hash, Debug)]
pub enum RootRole {
DefaultContent,
@@ -95,6 +96,7 @@ impl Revision {
let mut last_index = iterator.get_index();
while let Some(current) = iterator.peek() {
if let FileNodeData::RevisionManifestEndFND = current {
iterator.next();
break;
} else if let Some(object_group_list) = ObjectGroupList::try_parse(iterator, context)? {
// Skip: Used for reference counting (which we can ignore here)

View File

@@ -205,4 +205,7 @@ pub(crate) enum PropertyType {
EmbeddedInkSpaceHeight = 0x14001C28,
ImageEmbedType = 0x140035F2,
ImageEmbeddedUrl = 0x1C0035F3,
MathUnknown1 = 0x10003453, // Unknown 16-bit math-related property (operator variant?)
MathOperator = 0x1400344f,
}

View File

@@ -1,6 +1,7 @@
use crate::one::property::PropertyType;
use crate::onestore::object::Object;
use crate::shared::guid::Guid;
use crate::shared::prop_set::PropertySet;
use encoding_rs::mem::decode_latin1;
use parser_utils::Utf16ToString;
use parser_utils::errors::{ErrorKind, Result};
@@ -158,3 +159,24 @@ pub(crate) fn parse_guid(prop_type: PropertyType, object: &Object) -> Result<Opt
Ok(Some(Guid::parse(&mut Reader::new(data))?))
}
pub(crate) fn parse_property_values(
prop_type: PropertyType,
object: &Object,
) -> Result<Option<&[PropertySet]>> {
let value = match object.props().get(prop_type) {
Some(value) => Some(
value
.to_property_values()
.ok_or_else(|| {
parser_error!(
MalformedOneNoteFileData,
"PropertyValue value is not a PropertyValue"
)
})?
.1,
),
None => None,
};
Ok(value)
}

View File

@@ -7,8 +7,9 @@ use crate::one::property_set::PropertySetId;
use crate::one::property_set::note_tag_container::Data as NoteTagData;
use crate::onestore::object::Object;
use crate::shared::exguid::ExGuid;
use crate::shared::prop_set::PropertySet;
use parser_utils::errors::{ErrorKind, Result};
use parser_utils::log_warn;
use parser_utils::{Utf16ToString, log_warn};
/// A rich text paragraph.
///
@@ -23,12 +24,12 @@ pub(crate) struct Data {
pub(crate) text_run_formatting: Vec<ExGuid>,
pub(crate) text_run_indices: Vec<u32>,
pub(crate) text_run_data_object: Vec<ExGuid>,
pub(crate) text_run_data_values: Vec<PropertySet>,
pub(crate) paragraph_style: ExGuid,
pub(crate) paragraph_space_before: f32,
pub(crate) paragraph_space_after: f32,
pub(crate) paragraph_line_spacing_exact: Option<f32>,
pub(crate) paragraph_alignment: ParagraphAlignment,
pub(crate) text: Option<String>,
pub(crate) is_title_time: bool,
pub(crate) is_boiler_text: bool,
pub(crate) is_title_date: bool,
@@ -38,6 +39,8 @@ pub(crate) struct Data {
pub(crate) language_code: Option<u32>,
pub(crate) rtl: bool,
pub(crate) note_tags: Vec<NoteTagData>,
pub(crate) text: Option<String>,
pub(crate) text_utf_16: Option<Vec<u8>>,
}
pub(crate) fn parse(object: &Object) -> Result<Data> {
@@ -57,6 +60,8 @@ pub(crate) fn parse(object: &Object) -> Result<Data> {
simple::parse_vec_u32(PropertyType::TextRunIndex, object)?.unwrap_or_default();
let text_run_data_object =
ObjectReference::parse_vec(PropertyType::TextRunDataObject, object)?.unwrap_or_default();
let text_run_data_array =
simple::parse_property_values(PropertyType::TextRunData, object)?.unwrap_or(&[]);
let paragraph_style_result = ObjectReference::parse(PropertyType::ParagraphStyle, object);
let paragraph_style = match paragraph_style_result {
@@ -78,10 +83,23 @@ pub(crate) fn parse(object: &Object) -> Result<Data> {
simple::parse_f32(PropertyType::ParagraphLineSpacingExact, object)?;
let paragraph_alignment = ParagraphAlignment::parse(object)?.unwrap_or_default();
let text = match simple::parse_string(PropertyType::RichEditTextUnicode, object)? {
None => simple::parse_ascii(PropertyType::TextExtendedAscii, object)?,
text => text,
};
// Keep the text in its original UTF-16 byte array, if possible. This is needed later on for
// indexing.
let text_utf_16_bytes = simple::parse_vec(PropertyType::RichEditTextUnicode, object)?;
let text_ascii = simple::parse_ascii(PropertyType::TextExtendedAscii, object)?;
let text_string = text_utf_16_bytes
.as_ref()
.map(|data| data.as_slice().utf16_to_string())
.transpose()?
.or(text_ascii);
let text_utf_16_bytes = text_utf_16_bytes.or_else(|| {
// Fall back to re-encoding the ASCII representation as UTF-16, if it exists.
text_string.as_ref().map(|text| {
text.encode_utf16()
.flat_map(|two_bytes| two_bytes.to_le_bytes())
.collect()
})
});
let layout_alignment_in_parent =
LayoutAlignment::parse(PropertyType::LayoutAlignmentInParent, object)?;
@@ -103,13 +121,15 @@ pub(crate) fn parse(object: &Object) -> Result<Data> {
tight_layout,
text_run_formatting,
text_run_indices,
text_run_data_values: text_run_data_array.into(),
text_run_data_object,
paragraph_style,
paragraph_space_before,
paragraph_space_after,
paragraph_line_spacing_exact,
paragraph_alignment,
text,
text_utf_16: text_utf_16_bytes,
text: text_string,
is_title_time,
is_boiler_text,
is_title_date,

View File

@@ -19,6 +19,7 @@ pub(crate) mod page_series;
pub(crate) mod rich_text;
pub(crate) mod section;
pub(crate) mod table;
pub(crate) mod text_region;
/// The OneNote file parser.
pub struct Parser;

View File

@@ -7,6 +7,7 @@ use crate::one::property::paragraph_alignment::ParagraphAlignment;
use crate::one::property_set::{embedded_ink_container, paragraph_style_object, rich_text_node};
use crate::onenote::ink::{Ink, InkBoundingBox, parse_ink_data};
use crate::onenote::note_tag::{NoteTag, parse_note_tags};
use crate::onenote::text_region::TextRegion;
use crate::onestore::object::Object;
use crate::onestore::object_space::ObjectSpaceRef;
use crate::shared::exguid::ExGuid;
@@ -32,6 +33,7 @@ use parser_utils::log_warn;
#[derive(Clone, Debug)]
pub struct RichText {
pub(crate) text: String,
pub(crate) text_regions: Vec<TextRegion>,
pub(crate) text_run_formatting: Vec<ParagraphStyling>,
pub(crate) text_run_indices: Vec<u32>,
@@ -55,6 +57,11 @@ impl RichText {
&self.text
}
/// Computes which styles are associated with which text
pub fn text_segments(&self) -> &Vec<TextRegion> {
&self.text_regions
}
/// The formatting of each text run.
///
/// See [\[MS-ONE\] 2.3.77].
@@ -386,15 +393,16 @@ pub(crate) fn parse_rich_text(content_id: ExGuid, space: ObjectSpaceRef) -> Resu
.text_run_formatting
.iter()
.filter_map(|style_id| {
space
.get_object(*style_id)
.or_else(|| {
// Handle the case where styles are missing gracefully. It seems that style objects
// are sometimes missing, or can't be found:
// https://discourse.joplinapp.org/t/onenote-zip-file-import-not-working/47499/12
log_warn!("Paragraph styling not found: Unable to locate object with ID {:?}.", style_id);
None
})
space.get_object(*style_id).or_else(|| {
// Handle the case where styles are missing gracefully. It seems that style objects
// are sometimes missing, or can't be found:
// https://discourse.joplinapp.org/t/onenote-zip-file-import-not-working/47499/12
log_warn!(
"Paragraph styling not found: Unable to locate object with ID {:?}.",
style_id
);
None
})
})
.map(|style_object| paragraph_style_object::parse(&style_object))
.collect::<Result<Vec<_>>>()?;
@@ -460,6 +468,13 @@ pub(crate) fn parse_rich_text(content_id: ExGuid, space: ObjectSpaceRef) -> Resu
};
let text = RichText {
text_regions: TextRegion::parse(
&data.text_utf_16.unwrap_or_default(),
&data.text_run_indices,
&styles,
&data.text_run_data_values,
)?,
text,
embedded_objects,
text_run_formatting: styles,

View File

@@ -0,0 +1,369 @@
use crate::{
one::property::PropertyType, onenote::rich_text::ParagraphStyling,
shared::prop_set::PropertySet,
};
use parser_utils::{Utf16ToString, errors::Result};
/// Stores information about a part of a [RichText] region.
#[derive(Debug, Clone)]
pub struct TextRegion {
text: String,
style: Option<ParagraphStyling>,
hyperlink: Option<Hyperlink>,
math: Option<MathExpression>,
}
impl TextRegion {
/// The (visible) text content of this region
pub fn text(&self) -> &str {
&self.text
}
/// Styles associated with this region
pub fn style(&self) -> Option<&ParagraphStyling> {
self.style.as_ref()
}
/// If a hyperlink, the hyperlink data
pub fn hyperlink(&self) -> Option<&Hyperlink> {
self.hyperlink.as_ref()
}
/// If math, the math data
pub fn math(&self) -> Option<&MathExpression> {
self.math.as_ref()
}
fn from_text(text: &str) -> Self {
Self {
text: String::from(text),
style: None,
math: None,
hyperlink: None,
}
}
pub(crate) fn parse(
raw_text: &[u8],
text_run_indices: &[u32],
styles: &[ParagraphStyling],
text_run_data_values: &[PropertySet],
) -> Result<Vec<TextRegion>> {
if text_run_indices.is_empty() {
let text = raw_text.utf16_to_string()?;
return Ok(vec![TextRegion::from_text(&text)]);
}
let style_count = styles.len();
let index_count = text_run_indices.len();
if index_count + 1 < style_count {
return Err(parser_error!(
MalformedOneNoteData,
"Wrong number of styles in paragraph (styles: {style_count}, ranges: {index_count})"
)
.into());
}
// Split text into parts specified by indices
let texts = {
let mut text_iter = raw_text.iter().copied();
let mut texts: Vec<String> = Vec::new();
let mut last_index = 0;
for index in text_run_indices.iter().copied() {
let count = (index - last_index) as usize;
let count_utf_16 = count * 2;
let part: Vec<u8> = text_iter.by_ref().take(count_utf_16).collect();
let part_text = part.as_slice().utf16_to_string()?;
// TODO: When the bell character is at the start of the paragraph it shifts
// all styles and attributes by one. For now, ignore leading segments that contain
// only the bell character. In the future, look into why this issue is happening.
if !texts.is_empty() || part_text != "\u{000B}" {
texts.push(part_text);
}
last_index = index;
}
let end_text: Vec<u8> = text_iter.collect();
texts.push(end_text.as_slice().utf16_to_string()?);
texts
};
TextRegionParser::parse(texts, styles, text_run_data_values)
}
}
struct TextRegionParser {
parts: Vec<TextRegion>,
hyperlink_href: Option<String>,
// If true and hyperlink_href is Some, hyperlink_href contains a full HREF. Otherwise,
// hyperlink_href may be partial (in the process of being built).
hyperlink_href_finished: bool,
hyperlink_next_prefix: Option<String>,
}
impl TextRegionParser {
fn parse(
texts: Vec<String>,
styles: &[ParagraphStyling],
additional_data: &[PropertySet],
) -> Result<Vec<TextRegion>> {
let mut style_iterator = styles.iter();
let mut additional_data_iterator = additional_data.iter();
let mut text_region_parser = TextRegionParser::new();
for text_segment in texts.iter() {
let style = style_iterator.next();
let additional_data = additional_data_iterator.next();
text_region_parser.push(text_segment, style, additional_data)?;
}
text_region_parser.finish()
}
fn new() -> Self {
Self {
parts: Vec::new(),
hyperlink_href: None,
hyperlink_next_prefix: None,
hyperlink_href_finished: true,
}
}
fn push_hyperlink(&mut self, text: &str, styles: Option<&ParagraphStyling>) -> Result<()> {
let text = if let Some(prefix) = &self.hyperlink_next_prefix {
let prefixed = format!("{prefix}{text}");
self.hyperlink_next_prefix = None;
prefixed
} else {
text.into()
};
const HYPERLINK_MARKER: &str = "\u{fddf}HYPERLINK \"";
if text == "\u{fddf}" && self.parts.is_empty() {
self.hyperlink_next_prefix = Some(text);
} else if text.starts_with(HYPERLINK_MARKER) {
// Ensure that the previous link (if any) has ended
self.end_link();
let url = text.strip_prefix(HYPERLINK_MARKER).ok_or_else(|| {
parser_error!(MalformedOneNoteData, "Hyperlink has no start marker")
})?;
if let Some(url) = url.strip_suffix('"') {
self.hyperlink_href = Some(url.into());
self.hyperlink_href_finished = true;
} else {
// If we didn't find the double quotes, the HREF will be continued in
// the text regions that follow.
self.hyperlink_href = Some(url.into());
self.hyperlink_href_finished = false;
}
} else if let Some(href) = self.hyperlink_href.clone()
&& self.hyperlink_href_finished
{
self.hyperlink_href = None;
let is_link_start = if let Some(last) = self.parts.last() {
if let Some(link) = &last.hyperlink {
!link.is_link_end
} else {
true
}
} else {
true
};
self.parts.push(TextRegion {
text: text,
style: styles.cloned(),
hyperlink: Some(Hyperlink {
is_link_start,
is_link_end: false,
href,
}),
math: None,
});
} else if let Some(href_start) = &self.hyperlink_href
&& !self.hyperlink_href_finished
{
let url = text.strip_suffix('"');
if let Some(url) = url {
self.hyperlink_href = Some(format!("{href_start}{url}"));
self.hyperlink_href_finished = true;
} else {
self.hyperlink_href = Some(format!("{href_start}{text}"));
}
} else {
self.end_link();
self.parts.push(TextRegion {
text: text.clone(),
style: styles.cloned(),
hyperlink: Some(Hyperlink {
is_link_start: true,
is_link_end: true,
href: text,
}),
math: None,
})
}
Ok(())
}
fn push_math(
&mut self,
text: &str,
styles: Option<&ParagraphStyling>,
additional_data: Option<&PropertySet>,
) -> Result<()> {
let last_was_math = self
.parts
.last()
.map(|last| last.math.is_some())
.unwrap_or(false);
let additional_data = additional_data.cloned().unwrap_or_default();
self.parts.push(TextRegion {
text: text.into(),
style: styles.cloned(),
hyperlink: None,
math: Some(MathExpression {
latex: text_region_to_latex(text, &additional_data)?,
is_math_start: !last_was_math,
is_math_end: false,
}),
});
Ok(())
}
/// Updates the last item (if math) to mark it as a math-end region
fn end_math(&mut self) {
if let Some(last) = self.parts.last_mut()
&& let Some(math) = &mut last.math
{
math.is_math_end = true;
}
}
fn end_link(&mut self) {
if let Some(last) = self.parts.last_mut()
&& let Some(link) = &mut last.hyperlink
{
link.is_link_end = true;
// Reset link state
self.hyperlink_href_finished = true;
self.hyperlink_href = None;
}
}
fn push(
&mut self,
text: &str,
style: Option<&ParagraphStyling>,
additional_data: Option<&PropertySet>,
) -> Result<()> {
let (hyperlink, math) = match style {
Some(style) => (style.hyperlink(), style.math_formatting()),
None => (false, false),
};
if hyperlink {
self.end_math();
self.push_hyperlink(text, style)?;
} else if math {
self.end_link();
self.push_math(text, style, additional_data)?;
} else {
// Correct end information
self.end_math();
self.end_link();
self.parts.push(TextRegion {
text: text.into(),
style: style.cloned(),
hyperlink: None,
math: None,
});
}
Ok(())
}
fn finish(mut self) -> Result<Vec<TextRegion>> {
self.end_math();
self.end_link();
Ok(self.parts)
}
}
fn text_region_to_latex(text: &str, additional_data: &PropertySet) -> Result<String> {
let op_type = match additional_data
.get_from_type(PropertyType::MathOperator)
.and_then(|operator_value| operator_value.to_u32())
{
Some(21) => {
let variant = additional_data
.get_from_type(PropertyType::MathUnknown1)
.and_then(|variant| variant.to_u16())
.unwrap_or_default();
if variant == 8721 {
"".into()
} else {
"".into()
}
}
Some(13) => "parens".into(),
Some(17) => "fnCall".into(),
Some(19) => "withSubscript".into(),
Some(16 | 26) => "frac".into(),
Some(11) => "mathrm".into(),
Some(31) => "pow".into(),
Some(other) => {
format!("unknown{{{}}}", other)
}
None => "".into(),
};
let operator_name = if !op_type.is_empty() {
format!("\\{op_type}")
} else {
String::from("")
};
// See https://devblogs.microsoft.com/math-in-office/officemath/
let tex = text
.replace("\u{FDD0}", &format!("{operator_name}{{"))
.replace("\u{FDEF}", "}")
.replace("\u{FDEE}", "}{")
.replace("\u{FFFC}", "<obj>");
println!("Additional data: {:?}, for {}", additional_data, tex);
Ok(tex)
}
/// Information about a hyperlink region
#[allow(missing_docs)]
#[derive(Clone, Debug)]
pub struct Hyperlink {
pub is_link_start: bool,
pub is_link_end: bool,
pub href: String,
}
/// Information about a math expression
#[allow(missing_docs)]
#[derive(Clone, Debug)]
pub struct MathExpression {
pub is_math_start: bool,
pub is_math_end: bool,
pub latex: String,
}

View File

@@ -1,6 +1,5 @@
use super::{
object_stream_header::ObjectStreamHeader, prop_set::PropertySet, property::PropertyId,
property::PropertyValue,
object_stream_header::ObjectStreamHeader, prop_set::PropertySet, property::PropertyValue,
};
use crate::one::property::PropertyType;
use crate::shared::compact_id::CompactId;
@@ -77,6 +76,6 @@ impl Parse for ObjectPropSet {
impl ObjectPropSet {
pub(crate) fn get(&self, prop_type: PropertyType) -> Option<&PropertyValue> {
self.properties.get(PropertyId::new(prop_type as u32))
self.properties.get_from_type(prop_type)
}
}

View File

@@ -1,3 +1,4 @@
use crate::one::property::PropertyType;
use crate::shared::property::{PropertyId, PropertyValue};
use parser_utils::Reader;
use parser_utils::errors::Result;
@@ -62,6 +63,10 @@ impl PropertySet {
self.values.get(&id.id()).map(|(_, value)| value)
}
pub(crate) fn get_from_type(&self, prop_type: PropertyType) -> Option<&PropertyValue> {
self.get(PropertyId::new(prop_type as u32))
}
pub(crate) fn index(&self, id: PropertyId) -> Option<usize> {
self.values.get(&id.id()).map(|(index, _)| index).copied()
}

View File

@@ -12,6 +12,7 @@ keywords = ["onenote"]
askama = "0.14.0"
color-eyre = "0.5"
log = "0.4.11"
percent-encoding = "2.1.0"
mime_guess = "2.0.3"
once_cell = "1.4.1"
palette = "0.5.0"

View File

@@ -0,0 +1,56 @@
use crate::page::Renderer;
use crate::utils::{StyleSet, html_entities};
use color_eyre::Result;
use itertools::Itertools;
use parser::property::rich_text::MathExpression;
impl<'a> Renderer<'a> {
pub(crate) fn render_math(
&mut self,
math: &[MathExpression],
style: &StyleSet,
) -> Result<String> {
let tex = math.iter().map(|tex| &tex.latex).join("");
let source = format!("{}{}", self.render_tex_macros(&tex), tex,);
let opening_html = format!("<span class=\"joplin-editable\" {}>", style.to_html_attr(),);
let source_html = format!(
"<span class=\"joplin-source\" data-joplin-language=\"katex\" data-joplin-source-open=\"$\" data-joplin-source-close=\"$\" style=\"display: none;\">{}</span>",
html_entities(source.trim()),
);
// TODO: Render it! (For now, display the raw source).
let rendered_html = html_entities(tex.trim());
Ok(format!("{opening_html}{source_html}{rendered_html}</span>"))
}
/// Returns definitions for non-standard KaTeX macros used in `tex`.
fn render_tex_macros(&self, tex: &str) -> String {
let mut result = vec![];
if tex.contains("\\") {
result.push(r"\def\∫#1#2#3{\int_{#1}^{#2}{#3}}");
}
if tex.contains("\\") {
result.push(r"\def\∑#1#2#3{\sum_{#1}^{#2}{#3}}");
}
if tex.contains("\\parens") {
result.push(r"\def\parens#1{\left( {#1} \right)}");
}
if tex.contains("\\withSubscript") {
result.push(r"\def\withSubscript#1#2{{#1}_{#2}}");
}
if tex.contains("\\pow") {
result.push(r"\def\pow#1#2{{#1}^{#2}}");
}
if tex.contains("\\fnCall") {
result.push(r"\def\fnCall#1#2{{ \rm #1 }\ {#2}}");
}
if tex.contains("\\unknown") {
result.push(r"\def\unknown#1{\textsf{Unknown}(#1)}");
}
result.join("")
}
}

View File

@@ -9,6 +9,7 @@ pub(crate) mod embedded_file;
pub(crate) mod image;
pub(crate) mod ink;
pub(crate) mod list;
pub(crate) mod math;
pub(crate) mod note_tag;
pub(crate) mod outline;
pub(crate) mod rich_text;

View File

@@ -1,30 +1,29 @@
use crate::page::Renderer;
use crate::utils::{AttributeSet, StyleSet, px};
use crate::utils::{AttributeSet, StyleSet, html_entities, px, url_encode};
use color_eyre::Result;
use color_eyre::eyre::ContextCompat;
use itertools::Itertools;
use once_cell::sync::Lazy;
use parser::contents::{EmbeddedObject, RichText};
use parser::property::common::ColorRef;
use parser::property::rich_text::{ParagraphAlignment, ParagraphStyling};
use parser::property::rich_text::{MathExpression, ParagraphAlignment, ParagraphStyling};
use parser_utils::log_warn;
use regex::{Captures, Regex};
impl<'a> Renderer<'a> {
pub(crate) fn render_rich_text(&mut self, text: &RichText) -> Result<String> {
let mut content = String::new();
let mut content_html = String::new();
let mut attrs = AttributeSet::new();
let mut style = self.parse_paragraph_styles(text);
if let Some((note_tag_html, note_tag_styles)) = self.render_note_tags(text.note_tags()) {
content.push_str(&note_tag_html);
content_html.push_str(&note_tag_html);
style.extend(note_tag_styles);
}
content.push_str(&self.parse_content(text)?);
content_html.push_str(&self.parse_content(text)?);
if content.starts_with("http://") || content.starts_with("https://") {
content = format!("<a href=\"{}\">{}</a>", content, content);
if content_html.starts_with("http://") || content_html.starts_with("https://") {
content_html = format!("<a href=\"{}\">{}</a>", url_encode(&content_html), content_html);
}
if style.len() > 0 {
@@ -33,10 +32,10 @@ impl<'a> Renderer<'a> {
match text.paragraph_style().style_id() {
Some(t) if !self.in_list && is_tag(t) => {
Ok(format!("<{} {}>{}</{}>", t, attrs, content, t))
Ok(format!("<{} {}>{}</{}>", t, attrs, content_html, t))
}
_ if style.len() > 0 => Ok(format!("<span style=\"{}\">{}</span>", style, content)),
_ => Ok(content),
_ if style.len() > 0 => Ok(format!("<span {}>{}</span>", style.to_html_attr(), content_html)),
_ => Ok(content_html),
}
}
@@ -61,140 +60,61 @@ impl<'a> Renderer<'a> {
.join(""));
}
let mut indices = data.text_run_indices().to_vec();
let mut styles = data.text_run_formatting().to_vec();
let mut text = data.text().to_string();
if text.is_empty() {
text = "&nbsp;".to_string();
}
// TODO: Maybe this shouldn't be here
// When the this character is at the start of the paragraph it makes
// all the styles to be shifted by minus one.
// A better solution would be to look if there isn't anything wrong with the parser,
// but I haven't found what could be causing this yet.
if text.starts_with("\u{000B}") && !indices.is_empty() {
indices.remove(0);
styles.pop();
}
// Probably the best solution here would be to rewrite the render_hyperlink to take this
// case in account, backtracking if necessary, but this will do for now
// https://github.com/laurent22/joplin/issues/11617
if text.starts_with("\u{fddf}") {
let first_indice = match indices.get(0) {
Some(i) => *i,
None => 0,
};
if first_indice == 1 {
indices.remove(0);
styles.pop();
}
}
if indices.is_empty() {
return Ok(fix_newlines(&text));
}
assert!(indices.len() + 1 >= styles.len());
// Split text into parts specified by indices
let mut parts: Vec<String> = vec![];
for i in indices.iter().copied().rev() {
let part = text.chars().skip(i as usize).collect();
text = text.chars().take(i as usize).collect();
parts.push(part);
}
if !indices.is_empty() {
parts.push(text);
}
let mut in_hyperlink = false;
let mut is_href_finished = true;
let parts = data.text_segments();
// Stores LaTeX and original text data
let mut math_parts: Vec<MathExpression> = Vec::new();
let content = parts
.into_iter()
.rev()
.zip(styles.iter())
.map(|(text, style)| {
if style.hyperlink() {
let result =
self.render_hyperlink(text.clone(), style, in_hyperlink, is_href_finished);
if result.is_ok() {
in_hyperlink = true;
is_href_finished = result.as_ref().unwrap().1;
Ok(result.unwrap().0)
.iter()
.map(|part| -> Result<String> {
let style = part
.style()
.map(|style| self.parse_style(style))
.unwrap_or_default();
if let Some(hyperlink) = part.hyperlink() {
let hyperlink_start_html = if hyperlink.is_link_start {
format!(
"<a href=\"{}\" {}>",
url_encode(&hyperlink.href),
style.to_html_attr(),
)
} else {
Ok(text)
String::from("")
};
let hyperlink_end_html = if hyperlink.is_link_end { "</a>" } else { "" };
let content_html = html_entities(part.text());
Ok(format!(
"{hyperlink_start_html}{content_html}{hyperlink_end_html}"
))
} else if let Some(math) = part.math() {
if math.is_math_start {
math_parts.clear();
}
math_parts.push(math.clone());
if math.is_math_end {
Ok(self.render_math(&math_parts, &style)?)
} else {
Ok("".into())
}
} else {
in_hyperlink = false;
is_href_finished = true;
let style = self.parse_style(style);
let text_html = html_entities(part.text());
if style.len() > 0 {
Ok(format!("<span style=\"{}\">{}</span>", style, text))
let style_attr = style.to_html_attr();
Ok(format!("<span {style_attr}>{text_html}</span>"))
} else {
Ok(text)
Ok(text_html)
}
}
})
.collect::<Result<String>>()?;
Ok(fix_newlines(&content))
}
/// The hyperlink is delimited by the HYPERLINK_MARKER until the closing double quote
/// In some cases the hyperlink is broken in more than one style (e.g.: when there are
/// chinese characters on the url path), so we must keep track of the href status
/// https://github.com/laurent22/joplin/issues/11600
fn render_hyperlink(
&self,
text: String,
style: &ParagraphStyling,
in_hyperlink: bool,
is_href_finished: bool,
) -> Result<(String, bool)> {
const HYPERLINK_MARKER: &str = "\u{fddf}HYPERLINK \"";
let style = self.parse_style(style);
if text.starts_with(HYPERLINK_MARKER) {
let url = text
.strip_prefix(HYPERLINK_MARKER)
.wrap_err("Hyperlink has no start marker")?;
let url_2 = url.strip_suffix('"');
if url_2.is_some() {
return Ok((
format!("<a href=\"{}\" style=\"{}\">", url_2.unwrap(), style),
true,
));
} else {
// If we didn't find the double quotes means that href still has content in following styles
Ok((format!("<a href=\"{}", url), false))
}
} else if in_hyperlink && is_href_finished {
Ok((text + "</a>", true))
} else if in_hyperlink && !is_href_finished {
let url = text.strip_suffix('"');
if url.is_some() {
return Ok((format!("{}\" style=\"{}\">", url.unwrap(), style), true));
} else {
Ok((text, false))
}
let content = fix_newlines(&content);
if content.is_empty() {
Ok(String::from("&nbsp;"))
} else {
Ok((
format!("<a href=\"{}\" style=\"{}\">{}</a>", text, style, text),
true,
))
Ok(content)
}
}
@@ -288,21 +208,19 @@ impl<'a> Renderer<'a> {
}
if let Some(align) = &style.paragraph_alignment() {
styles.set("text-align", match align {
ParagraphAlignment::Center => {
"center"
},
ParagraphAlignment::Left => {
"left"
},
ParagraphAlignment::Right => {
"right"
},
other => {
log_warn!("Unknown/unsupported text-align value: {:?}", other);
""
styles.set(
"text-align",
match align {
ParagraphAlignment::Center => "center",
ParagraphAlignment::Left => "left",
ParagraphAlignment::Right => "right",
other => {
log_warn!("Unknown/unsupported text-align value: {:?}", other);
""
}
}
}.into());
.into(),
);
}
if let Some(space) = style.paragraph_space_before() {
@@ -320,10 +238,7 @@ impl<'a> Renderer<'a> {
if let Some(space) = style.paragraph_line_spacing_exact() {
if space != 0.0 {
styles.set(
"line-height",
format!("{}in", space / 2.),
)
styles.set("line-height", format!("{}in", space / 2.))
} else if let Some(size) = style.font_size() {
styles.set(
"line-height",

View File

@@ -1,5 +1,6 @@
use itertools::Itertools;
use parser_utils::errors::Result;
use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
use std::collections::HashMap;
use std::fmt;
use std::fmt::Display;
@@ -42,7 +43,7 @@ impl Display for AttributeSet {
}
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
pub(crate) struct StyleSet(HashMap<&'static str, String>);
impl StyleSet {
@@ -61,6 +62,11 @@ impl StyleSet {
pub(crate) fn len(&self) -> usize {
self.0.len()
}
pub(crate) fn to_html_attr(&self) -> String {
let attr_content = format!("{}", self);
format!("style=\"{}\"", html_entities(&attr_content))
}
}
impl Display for StyleSet {
@@ -93,3 +99,41 @@ impl Utf16ToString for &[u8] {
Ok(value.to_string().unwrap())
}
}
pub(crate) fn html_entities(text: &str) -> String {
// Match the "special chars" mode of the html-entities library:
// https://github.com/mdevils/html-entities/blob/9ee63a120597292967f7d0d704d68d33950625ee/src/index.ts#L30
text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&apos;")
}
pub(crate) fn url_encode(url: &str) -> String {
const ENCODED_CHARS: &AsciiSet = &CONTROLS.add(b'\'').add(b'\n').add(b'"').add(b'<').add(b'>');
utf8_percent_encode(url, ENCODED_CHARS).to_string()
}
#[cfg(test)]
mod test {
use crate::utils::url_encode;
use super::html_entities;
#[test]
fn should_encode_html_entities() {
assert_eq!(
html_entities("<a href=\"http://example.com/\">test</a>"),
"&lt;a href=&quot;http://example.com/&quot;&gt;test&lt;/a&gt;"
);
assert_eq!(html_entities("&gt;"), "&amp;gt;");
assert_eq!(html_entities("'&gt;'"), "&apos;&amp;gt;&apos;");
}
#[test]
fn should_encode_urls() {
assert_eq!(url_encode("http://example.com/"), "http://example.com/");
assert_eq!(url_encode("http://example.com/\""), "http://example.com/%22");
}
}

View File

@@ -75,3 +75,28 @@ fn convert_desktop_export() {
.exists()
);
}
#[test]
fn convert_page_versions() {
let TestResources {
output_dir,
test_data_dir,
} = setup("page_versions");
convert(
&test_data_dir.join("Page versions.one").to_string_lossy(),
&output_dir.to_string_lossy(),
&test_data_dir.to_string_lossy(),
)
.unwrap();
// Should create a table of contents file
assert!(output_dir.join("Page versions.html").exists());
// Should convert the input page to an HTML file
assert!(
output_dir
.join("Page versions")
.join("Test!.html")
.exists()
);
}

Binary file not shown.

View File

@@ -36,7 +36,7 @@
"jest-environment-jsdom": "29.7.0",
"style-loader": "3.3.4",
"ts-jest": "29.4.1",
"ts-loader": "9.5.2",
"ts-loader": "9.5.4",
"typescript": "5.8.3",
"webpack": "5.74.0",
"webpack-cli": "4.10.0"

View File

@@ -21,7 +21,7 @@
"@joplin/lib": "^3.5.1",
"@joplin/tools": "^3.5.1",
"@joplin/utils": "^3.5.1",
"fs-extra": "11.2.0",
"fs-extra": "11.3.1",
"gh-release-assets": "2.0.1",
"node-fetch": "2.6.7",
"source-map-support": "0.5.21",

View File

@@ -36,7 +36,7 @@
"@types/json5": "2.2.0",
"abcjs": "6.5.2",
"font-awesome-filetypes": "2.1.0",
"fs-extra": "11.2.0",
"fs-extra": "11.3.1",
"highlight.js": "11.11.1",
"html-entities": "1.4.0",
"json-stringify-safe": "5.0.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/server",
"version": "3.5.0",
"version": "3.5.1",
"private": true,
"scripts": {
"start-dev": "yarn build && JOPLIN_IS_TESTING=1 nodemon --config nodemon.json --ext ts,js,mustache,css,tsx dist/app.js --env dev",
@@ -33,9 +33,9 @@
"bcryptjs": "2.4.3",
"bulma": "1.0.4",
"compare-versions": "6.1.1",
"dayjs": "1.11.13",
"dayjs": "1.11.18",
"formidable": "2.1.2",
"fs-extra": "11.2.0",
"fs-extra": "11.3.1",
"html-entities": "1.4.0",
"jquery": "3.7.1",
"knex": "3.1.0",
@@ -51,8 +51,8 @@
"pretty-bytes": "5.6.0",
"prettycron": "0.10.0",
"query-string": "7.1.3",
"rate-limiter-flexible": "7.1.1",
"raw-body": "3.0.0",
"rate-limiter-flexible": "7.2.0",
"raw-body": "3.0.1",
"samlify": "2.10.1",
"sqlite3": "5.1.6",
"stripe": "8.222.0",

View File

@@ -0,0 +1,43 @@
import { beforeAllDb, afterAllTests, beforeEachDb, models } from '../utils/testing/testUtils';
import { BackupItem, BackupItemType } from '../services/database/types';
import { Day } from '../utils/time';
describe('BackupItemModel', () => {
beforeAll(async () => {
await beforeAllDb('BackupItemModel');
jest.useFakeTimers({ advanceTimers: true });
});
afterAll(async () => {
await afterAllTests();
});
beforeEach(async () => {
await beforeEachDb();
});
test('should delete archived account backups older than 90 days', async () => {
const backupModel = models().backupItem();
// Items older than 90 days
await backupModel.add(BackupItemType.UserAccount, 'key1', 'value');
await backupModel.add(BackupItemType.UserAccount, 'key2', 'value');
jest.advanceTimersByTime(30 * Day);
// Items newer than 90 days
await backupModel.add(BackupItemType.UserAccount, 'key3', 'value');
jest.advanceTimersByTime(61 * Day);
await backupModel.add(BackupItemType.UserAccount, 'key4', 'value');
const sortedKeys = (items: BackupItem[]) => items.map(item => item.key).sort();
expect(sortedKeys(await backupModel.all())).toEqual(['key1', 'key2', 'key3', 'key4']);
await backupModel.deleteOldAccountBackups();
expect(sortedKeys(await backupModel.all())).toEqual(['key3', 'key4']);
// Re-running, should not delete items newer than 90 days
await backupModel.deleteOldAccountBackups();
expect(sortedKeys(await backupModel.all())).toEqual(['key3', 'key4']);
});
});

View File

@@ -1,5 +1,9 @@
import { Day } from '@joplin/utils/time';
import { BackupItem, BackupItemType } from '../services/database/types';
import BaseModel from './BaseModel';
import Logger from '@joplin/utils/Logger';
const logger = Logger.create('BackupItemModel');
export default class BackupItemModel extends BaseModel<BackupItem> {
@@ -27,4 +31,14 @@ export default class BackupItemModel extends BaseModel<BackupItem> {
return this.save(item);
}
public async deleteOldAccountBackups() {
const cutOffDate = Date.now() - 90 * Day;
const deletedCount = await this
.db(this.tableName)
.where('type', '=', BackupItemType.UserAccount)
.where('created_time', '<', cutOffDate)
.delete();
logger.info('Deleted', deletedCount, 'archived account record(s)');
}
}

View File

@@ -83,7 +83,7 @@ export default class ChangeModel extends BaseModel<Change> {
const startChange: Change = id ? await this.load(id) : null;
const query = this.db(this.tableName).select(...this.defaultFields);
if (startChange) void query.where('counter', '>', startChange.counter);
void query.limit(limit);
void query.limit(limit).orderBy('counter', 'asc');
let results: Change[] = await query;
const hasMore = !!results.length;
const cursor = results.length ? results[results.length - 1].id : id;

View File

@@ -75,6 +75,11 @@ export default class UserItemModel extends BaseModel<UserItem> {
.where('user_items.user_id', '=', userId);
}
public async countWithUserId(userId: Uuid): Promise<number> {
const count = await this.db(this.tableName).count('*').where('user_id', '=', userId);
return count[0].count;
}
public async byUserId(userId: Uuid): Promise<UserItem[]> {
return this.db(this.tableName).where('user_id', '=', userId);
}

View File

@@ -8,6 +8,7 @@ import { SubPath } from '../../utils/routeUtils';
import { AppContext } from '../../utils/types';
import { ErrorForbidden } from '../../utils/errors';
import createTestUsers, { CreateTestUsersOptions } from '../../tools/debug/createTestUsers';
import benchmarkDeltaPerformance from '../../tools/benchmark/benchmarkDeltaPerformance';
import createUserDeletions from '../../tools/debug/createUserDeletions';
import clearDatabase from '../../tools/debug/clearDatabase';
import populateDatabase from '../../tools/debug/populateDatabase';
@@ -52,6 +53,10 @@ router.post('api/debug', async (_path: SubPath, ctx: AppContext) => {
await models.keyValue().deleteAll();
}
if (query.action === 'benchmarkDeltaPerformance') {
await benchmarkDeltaPerformance(ctx.joplin.models);
}
if (query.action === 'populateDatabase') {
const size = 'size' in query ? Number(query.size) : 1;
const actionCount = (() => {

View File

@@ -34,6 +34,7 @@ export const taskIdToLabel = (taskId: TaskId): string => {
[TaskId.LogHeartbeatMessage]: 'Log heartbeat message',
[TaskId.DeleteOldEvents]: 'Delete old events',
[TaskId.DeleteExpiredAuthCodes]: 'Delete expired authentication codes',
[TaskId.DeleteArchivedBackups]: 'Delete archived account backups',
};
const s = strings[taskId];

View File

@@ -138,6 +138,7 @@ export enum TaskId {
LogHeartbeatMessage,
DeleteOldEvents,
DeleteExpiredAuthCodes,
DeleteArchivedBackups,
}
// AUTO-GENERATED-TYPES

View File

@@ -0,0 +1,52 @@
import { Models } from '../../models/factory';
import { ShareType, Uuid } from '../../services/database/types';
import recordBenchmark from './recordBenchmark';
const benchmarkDeltaPerformance = async (models: Models) => {
const iterateUsers = async function*() {
let page = 1;
let hasMore = true;
while (hasMore) {
const batchSize = 50;
const items = await models.user().allPaginated({ page: page++, limit: batchSize });
hasMore = items.has_more;
const users = items.items;
yield await Promise.all(users.map(async user => {
const userItemCount = await models.userItem().countWithUserId(user.id);
const shareCount = (await models.share().byUserId(user.id, ShareType.Folder)).length;
return {
labels: {
'User ID': user.id,
'Share count': shareCount,
'user_items count': userItemCount,
'Total item size': user.total_item_size,
},
data: user.id,
};
}));
}
};
await recordBenchmark<Uuid>({
taskLabel: 'full delta',
batchIterator: iterateUsers(),
trialCount: 10,
outputFile: 'delta-perf-full.csv',
runTask: async (userId) => {
await models.change().delta(userId, { cursor: '', limit: 200 });
},
});
await recordBenchmark<Uuid>({
taskLabel: 'changes query',
batchIterator: iterateUsers(),
trialCount: 10,
outputFile: 'delta-perf-query-only.csv',
runTask: async (userId) => {
await models.change().changesForUserQuery(userId, -1, 200, false);
},
});
};
export default benchmarkDeltaPerformance;

View File

@@ -0,0 +1,104 @@
import shim from '@joplin/lib/shim';
import { getRootDir } from '@joplin/utils';
import Logger from '@joplin/utils/Logger';
import { writeFile } from 'fs/promises';
const logger = Logger.create('benchmark');
const computeAverage = (data: number[]) => {
const total = data.reduce((a, b) => a + b, 0);
return total / (data.length || 1);
};
const computeStandardDeviation = (data: number[]) => {
const average = computeAverage(data);
// Variance(X) = average square distance from the mean
// = average((x - average(X))^2 : for all (x in X))
const variance = computeAverage(data.map((x) => Math.pow(x - average, 2)));
const standardDeviation = Math.sqrt(variance);
return standardDeviation;
};
const computeStatistics = (data: number[]) => {
return { average: computeAverage(data), standardDeviation: computeStandardDeviation(data) };
};
type TrialLabel = Record<string, string|number>;
interface LabelledInputs<DataPoint> {
labels: TrialLabel;
data: DataPoint;
}
interface BenchmarkOptions<InputData> {
// Brief label of the task (to be used for column headers
// in the output file)
taskLabel: string;
// Should run the task once
runTask: (dataPoint: InputData)=> Promise<void>;
// Returns data in groups
batchIterator: AsyncIterable<LabelledInputs<InputData>[]>;
// Number of times to re-test the performance of each group of data
trialCount: number;
outputFile: string;
}
const recordBenchmark = async <DataPoint> ({ batchIterator: inputData, trialCount: trials, outputFile, taskLabel, runTask: runTrial }: BenchmarkOptions<DataPoint>) => {
logger.info('Creating benchmark for', outputFile);
let page = 0;
const results = [];
const dataPointToDurations = new Map<TrialLabel, number[]>();
for await (const batch of inputData) {
logger.info('Page', page, '. Preparing to process', batch.length, 'items...');
page++;
for (let trial = 0; trial < trials; trial ++) {
logger.info('Trial', trial);
for (const item of batch) {
const startTime = performance.now();
await runTrial(item.data);
const endTime = performance.now();
const duration = endTime - startTime;
const values = dataPointToDurations.get(item.labels);
if (values) {
values.push(duration);
} else {
dataPointToDurations.set(item.labels, [duration]);
}
}
}
for (const [labels, durations] of dataPointToDurations) {
const { average, standardDeviation } = computeStatistics(durations);
results.push({
...labels,
[`Avg. ${taskLabel} duration (ms)`]: average,
[`standardDeviation(${taskLabel} duration) (ms)`]: standardDeviation,
});
}
dataPointToDurations.clear();
}
if (results.length === 0) {
throw new Error('No data collected.');
}
const resultCsv = [
Object.keys(results[0]).join(','),
...results.map(result => Object.values(result).join(',')),
].join('\n');
const outputDir = `${await getRootDir()}/packages/server/benchmarks`;
await shim.fsDriver().mkdir(outputDir);
const outputPath = await shim.fsDriver().resolveRelativePathWithinDir(outputDir, outputFile);
await writeFile(outputPath, resultCsv);
logger.info('Done. Wrote output to', outputPath);
};
export default recordBenchmark;

View File

@@ -1,6 +1,6 @@
import { FolderEntity, NoteEntity } from '@joplin/lib/services/database/types';
import Logger, { LogLevel, TargetType } from '@joplin/utils/Logger';
import { User } from '../../services/database/types';
import { Share, ShareUserStatus, User } from '../../services/database/types';
import { Models } from '../../models/factory';
import { randomWords } from '../../utils/testing/randomWords';
import { makeFolderSerializedBody, makeNoteSerializedBody, makeResourceSerializedBody } from '../../utils/testing/serializedItems';
@@ -44,9 +44,12 @@ enum Action {
DeleteFolder = 'deleteFolder',
}
const createActions = [Action.CreateNote, Action.CreateFolder, Action.CreateAndShareFolder, Action.CreateNoteAndResource];
const createActions = [Action.CreateNote, Action.CreateFolder, Action.CreateNoteAndResource, Action.CreateAndShareFolder];
const createActionWeights = [0.5, 0.3, 0.1, 0.1];
const updateActions = [Action.UpdateNote, Action.UpdateFolder];
const updateActionWeights = [0.7, 0.3];
const deleteActions = [Action.DeleteNote, Action.DeleteFolder];
const deleteActionWeights = [0.7, 0.3];
const isCreateAction = (action: Action) => {
return createActions.includes(action);
@@ -66,6 +69,42 @@ const randomInt = (min: number, max: number) => {
return Math.floor(Math.random() * (max - min + 1)) + min;
};
const randomWeightedElement = <T> (items: T[], weights: number[]): T => {
if (items.length !== weights.length) {
throw new Error('Items and weights must have the same length');
}
// Normalize the weights so that they add up to one.
const weightsSum = weights.reduce((a, b) => a + b, 0);
const normalizedWeights = weights.map(w => w / weightsSum);
// Pair items and weights
const weightedItems = items.map((item, index) => ({ item, weight: normalizedWeights[index] }));
let weightSum = 0;
const value = Math.random();
// Find the last item with `value` in its range
for (const item of weightedItems) {
weightSum += item.weight;
if (weightSum > value) {
return item.item;
}
}
return null;
};
const shuffled = <T> (items: T[]) => {
const result = [...items];
// See: https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle
for (let i = 0; i < result.length - 1; i++) {
const targetIndex = randomInt(i, result.length - 1);
const tmp = result[targetIndex];
result[targetIndex] = result[i];
result[i] = tmp;
}
return result;
};
const joplinIdToItem = (context: Context, user: User, parentId: string) => {
return context.models.item().loadByName(user.id, `${parentId}.md`);
};
@@ -118,6 +157,15 @@ const createRandomFolder = async (context: Context, user: User, folder: FolderEn
return item;
};
const addUserToShareWithStatus = async (context: Context, share: Share, email: string, status: ShareUserStatus) => {
const shareUser = await context.models.shareUser().addByEmail(share.id, email, '');
const defaultStatus = ShareUserStatus.Waiting;
if (status !== defaultStatus) {
await context.models.shareUser().setStatus(share.id, shareUser.user_id, status);
}
};
const reactions: Record<Action, Reaction> = {
[Action.CreateNote]: async (context, user) => {
const item = await createRandomNote(context, user);
@@ -137,8 +185,29 @@ const reactions: Record<Action, Reaction> = {
const item = await createRandomFolder(context, user);
const share = await context.models.share().shareFolder(user, item.jop_id, '');
const recipientEmail = randomElement(context.userEmails.filter(email => email !== user.email));
await context.models.shareUser().addByEmail(share.id, recipientEmail, '');
// Tag the folder with the share ID so that items created within
// the folder can be part of the share:
const folder = await context.models.item().loadAsJoplinItem(item.id);
const serialized = makeFolderSerializedBody({
...folder,
share_id: share.id,
});
await context.models.item().saveFromRawContent(user, {
name: `${folder.id}.md`,
body: Buffer.from(serialized),
});
// Add users to the share
for (const email of shuffled(context.userEmails)) {
const status = randomWeightedElement([
ShareUserStatus.Accepted,
ShareUserStatus.Waiting,
ShareUserStatus.Rejected,
], [0.8, 0.1, 0.1]);
await addUserToShareWithStatus(context, share, email, status);
if (Math.random() < 0.1) break;
}
return true;
},
@@ -182,6 +251,7 @@ const reactions: Record<Action, Reaction> = {
try {
const noteItem = await context.models.item().loadByJopId(user.id, noteId);
if (!noteItem) return false;
const note = await context.models.item().loadAsJoplinItem(noteItem.id);
const serialized = makeNoteSerializedBody({
title: randomWords(10),
@@ -228,6 +298,7 @@ const reactions: Record<Action, Reaction> = {
const noteId = randomElement(context.createdNoteIds[user.id]);
if (!noteId) return false;
const item = await context.models.item().loadByJopId(user.id, noteId, { fields: ['id'] });
if (!item) return false;
await context.models.item().delete(item.id, { allowNoOp: true });
return true;
},
@@ -244,11 +315,11 @@ const reactions: Record<Action, Reaction> = {
const randomActionKey = () => {
const r = Math.random();
if (r <= .35) {
return randomElement(createActions);
return randomWeightedElement(createActions, createActionWeights);
} else if (r <= .8) {
return randomElement(updateActions);
return randomWeightedElement(updateActions, updateActionWeights);
} else {
return randomElement(deleteActions);
return randomWeightedElement(deleteActions, deleteActionWeights);
}
};
@@ -323,7 +394,7 @@ const populateDatabase = async (models: Models, options: Options) => {
for (let j = 0; j < batchSize; j++) {
promises.push((async () => {
const user = randomElement(users);
const action = randomElement(createActions);
const action = randomWeightedElement(createActions, createActionWeights);
await reactions[action](context, user);
updateReport(action);
logger().info(`Done action ${i}: ${action}. User: ${user.email}`);

View File

@@ -83,6 +83,13 @@ export default async function(env: Env, models: Models, config: Config, services
schedule: '*/15 * * * *',
run: (models: Models) => models.user().deleteExpiredAuthCodes(),
},
{
id: TaskId.DeleteArchivedBackups,
description: taskIdToLabel(TaskId.DeleteArchivedBackups),
schedule: '0 0 * * *',
run: (models: Models) => models.backupItem().deleteOldAccountBackups(),
},
];
if (config.DELETE_EXPIRED_SESSIONS_SCHEDULE) {

View File

@@ -218,4 +218,6 @@ Peacherine
tablatures
tablature
REVO
REVOGB
REVOGB
Bopomofo
Linkosed

View File

@@ -16,7 +16,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Poedit 3.8\n"
"X-Generator: Poedit 3.4.2\n"
#: packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.tsx:621
msgid "- Camera: to allow taking a picture and attaching it to a note."
@@ -161,7 +161,7 @@ msgstr[1] "%d Minuten"
#: packages/app-cli/app/command-rmnote.ts:34
msgid "%d note matches this pattern. Delete it?"
msgid_plural "%d notes match this pattern. Delete them?"
msgstr[0] "%d Notize stimmt mit diesem Muster überein. Löschen?"
msgstr[0] "%d Notiz stimmt mit diesem Muster überein. Löschen?"
msgstr[1] "%d Notizen stimmen mit diesem Muster überein. Löschen?"
#: packages/app-cli/app/command-rmnote.ts:40
@@ -1234,9 +1234,9 @@ msgstr "Anstehende Alarme"
#: packages/lib/models/settings/builtInMetadata.ts:1591
msgid ""
"Comma-separated list of paths to directories to load the certificates from, "
"or path to individual cert files. For example: /my/cert_dir, /other/"
"custom.pem. Note that if you make changes to the TLS settings, you must save "
"your changes before clicking on \"Check synchronisation configuration\"."
"or path to individual cert files. For example: /my/cert_dir, /other/custom."
"pem. Note that if you make changes to the TLS settings, you must save your "
"changes before clicking on \"Check synchronisation configuration\"."
msgstr ""
"Kommagetrennte Liste von Pfaden zu Verzeichnissen, aus denen die Zertifikate "
"geladen werden, oder Pfad zu einzelnen Zertifikatsdateien. Zum Beispiel: /my/"
@@ -2154,7 +2154,7 @@ msgstr "Duplizieren"
#: packages/app-desktop/gui/NoteEditor/editorCommandDeclarations.ts:114
#: packages/app-mobile/components/NoteEditor/commandDeclarations.ts:128
msgid "Duplicate line"
msgstr "Dupliziere Zeile"
msgstr "Zeile duplizieren"
#: packages/app-mobile/components/ScreenHeader/index.tsx:526
msgid "Duplicate selected notes"
@@ -2343,7 +2343,7 @@ msgstr "Verschlüsselung aktivieren"
#: packages/lib/models/settings/builtInMetadata.ts:1116
msgid "Enable file:// URLs for images and videos"
msgstr "Aktiviere file://-URLs für Bilder und Videos"
msgstr "file://-URLs für Bilder und Videos aktivieren"
#: packages/lib/models/settings/builtInMetadata.ts:1097
msgid "Enable footnotes"
@@ -3760,7 +3760,7 @@ msgstr "Markdown-Editor: Bilder darstellen"
#: packages/lib/models/settings/builtInMetadata.ts:1494
msgid "Markdown editor: Render markup in editor"
msgstr "Markdown-Editor: Auszeichnungen im Editor darstellen"
msgstr "Markdown-Editor: Markup im Editor darstellen"
#: packages/app-cli/app/command-done.ts:15
msgid "Marks a to-do as done."
@@ -5102,8 +5102,7 @@ msgstr "Benennt das angegebene <item> (Notiz oder Notizbuch) zu <name> um."
#: packages/lib/models/settings/builtInMetadata.ts:1495
msgid "Renders markup on all lines that don't include the cursor."
msgstr ""
"Stellt Auszeichnungen in allen Zeilen dar, die den Cursor nicht enthalten."
msgstr "Rendert Markup in allen Zeilen, die den Cursor nicht enthalten."
#: packages/app-desktop/gui/ClipperConfigScreen.tsx:162
msgid "Renew token"
@@ -5766,7 +5765,7 @@ msgstr "Notizbücher sortieren nach"
#: packages/app-mobile/components/ScreenHeader/index.tsx:542
#: packages/lib/models/settings/builtInMetadata.ts:722
msgid "Sort notes by"
msgstr "Sortiere Notizen nach"
msgstr "Notizen sortieren nach"
#: packages/app-desktop/gui/NoteEditor/editorCommandDeclarations.ts:138
#: packages/app-mobile/components/NoteEditor/commandDeclarations.ts:133
@@ -7001,9 +7000,9 @@ msgid ""
"Uninstall and reinstall the application. Make sure you create a backup first "
"by exporting all your notes as JEX from the desktop application."
msgstr ""
"Deinstalliere die Anwendung und installieren sie dann neu. Stelle sicher, "
"dass du zuerst ein Backup erstellst, indem du alle Notizen als JEX aus der "
"Desktop-Anwendung exportierst."
"Deinstalliere die Anwendung und installiere sie erneut. Erstelle zuvor "
"unbedingt eine Sicherungskopie, indem du alle Ihre Notizen aus der Desktop-"
"Anwendung als JEX exportieren."
#: packages/app-mobile/utils/getVersionInfoText.ts:13
#: packages/app-mobile/utils/getVersionInfoText.ts:14
@@ -7554,8 +7553,8 @@ msgid ""
"Your password is needed to decrypt some of your data. Type `:e2ee decrypt` "
"to set it."
msgstr ""
"Dein Passwort ist nötig um einige deiner Daten zu entschlüsseln. Tippe "
"„:e2ee decrypt“ um es zu setzen."
"Dein Passwort ist nötig um einige deiner Daten zu entschlüsseln. Tippe „:"
"e2ee decrypt“ um es zu setzen."
#: packages/app-desktop/checkForUpdates.ts:108
msgid "Your version: %s"
@@ -8280,8 +8279,8 @@ msgstr "Herauszoomen"
#~ "`sync.2.path` to specify the target directory."
#~ msgstr ""
#~ "Das Synchronisationsziel, mit dem synchronisiert werden soll. Wenn mit "
#~ "dem Dateisystem synchronisiert werden soll, setze den Wert zu "
#~ "`sync.2.path`, um den Zielpfad zu spezifizieren."
#~ "dem Dateisystem synchronisiert werden soll, setze den Wert zu `sync.2."
#~ "path`, um den Zielpfad zu spezifizieren."
#~ msgid "To-do title:"
#~ msgstr "Aufgabentitel:"

View File

@@ -25,9 +25,9 @@
"@joplin/renderer": "^3.5.1",
"@joplin/utils": "^3.5.1",
"compare-versions": "6.1.1",
"dayjs": "1.11.13",
"dayjs": "1.11.18",
"execa": "4.1.0",
"fs-extra": "11.2.0",
"fs-extra": "11.3.1",
"gettext-parser": "7.0.1",
"glob": "11.0.3",
"license-checker-rseidelsohn": "4.4.2",

View File

@@ -184,6 +184,9 @@
"v3.5.5": true,
"ios-v13.4.4": true,
"v3.5.6": true,
"v3.5.7": true
"v3.5.7": true,
"android-v3.5.1": true,
"ios-v13.5.1": true,
"v3.5.9": true
}
}

View File

@@ -1,6 +1,6 @@
import { execCommand } from '@joplin/utils';
import { rootDir, completeReleaseWithChangelog } from './tool-utils';
import { yarnVersionPatch } from '@joplin/utils/version';
import { versionPatch } from '@joplin/utils/version';
const appDir = `${rootDir}/packages/app-cli`;
const changelogPath = `${rootDir}/readme/about/changelog/cli.md`;
@@ -12,7 +12,7 @@ async function main() {
await execCommand('git pull');
const newVersion = await yarnVersionPatch();
const newVersion = await versionPatch();
console.info(`Building ${newVersion}...`);
const newTag = `cli-${newVersion}`;

View File

@@ -1,5 +1,5 @@
import { execCommand } from '@joplin/utils';
import { yarnVersionPatch } from '@joplin/utils/version';
import { versionPatch } from '@joplin/utils/version';
import { gitCurrentBranch, githubRelease, gitPullTry, rootDir } from './tool-utils';
const appDir = `${rootDir}/packages/app-desktop`;
@@ -11,7 +11,7 @@ async function main() {
console.info(`Running from: ${process.cwd()}`);
const version = await yarnVersionPatch();
const version = await versionPatch();
const tagName = version;
console.info(`New version number: ${version}`);

Some files were not shown because too many files have changed in this diff Show More