You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2026-04-18 19:42:23 +02:00
Compare commits
10 Commits
plugin_ext
...
v3.6.9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be1a018746 | ||
|
|
ae390469b5 | ||
|
|
c31a7392cc | ||
|
|
9ca213cefd | ||
|
|
a6a5ab9bc9 | ||
|
|
0a94d02795 | ||
|
|
222bb002c8 | ||
|
|
af8eb30844 | ||
|
|
cb009cb084 | ||
|
|
fc212d0144 |
@@ -201,6 +201,8 @@ packages/app-desktop/gui/ConfigScreen/ButtonBar.js
|
||||
packages/app-desktop/gui/ConfigScreen/ConfigScreen.js
|
||||
packages/app-desktop/gui/ConfigScreen/Sidebar.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/FontSearch.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.test.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/MissingPasswordHelpLink.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/SettingComponent.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/SettingDescription.js
|
||||
@@ -358,8 +360,6 @@ packages/app-desktop/gui/NoteList/utils/useMoveNote.js
|
||||
packages/app-desktop/gui/NoteList/utils/useOnKeyDown.js
|
||||
packages/app-desktop/gui/NoteList/utils/useOnNoteClick.js
|
||||
packages/app-desktop/gui/NoteList/utils/useOnNoteDoubleClick.js
|
||||
packages/app-desktop/gui/NoteList/utils/useRefocusOnDeletion.test.js
|
||||
packages/app-desktop/gui/NoteList/utils/useRefocusOnDeletion.js
|
||||
packages/app-desktop/gui/NoteList/utils/useScroll.js
|
||||
packages/app-desktop/gui/NoteList/utils/useVisibleRange.test.js
|
||||
packages/app-desktop/gui/NoteList/utils/useVisibleRange.js
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -174,6 +174,8 @@ packages/app-desktop/gui/ConfigScreen/ButtonBar.js
|
||||
packages/app-desktop/gui/ConfigScreen/ConfigScreen.js
|
||||
packages/app-desktop/gui/ConfigScreen/Sidebar.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/FontSearch.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.test.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/MissingPasswordHelpLink.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/SettingComponent.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/SettingDescription.js
|
||||
@@ -331,8 +333,6 @@ packages/app-desktop/gui/NoteList/utils/useMoveNote.js
|
||||
packages/app-desktop/gui/NoteList/utils/useOnKeyDown.js
|
||||
packages/app-desktop/gui/NoteList/utils/useOnNoteClick.js
|
||||
packages/app-desktop/gui/NoteList/utils/useOnNoteDoubleClick.js
|
||||
packages/app-desktop/gui/NoteList/utils/useRefocusOnDeletion.test.js
|
||||
packages/app-desktop/gui/NoteList/utils/useRefocusOnDeletion.js
|
||||
packages/app-desktop/gui/NoteList/utils/useScroll.js
|
||||
packages/app-desktop/gui/NoteList/utils/useVisibleRange.test.js
|
||||
packages/app-desktop/gui/NoteList/utils/useVisibleRange.js
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
- Focus on testing essential behaviour and edge cases — avoid adding tests for every minor detail.
|
||||
- Avoid duplicating code in tests; when testing the same logic with different inputs, use `test.each` or shared helpers instead of repeating similar test blocks.
|
||||
- Do not make white space changes - do not add unnecessary new lines, or spaces to existing code, or wrap existing code.
|
||||
- If you add a new TypeScript file, run `yarn updateIgnored` from the root.
|
||||
|
||||
## Full Documentation
|
||||
|
||||
|
||||
@@ -939,6 +939,7 @@ export default class ElectronAppWrapper {
|
||||
this.electronApp_.on('before-quit', () => {
|
||||
this.appLogger_.info('[appClose] before-quit event fired, setting willQuitApp_ = true');
|
||||
this.willQuitApp_ = true;
|
||||
bridge().unregisterGlobalHotkey();
|
||||
});
|
||||
|
||||
this.electronApp_.on('window-all-closed', () => {
|
||||
|
||||
@@ -150,6 +150,10 @@ class Application extends BaseApplication {
|
||||
bridge().extraAllowedOpenExtensions = Setting.value('linking.extraAllowedExtensions');
|
||||
}
|
||||
|
||||
if ((action.type === 'SETTING_UPDATE_ONE' && action.key === 'globalHotkey') || action.type === 'SETTING_UPDATE_ALL') {
|
||||
bridge().updateGlobalHotkey(Setting.value('globalHotkey'));
|
||||
}
|
||||
|
||||
if (['EVENT_NOTE_ALARM_FIELD_CHANGE', 'NOTE_DELETE'].indexOf(action.type) >= 0) {
|
||||
await AlarmService.updateNoteNotification(action.id, action.type === 'NOTE_DELETE');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import ElectronAppWrapper from './ElectronAppWrapper';
|
||||
import shim, { MessageBoxType } from '@joplin/lib/shim';
|
||||
import { _, setLocale } from '@joplin/lib/locale';
|
||||
import { BrowserWindow, nativeTheme, nativeImage, shell, dialog, MessageBoxSyncOptions, safeStorage, Menu, MenuItemConstructorOptions, MenuItem, BrowserWindowConstructorOptions, FileFilter, SaveDialogOptions } from 'electron';
|
||||
import { BrowserWindow, nativeTheme, nativeImage, shell, dialog, MessageBoxSyncOptions, safeStorage, Menu, MenuItemConstructorOptions, MenuItem, BrowserWindowConstructorOptions, FileFilter, SaveDialogOptions, globalShortcut } from 'electron';
|
||||
import { dirname, toSystemSlashes } from '@joplin/lib/path-utils';
|
||||
import { fileUriToPath } from '@joplin/utils/url';
|
||||
import { urlDecode } from '@joplin/lib/string-utils';
|
||||
@@ -46,6 +46,7 @@ export class Bridge {
|
||||
|
||||
private extraAllowedExtensions_: string[] = [];
|
||||
private onAllowedExtensionsChangeListener_: OnAllowedExtensionsChange = ()=>{};
|
||||
private registeredGlobalHotkey_ = '';
|
||||
|
||||
public constructor(electronWrapper: ElectronAppWrapper, appId: string, appName: string, rootProfileDir: string, autoUploadCrashDumps: boolean, altInstanceId: string) {
|
||||
this.electronWrapper_ = electronWrapper;
|
||||
@@ -207,6 +208,54 @@ export class Bridge {
|
||||
this.onAllowedExtensionsChangeListener_ = listener;
|
||||
}
|
||||
|
||||
public updateGlobalHotkey(accelerator: string) {
|
||||
// Skip if the accelerator hasn't changed
|
||||
if (accelerator === this.registeredGlobalHotkey_) return;
|
||||
|
||||
// Unregister the previous shortcut (only Joplin's own)
|
||||
this.unregisterGlobalHotkey();
|
||||
|
||||
if (!accelerator) return;
|
||||
|
||||
try {
|
||||
const registered = globalShortcut.register(accelerator, () => {
|
||||
const win = this.mainWindow();
|
||||
if (!win) return;
|
||||
|
||||
if (win.isVisible() && win.isFocused()) {
|
||||
win.hide();
|
||||
} else {
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
win.focus();
|
||||
}
|
||||
});
|
||||
|
||||
if (registered) {
|
||||
this.registeredGlobalHotkey_ = accelerator;
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`Bridge: Failed to register global shortcut: ${accelerator}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Bridge: Error registering global shortcut "${accelerator}":`, error);
|
||||
}
|
||||
}
|
||||
|
||||
public unregisterGlobalHotkey() {
|
||||
if (this.registeredGlobalHotkey_) {
|
||||
try {
|
||||
globalShortcut.unregister(this.registeredGlobalHotkey_);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Bridge: Error removing global shortcut:', error);
|
||||
}
|
||||
this.registeredGlobalHotkey_ = '';
|
||||
}
|
||||
}
|
||||
|
||||
public async captureException(error: unknown) {
|
||||
Sentry.captureException(error);
|
||||
// We wait to give the "beforeSend" event handler time to process the crash dump and write
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
|
||||
import GlobalHotkeyInput from './GlobalHotkeyInput';
|
||||
|
||||
describe('GlobalHotkeyInput', () => {
|
||||
test('should render ShortcutRecorder with Save and Restore buttons', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<GlobalHotkeyInput value="CommandOrControl+Shift+J" themeId={1} onChange={onChange} />);
|
||||
|
||||
// ShortcutRecorder is always visible with its built-in buttons
|
||||
expect(screen.getByText('Save')).toBeTruthy();
|
||||
expect(screen.getByText('Restore')).toBeTruthy();
|
||||
expect(screen.getByText('Cancel')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should clear value when Restore is clicked', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<GlobalHotkeyInput value="CommandOrControl+Shift+J" themeId={1} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Restore'));
|
||||
expect(onChange).toHaveBeenCalledWith({ value: '' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { ShortcutRecorder } from '../../KeymapConfig/ShortcutRecorder';
|
||||
|
||||
interface OnChangeEvent {
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
themeId: number;
|
||||
onChange: (event: OnChangeEvent)=> void;
|
||||
}
|
||||
|
||||
// A thin wrapper around ShortcutRecorder for the global hotkey setting.
|
||||
// Reuses ShortcutRecorder directly instead of maintaining a separate display mode.
|
||||
export default function GlobalHotkeyInput(props: Props) {
|
||||
const value = props.value || '';
|
||||
|
||||
const onSave = useCallback((event: { commandName: string; accelerator: string }) => {
|
||||
// Normalize platform-specific modifiers to CommandOrControl for
|
||||
// consistent cross-platform storage.
|
||||
const accelerator = event.accelerator
|
||||
.replace(/\bCmd\b/, 'CommandOrControl')
|
||||
.replace(/\bCtrl\b/, 'CommandOrControl');
|
||||
props.onChange({ value: accelerator });
|
||||
}, [props.onChange]);
|
||||
|
||||
const onReset = useCallback(() => {
|
||||
props.onChange({ value: '' });
|
||||
}, [props.onChange]);
|
||||
|
||||
// No-op: global hotkeys don't have a separate editing mode to cancel out of.
|
||||
const onCancel = useCallback(() => {}, []);
|
||||
|
||||
// No-op: ShortcutRecorder validates against the keymap (command
|
||||
// conflicts), which doesn't apply to global hotkeys.
|
||||
const onError = useCallback((_event: { recorderError: Error }) => {}, []);
|
||||
|
||||
return (
|
||||
<ShortcutRecorder
|
||||
onSave={onSave}
|
||||
onReset={onReset}
|
||||
onCancel={onCancel}
|
||||
onError={onError}
|
||||
initialAccelerator={value}
|
||||
commandName="globalHotkey"
|
||||
themeId={props.themeId}
|
||||
skipKeymapValidation
|
||||
autoFocus={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { themeStyle } from '@joplin/lib/theme';
|
||||
import * as React from 'react';
|
||||
import { useCallback, useId } from 'react';
|
||||
import control_PluginsStates from './plugins/PluginsStates';
|
||||
import control_GlobalHotkeyInput from './GlobalHotkeyInput';
|
||||
import bridge from '../../../services/bridge';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import Button, { ButtonLevel, ButtonSize } from '../../Button/Button';
|
||||
@@ -11,8 +12,10 @@ import * as pathUtils from '@joplin/lib/path-utils';
|
||||
import SettingLabel from './SettingLabel';
|
||||
import SettingDescription from './SettingDescription';
|
||||
|
||||
const settingKeyToControl: Record<string, typeof control_PluginsStates> = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Each control component has different prop types
|
||||
const settingKeyToControl: Record<string, React.FC<any>> = {
|
||||
'plugins.states': control_PluginsStates,
|
||||
'globalHotkey': control_GlobalHotkeyInput,
|
||||
};
|
||||
|
||||
export interface UpdateSettingValueEvent {
|
||||
|
||||
@@ -15,9 +15,14 @@ export interface ShortcutRecorderProps {
|
||||
initialAccelerator: string;
|
||||
commandName: string;
|
||||
themeId: number;
|
||||
// When true, skip keymap conflict validation (useful for global hotkeys
|
||||
// that aren't part of the internal command keymap).
|
||||
skipKeymapValidation?: boolean;
|
||||
// Controls whether the input auto-focuses on mount. Defaults to true.
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
export const ShortcutRecorder = ({ onSave, onReset, onCancel, onError, initialAccelerator, commandName, themeId }: ShortcutRecorderProps) => {
|
||||
export const ShortcutRecorder = ({ onSave, onReset, onCancel, onError, initialAccelerator, commandName, themeId, skipKeymapValidation, autoFocus = true }: ShortcutRecorderProps) => {
|
||||
const styles = styles_(themeId);
|
||||
|
||||
const [accelerator, setAccelerator] = useState(initialAccelerator);
|
||||
@@ -29,7 +34,9 @@ export const ShortcutRecorder = ({ onSave, onReset, onCancel, onError, initialAc
|
||||
// Otherwise performing a save means that it's going to be disabled
|
||||
if (accelerator) {
|
||||
keymapService.validateAccelerator(accelerator);
|
||||
keymapService.validateKeymap({ accelerator, command: commandName });
|
||||
if (!skipKeymapValidation) {
|
||||
keymapService.validateKeymap({ accelerator, command: commandName });
|
||||
}
|
||||
}
|
||||
|
||||
// Discard previous errors
|
||||
@@ -86,7 +93,7 @@ export const ShortcutRecorder = ({ onSave, onReset, onCancel, onError, initialAc
|
||||
|
||||
onKeyDown={handleKeyDown}
|
||||
readOnly
|
||||
autoFocus
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
|
||||
<button style={styles.inlineButton} disabled={!saveAllowed} onClick={() => onSave({ commandName, accelerator })}>
|
||||
|
||||
@@ -31,7 +31,6 @@ import { stateUtils } from '@joplin/lib/reducer';
|
||||
import { connect } from 'react-redux';
|
||||
import useOnNoteDoubleClick from './utils/useOnNoteDoubleClick';
|
||||
import useAutoScroll from './utils/useAutoScroll';
|
||||
import useRefocusOnDeletion from './utils/useRefocusOnDeletion';
|
||||
|
||||
const commands = {
|
||||
focusElementNoteList,
|
||||
@@ -75,7 +74,6 @@ const NoteList = (props: Props) => {
|
||||
|
||||
const { activeNoteId, setActiveNoteId } = useActiveDescendantId(props.selectedFolderId, props.selectedNoteIds);
|
||||
const focusNote = useFocusNote(listRef, props.notes, makeItemIndexVisible, setActiveNoteId);
|
||||
useRefocusOnDeletion(props.notes.length, props.selectedNoteIds, props.focusedField, props.selectedFolderId, focusNote);
|
||||
|
||||
const moveNote = useMoveNote(
|
||||
props.notesParentType,
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import useRefocusOnDeletion from './useRefocusOnDeletion';
|
||||
|
||||
describe('useRefocusOnDeletion', () => {
|
||||
it('should refocus when a note is deleted in the same folder', () => {
|
||||
const focusNote = jest.fn();
|
||||
const { rerender } = renderHook(
|
||||
({ noteCount }: { noteCount: number }) =>
|
||||
useRefocusOnDeletion(noteCount, ['note-1'], '', 'folder-1', focusNote),
|
||||
{ initialProps: { noteCount: 3 } },
|
||||
);
|
||||
rerender({ noteCount: 2 });
|
||||
expect(focusNote).toHaveBeenCalledWith('note-1');
|
||||
});
|
||||
|
||||
test.each([
|
||||
['note count increases', 2, 3, '', ['note-1']],
|
||||
['another field has focus', 3, 2, 'editor', ['note-1']],
|
||||
['multiple notes are selected', 3, 2, '', ['note-1', 'note-2']],
|
||||
])('should not refocus when %s', (_label, initialCount, newCount, focusedField, noteIds) => {
|
||||
const focusNote = jest.fn();
|
||||
const { rerender } = renderHook(
|
||||
({ noteCount }: { noteCount: number }) =>
|
||||
useRefocusOnDeletion(noteCount, noteIds, focusedField, 'folder-1', focusNote),
|
||||
{ initialProps: { noteCount: initialCount } },
|
||||
);
|
||||
rerender({ noteCount: newCount });
|
||||
expect(focusNote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not refocus when switching to a folder with fewer notes', () => {
|
||||
const focusNote = jest.fn();
|
||||
const { rerender } = renderHook(
|
||||
({ noteCount, folderId }: { noteCount: number; folderId: string }) =>
|
||||
useRefocusOnDeletion(noteCount, ['note-1'], '', folderId, focusNote),
|
||||
{ initialProps: { noteCount: 3, folderId: 'folder-1' } },
|
||||
);
|
||||
rerender({ noteCount: 2, folderId: 'folder-2' });
|
||||
expect(focusNote).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import usePrevious from '@joplin/lib/hooks/usePrevious';
|
||||
const useRefocusOnDeletion = (
|
||||
noteCount: number,
|
||||
selectedNoteIds: string[],
|
||||
focusedField: string,
|
||||
selectedFolderId: string,
|
||||
focusNote: (noteId: string)=> void,
|
||||
) => {
|
||||
const previousNoteCount = usePrevious(noteCount, 0);
|
||||
const previousFolderId = usePrevious(selectedFolderId, '');
|
||||
useEffect(() => {
|
||||
const noteWasRemoved = noteCount < previousNoteCount;
|
||||
const folderDidNotChange = selectedFolderId === previousFolderId;
|
||||
if (noteWasRemoved && folderDidNotChange && selectedNoteIds.length === 1 && !focusedField) {
|
||||
focusNote(selectedNoteIds[0]);
|
||||
}
|
||||
}, [noteCount, previousNoteCount, selectedNoteIds, focusedField, selectedFolderId, previousFolderId, focusNote]);
|
||||
};
|
||||
export default useRefocusOnDeletion;
|
||||
@@ -7,26 +7,40 @@ import activateMainMenuItem from './util/activateMainMenuItem';
|
||||
import setSettingValue from './util/setSettingValue';
|
||||
import { toForwardSlashes } from '@joplin/utils/path';
|
||||
import mockClipboard from './util/mockClipboard';
|
||||
import { ElectronApplication, Page } from '@playwright/test';
|
||||
|
||||
const importAndOpenHtmlExport = async (mainWindow: Page, electronApp: ElectronApplication, noteTitle: string) => {
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
await mainScreen.waitFor();
|
||||
|
||||
await mainScreen.importHtmlDirectory(electronApp, join(__dirname, 'resources', 'html-import'));
|
||||
const importedFolder = mainScreen.sidebar.container.getByText('html-import');
|
||||
await importedFolder.waitFor();
|
||||
|
||||
// Retry -- focusing the imported-folder may fail in some cases
|
||||
await expect(async () => {
|
||||
await importedFolder.click();
|
||||
|
||||
await mainScreen.noteList.focusContent(electronApp);
|
||||
|
||||
const importedHtmlFileItem = mainScreen.noteList.getNoteItemByTitle(noteTitle);
|
||||
await importedHtmlFileItem.click({ timeout: 300 });
|
||||
}).toPass();
|
||||
|
||||
return { mainScreen };
|
||||
};
|
||||
|
||||
test.describe('markdownEditor', () => {
|
||||
test('editor should render the full content of HTML notes', async ({ mainWindow, electronApp }) => {
|
||||
const { mainScreen } = await importAndOpenHtmlExport(mainWindow, electronApp, 'test-html-file-with-spans');
|
||||
|
||||
const editor = mainScreen.noteEditor.codeMirrorEditor;
|
||||
// Regression test: The <span> should not be hidden by inline Markdown rendering (since this is an HTML note):
|
||||
await expect(editor).toHaveText('<p><span style="margin-left: 100px;">test</span></p>');
|
||||
});
|
||||
|
||||
test('preview pane should render images in HTML notes', async ({ mainWindow, electronApp }) => {
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
await mainScreen.waitFor();
|
||||
|
||||
await mainScreen.importHtmlDirectory(electronApp, join(__dirname, 'resources', 'html-import'));
|
||||
const importedFolder = mainScreen.sidebar.container.getByText('html-import');
|
||||
await importedFolder.waitFor();
|
||||
|
||||
// Retry -- focusing the imported-folder may fail in some cases
|
||||
await expect(async () => {
|
||||
await importedFolder.click();
|
||||
|
||||
await mainScreen.noteList.focusContent(electronApp);
|
||||
|
||||
const importedHtmlFileItem = mainScreen.noteList.getNoteItemByTitle('test-html-file-with-image');
|
||||
await importedHtmlFileItem.click({ timeout: 300 });
|
||||
}).toPass();
|
||||
const { mainScreen } = await importAndOpenHtmlExport(mainWindow, electronApp, 'test-html-file-with-image');
|
||||
|
||||
const viewerFrame = mainScreen.noteEditor.getNoteViewerFrameLocator();
|
||||
// Should render headers
|
||||
|
||||
@@ -101,6 +101,35 @@ test.describe('noteList', () => {
|
||||
await expect(testNoteItem).toBeVisible();
|
||||
});
|
||||
|
||||
test('should remain focused after deleting a note to the trash', async ({ electronApp, mainWindow }) => {
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
await mainScreen.createNewNote('test note 1');
|
||||
await mainScreen.createNewNote('test note 2');
|
||||
await mainScreen.createNewNote('test note 3');
|
||||
|
||||
const noteList = mainScreen.noteList;
|
||||
await noteList.sortByTitle(electronApp);
|
||||
await noteList.focusContent(electronApp);
|
||||
|
||||
// The most-recently created note should be selected
|
||||
await noteList.expectNoteToBeSelected('test note 3');
|
||||
|
||||
// All three notes should be visible
|
||||
const getNote = (i: number) => noteList.getNoteItemByTitle(`test note ${i}`);
|
||||
await expect(getNote(1)).toBeVisible();
|
||||
await expect(getNote(2)).toBeVisible();
|
||||
await expect(getNote(3)).toBeVisible();
|
||||
|
||||
await getNote(3).press('Delete');
|
||||
await expect(getNote(3)).not.toBeVisible();
|
||||
|
||||
// Pressing the up arrow should change the selection
|
||||
// (Regression test for https://github.com/laurent22/joplin/issues/10753)
|
||||
await noteList.expectNoteToBeSelected('test note 2');
|
||||
await noteList.container.press('ArrowUp');
|
||||
await noteList.expectNoteToBeSelected('test note 1');
|
||||
});
|
||||
|
||||
test('arrow keys should navigate the note list', async ({ electronApp, mainWindow }) => {
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
const sidebar = mainScreen.sidebar;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<p><span style="margin-left: 100px;">test</span></p>
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from './util/test';
|
||||
import MainScreen from './models/MainScreen';
|
||||
import { Second } from '@joplin/utils/time';
|
||||
|
||||
test.describe('sidebar', () => {
|
||||
test('should be able to create new folders', async ({ mainWindow }) => {
|
||||
@@ -44,6 +45,54 @@ test.describe('sidebar', () => {
|
||||
await expect(mainWindow.locator(':focus')).toHaveText('All notes');
|
||||
});
|
||||
|
||||
// Regression test for https://github.com/laurent22/joplin/issues/15029
|
||||
test('should remain focused when navigating with the arrow keys', async ({ electronApp, mainWindow }) => {
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
const sidebar = mainScreen.sidebar;
|
||||
|
||||
// Build the folder hierarchy: Navigating upwards through the list
|
||||
// should transition from a notebook with more notes to a notebook with
|
||||
// fewer notes.
|
||||
const folderAHeader = await sidebar.createNewFolder('Folder A');
|
||||
await mainScreen.createNewNote('Test');
|
||||
await expect(folderAHeader).toBeVisible();
|
||||
const folderBHeader = await sidebar.createNewFolder('Folder B');
|
||||
await mainScreen.createNewNote('Test 2');
|
||||
await mainScreen.createNewNote('Test 3');
|
||||
const folderCHeader = await sidebar.createNewFolder('Folder C');
|
||||
const folderDHeader = await sidebar.createNewFolder('Folder D');
|
||||
|
||||
await folderBHeader.dragTo(folderAHeader);
|
||||
await folderCHeader.dragTo(folderAHeader);
|
||||
|
||||
// Should have the correct initial state
|
||||
await sidebar.forceUpdateSorting(electronApp);
|
||||
await sidebar.expectToHaveDepths([
|
||||
[folderAHeader, 2],
|
||||
[folderBHeader, 3],
|
||||
[folderCHeader, 3],
|
||||
[folderDHeader, 2],
|
||||
]);
|
||||
|
||||
const assertFocused = async (title: RegExp) => {
|
||||
await expect(mainWindow.locator(':focus')).toHaveText(title);
|
||||
// Pause to help check that focus is stable. This is present to help this test more reliably detect
|
||||
// timing-related issues.
|
||||
await mainWindow.waitForTimeout(Second);
|
||||
await expect(mainWindow.locator(':focus')).toHaveText(title);
|
||||
};
|
||||
|
||||
await folderDHeader.click();
|
||||
|
||||
// Focus should remain on the correct folder header while navigating
|
||||
await mainWindow.keyboard.press('ArrowUp');
|
||||
await assertFocused(/^Folder C/);
|
||||
await mainWindow.keyboard.press('ArrowUp');
|
||||
await assertFocused(/^Folder B/);
|
||||
await mainWindow.keyboard.press('ArrowUp');
|
||||
await assertFocused(/^Folder A/);
|
||||
});
|
||||
|
||||
test('should allow changing the focused folder by pressing the first character of the title', async ({ electronApp, mainWindow }) => {
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
const sidebar = mainScreen.sidebar;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "3.6.8",
|
||||
"version": "3.6.9",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.bundle.js",
|
||||
"private": true,
|
||||
|
||||
@@ -38,6 +38,9 @@ const useStyle = (themeId: number) => {
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
profileList: {
|
||||
flex: 1,
|
||||
},
|
||||
profileListItem: {
|
||||
paddingLeft: theme.margin,
|
||||
paddingRight: theme.margin,
|
||||
@@ -206,15 +209,15 @@ export default (props: Props) => {
|
||||
return (
|
||||
<View style={style.root}>
|
||||
<ScreenHeader title={_('Profiles')} showSaveButton={false} showSideMenuButton={false} showSearchButton={false} />
|
||||
<View>
|
||||
<FlatList
|
||||
data={profiles}
|
||||
renderItem={renderProfileItem}
|
||||
keyExtractor={profile => profile.id}
|
||||
// Needed so that the list rerenders when its dependencies change:
|
||||
extraData={extraListItemData}
|
||||
/>
|
||||
</View>
|
||||
<FlatList
|
||||
style={style.profileList}
|
||||
data={profiles}
|
||||
renderItem={renderProfileItem}
|
||||
keyExtractor={profile => profile.id}
|
||||
// Needed so that the list rerenders when its dependencies change:
|
||||
extraData={extraListItemData}
|
||||
contentContainerStyle={{ paddingBottom: 80 }}
|
||||
/>
|
||||
<FAB
|
||||
icon="plus"
|
||||
accessibilityLabel={_('New profile')}
|
||||
|
||||
@@ -404,6 +404,29 @@ describe('screens/Note', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should set title, body, and parent_id correctly when a note is created via share', async () => {
|
||||
const folder = await Folder.save({ title: 'Share target folder', parent_id: '' });
|
||||
const note = await Note.save({ parent_id: folder.id }, { provisional: true });
|
||||
|
||||
store.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Note',
|
||||
noteId: note.id,
|
||||
sharedData: { title: 'Shared title', text: 'https://example.com' },
|
||||
});
|
||||
store.dispatch({ type: 'NOTE_UPDATE_ONE', note: { ...note }, provisional: true });
|
||||
|
||||
const { unmount } = render(<WrappedNoteScreen />);
|
||||
|
||||
await waitForNoteToMatch(note.id, {
|
||||
title: 'Shared title',
|
||||
body: 'https://example.com',
|
||||
parent_id: folder.id,
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should always start in edit mode for provisional notes regardless of noteVisiblePanes', async () => {
|
||||
store.dispatch({
|
||||
type: 'NOTE_VISIBLE_PANES_SET',
|
||||
|
||||
@@ -2066,7 +2066,7 @@ PODS:
|
||||
- React
|
||||
- RNSecureRandom (1.0.1):
|
||||
- React
|
||||
- RNShare (12.2.1):
|
||||
- RNShare (12.2.2):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
@@ -2133,7 +2133,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- SDWebImage/Core (5.21.7)
|
||||
- SDWebImage/Core (5.21.5)
|
||||
- SDWebImageWebPCoder (0.15.0):
|
||||
- libwebp (~> 1.0)
|
||||
- SDWebImage/Core (~> 5.17)
|
||||
@@ -2632,9 +2632,9 @@ SPEC CHECKSUMS:
|
||||
RNLocalize: 44b09911588826d01c5b949e8e3f9ed5fae16b32
|
||||
RNQuickAction: c2c8f379e614428be0babe4d53a575739667744d
|
||||
RNSecureRandom: b64d263529492a6897e236a22a2c4249aa1b53dc
|
||||
RNShare: 0e600372fb35783fe30d413efd28d11de2bf6cf0
|
||||
RNShare: a075abc351f03fd89517bbee912593f299eb8a64
|
||||
RNSVG: cf9ae78f2edf2988242c71a6392d15ff7dd62522
|
||||
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
|
||||
SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838
|
||||
SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377
|
||||
WhisperVoiceTyping: 343ea840cbde2a5f3508f8b016ebcf1c089179ea
|
||||
Yoga: 786fa7d9d2ff6060b4e688062243fa69c323d140
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"scripts": {
|
||||
"start": "BROWSERSLIST_IGNORE_OLD_DATA=true react-native start --reset-cache",
|
||||
"android": "react-native run-android",
|
||||
"android-log": "adb logcat 'ReactNative:V' 'ReactNativeJS:V' 'chromium:V' '*:S'",
|
||||
"build": "NO_FLIPPER=1 gulp build",
|
||||
"web": "webpack --mode production --config ./web/webpack.config.ts --progress && cp -r ./web/public/* ./web/dist/",
|
||||
"serve-web-hot-reload": "yarn serve-web --env HOT_RELOAD",
|
||||
|
||||
@@ -586,14 +586,18 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
|
||||
if (sharedData) {
|
||||
reg.logger().info('Received shared data');
|
||||
|
||||
// selectedFolderId can be null if no screens other than "All notes"
|
||||
// have been opened.
|
||||
const targetFolder = this.props.selectedFolderId ?? (await Folder.defaultFolder())?.id;
|
||||
if (targetFolder) {
|
||||
const activeFolder = await Folder.getValidActiveFolder();
|
||||
if (activeFolder) {
|
||||
logger.info('Sharing: handleShareData: Processing...');
|
||||
await handleShared(sharedData, targetFolder, this.props.dispatch);
|
||||
await handleShared(sharedData, activeFolder.id, this.props.dispatch);
|
||||
} else {
|
||||
reg.logger().info('Cannot handle share - default folder id is not set');
|
||||
reg.logger().warn('Cannot handle share - no valid active folder found');
|
||||
void this.dropdownAlert_({
|
||||
type: 'error',
|
||||
title: _('Cannot share'),
|
||||
message: _('No valid notebook is available. Please create or select a notebook and try again.'),
|
||||
});
|
||||
ShareExtension.close();
|
||||
}
|
||||
} else {
|
||||
logger.info('Sharing: received empty share data.');
|
||||
|
||||
@@ -109,7 +109,9 @@ const configFromSettings = (settings: EditorSettings, context: RenderedContentCo
|
||||
extensions.push(Prec.low(keymap.of(defaultKeymap)));
|
||||
}
|
||||
|
||||
if (settings.inlineRenderingEnabled) {
|
||||
// Only enable in-editor rendering for Markdown notes. In-editor rendering can result in
|
||||
// confusing output in HTML notes (e.g. some, but not most, tags hidden).
|
||||
if (settings.inlineRenderingEnabled && settings.language === EditorLanguageType.Markdown) {
|
||||
extensions.push(renderingExtension());
|
||||
}
|
||||
|
||||
|
||||
@@ -304,7 +304,10 @@ shared.reloadNote = async (comp: BaseNoteScreenComponent) => {
|
||||
|
||||
const fromShare = !!comp.props.sharedData;
|
||||
if (note) {
|
||||
const folder = Folder.byId(comp.props.folders, note.parent_id);
|
||||
let folder = Folder.byId(comp.props.folders, note.parent_id);
|
||||
if (!folder && note.parent_id) {
|
||||
folder = await Folder.load(note.parent_id);
|
||||
}
|
||||
comp.setState({
|
||||
lastSavedNote: { ...note },
|
||||
note: note,
|
||||
@@ -337,12 +340,24 @@ shared.reloadNote = async (comp: BaseNoteScreenComponent) => {
|
||||
shared.initState = async function(comp: BaseNoteScreenComponent) {
|
||||
const note = await shared.reloadNote(comp);
|
||||
|
||||
if (comp.props.sharedData) {
|
||||
if (comp.props.sharedData && note) {
|
||||
// Use the note returned by reloadNote directly to avoid a race condition where
|
||||
// comp.state.note is still the initial empty note (Note.new() with parent_id='')
|
||||
// because React hasn't flushed reloadNote's setState yet. Without this, the
|
||||
// scheduled save would overwrite parent_id with an empty string in the DB.
|
||||
const updatedNote = { ...note };
|
||||
const fieldsToSave: NoteEntity = { id: note.id };
|
||||
if (comp.props.sharedData.title) {
|
||||
this.noteComponent_change(comp, 'title', comp.props.sharedData.title);
|
||||
updatedNote.title = comp.props.sharedData.title;
|
||||
fieldsToSave.title = comp.props.sharedData.title;
|
||||
}
|
||||
if (comp.props.sharedData.text) {
|
||||
this.noteComponent_change(comp, 'body', comp.props.sharedData.text);
|
||||
updatedNote.body = comp.props.sharedData.text;
|
||||
fieldsToSave.body = comp.props.sharedData.text;
|
||||
}
|
||||
if (fieldsToSave.title !== undefined || fieldsToSave.body !== undefined) {
|
||||
await Note.save(fieldsToSave);
|
||||
comp.setState({ note: updatedNote, lastSavedNote: updatedNote });
|
||||
}
|
||||
if (comp.props.sharedData.resources) {
|
||||
for (let i = 0; i < comp.props.sharedData.resources.length; i++) {
|
||||
|
||||
@@ -1177,6 +1177,25 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
|
||||
startMinimized: { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, section: 'application', public: true, appTypes: [AppType.Desktop], label: () => _('Start application minimised in the tray icon'), show: settings => !!settings['showTrayIcon'] },
|
||||
|
||||
'globalHotkey': {
|
||||
value: '',
|
||||
type: SettingItemType.String,
|
||||
section: 'application',
|
||||
public: true,
|
||||
appTypes: [AppType.Desktop],
|
||||
label: () => _('Global shortcut to show/hide Joplin'),
|
||||
description: () => _('A system-wide keyboard shortcut that toggles the Joplin window. Works even when Joplin is not focused. Example: CommandOrControl+Shift+J. Leave empty to disable.'),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
autoSave: true,
|
||||
// Electron's globalShortcut API does not yet work under Wayland,
|
||||
// so we hide this option when running on a Wayland session.
|
||||
show: () => {
|
||||
if (platform !== 'linux') return true;
|
||||
return process.env.XDG_SESSION_TYPE !== 'wayland' && !process.env.WAYLAND_DISPLAY;
|
||||
},
|
||||
},
|
||||
|
||||
collapsedFolderIds: { value: [] as string[], type: SettingItemType.Array, public: false },
|
||||
|
||||
'keychain.supported': { value: -1, type: SettingItemType.Int, public: false },
|
||||
|
||||
@@ -162,7 +162,17 @@ export default class ExternalEditWatcher {
|
||||
return;
|
||||
}
|
||||
|
||||
let noteContent = await shim.fsDriver().readFile(path, 'utf-8');
|
||||
let noteContent: string;
|
||||
try {
|
||||
noteContent = await shim.fsDriver().readFile(path, 'utf-8');
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
this.logger().warn(`ExternalEditWatcher: Watched file no longer exists: ${path}`);
|
||||
void this.stopWatching(id);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// In some very rare cases, the "change" event is going to be emitted but the file will be empty.
|
||||
// This is likely to be the editor that first clears the file, then writes the content to it, so if
|
||||
|
||||
@@ -19,11 +19,6 @@ const uslug = require('@joplin/fork-uslug');
|
||||
|
||||
const logger = Logger.create('PluginService');
|
||||
|
||||
interface PluginExtractionState {
|
||||
size: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// Plugin data is split into two:
|
||||
//
|
||||
// - First there's the service `plugins` property, which contains the
|
||||
@@ -110,7 +105,6 @@ export default class PluginService extends BaseService {
|
||||
private startedPlugins_: Record<string, boolean> = {};
|
||||
private isSafeMode_ = false;
|
||||
private pluginsChangeListeners_: LoadedPluginsChangeListener[] = [];
|
||||
private extractionStates_: Record<string, PluginExtractionState> = null;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public initialize(appVersion: string, platformImplementation: any, runner: BasePluginRunner, store: any) {
|
||||
@@ -203,31 +197,6 @@ export default class PluginService extends BaseService {
|
||||
await shim.fsDriver().remove(plugin.baseDir);
|
||||
}
|
||||
|
||||
private extractionStatePath(): string {
|
||||
return `${Setting.value('cacheDir')}/plugin-extraction-state.json`;
|
||||
}
|
||||
|
||||
private async loadExtractionStates(): Promise<Record<string, PluginExtractionState>> {
|
||||
if (!this.extractionStates_) {
|
||||
try {
|
||||
const text = await shim.fsDriver().readFile(this.extractionStatePath(), 'utf8');
|
||||
this.extractionStates_ = JSON.parse(text);
|
||||
} catch {
|
||||
this.extractionStates_ = {};
|
||||
}
|
||||
}
|
||||
return this.extractionStates_;
|
||||
}
|
||||
|
||||
private async saveExtractionStates(states: Record<string, PluginExtractionState>): Promise<void> {
|
||||
this.extractionStates_ = states;
|
||||
try {
|
||||
await shim.fsDriver().writeFile(this.extractionStatePath(), JSON.stringify(states), 'utf8');
|
||||
} catch (error) {
|
||||
logger.error('Failed to save extraction states:', error);
|
||||
}
|
||||
}
|
||||
|
||||
public pluginById(id: string): Plugin {
|
||||
if (!this.plugins_[id]) throw new Error(`Plugin not found: ${id}`);
|
||||
|
||||
@@ -322,24 +291,15 @@ export default class PluginService extends BaseService {
|
||||
baseDir = rtrimSlashes(baseDir);
|
||||
|
||||
const fname = filename(path);
|
||||
const hash = await shim.fsDriver().md5File(path);
|
||||
|
||||
const unpackDir = `${Setting.value('cacheDir')}/${fname}`;
|
||||
const manifestFilePath = `${unpackDir}/manifest.json`;
|
||||
|
||||
// Use file size + mtime to check if the .jpl has changed, to
|
||||
// avoid computing an MD5 hash of the full file on every startup.
|
||||
const stat = await shim.fsDriver().stat(path);
|
||||
const extractionStates = await this.loadExtractionStates();
|
||||
const extractionState = extractionStates[fname];
|
||||
const scriptFilePath = `${unpackDir}/index.js`;
|
||||
const extractionValid = extractionState
|
||||
&& extractionState.size === stat.size
|
||||
&& extractionState.timestamp === stat.mtime.getTime()
|
||||
&& await shim.fsDriver().exists(manifestFilePath)
|
||||
&& await shim.fsDriver().exists(scriptFilePath);
|
||||
|
||||
if (!extractionValid) {
|
||||
logger.info(`Extracting plugin: ${fname}`);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
let manifest: any = await this.loadManifestToObject(manifestFilePath);
|
||||
|
||||
if (!manifest || manifest._package_hash !== hash) {
|
||||
await shim.fsDriver().remove(unpackDir);
|
||||
await shim.fsDriver().mkdir(unpackDir);
|
||||
|
||||
@@ -350,16 +310,12 @@ export default class PluginService extends BaseService {
|
||||
cwd: unpackDir,
|
||||
});
|
||||
|
||||
const manifest = await this.loadManifestToObject(manifestFilePath);
|
||||
manifest = await this.loadManifestToObject(manifestFilePath);
|
||||
if (!manifest) throw new Error(`Missing manifest file at: ${manifestFilePath}`);
|
||||
|
||||
extractionStates[fname] = {
|
||||
size: stat.size,
|
||||
timestamp: stat.mtime.getTime(),
|
||||
};
|
||||
await this.saveExtractionStates(extractionStates);
|
||||
} else {
|
||||
logger.info(`Using already extracted plugin: ${fname}`);
|
||||
manifest._package_hash = hash;
|
||||
|
||||
await shim.fsDriver().writeFile(manifestFilePath, JSON.stringify(manifest, null, '\t'), 'utf8');
|
||||
}
|
||||
|
||||
return this.loadPluginFromPath(unpackDir);
|
||||
@@ -539,7 +495,6 @@ export default class PluginService extends BaseService {
|
||||
logger.error(`Could not load plugin: ${pluginPath}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public async loadAndRunDevPlugins(settings: PluginSettings) {
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Action, createStore } from 'redux';
|
||||
import MockPlatformImplementation from './testing/MockPlatformImplementation';
|
||||
import createTestPlugin from '../../testing/plugins/createTestPlugin';
|
||||
import Plugin from './Plugin';
|
||||
import shim from '../../shim';
|
||||
|
||||
const createMockReduxStore = () => {
|
||||
return createStore((state: State = defaultState, action: Action<string>) => {
|
||||
@@ -137,44 +136,6 @@ describe('loadPlugins', () => {
|
||||
expect([...pluginRunner.runningPluginIds].sort()).toMatchObject(expectedRunningIds);
|
||||
});
|
||||
|
||||
test('should skip extraction when jpl has not changed', async () => {
|
||||
const pluginId = 'joplin.test.plugin.packed';
|
||||
await createTestPlugin({
|
||||
...defaultManifestProperties,
|
||||
id: pluginId,
|
||||
name: 'Test JPL Plugin',
|
||||
}, { format: 'jpl' });
|
||||
|
||||
const pluginRunner = new MockPluginRunner();
|
||||
const store = createMockReduxStore();
|
||||
const service = PluginService.instance();
|
||||
service.initialize('2.3.4', platformImplementation, pluginRunner, store);
|
||||
|
||||
const tarExtractSpy = jest.spyOn(shim.fsDriver(), 'tarExtract');
|
||||
|
||||
// First load should extract
|
||||
await service.loadAndRunPlugins(Setting.value('pluginDir'), Setting.value('plugins.states'));
|
||||
expect(tarExtractSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second load with same file should skip extraction
|
||||
await service.unloadPlugin(pluginId);
|
||||
await service.loadAndRunPlugins(Setting.value('pluginDir'), Setting.value('plugins.states'));
|
||||
expect(tarExtractSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Recreating the jpl (different mtime/size) should trigger re-extraction
|
||||
await service.unloadPlugin(pluginId);
|
||||
await createTestPlugin({
|
||||
...defaultManifestProperties,
|
||||
id: pluginId,
|
||||
name: 'Test JPL Plugin',
|
||||
}, { format: 'jpl', onStart: '/* changed */' });
|
||||
|
||||
await service.loadAndRunPlugins(Setting.value('pluginDir'), Setting.value('plugins.states'));
|
||||
expect(tarExtractSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
tarExtractSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('should not block allPluginsStarted when a plugin fails to start', async () => {
|
||||
// This tests the fix for https://github.com/laurent22/joplin/issues/12793
|
||||
// When a plugin crashes before calling register(), it should not block
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { join } from 'path';
|
||||
import { PluginManifest } from '../../services/plugins/utils/types';
|
||||
import Setting from '../../models/Setting';
|
||||
import { mkdirp, writeFile } from 'fs-extra';
|
||||
import { writeFile } from 'fs-extra';
|
||||
import { defaultPluginSetting } from '../../services/plugins/PluginService';
|
||||
import shim from '../../shim';
|
||||
|
||||
|
||||
const setPluginEnabled = (id: string, enabled: boolean) => {
|
||||
@@ -20,35 +19,22 @@ const setPluginEnabled = (id: string, enabled: boolean) => {
|
||||
interface Options {
|
||||
onStart?: string;
|
||||
enabled?: boolean;
|
||||
format?: 'js' | 'jpl';
|
||||
}
|
||||
|
||||
const createTestPlugin = async (manifest: PluginManifest, { onStart = '', enabled = true, format = 'js' }: Options = {}) => {
|
||||
const scriptSource = `joplin.plugins.register({
|
||||
onStart: async function() {
|
||||
${onStart}
|
||||
},
|
||||
});`;
|
||||
|
||||
if (format === 'jpl') {
|
||||
const tempDir = join(Setting.value('tempDir'), `plugin-build-${manifest.id}`);
|
||||
await mkdirp(tempDir);
|
||||
await writeFile(join(tempDir, 'manifest.json'), JSON.stringify(manifest), 'utf-8');
|
||||
await writeFile(join(tempDir, 'index.js'), scriptSource, 'utf-8');
|
||||
|
||||
const jplPath = join(Setting.value('pluginDir'), `${manifest.id}.jpl`);
|
||||
await shim.fsDriver().tarCreate({ cwd: tempDir, file: jplPath }, ['manifest.json', 'index.js']);
|
||||
} else {
|
||||
const pluginSource = `
|
||||
const createTestPlugin = async (manifest: PluginManifest, { onStart = '', enabled = true }: Options = {}) => {
|
||||
const pluginSource = `
|
||||
/* joplin-manifest:
|
||||
${JSON.stringify(manifest)}
|
||||
*/
|
||||
|
||||
${scriptSource}
|
||||
joplin.plugins.register({
|
||||
onStart: async function() {
|
||||
${onStart}
|
||||
},
|
||||
});
|
||||
`;
|
||||
const pluginPath = join(Setting.value('pluginDir'), `${manifest.id}.js`);
|
||||
await writeFile(pluginPath, pluginSource, 'utf-8');
|
||||
}
|
||||
const pluginPath = join(Setting.value('pluginDir'), `${manifest.id}.js`);
|
||||
await writeFile(pluginPath, pluginSource, 'utf-8');
|
||||
|
||||
setPluginEnabled(manifest.id, enabled);
|
||||
|
||||
|
||||
@@ -90,7 +90,23 @@ Suppose that the importer's Rust code is failing to parse a specific `example.on
|
||||
2. Setting up Rust and Rust debugging. See [the relevant VSCode documentation](https://code.visualstudio.com/docs/languages/rust#_debugging) for details.
|
||||
3. Clicking the "Debug" button for the test added in step 1. This button should be provided by extensions set up in step 2.
|
||||
|
||||
### Inspecting `.one` files
|
||||
|
||||
The `inspect` binary target of the `parser` crate allows inspecting `.one` file data.
|
||||
|
||||
For example, to inspect lower-level OneStore data:
|
||||
```console
|
||||
bash$ cd parser/
|
||||
bash$ cargo run -- ../test-data/ink.one --onestore
|
||||
```
|
||||
|
||||
To inspect higher-level (parsed) section data:
|
||||
```console
|
||||
bash$ cd parser/
|
||||
bash$ cargo run -- ../test-data/ink.one --section
|
||||
```
|
||||
|
||||
**Note**: `inspect`'s output is unstable and should not be relied upon by scripts.
|
||||
|
||||
### Developing
|
||||
|
||||
|
||||
15
packages/onenote-converter/parser-utils/src/debug.rs
Normal file
15
packages/onenote-converter/parser-utils/src/debug.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
/// A struct that has a specific `fmt::Debug` serialization.
|
||||
/// Useful when customizing a `struct`'s debug output.
|
||||
pub struct DebugOutput<'a>(&'a str);
|
||||
|
||||
impl<'a> From<&'a str> for DebugOutput<'a> {
|
||||
fn from(value: &'a str) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> std::fmt::Debug for DebugOutput<'a> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.0)
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,12 @@ impl From<widestring::error::MissingNulTerminator> for Error {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<widestring::error::Utf16Error> for Error {
|
||||
fn from(err: widestring::error::Utf16Error) -> Self {
|
||||
ErrorKind::from(err).into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<uuid::Error> for Error {
|
||||
fn from(err: uuid::Error) -> Self {
|
||||
ErrorKind::from(err).into()
|
||||
@@ -128,6 +134,13 @@ pub enum ErrorKind {
|
||||
err: string::FromUtf16Error,
|
||||
},
|
||||
|
||||
/// A different type of malformed UTF-16 string was encountered during parsing.
|
||||
#[error("Malformed UTF-16 string: {err}")]
|
||||
Utf16LibError {
|
||||
#[from]
|
||||
err: widestring::error::Utf16Error,
|
||||
},
|
||||
|
||||
/// A UTF-16 string without a null terminator was encountered during parsing.
|
||||
#[error("UTF-16 string is missing null terminator: {err}")]
|
||||
Utf16MissingNull {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use widestring::U16CString;
|
||||
|
||||
pub mod debug;
|
||||
pub mod errors;
|
||||
mod file_api;
|
||||
pub mod log;
|
||||
@@ -26,6 +27,6 @@ impl Utf16ToString for &[u8] {
|
||||
.collect();
|
||||
|
||||
let value = U16CString::from_vec_truncate(data);
|
||||
Ok(value.to_string().unwrap())
|
||||
value.to_string().map_err(|err| err.into())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,3 +33,6 @@ features = [
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "lib"]
|
||||
|
||||
[[bin]]
|
||||
name = "inspect"
|
||||
|
||||
121
packages/onenote-converter/parser/src/bin/inspect.rs
Normal file
121
packages/onenote-converter/parser/src/bin/inspect.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
use parser::Parser;
|
||||
use parser_utils::errors::Error;
|
||||
use std::{
|
||||
env::{self, Args},
|
||||
path::PathBuf,
|
||||
process::exit,
|
||||
};
|
||||
|
||||
pub fn main() {
|
||||
let config = match Config::from_args(&mut env::args()) {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
print_help_text(&error.program_name, error.reason);
|
||||
exit(1)
|
||||
}
|
||||
};
|
||||
|
||||
let input_path_string = &config.input_file.to_string_lossy();
|
||||
eprintln!("Reading {}", input_path_string);
|
||||
let data = match std::fs::read(&config.input_file) {
|
||||
Ok(data) => data,
|
||||
Err(error) => {
|
||||
let error = format!("File read error: {error}");
|
||||
print_help_text(&config.program_name, &error);
|
||||
exit(2)
|
||||
}
|
||||
};
|
||||
|
||||
let mut parser = Parser::new();
|
||||
if config.output_mode == OutputMode::Section {
|
||||
let parsed_section = match parser.parse_section_from_data(&data, input_path_string) {
|
||||
Ok(section) => section,
|
||||
Err(error) => handle_parse_error(&config, error),
|
||||
};
|
||||
|
||||
println!("{:#?}", parsed_section);
|
||||
} else {
|
||||
let parsed_onestore = match parser.parse_onestore_raw(&data) {
|
||||
Ok(section) => section,
|
||||
Err(error) => handle_parse_error(&config, error),
|
||||
};
|
||||
|
||||
println!("{:#?}", parsed_onestore);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_parse_error(config: &Config, error: Error) -> ! {
|
||||
let error = format!("Parse error: {error}");
|
||||
print_help_text(&config.program_name, &error);
|
||||
exit(3)
|
||||
}
|
||||
|
||||
fn print_help_text(program_name: &str, error: &str) {
|
||||
let error_info = if error.is_empty() { "" } else { error };
|
||||
|
||||
eprintln!("Usage: {program_name} <input_file> [--section|--onestore]");
|
||||
eprintln!("Description: Prints debug information about the given <input_file>");
|
||||
eprintln!("{error_info}");
|
||||
}
|
||||
|
||||
struct ConfigParseError {
|
||||
reason: &'static str,
|
||||
program_name: String,
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum OutputMode {
|
||||
/// Lower-level output
|
||||
FileContent,
|
||||
/// Higher-level output, including the parsed objects
|
||||
Section,
|
||||
}
|
||||
|
||||
struct Config {
|
||||
input_file: PathBuf,
|
||||
output_mode: OutputMode,
|
||||
program_name: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_args(args: &mut Args) -> Result<Self, ConfigParseError> {
|
||||
let Some(program_name) = &args.next() else {
|
||||
return Err(ConfigParseError {
|
||||
reason: "Missing program name",
|
||||
program_name: "??".into(),
|
||||
});
|
||||
};
|
||||
let program_name = program_name.to_string();
|
||||
let Some(input_file) = &args.next() else {
|
||||
return Err(ConfigParseError {
|
||||
reason: "Not enough arguments",
|
||||
program_name,
|
||||
});
|
||||
};
|
||||
|
||||
let output_mode = args.next().unwrap_or("--onestore".into());
|
||||
let output_mode = match output_mode.as_str() {
|
||||
"--onestore" => Ok(OutputMode::FileContent),
|
||||
"--section" => Ok(OutputMode::Section),
|
||||
_ => {
|
||||
return Err(ConfigParseError {
|
||||
reason: "Invalid output mode (expected --onestore or --section)",
|
||||
program_name,
|
||||
});
|
||||
}
|
||||
}?;
|
||||
|
||||
if args.next().is_some() {
|
||||
return Err(ConfigParseError {
|
||||
reason: "Too many arguments",
|
||||
program_name,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Config {
|
||||
input_file: input_file.into(),
|
||||
output_mode,
|
||||
program_name,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -539,7 +539,7 @@ pub struct RootObjectReference3FND {
|
||||
pub struct RevisionRoleDeclarationFND {
|
||||
pub rid: ExGuid,
|
||||
/// "should be 0x01"
|
||||
revision_role: u32,
|
||||
pub revision_role: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Parse)]
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::{
|
||||
},
|
||||
shared::exguid::ExGuid,
|
||||
};
|
||||
use parser_utils::{errors::Result, log};
|
||||
use parser_utils::errors::Result;
|
||||
use std::fmt::Debug;
|
||||
use std::rc::Rc;
|
||||
|
||||
@@ -73,8 +73,9 @@ impl ObjectGroupList {
|
||||
if matches!(item, FileNodeData::ObjectGroupEndFND) {
|
||||
break;
|
||||
} else if let FileNodeData::DataSignatureGroupDefinitionFND(_) = item {
|
||||
// Marks the end of a signature block. Ignored.
|
||||
// See https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-onestore/0fa4c886-011a-4c19-9651-9a69e43a19c6
|
||||
iterator.next();
|
||||
log!("Ignoring DataSignatureGroupDefinitionFND");
|
||||
} else if let Some(object) = Object::try_parse(iterator, &parse_context)? {
|
||||
objects.push(Rc::new(object));
|
||||
} else {
|
||||
|
||||
@@ -60,8 +60,15 @@ impl RevisionManifestList {
|
||||
let revision = revisions_map.get(&data.base.rid);
|
||||
if let Some(_revision) = revision {
|
||||
iterator.next();
|
||||
// TODO: Find a test .one file that uses this and implement:
|
||||
log_warn!("TO-DO: Apply the new role and context to the revision");
|
||||
|
||||
// According to MS-ONESTORE 2.1.12, revision_role *should* always be 0x1
|
||||
if data.base.revision_role != 0x1 {
|
||||
// TODO: Find a test .one file that uses this and implement:
|
||||
log_warn!(
|
||||
"TO-DO: Apply the new role and context to the revision (role {:x})",
|
||||
data.base.revision_role
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return Err(
|
||||
ErrorKind::MalformedOneStoreData("RevisionRoleAndContextDeclarationFND points to a non-existent revision".into()).into()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::onenote::notebook::Notebook;
|
||||
use crate::onenote::section::{Section, SectionEntry, SectionGroup};
|
||||
use crate::onestore::{OneStoreType, parse_onestore};
|
||||
use crate::onestore::{OneStore, OneStoreType, parse_onestore};
|
||||
use parser_utils::errors::{ErrorKind, Result};
|
||||
use parser_utils::{fs_driver, log, reader::Reader};
|
||||
|
||||
@@ -73,6 +75,11 @@ impl Parser {
|
||||
self.parse_section_from_data(&data, &path)
|
||||
}
|
||||
|
||||
/// Parses low-level OneStore data
|
||||
pub fn parse_onestore_raw(&mut self, data: &[u8]) -> Result<Rc<dyn OneStore>> {
|
||||
parse_onestore(&mut Reader::new(data))
|
||||
}
|
||||
|
||||
/// Parse a OneNote section file from a byte array.
|
||||
/// The [path] is used to provide debugging information and determine
|
||||
/// the name of the section file.
|
||||
|
||||
@@ -21,7 +21,7 @@ pub mod mapping_table;
|
||||
pub mod object;
|
||||
pub mod object_space;
|
||||
|
||||
pub trait OneStore {
|
||||
pub trait OneStore: std::fmt::Debug {
|
||||
fn get_type(&self) -> OneStoreType;
|
||||
fn data_root(&self) -> ObjectSpaceRef;
|
||||
/// Fetches the object space that is parent to the object identified by the
|
||||
|
||||
@@ -22,7 +22,7 @@ impl ObjectFileData for FileBlob {
|
||||
|
||||
/// See [\[MS-ONESTORE\] 2.1.5](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-onestore/ce60b62f-82e5-401a-bf2c-3255457732ad)
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Object {
|
||||
pub struct Object {
|
||||
pub(crate) context_id: ExGuid,
|
||||
|
||||
pub(crate) jc_id: JcId,
|
||||
@@ -51,11 +51,11 @@ impl std::fmt::Debug for Object {
|
||||
}
|
||||
|
||||
impl Object {
|
||||
pub fn id(&self) -> JcId {
|
||||
pub(crate) fn id(&self) -> JcId {
|
||||
self.jc_id
|
||||
}
|
||||
|
||||
pub fn props(&self) -> &ObjectPropSet {
|
||||
pub(crate) fn props(&self) -> &ObjectPropSet {
|
||||
&self.props
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use crate::one::property::PropertyType;
|
||||
use crate::shared::property::{PropertyId, PropertyValue};
|
||||
use parser_utils::Reader;
|
||||
use parser_utils::Utf16ToString;
|
||||
use parser_utils::debug::DebugOutput;
|
||||
use parser_utils::errors::Result;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Debug;
|
||||
@@ -25,12 +27,41 @@ pub(crate) struct PropertySet {
|
||||
|
||||
impl Debug for PropertySet {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
fn format_value(value: &PropertyValue) -> String {
|
||||
match value {
|
||||
PropertyValue::Vec(vec) => {
|
||||
// Vec() property values are used to represent strings. Try creating a string representation for
|
||||
// debugging purposes:
|
||||
let s = vec
|
||||
.as_slice()
|
||||
// OneNote file strings are usually UTF-16
|
||||
.utf16_to_string()
|
||||
.unwrap_or("".to_string());
|
||||
|
||||
// Heuristic: If the text contains at least one ASCII letter/space character, it's probably a string.
|
||||
// This will miss some non-ASCII strings and incorrectly print some non-string vecs.
|
||||
let is_probably_string = !s.is_empty()
|
||||
&& s.chars()
|
||||
.any(|c| c.is_ascii_whitespace() || c.is_ascii_alphanumeric());
|
||||
if is_probably_string {
|
||||
format!("{:?} ({:?})", s, vec)
|
||||
} else {
|
||||
format!("{:?}", vec)
|
||||
}
|
||||
}
|
||||
// Use the default compact representation of the value.
|
||||
// This keeps potentially-long property values on a single line when producing
|
||||
// multi-line debug output, which is usually more readable.
|
||||
_ => format!("{:?}", value),
|
||||
}
|
||||
}
|
||||
|
||||
let mut debug_map = f.debug_map();
|
||||
for (key, (_, value)) in &self.values {
|
||||
let formatted_key = format!("{:#0x}", key);
|
||||
// Use the default compact representation of the value
|
||||
let formatted_value = format!("{:?}", value);
|
||||
debug_map.entry(&formatted_key, &formatted_value);
|
||||
let formatted_value = format_value(value);
|
||||
|
||||
debug_map.entry(&formatted_key, &DebugOutput::from(formatted_value.as_str()));
|
||||
}
|
||||
debug_map.finish()
|
||||
}
|
||||
|
||||
@@ -261,3 +261,4 @@ llamacpp
|
||||
bgcolor
|
||||
bordercolor
|
||||
togglefullscreen
|
||||
onestore
|
||||
|
||||
Reference in New Issue
Block a user