You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2026-04-18 19:42:23 +02:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1ee89c2b7 | |||
| 33e8f0ee1b | |||
| 1cde57601b |
+2
-2
@@ -201,8 +201,6 @@ 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
|
||||
@@ -360,6 +358,8 @@ 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
|
||||
|
||||
+2
-2
@@ -174,8 +174,6 @@ 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
|
||||
@@ -333,6 +331,8 @@ 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,9 +11,6 @@
|
||||
- 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.
|
||||
- When an unknown word is detected by cSpell, handle is as per the specification in `readme/dev/spellcheck.md`
|
||||
- To compile TypeScript, use `yarn tsc`. To type-check without emitting files, use `yarn tsc --noEmit`.
|
||||
|
||||
## Full Documentation
|
||||
|
||||
|
||||
@@ -939,7 +939,6 @@ 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,10 +150,6 @@ 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, globalShortcut } from 'electron';
|
||||
import { BrowserWindow, nativeTheme, nativeImage, shell, dialog, MessageBoxSyncOptions, safeStorage, Menu, MenuItemConstructorOptions, MenuItem, BrowserWindowConstructorOptions, FileFilter, SaveDialogOptions } from 'electron';
|
||||
import { dirname, toSystemSlashes } from '@joplin/lib/path-utils';
|
||||
import { fileUriToPath } from '@joplin/utils/url';
|
||||
import { urlDecode } from '@joplin/lib/string-utils';
|
||||
@@ -46,7 +46,6 @@ 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;
|
||||
@@ -208,54 +207,6 @@ 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
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
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: '' });
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
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,7 +3,6 @@ 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';
|
||||
@@ -12,10 +11,8 @@ import * as pathUtils from '@joplin/lib/path-utils';
|
||||
import SettingLabel from './SettingLabel';
|
||||
import SettingDescription from './SettingDescription';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Each control component has different prop types
|
||||
const settingKeyToControl: Record<string, React.FC<any>> = {
|
||||
const settingKeyToControl: Record<string, typeof control_PluginsStates> = {
|
||||
'plugins.states': control_PluginsStates,
|
||||
'globalHotkey': control_GlobalHotkeyInput,
|
||||
};
|
||||
|
||||
export interface UpdateSettingValueEvent {
|
||||
|
||||
@@ -15,14 +15,9 @@ 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, skipKeymapValidation, autoFocus = true }: ShortcutRecorderProps) => {
|
||||
export const ShortcutRecorder = ({ onSave, onReset, onCancel, onError, initialAccelerator, commandName, themeId }: ShortcutRecorderProps) => {
|
||||
const styles = styles_(themeId);
|
||||
|
||||
const [accelerator, setAccelerator] = useState(initialAccelerator);
|
||||
@@ -34,9 +29,7 @@ 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);
|
||||
if (!skipKeymapValidation) {
|
||||
keymapService.validateKeymap({ accelerator, command: commandName });
|
||||
}
|
||||
keymapService.validateKeymap({ accelerator, command: commandName });
|
||||
}
|
||||
|
||||
// Discard previous errors
|
||||
@@ -93,7 +86,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,6 +31,7 @@ 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,
|
||||
@@ -74,6 +75,7 @@ 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,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
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,40 +7,26 @@ 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 importAndOpenHtmlExport(mainWindow, electronApp, 'test-html-file-with-image');
|
||||
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 viewerFrame = mainScreen.noteEditor.getNoteViewerFrameLocator();
|
||||
// Should render headers
|
||||
|
||||
@@ -101,35 +101,6 @@ 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;
|
||||
|
||||
-1
@@ -1 +0,0 @@
|
||||
<p><span style="margin-left: 100px;">test</span></p>
|
||||
@@ -1,6 +1,5 @@
|
||||
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 }) => {
|
||||
@@ -45,54 +44,6 @@ 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.9",
|
||||
"version": "3.6.8",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.bundle.js",
|
||||
"private": true,
|
||||
|
||||
@@ -83,8 +83,8 @@ android {
|
||||
applicationId "net.cozic.joplin"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2097804
|
||||
versionName "3.6.16"
|
||||
versionCode 2097803
|
||||
versionName "3.6.15"
|
||||
|
||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||
|
||||
|
||||
@@ -38,9 +38,6 @@ const useStyle = (themeId: number) => {
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
profileList: {
|
||||
flex: 1,
|
||||
},
|
||||
profileListItem: {
|
||||
paddingLeft: theme.margin,
|
||||
paddingRight: theme.margin,
|
||||
@@ -209,15 +206,15 @@ export default (props: Props) => {
|
||||
return (
|
||||
<View style={style.root}>
|
||||
<ScreenHeader title={_('Profiles')} showSaveButton={false} showSideMenuButton={false} showSearchButton={false} />
|
||||
<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 }}
|
||||
/>
|
||||
<View>
|
||||
<FlatList
|
||||
data={profiles}
|
||||
renderItem={renderProfileItem}
|
||||
keyExtractor={profile => profile.id}
|
||||
// Needed so that the list rerenders when its dependencies change:
|
||||
extraData={extraListItemData}
|
||||
/>
|
||||
</View>
|
||||
<FAB
|
||||
icon="plus"
|
||||
accessibilityLabel={_('New profile')}
|
||||
|
||||
@@ -404,29 +404,6 @@ 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.2):
|
||||
- RNShare (12.2.1):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
@@ -2133,7 +2133,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- SDWebImage/Core (5.21.5)
|
||||
- SDWebImage/Core (5.21.7)
|
||||
- SDWebImageWebPCoder (0.15.0):
|
||||
- libwebp (~> 1.0)
|
||||
- SDWebImage/Core (~> 5.17)
|
||||
@@ -2632,9 +2632,9 @@ SPEC CHECKSUMS:
|
||||
RNLocalize: 44b09911588826d01c5b949e8e3f9ed5fae16b32
|
||||
RNQuickAction: c2c8f379e614428be0babe4d53a575739667744d
|
||||
RNSecureRandom: b64d263529492a6897e236a22a2c4249aa1b53dc
|
||||
RNShare: a075abc351f03fd89517bbee912593f299eb8a64
|
||||
RNShare: 0e600372fb35783fe30d413efd28d11de2bf6cf0
|
||||
RNSVG: cf9ae78f2edf2988242c71a6392d15ff7dd62522
|
||||
SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838
|
||||
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
|
||||
SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377
|
||||
WhisperVoiceTyping: 343ea840cbde2a5f3508f8b016ebcf1c089179ea
|
||||
Yoga: 786fa7d9d2ff6060b4e688062243fa69c323d140
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
"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,18 +586,14 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
|
||||
if (sharedData) {
|
||||
reg.logger().info('Received shared data');
|
||||
|
||||
const activeFolder = await Folder.getValidActiveFolder();
|
||||
if (activeFolder) {
|
||||
// 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) {
|
||||
logger.info('Sharing: handleShareData: Processing...');
|
||||
await handleShared(sharedData, activeFolder.id, this.props.dispatch);
|
||||
await handleShared(sharedData, targetFolder, this.props.dispatch);
|
||||
} else {
|
||||
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();
|
||||
reg.logger().info('Cannot handle share - default folder id is not set');
|
||||
}
|
||||
} else {
|
||||
logger.info('Sharing: received empty share data.');
|
||||
|
||||
@@ -109,9 +109,7 @@ const configFromSettings = (settings: EditorSettings, context: RenderedContentCo
|
||||
extensions.push(Prec.low(keymap.of(defaultKeymap)));
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (settings.inlineRenderingEnabled) {
|
||||
extensions.push(renderingExtension());
|
||||
}
|
||||
|
||||
|
||||
@@ -304,10 +304,7 @@ shared.reloadNote = async (comp: BaseNoteScreenComponent) => {
|
||||
|
||||
const fromShare = !!comp.props.sharedData;
|
||||
if (note) {
|
||||
let folder = Folder.byId(comp.props.folders, note.parent_id);
|
||||
if (!folder && note.parent_id) {
|
||||
folder = await Folder.load(note.parent_id);
|
||||
}
|
||||
const folder = Folder.byId(comp.props.folders, note.parent_id);
|
||||
comp.setState({
|
||||
lastSavedNote: { ...note },
|
||||
note: note,
|
||||
@@ -340,24 +337,12 @@ shared.reloadNote = async (comp: BaseNoteScreenComponent) => {
|
||||
shared.initState = async function(comp: BaseNoteScreenComponent) {
|
||||
const note = await shared.reloadNote(comp);
|
||||
|
||||
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) {
|
||||
if (comp.props.sharedData.title) {
|
||||
updatedNote.title = comp.props.sharedData.title;
|
||||
fieldsToSave.title = comp.props.sharedData.title;
|
||||
this.noteComponent_change(comp, 'title', comp.props.sharedData.title);
|
||||
}
|
||||
if (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 });
|
||||
this.noteComponent_change(comp, 'body', comp.props.sharedData.text);
|
||||
}
|
||||
if (comp.props.sharedData.resources) {
|
||||
for (let i = 0; i < comp.props.sharedData.resources.length; i++) {
|
||||
|
||||
@@ -1177,25 +1177,6 @@ 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,17 +162,7 @@ export default class ExternalEditWatcher {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
let noteContent = await shim.fsDriver().readFile(path, 'utf-8');
|
||||
|
||||
// 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
|
||||
|
||||
@@ -287,15 +287,25 @@ export default class PluginService extends BaseService {
|
||||
return this.loadPlugin(baseDir, r.manifestText, r.scriptText, pluginIdIfNotSpecified);
|
||||
}
|
||||
|
||||
public async loadPluginFromPackage(baseDir: string, path: string): Promise<Plugin> {
|
||||
public async loadPluginFromPackage(baseDir: string, path: string, manifestOnly = false): Promise<Plugin> {
|
||||
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`;
|
||||
|
||||
if (manifestOnly) {
|
||||
// When loading only the manifest (e.g. for disabled plugins), try
|
||||
// to use an already-extracted manifest from cache to avoid the
|
||||
// expensive MD5 hash and tar extraction.
|
||||
const manifest = await this.loadManifestToObject(manifestFilePath);
|
||||
if (manifest) {
|
||||
return this.loadPlugin(unpackDir, JSON.stringify(manifest), '', makePluginId(fname));
|
||||
}
|
||||
}
|
||||
|
||||
const hash = await shim.fsDriver().md5File(path);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
let manifest: any = await this.loadManifestToObject(manifestFilePath);
|
||||
|
||||
@@ -318,7 +328,7 @@ export default class PluginService extends BaseService {
|
||||
await shim.fsDriver().writeFile(manifestFilePath, JSON.stringify(manifest, null, '\t'), 'utf8');
|
||||
}
|
||||
|
||||
return this.loadPluginFromPath(unpackDir);
|
||||
return this.loadPluginFromPath(unpackDir, manifestOnly);
|
||||
}
|
||||
|
||||
// Loads the manifest as a simple object with no validation. Used only
|
||||
@@ -333,7 +343,7 @@ export default class PluginService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
public async loadPluginFromPath(path: string): Promise<Plugin> {
|
||||
public async loadPluginFromPath(path: string, manifestOnly = false): Promise<Plugin> {
|
||||
path = rtrimSlashes(path);
|
||||
|
||||
const fsDriver = shim.fsDriver();
|
||||
@@ -341,7 +351,7 @@ export default class PluginService extends BaseService {
|
||||
if (path.toLowerCase().endsWith('.js')) {
|
||||
return this.loadPluginFromJsBundle(dirname(path), await fsDriver.readFile(path), filename(path));
|
||||
} else if (path.toLowerCase().endsWith('.jpl')) {
|
||||
return this.loadPluginFromPackage(dirname(path), path);
|
||||
return this.loadPluginFromPackage(dirname(path), path, manifestOnly);
|
||||
} else {
|
||||
let distPath = path;
|
||||
if (!(await fsDriver.exists(`${distPath}/manifest.json`))) {
|
||||
@@ -350,8 +360,8 @@ export default class PluginService extends BaseService {
|
||||
|
||||
logger.info(`Loading plugin from ${path}`);
|
||||
|
||||
const scriptText = await fsDriver.readFile(`${distPath}/index.js`);
|
||||
const manifestText = await fsDriver.readFile(`${distPath}/manifest.json`);
|
||||
const scriptText = manifestOnly ? '' : await fsDriver.readFile(`${distPath}/index.js`);
|
||||
const pluginId = makePluginId(filename(path));
|
||||
|
||||
return this.loadPlugin(distPath, manifestText, scriptText, pluginId);
|
||||
@@ -449,8 +459,16 @@ export default class PluginService extends BaseService {
|
||||
}
|
||||
|
||||
try {
|
||||
const plugin = await this.loadPluginFromPath(pluginPath);
|
||||
// Load only the manifest first to check if the plugin is
|
||||
// enabled before doing the expensive full load.
|
||||
let plugin = await this.loadPluginFromPath(pluginPath, true);
|
||||
const enabled = this.pluginEnabled(settings, plugin.id);
|
||||
if (enabled) {
|
||||
logger.info(`Loading full plugin: ${plugin.id}`);
|
||||
plugin = await this.loadPluginFromPath(pluginPath, false);
|
||||
} else {
|
||||
logger.info(`Loading manifest only for disabled plugin: ${plugin.id}`);
|
||||
}
|
||||
|
||||
const existingPlugin = this.plugins_[plugin.id];
|
||||
if (existingPlugin) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Setting from '../../models/Setting';
|
||||
import PluginService from '../../services/plugins/PluginService';
|
||||
import PluginService, { defaultPluginSetting } from '../../services/plugins/PluginService';
|
||||
import { setupDatabaseAndSynchronizer, switchClient, withWarningSilenced } from '../../testing/test-utils';
|
||||
import loadPlugins, { Props as LoadPluginsProps } from './loadPlugins';
|
||||
import MockPluginRunner from './testing/MockPluginRunner';
|
||||
@@ -8,6 +8,9 @@ import { Action, createStore } from 'redux';
|
||||
import MockPlatformImplementation from './testing/MockPlatformImplementation';
|
||||
import createTestPlugin from '../../testing/plugins/createTestPlugin';
|
||||
import Plugin from './Plugin';
|
||||
import { PluginManifest } from './utils/types';
|
||||
import { writeFile, mkdirp } from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
|
||||
const createMockReduxStore = () => {
|
||||
return createStore((state: State = defaultState, action: Action<string>) => {
|
||||
@@ -136,6 +139,33 @@ describe('loadPlugins', () => {
|
||||
expect([...pluginRunner.runningPluginIds].sort()).toMatchObject(expectedRunningIds);
|
||||
});
|
||||
|
||||
test('should not load the script for disabled plugins', async () => {
|
||||
const createDirPlugin = async (manifest: PluginManifest, enabled: boolean) => {
|
||||
const dir = join(Setting.value('pluginDir'), manifest.id);
|
||||
await mkdirp(dir);
|
||||
await writeFile(join(dir, 'manifest.json'), JSON.stringify(manifest), 'utf-8');
|
||||
await writeFile(join(dir, 'index.js'), 'joplin.plugins.register({ onStart: async function() {} });', 'utf-8');
|
||||
const newStates = {
|
||||
...Setting.value('plugins.states'),
|
||||
[manifest.id]: { ...defaultPluginSetting(), enabled },
|
||||
};
|
||||
Setting.setValue('plugins.states', newStates);
|
||||
};
|
||||
|
||||
const enabledId = 'joplin.test.plugin.enabled';
|
||||
const disabledId = 'joplin.test.plugin.disabled';
|
||||
await createDirPlugin({ ...defaultManifestProperties, id: enabledId, name: 'Enabled' }, true);
|
||||
await createDirPlugin({ ...defaultManifestProperties, id: disabledId, name: 'Disabled' }, false);
|
||||
|
||||
const pluginRunner = new MockPluginRunner();
|
||||
const store = createMockReduxStore();
|
||||
PluginService.instance().initialize('2.3.4', platformImplementation, pluginRunner, store);
|
||||
await PluginService.instance().loadAndRunPlugins(Setting.value('pluginDir'), Setting.value('plugins.states'));
|
||||
|
||||
expect(PluginService.instance().plugins[disabledId].scriptText).toBe('');
|
||||
expect(PluginService.instance().plugins[enabledId].scriptText).not.toBe('');
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
@@ -90,23 +90,7 @@ 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
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
/// 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,12 +43,6 @@ 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()
|
||||
@@ -134,13 +128,6 @@ 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,7 +2,6 @@
|
||||
|
||||
use widestring::U16CString;
|
||||
|
||||
pub mod debug;
|
||||
pub mod errors;
|
||||
mod file_api;
|
||||
pub mod log;
|
||||
@@ -27,6 +26,6 @@ impl Utf16ToString for &[u8] {
|
||||
.collect();
|
||||
|
||||
let value = U16CString::from_vec_truncate(data);
|
||||
value.to_string().map_err(|err| err.into())
|
||||
Ok(value.to_string().unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,3 @@ features = [
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "lib"]
|
||||
|
||||
[[bin]]
|
||||
name = "inspect"
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
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"
|
||||
pub revision_role: u32,
|
||||
revision_role: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Parse)]
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::{
|
||||
},
|
||||
shared::exguid::ExGuid,
|
||||
};
|
||||
use parser_utils::errors::Result;
|
||||
use parser_utils::{errors::Result, log};
|
||||
use std::fmt::Debug;
|
||||
use std::rc::Rc;
|
||||
|
||||
@@ -73,9 +73,8 @@ 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 {
|
||||
|
||||
+2
-9
@@ -60,15 +60,8 @@ impl RevisionManifestList {
|
||||
let revision = revisions_map.get(&data.base.rid);
|
||||
if let Some(_revision) = revision {
|
||||
iterator.next();
|
||||
|
||||
// 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
|
||||
);
|
||||
}
|
||||
// TODO: Find a test .one file that uses this and implement:
|
||||
log_warn!("TO-DO: Apply the new role and context to the revision");
|
||||
} else {
|
||||
return Err(
|
||||
ErrorKind::MalformedOneStoreData("RevisionRoleAndContextDeclarationFND points to a non-existent revision".into()).into()
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::onenote::notebook::Notebook;
|
||||
use crate::onenote::section::{Section, SectionEntry, SectionGroup};
|
||||
use crate::onestore::{OneStore, OneStoreType, parse_onestore};
|
||||
use crate::onestore::{OneStoreType, parse_onestore};
|
||||
use parser_utils::errors::{ErrorKind, Result};
|
||||
use parser_utils::{fs_driver, log, reader::Reader};
|
||||
|
||||
@@ -75,11 +73,6 @@ 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: std::fmt::Debug {
|
||||
pub trait OneStore {
|
||||
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 struct Object {
|
||||
pub(crate) 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(crate) fn id(&self) -> JcId {
|
||||
pub fn id(&self) -> JcId {
|
||||
self.jc_id
|
||||
}
|
||||
|
||||
pub(crate) fn props(&self) -> &ObjectPropSet {
|
||||
pub fn props(&self) -> &ObjectPropSet {
|
||||
&self.props
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
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;
|
||||
@@ -27,41 +25,12 @@ 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);
|
||||
let formatted_value = format_value(value);
|
||||
|
||||
debug_map.entry(&formatted_key, &DebugOutput::from(formatted_value.as_str()));
|
||||
// Use the default compact representation of the value
|
||||
let formatted_value = format!("{:?}", value);
|
||||
debug_map.entry(&formatted_key, &formatted_value);
|
||||
}
|
||||
debug_map.finish()
|
||||
}
|
||||
|
||||
@@ -261,5 +261,3 @@ llamacpp
|
||||
bgcolor
|
||||
bordercolor
|
||||
togglefullscreen
|
||||
onestore
|
||||
pdate
|
||||
@@ -38,8 +38,6 @@ describe('git-changelog', () => {
|
||||
['Update aws-sdk-js-v3 monorepo to v3.215.0', 'aws-sdk-js-v3', 'v3.215.0'],
|
||||
['Update dependency moment to v2.29.4 (#7087)', 'moment', 'v2.29.4'],
|
||||
['Update aws (#8106)', 'aws', ''],
|
||||
['fix(deps): update dependency prosemirror-gapcursor to v1.4.0 (#15069)', 'prosemirror-gapcursor', 'v1.4.0'],
|
||||
['chore(deps): update dependency webpack-dev-server to v5.2.3 (#15078)', 'webpack-dev-server', 'v5.2.3'],
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
|
||||
@@ -145,9 +145,9 @@ export interface RenovateMessage {
|
||||
|
||||
export const parseRenovateMessage = (message: string): RenovateMessage => {
|
||||
const regexes = [
|
||||
/^(?:(?:fix|chore)\(deps\): )?[Uu]pdate dependency ([^\s]+) to ([^\s]+)/,
|
||||
/^(?:(?:fix|chore)\(deps\): )?[Uu]pdate ([^\s]+) monorepo to ([^\s]+)/,
|
||||
/^(?:(?:fix|chore)\(deps\): )?[Uu]pdate ([^\s]+)/,
|
||||
/^Update dependency ([^\s]+) to ([^\s]+)/,
|
||||
/^Update ([^\s]+) monorepo to ([^\s]+)/,
|
||||
/^Update ([^\s]+)/,
|
||||
];
|
||||
|
||||
for (const regex of regexes) {
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
# Joplin Android Changelog
|
||||
|
||||
## [android-v3.6.16](https://github.com/laurent22/joplin/releases/tag/android-v3.6.16) - 2026-04-14T15:38:56Z
|
||||
|
||||
- New: Add 'Go to start/end of note' toolbar buttons (#15015 by [@Vpatel1093](https://github.com/Vpatel1093))
|
||||
- Improved: Updated packages react-native-share (v12.2.2), sass (v1.95.1)
|
||||
- Fixed: Fix Android markdown editor text replacement (characters disappearing during typing) (#15007) (#13134 by Sriram Varun Kumar)
|
||||
- Fixed: Fix back button disabled after navigating away from a deleted notebook (#15028) (#15004 by Sriram Varun Kumar)
|
||||
- Fixed: Fix profile list not scrollable to last item on Manage Profiles screen (#15074) (#15061 by Sriram Varun Kumar)
|
||||
- Fixed: Fix shared note not persisted to active notebook (#15064) (#15060 by Sriram Varun Kumar)
|
||||
- Fixed: Migrate expo-av to expo-audio (#14847) (#14804 by [@gherardi](https://github.com/gherardi))
|
||||
|
||||
## [android-v3.6.15](https://github.com/laurent22/joplin/releases/tag/android-v3.6.15) - 2026-04-05T13:00:51Z
|
||||
|
||||
- New: Add toolbar button reordering with up/down arrows (#14485 by [@Vpatel1093](https://github.com/Vpatel1093))
|
||||
|
||||
Reference in New Issue
Block a user