You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-12-08 23:07:32 +02:00
Compare commits
88 Commits
ios-v13.5.
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
819a591cc0 | ||
|
|
421b82c86d | ||
|
|
16169b2780 | ||
|
|
49ed4ae920 | ||
|
|
13777d261c | ||
|
|
1c7b0e6266 | ||
|
|
4589670126 | ||
|
|
b6ab6e0b46 | ||
|
|
9b28b618bb | ||
|
|
bf7cc6be03 | ||
|
|
e5e5b342a7 | ||
|
|
9709721a73 | ||
|
|
a34010ef62 | ||
|
|
9a6043e6a6 | ||
|
|
992bf683c4 | ||
|
|
b40c2b8a41 | ||
|
|
8dcd08e21d | ||
|
|
cb2b32520d | ||
|
|
315b1d8275 | ||
|
|
8018f1269a | ||
|
|
c2d186188b | ||
|
|
d5798e558b | ||
|
|
224bcd54f1 | ||
|
|
1a3d572498 | ||
|
|
848a2c986a | ||
|
|
fc61a2bc6a | ||
|
|
f9d58742c0 | ||
|
|
5ba8cefe7c | ||
|
|
74484f194e | ||
|
|
eae569aff8 | ||
|
|
8734bc8467 | ||
|
|
612d09d16f | ||
|
|
eb2e9419b9 | ||
|
|
17935458e6 | ||
|
|
a69a5d98ee | ||
|
|
48c9c1112c | ||
|
|
a6585a67d0 | ||
|
|
959e1522d4 | ||
|
|
8605e5aad5 | ||
|
|
88af5208f5 | ||
|
|
bef73dbbf5 | ||
|
|
b23c50cc7d | ||
|
|
3e90a9392d | ||
|
|
e2a32c5993 | ||
|
|
759761086d | ||
|
|
ca29ed94cc | ||
|
|
f815933ad0 | ||
|
|
67af879d38 | ||
|
|
3caf41984f | ||
|
|
865d39d657 | ||
|
|
d701b9b1bd | ||
|
|
f8fe143809 | ||
|
|
9e0491ef2f | ||
|
|
1f77357c7d | ||
|
|
c53d18e068 | ||
|
|
fe8ad1fa74 | ||
|
|
dfc0a96567 | ||
|
|
2eb70be937 | ||
|
|
3ef138c9fe | ||
|
|
4e21643bbe | ||
|
|
d9d9946faf | ||
|
|
032dfa949d | ||
|
|
7e703ed405 | ||
|
|
3b0cc08e6b | ||
|
|
8961a4a10d | ||
|
|
fed580ae18 | ||
|
|
f036869f53 | ||
|
|
3a1b36d594 | ||
|
|
b9ba747327 | ||
|
|
5631e1d57b | ||
|
|
740a5628dd | ||
|
|
0a758561f3 | ||
|
|
4986b1f084 | ||
|
|
7aaad4e7f3 | ||
|
|
b0497bfa07 | ||
|
|
2d0f02cb8a | ||
|
|
1ae72235fc | ||
|
|
86f2a3a7d0 | ||
|
|
5b106d4827 | ||
|
|
3bf2eb0399 | ||
|
|
8302afda19 | ||
|
|
ba970ac7a5 | ||
|
|
89018e497f | ||
|
|
53a05eb781 | ||
|
|
7637915bed | ||
|
|
d5dd55a813 | ||
|
|
e80a0c39f8 | ||
|
|
357199658f |
@@ -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
8
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:18-bullseye
|
||||
FROM node:24-bullseye
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
BIN
packages/app-cli/tests/support/onenote/Math.one
Normal file
BIN
packages/app-cli/tests/support/onenote/Math.one
Normal file
Binary file not shown.
@@ -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',
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
20
packages/app-desktop/commands/showProfileEditor.ts
Normal file
20
packages/app-desktop/commands/showProfileEditor.ts
Normal 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',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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' }}/>;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
47
packages/app-desktop/gui/ProfileEditor.scss
Normal file
47
packages/app-desktop/gui/ProfileEditor.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
193
packages/app-desktop/gui/ProfileEditor.tsx
Normal file
193
packages/app-desktop/gui/ProfileEditor.tsx
Normal 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);
|
||||
@@ -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>
|
||||
|
||||
@@ -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') },
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function() {
|
||||
'commandPalette',
|
||||
'openMasterPasswordDialog',
|
||||
'addProfile',
|
||||
'editProfileConfig',
|
||||
'showProfileEditor',
|
||||
'switchProfile1',
|
||||
'switchProfile2',
|
||||
'switchProfile3',
|
||||
|
||||
16
packages/app-desktop/gui/styles/base-button.scss
Normal file
16
packages/app-desktop/gui/styles/base-button.scss
Normal 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;
|
||||
}
|
||||
8
packages/app-desktop/gui/styles/base-text.scss
Normal file
8
packages/app-desktop/gui/styles/base-text.scss
Normal 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);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
19
packages/app-desktop/gui/styles/share-note-dialog.scss
Normal file
19
packages/app-desktop/gui/styles/share-note-dialog.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
20
packages/app-desktop/gui/styles/shared-note-list-item.scss
Normal file
20
packages/app-desktop/gui/styles/shared-note-list-item.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -274,6 +274,7 @@ function NoteEditor(props: Props) {
|
||||
highlightActiveLine,
|
||||
|
||||
keymap: EditorKeymap.Default,
|
||||
preferMacShortcuts: shim.mobilePlatform() === 'ios',
|
||||
|
||||
automatchBraces: false,
|
||||
ignoreModifiers: false,
|
||||
|
||||
@@ -11,7 +11,7 @@ export enum InstallState {
|
||||
|
||||
interface Props {
|
||||
onPress: ()=> void;
|
||||
disabled: boolean;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
style?: ViewStyle;
|
||||
testID?: string;
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@joplin/utils": "~3.5",
|
||||
"fs-extra": "11.2.0",
|
||||
"fs-extra": "11.3.1",
|
||||
"yargs": "17.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
];
|
||||
@@ -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),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}${spaceAfter}`, true);
|
||||
const editor = await createEditor(`${spaceBefore}${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('\n\n', true);
|
||||
const editor = await createEditor('\n\n');
|
||||
const images = findImages(editor);
|
||||
expect(images).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should allow reloading specific images', async () => {
|
||||
const editor = await createEditor('\n', true);
|
||||
const editor = await createEditor('\n');
|
||||
|
||||
// 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(
|
||||
'\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);
|
||||
|
||||
@@ -17,6 +17,7 @@ const createEditorSettings = (themeId: number) => {
|
||||
highlightActiveLine: false,
|
||||
|
||||
keymap: EditorKeymap.Default,
|
||||
preferMacShortcuts: false,
|
||||
language: EditorLanguageType.Markdown,
|
||||
themeData,
|
||||
|
||||
|
||||
@@ -180,6 +180,7 @@ export interface EditorSettings {
|
||||
language: EditorLanguageType;
|
||||
|
||||
keymap: EditorKeymap;
|
||||
preferMacShortcuts: boolean;
|
||||
tabMovesFocus: boolean;
|
||||
|
||||
markdownMarkEnabled: boolean;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
75
packages/lib/commands/convertNoteToMarkdown.ts
Normal file
75
packages/lib/commands/convertNoteToMarkdown.ts
Normal 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)',
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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§ion-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§ion-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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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}$
|
||||
|
||||
|
||||
|
||||
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}{𝑥}+𝐶$
|
||||
|
||||
"
|
||||
`;
|
||||
|
||||
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;">"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="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 "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="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'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'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;"> </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§ion-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§ion-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;"> </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:#风景§ion-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§ion-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 "<span style="font-weight: bold;">do</span>" not to "know." 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;"> </p></div>
|
||||
<div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt; line-height: 17px;"> </p></div>
|
||||
<div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt; line-height: 17px;"> </p></div>
|
||||
|
||||
@@ -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');
|
||||
|
||||
2
packages/onenote-converter/Cargo.lock
generated
2
packages/onenote-converter/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
369
packages/onenote-converter/parser/src/onenote/text_region.rs
Normal file
369
packages/onenote-converter/parser/src/onenote/text_region.rs
Normal 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,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
56
packages/onenote-converter/renderer/src/page/math.rs
Normal file
56
packages/onenote-converter/renderer/src/page/math.rs
Normal 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("")
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(¬e_tag_html);
|
||||
content_html.push_str(¬e_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 = " ".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(" "))
|
||||
} 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",
|
||||
|
||||
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'")
|
||||
}
|
||||
|
||||
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>"),
|
||||
"<a href="http://example.com/">test</a>"
|
||||
);
|
||||
assert_eq!(html_entities(">"), "&gt;");
|
||||
assert_eq!(html_entities("'>'"), "'&gt;'");
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
BIN
packages/onenote-converter/test-data/Page versions.one
Normal file
BIN
packages/onenote-converter/test-data/Page versions.one
Normal file
Binary file not shown.
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
43
packages/server/src/models/BackupItemModel.test.ts
Normal file
43
packages/server/src/models/BackupItemModel.test.ts
Normal 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']);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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)');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 = (() => {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -138,6 +138,7 @@ export enum TaskId {
|
||||
LogHeartbeatMessage,
|
||||
DeleteOldEvents,
|
||||
DeleteExpiredAuthCodes,
|
||||
DeleteArchivedBackups,
|
||||
}
|
||||
|
||||
// AUTO-GENERATED-TYPES
|
||||
|
||||
@@ -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;
|
||||
104
packages/server/src/tools/benchmark/recordBenchmark.ts
Normal file
104
packages/server/src/tools/benchmark/recordBenchmark.ts
Normal 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;
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -218,4 +218,6 @@ Peacherine
|
||||
tablatures
|
||||
tablature
|
||||
REVO
|
||||
REVOGB
|
||||
REVOGB
|
||||
Bopomofo
|
||||
Linkosed
|
||||
@@ -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:"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user