You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2026-04-18 19:42:23 +02:00
Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6197d05c90 | |||
| 02d92228ec | |||
| 0fe4de184d | |||
| 594d70fc88 | |||
| 8358a2a9f2 | |||
| 67e51bff41 | |||
| 7acdb9b67e | |||
| e0410db26c | |||
| 4387c82fda | |||
| 7f2e78bee5 | |||
| b9fb6cbd33 | |||
| 953d8fc050 | |||
| 26160b7924 | |||
| 738a83c57c | |||
| b5abc12486 | |||
| 9a1b978288 | |||
| 270a2d0a3a | |||
| 4b9918a770 | |||
| 400dc70f32 | |||
| babea9f388 | |||
| 3279ea227a | |||
| cbdb3f124a | |||
| 94560fb340 | |||
| 1f0519722f | |||
| 1548d7008f | |||
| d406e27f28 | |||
| e3a55ad1c6 | |||
| be1a018746 | |||
| ae390469b5 | |||
| c31a7392cc | |||
| 9ca213cefd | |||
| a6a5ab9bc9 | |||
| 0a94d02795 | |||
| 222bb002c8 | |||
| af8eb30844 | |||
| cb009cb084 | |||
| fc212d0144 | |||
| 1bbd60318a | |||
| 489b77af56 | |||
| ff24ad7c9f | |||
| 0021339f36 | |||
| f545726339 | |||
| 825a5d4bc0 | |||
| 1a1f3cdd03 | |||
| 0d1b2aaa8b | |||
| 171b979bc4 | |||
| e19856a6d0 | |||
| 9a180fcd50 | |||
| 0473dd3116 | |||
| cbf443de34 | |||
| 9a28c65baf | |||
| 623da377db | |||
| de6378473f | |||
| 886a8cc1a1 | |||
| 000d321f56 | |||
| f1e4545813 | |||
| e794176171 | |||
| 04d0626ede | |||
| 4421bf8bde | |||
| f0eb41fe6c | |||
| 9e997503a1 | |||
| 3a6b4b12e7 |
+18
-16
@@ -4,9 +4,11 @@ reviews:
|
||||
high_level_summary: false
|
||||
estimate_code_review_effort: false
|
||||
poem: false
|
||||
review_status: false
|
||||
review_details: false
|
||||
auto_review:
|
||||
enabled: true
|
||||
drafts: false
|
||||
drafts: true
|
||||
ignore_usernames:
|
||||
- "renovate[bot]"
|
||||
auto_apply_labels: true
|
||||
@@ -86,21 +88,21 @@ reviews:
|
||||
- label: "windows"
|
||||
instructions: "Apply when the PR is mainly about changes specific to Windows"
|
||||
|
||||
pre_merge_checks:
|
||||
description:
|
||||
mode: "warning"
|
||||
custom_checks:
|
||||
- name: "PR Description Must Follow Guidelines"
|
||||
mode: "error"
|
||||
instructions: |
|
||||
Fail if the pull request description does not include clear sections for:
|
||||
- Problem or user-impact description
|
||||
- A high-level Solution explanation
|
||||
- Any Test Plan or verification steps
|
||||
|
||||
The description should align with our PR guidelines
|
||||
at https://github.com/joplin/gsoc/blob/master/pull_request_guidelines.md
|
||||
and should not just restate the diff or implementation details.
|
||||
# pre_merge_checks:
|
||||
# description:
|
||||
# mode: "warning"
|
||||
# custom_checks:
|
||||
# - name: "PR Description Must Follow Guidelines"
|
||||
# mode: "error"
|
||||
# instructions: |
|
||||
# Fail if the pull request description does not include clear sections for:
|
||||
# - Problem or user-impact description
|
||||
# - A high-level Solution explanation
|
||||
# - Any Test Plan or verification steps
|
||||
#
|
||||
# The description should align with our PR guidelines
|
||||
# at https://github.com/joplin/gsoc/blob/master/pull_request_guidelines.md
|
||||
# and should not just restate the diff or implementation details.
|
||||
knowledge_base:
|
||||
code_guidelines:
|
||||
enabled: true
|
||||
|
||||
+3
-2
@@ -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
|
||||
@@ -966,6 +966,7 @@ packages/app-mobile/utils/ShareUtils.test.js
|
||||
packages/app-mobile/utils/ShareUtils.js
|
||||
packages/app-mobile/utils/TlsUtils.js
|
||||
packages/app-mobile/utils/appDefaultState.js
|
||||
packages/app-mobile/utils/appReducer.test.js
|
||||
packages/app-mobile/utils/appReducer.js
|
||||
packages/app-mobile/utils/autodetectTheme.js
|
||||
packages/app-mobile/utils/buildStartupTasks.js
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
- name: "CLA Assistant"
|
||||
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
|
||||
# Beta Release
|
||||
uses: contributor-assistant/github-action@v2.3.2
|
||||
uses: contributor-assistant/github-action@v2.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# the below token should have repo scope and must be manually added by you in the repository's secret
|
||||
|
||||
+3
-2
@@ -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
|
||||
@@ -939,6 +939,7 @@ packages/app-mobile/utils/ShareUtils.test.js
|
||||
packages/app-mobile/utils/ShareUtils.js
|
||||
packages/app-mobile/utils/TlsUtils.js
|
||||
packages/app-mobile/utils/appDefaultState.js
|
||||
packages/app-mobile/utils/appReducer.test.js
|
||||
packages/app-mobile/utils/appReducer.js
|
||||
packages/app-mobile/utils/autodetectTheme.js
|
||||
packages/app-mobile/utils/buildStartupTasks.js
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
- 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
|
||||
|
||||
|
||||
@@ -102,6 +102,9 @@
|
||||
},
|
||||
"packageManager": "yarn@4.9.2",
|
||||
"resolutions": {
|
||||
"@codemirror/state": "6.6.0",
|
||||
"@codemirror/view": "6.41.0",
|
||||
"@codemirror/language": "6.12.3",
|
||||
"react-native-camera@4.2.1": "patch:react-native-camera@npm%3A4.2.1#./.yarn/patches/react-native-camera-npm-4.2.1-24b2600a7e.patch",
|
||||
"eslint": "patch:eslint@8.57.1#./.yarn/patches/eslint-npm-8.39.0-d92bace04d.patch",
|
||||
"app-builder-lib@24.4.0": "patch:app-builder-lib@npm%3A24.4.0#./.yarn/patches/app-builder-lib-npm-24.4.0-05322ff057.patch",
|
||||
|
||||
@@ -338,7 +338,7 @@ describe('MdToHtml', () => {
|
||||
for (const [tex, input] of tests) {
|
||||
const html = await mdToHtml.render(input, null, { bodyOnly: true });
|
||||
|
||||
const opening = '<pre class="joplin-source" data-joplin-language="katex" data-joplin-source-open="$$ " data-joplin-source-close=" $$ ">';
|
||||
const opening = '<pre class="joplin-source" hidden data-joplin-language="katex" data-joplin-source-open="$$ " data-joplin-source-close=" $$ ">';
|
||||
const closing = '</pre>';
|
||||
|
||||
// Remove any single leading and trailing newlines, those are included in data-joplin-source-open
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
<div class="joplin-editable joplin-abc-notation">
|
||||
<pre class="joplin-source" data-abc-options="{"responsive":"resize"}" data-joplin-language="abc" data-joplin-source-open="```abc " data-joplin-source-close=" ``` ">{responsive:'resize'}
|
||||
<pre class="joplin-source" hidden data-abc-options="{"responsive":"resize"}" data-joplin-language="abc" data-joplin-source-open="```abc " data-joplin-source-close=" ``` ">{responsive:'resize'}
|
||||
---
|
||||
K:F
|
||||
!f!(fgag-g2c2)|</pre>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="joplin-editable"><pre class="joplin-source" data-joplin-language="javascript" data-joplin-source-open="```javascript " data-joplin-source-close=" ```">function() {
|
||||
<div class="joplin-editable"><pre class="joplin-source" hidden data-joplin-language="javascript" data-joplin-source-open="```javascript " data-joplin-source-close=" ```">function() {
|
||||
console.info('bonjour');
|
||||
}</pre><pre class="hljs"><code><span class="hljs-keyword">function</span>(<span class="hljs-params"></span>) {
|
||||
<span class="hljs-variable language_">console</span>.<span class="hljs-title function_">info</span>(<span class="hljs-string">'bonjour'</span>);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
<div class="joplin-editable">
|
||||
<span class="joplin-source" data-joplin-source-open="" data-joplin-source-close="">https://www.youtube.com/watch?v=iJqe9pC-z-Y</span>
|
||||
<span class="joplin-source" hidden data-joplin-source-open="" data-joplin-source-close="">https://www.youtube.com/watch?v=iJqe9pC-z-Y</span>
|
||||
<div class="joplin-youtube-player-rendered">
|
||||
<iframe src="https://www.youtube-nocookie.com/embed/iJqe9pC-z-Y" title="YouTube video player" frameborder="0" allowfullscreen></iframe>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<p>Link: <a data-from-md title='https://www.youtube.com/watch?v=iJqe9pC-z-Y' href='https://www.youtube.com/watch?v=iJqe9pC-z-Y' onclick='postMessage("https://www.youtube.com/watch?v=iJqe9pC-z-Y", { resourceId: "" }); return false;'>https://www.youtube.com/watch?v=iJqe9pC-z-Y</a></p>
|
||||
<p>
|
||||
<div class="joplin-editable">
|
||||
<span class="joplin-source" data-joplin-source-open="" data-joplin-source-close="">https://www.youtube.com/watch?v=iJqe9pC-z-Y</span>
|
||||
<span class="joplin-source" hidden data-joplin-source-open="" data-joplin-source-close="">https://www.youtube.com/watch?v=iJqe9pC-z-Y</span>
|
||||
<div class="joplin-youtube-player-rendered">
|
||||
<iframe src="https://www.youtube-nocookie.com/embed/iJqe9pC-z-Y" title="YouTube video player" frameborder="0" allowfullscreen></iframe>
|
||||
</div>
|
||||
|
||||
@@ -1 +1 @@
|
||||
<div class="joplin-editable"><pre class="joplin-source" data-joplin-language=""><svg/onload=top.eval(atob("cmVxdWlyZSgnY2hpbGRfcHJvY2VzcycpLmV4ZWMoJ29wZW4gLW4gL1N5c3RlbS9BcHBsaWNhdGlvbnMvQ2FsY3VsYXRvci5hcHAvQ29udGVudHMvTWFjT1MvQ2FsY3VsYXRvcicp"))>" data-joplin-source-open="```"><svg/onload=top.eval(atob("cmVxdWlyZSgnY2hpbGRfcHJvY2VzcycpLmV4ZWMoJ29wZW4gLW4gL1N5c3RlbS9BcHBsaWNhdGlvbnMvQ2FsY3VsYXRvci5hcHAvQ29udGVudHMvTWFjT1MvQ2FsY3VsYXRvcicp"))> " data-joplin-source-close=" ```">ts</pre><pre class="hljs"><code><span class="hljs-attribute">ts</span></code></pre></div>
|
||||
<div class="joplin-editable"><pre class="joplin-source" hidden data-joplin-language=""><svg/onload=top.eval(atob("cmVxdWlyZSgnY2hpbGRfcHJvY2VzcycpLmV4ZWMoJ29wZW4gLW4gL1N5c3RlbS9BcHBsaWNhdGlvbnMvQ2FsY3VsYXRvci5hcHAvQ29udGVudHMvTWFjT1MvQ2FsY3VsYXRvcicp"))>" data-joplin-source-open="```"><svg/onload=top.eval(atob("cmVxdWlyZSgnY2hpbGRfcHJvY2VzcycpLmV4ZWMoJ29wZW4gLW4gL1N5c3RlbS9BcHBsaWNhdGlvbnMvQ2FsY3VsYXRvci5hcHAvQ29udGVudHMvTWFjT1MvQ2FsY3VsYXRvcicp"))> " data-joplin-source-close=" ```">ts</pre><pre class="hljs"><code><span class="hljs-attribute">ts</span></code></pre></div>
|
||||
|
||||
@@ -1 +1 @@
|
||||
<div class="joplin-editable"><pre class="joplin-source" data-joplin-language="html" data-joplin-source-open="```html " data-joplin-source-close=" ```"><a href="#" onclick="leavethisalone">testing fence</a></pre><pre class="hljs"><code><span class="hljs-tag"><<span class="hljs-name">a</span> <span class="hljs-attr">href</span>=<span class="hljs-string">"#"</span> <span class="hljs-attr">onclick</span>=<span class="hljs-string">"leavethisalone"</span>></span>testing fence<span class="hljs-tag"></<span class="hljs-name">a</span>></span></code></pre></div>
|
||||
<div class="joplin-editable"><pre class="joplin-source" hidden data-joplin-language="html" data-joplin-source-open="```html " data-joplin-source-close=" ```"><a href="#" onclick="leavethisalone">testing fence</a></pre><pre class="hljs"><code><span class="hljs-tag"><<span class="hljs-name">a</span> <span class="hljs-attr">href</span>=<span class="hljs-string">"#"</span> <span class="hljs-attr">onclick</span>=<span class="hljs-string">"leavethisalone"</span>></span>testing fence<span class="hljs-tag"></<span class="hljs-name">a</span>></span></code></pre></div>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -59,6 +59,7 @@ export default class ElectronAppWrapper {
|
||||
private secondaryWindows_: Map<SecondaryWindowId, SecondaryWindowData> = new Map();
|
||||
|
||||
private willQuitApp_ = false;
|
||||
private enableUnresponsiveCheck_ = true;
|
||||
private tray_: Tray = null;
|
||||
private buildDir_: string = null;
|
||||
private rendererProcessQuitReply_: RendererProcessQuitReply = null;
|
||||
@@ -307,6 +308,8 @@ export default class ElectronAppWrapper {
|
||||
let unresponsiveTimeout: ReturnType<typeof setTimeout>|null = null;
|
||||
|
||||
this.win_.webContents.on('unresponsive', () => {
|
||||
if (!this.enableUnresponsiveCheck_) return;
|
||||
|
||||
// Don't show the "unresponsive" dialog immediately -- the "unresponsive" event
|
||||
// can be fired when showing a dialog or modal (e.g. the update dialog).
|
||||
//
|
||||
@@ -896,6 +899,10 @@ export default class ElectronAppWrapper {
|
||||
return this.customProtocolHandlers_.pluginContent;
|
||||
}
|
||||
|
||||
public setEnableUnresponsiveCheck(enabled: boolean) {
|
||||
this.enableUnresponsiveCheck_ = enabled;
|
||||
}
|
||||
|
||||
private async fixLinuxAccessibility_() {
|
||||
if (this.electronApp().accessibilitySupportEnabled) return;
|
||||
|
||||
@@ -939,6 +946,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
|
||||
@@ -434,6 +483,10 @@ export class Bridge {
|
||||
setLocale(locale);
|
||||
}
|
||||
|
||||
public setEnableUnresponsiveCheck(enabled: boolean) {
|
||||
this.electronWrapper_.setEnableUnresponsiveCheck(enabled);
|
||||
}
|
||||
|
||||
public get Menu() {
|
||||
return Menu;
|
||||
}
|
||||
|
||||
@@ -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 })}>
|
||||
|
||||
@@ -200,6 +200,11 @@ function menuItemSetEnabled(id: string, enabled: boolean) {
|
||||
const menu = Menu.getApplicationMenu();
|
||||
const menuItem = menu.getMenuItemById(id);
|
||||
if (!menuItem) return;
|
||||
// Don't disable menu items that have a role (e.g. copy, paste, cut,
|
||||
// selectAll). Since Electron 40, disabling a role-based menu item also
|
||||
// prevents the native role behaviour, which breaks clipboard operations
|
||||
// in non-editor input fields such as the Settings screen.
|
||||
if (!enabled && menuItem.role) return;
|
||||
menuItem.enabled = enabled;
|
||||
}
|
||||
|
||||
|
||||
@@ -94,10 +94,6 @@ const useDocumentSetup = (doc: Document|null, setLoaded: OnSetLoaded) => {
|
||||
useEffect(() => {
|
||||
if (!doc) return;
|
||||
|
||||
doc.open();
|
||||
doc.write('<!DOCTYPE html><html><head></head><body></body></html>');
|
||||
doc.close();
|
||||
|
||||
const cssUrls = [
|
||||
'style.min.css',
|
||||
];
|
||||
|
||||
@@ -11,6 +11,7 @@ const baseContext: Record<string, any> = {
|
||||
noteIsMarkdown: true,
|
||||
noteIsReadOnly: false,
|
||||
richTextEditorVisible: false,
|
||||
hasActivePluginEditor: false,
|
||||
};
|
||||
|
||||
describe('editorCommandDeclarations', () => {
|
||||
|
||||
@@ -22,10 +22,10 @@ export const enabledCondition = (commandName: string) => {
|
||||
const allowInViewerAndReadOnlyMode = worksInViewerAndReadOnlyMode.includes(commandName);
|
||||
|
||||
const editorPaneCondition = markdownEditorOnly
|
||||
? 'markdownEditorPaneVisible'
|
||||
? '(markdownEditorPaneVisible || hasActivePluginEditor)'
|
||||
: allowInViewerAndReadOnlyMode
|
||||
? '(markdownEditorPaneVisible || richTextEditorVisible || markdownViewerPaneVisible)'
|
||||
: '(markdownEditorPaneVisible || richTextEditorVisible)';
|
||||
? '(markdownEditorPaneVisible || richTextEditorVisible || markdownViewerPaneVisible || hasActivePluginEditor)'
|
||||
: '(markdownEditorPaneVisible || richTextEditorVisible || hasActivePluginEditor)';
|
||||
|
||||
const output = [
|
||||
// gotoAnythingVisible: Enable if the command palette (which is a modal dialog) is visible
|
||||
|
||||
@@ -90,6 +90,19 @@ export function resourcesStatus(resourceInfos: any) {
|
||||
return joplinRendererUtils.resourceStatusName(lowestIndex);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const clipboardImageToResource = async (image: any, mime: string) => {
|
||||
const fileExt = mimeUtils.toFileExtension(mime);
|
||||
const filePath = `${Setting.value('tempDir')}/${md5(Date.now())}.${fileExt}`;
|
||||
await shim.writeImageToFile(image, mime, filePath);
|
||||
try {
|
||||
const md = await commandAttachFileToBody('', [filePath]);
|
||||
return md;
|
||||
} finally {
|
||||
await shim.fsDriver().remove(filePath);
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
export async function getResourcesFromPasteEvent(event: any) {
|
||||
const output = [];
|
||||
@@ -104,19 +117,22 @@ export async function getResourcesFromPasteEvent(event: any) {
|
||||
continue;
|
||||
}
|
||||
if (event) event.preventDefault();
|
||||
|
||||
const image = clipboard.readImage();
|
||||
|
||||
const fileExt = mimeUtils.toFileExtension(format);
|
||||
const filePath = `${Setting.value('tempDir')}/${md5(Date.now())}.${fileExt}`;
|
||||
|
||||
await shim.writeImageToFile(image, format, filePath);
|
||||
const md = await commandAttachFileToBody('', [filePath]);
|
||||
await shim.fsDriver().remove(filePath);
|
||||
|
||||
const md = await clipboardImageToResource(clipboard.readImage(), format);
|
||||
if (md) output.push(md);
|
||||
}
|
||||
}
|
||||
|
||||
// Some applications (e.g. macshot) copy images to the clipboard without
|
||||
// an image/* format, but clipboard.readImage() can still read them.
|
||||
if (!output.length) {
|
||||
const image = clipboard.readImage();
|
||||
if (!image.isEmpty()) {
|
||||
if (event) event.preventDefault();
|
||||
const md = await clipboardImageToResource(image, 'image/png');
|
||||
if (md) output.push(md);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { StyledSyncReportText, StyledSyncReport, StyledSynchronizeButton, StyledRoot } from './styles';
|
||||
import { ButtonLevel } from '../Button/Button';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import Synchronizer from '@joplin/lib/Synchronizer';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { AppState } from '../../app.reducer';
|
||||
import { StateDecryptionWorker, StateResourceFetcher } from '@joplin/lib/reducer';
|
||||
@@ -11,8 +11,6 @@ import { connect } from 'react-redux';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import { Dispatch } from 'redux';
|
||||
import FolderAndTagList from './FolderAndTagList';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import time from '@joplin/lib/time';
|
||||
|
||||
|
||||
interface Props {
|
||||
@@ -23,18 +21,26 @@ interface Props {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
syncReport: any;
|
||||
syncStarted: boolean;
|
||||
syncReportLogExpanded: boolean;
|
||||
syncPending: boolean;
|
||||
syncReportIsVisible: boolean;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- The generated report does not currently have a type
|
||||
const syncCompletedWithoutError = (syncReport: any) => {
|
||||
return syncReport.completedTime && (!syncReport.errors || !syncReport.errors.length);
|
||||
};
|
||||
|
||||
const SidebarComponent = (props: Props) => {
|
||||
const renderSynchronizeButton = (type: string) => {
|
||||
const label = type === 'sync' ? _('Synchronise') : _('Cancel');
|
||||
const nothingToSync = type === 'sync' && !props.syncPending && syncCompletedWithoutError(props.syncReport);
|
||||
const iconName = nothingToSync ? 'fas fa-check' : 'icon-sync';
|
||||
|
||||
return (
|
||||
<StyledSynchronizeButton
|
||||
level={ButtonLevel.SidebarSecondary}
|
||||
className={`sidebar-sync-button ${type === 'sync' ? '' : '-syncing'}`}
|
||||
iconName="icon-sync"
|
||||
className={`sidebar-sync-button ${type === 'sync' ? '' : '-syncing'} ${nothingToSync ? '-synced' : ''}`}
|
||||
iconName={iconName}
|
||||
key="sync_button"
|
||||
title={label}
|
||||
onClick={() => {
|
||||
@@ -56,55 +62,46 @@ const SidebarComponent = (props: Props) => {
|
||||
resourceFetcherText = _('Fetching resources: %d/%d', props.resourceFetcher.fetchingCount, props.resourceFetcher.toFetchCount);
|
||||
}
|
||||
|
||||
const syncReportExpanded = props.syncReportLogExpanded;
|
||||
|
||||
const toggleSyncReport = useCallback(() => {
|
||||
Setting.setValue('syncReportLogExpanded', !syncReportExpanded);
|
||||
}, [syncReportExpanded]);
|
||||
|
||||
const lines = Synchronizer.reportToLines(props.syncReport);
|
||||
if (resourceFetcherText) lines.push(resourceFetcherText);
|
||||
if (decryptionReportText) lines.push(decryptionReportText);
|
||||
|
||||
const completedTime = props.syncReport && props.syncReport.completedTime
|
||||
? time.formatMsToLocal(props.syncReport.completedTime)
|
||||
: null;
|
||||
const syncReportText = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
syncReportText.push(
|
||||
<StyledSyncReportText key={i}>
|
||||
{lines[i]}
|
||||
</StyledSyncReportText>,
|
||||
);
|
||||
}
|
||||
|
||||
const syncButton = renderSynchronizeButton(props.syncStarted ? 'cancel' : 'sync');
|
||||
|
||||
// Show toggle when there are log lines or a completed timestamp
|
||||
const hasContent = lines.length > 0 || completedTime;
|
||||
const hasSyncReport = syncReportText.length > 0;
|
||||
|
||||
// Toggle to show/hide sync log output
|
||||
const toggleButton = hasContent ? (
|
||||
<button
|
||||
className="sidebar-sync-toggle"
|
||||
onClick={toggleSyncReport}
|
||||
aria-expanded={syncReportExpanded}
|
||||
aria-label={syncReportExpanded ? _('Hide sync log') : _('Show sync log')}
|
||||
title={syncReportExpanded ? _('Hide sync log') : _('Show sync log')}
|
||||
>
|
||||
<i className={`fas fa-caret-${syncReportExpanded ? 'down' : 'up'}`} />
|
||||
{!syncReportExpanded && completedTime ? <span className="timestamp">{_('Last sync: %s', completedTime)}</span> : ''}
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
// Sync log output, only visible when expanded
|
||||
const syncReportComp = (syncReportExpanded && lines.length > 0) ? (
|
||||
<StyledSyncReport key="sync_report">
|
||||
{lines.map((line, i) => (
|
||||
<StyledSyncReportText key={i}>
|
||||
{line}
|
||||
</StyledSyncReportText>
|
||||
))}
|
||||
const syncReportComp = !hasSyncReport || !props.syncReportIsVisible ? null : (
|
||||
<StyledSyncReport key="sync_report" id="sync-report">
|
||||
{syncReportText}
|
||||
</StyledSyncReport>
|
||||
) : null;
|
||||
);
|
||||
|
||||
const syncReportToggle = (
|
||||
<button
|
||||
className="sync-report-toggle"
|
||||
style={{ color: theme.color2 }}
|
||||
onClick={() => Setting.toggle('syncReportIsVisible')}
|
||||
aria-label={_('Sync report')}
|
||||
aria-expanded={props.syncReportIsVisible}
|
||||
aria-controls="sync-report"
|
||||
>
|
||||
<i className={`fas fa-chevron-${props.syncReportIsVisible ? 'down' : 'up'}`}/>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledRoot className='sidebar _scrollbar2' role='navigation' aria-label={_('Sidebar')}>
|
||||
<div style={{ flex: 1 }}><FolderAndTagList /></div>
|
||||
<div style={{ flex: 1 }}><FolderAndTagList/></div>
|
||||
<div style={{ flex: 0, padding: theme.mainPadding }}>
|
||||
{toggleButton}
|
||||
{syncReportToggle}
|
||||
{syncReportComp}
|
||||
{syncButton}
|
||||
</div>
|
||||
@@ -116,6 +113,7 @@ const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
searches: state.searches,
|
||||
syncStarted: state.syncStarted,
|
||||
syncPending: state.syncPending,
|
||||
syncReport: state.syncReport,
|
||||
selectedSearchId: state.selectedSearchId,
|
||||
selectedSmartFilterId: state.selectedSmartFilterId,
|
||||
@@ -124,7 +122,7 @@ const mapStateToProps = (state: AppState) => {
|
||||
collapsedFolderIds: state.collapsedFolderIds,
|
||||
decryptionWorker: state.decryptionWorker,
|
||||
resourceFetcher: state.resourceFetcher,
|
||||
syncReportLogExpanded: state.settings.syncReportLogExpanded,
|
||||
syncReportIsVisible: state.settings.syncReportIsVisible,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -6,5 +6,4 @@
|
||||
@use 'styles/sidebar-header-container.scss';
|
||||
@use 'styles/sidebar-spacer-item.scss';
|
||||
@use 'styles/sidebar-header-button.scss';
|
||||
@use 'styles/sidebar-sync-button.scss';
|
||||
@use 'styles/sidebar-sync-toggle.scss';
|
||||
@use 'styles/sidebar-sync-button.scss';
|
||||
@@ -5,6 +5,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes icon-fade-in-a {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes icon-fade-in-b {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.sidebar-sync-button > .icon {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px !important;
|
||||
height: 16px;
|
||||
margin-right: 8px !important;
|
||||
}
|
||||
|
||||
.sidebar-sync-button {
|
||||
&.-syncing > .icon {
|
||||
animation: icon-infinite-rotation 1s linear infinite;
|
||||
@@ -13,4 +32,29 @@
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.-syncing).-synced > .icon {
|
||||
animation: icon-fade-in-a 300ms ease-in-out;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
&:not(.-syncing):not(.-synced) > .icon {
|
||||
animation: icon-fade-in-b 300ms ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.sync-report-toggle {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
margin-bottom: 4px;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
.sidebar-sync-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--joplin-color2);
|
||||
opacity: 0.5;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
width: 100%;
|
||||
font-size: calc(var(--joplin-font-size) * 1.6);
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
>.timestamp {
|
||||
font-size: 0.6em;
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
@@ -373,7 +373,7 @@
|
||||
ipc.focus = (event) => {
|
||||
const dummyID = 'joplin-content-focus-dummy';
|
||||
if (! document.getElementById(dummyID)) {
|
||||
const focusDummy = '<div style="width: 0; height: 0; overflow: hidden"><a id="' + dummyID + '" href="#">Note viewer top</a></div>';
|
||||
const focusDummy = '<div style="width: 0; height: 0; overflow: hidden"><a id="' + dummyID + '" href="#" aria-label="Note viewer top"></a></div>';
|
||||
contentElement.insertAdjacentHTML("afterbegin", focusDummy);
|
||||
}
|
||||
const scrollTop = contentElement.scrollTop;
|
||||
@@ -867,8 +867,19 @@
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.appendChild(range.cloneContents());
|
||||
|
||||
|
||||
wrapper.querySelectorAll('style').forEach(s => s.remove());
|
||||
|
||||
// The hidden attribute on .joplin-source helps some apps, but many
|
||||
// external editors (Word, Google Docs) ignore hidden/display:none
|
||||
// when pasting clipboard HTML. Remove them explicitly as a fallback.
|
||||
wrapper.querySelectorAll('.joplin-source').forEach(s => s.remove());
|
||||
|
||||
// Remove the accessibility focus dummy link container.
|
||||
const focusDummy = wrapper.querySelector('#joplin-content-focus-dummy');
|
||||
if (focusDummy && focusDummy.parentElement) focusDummy.parentElement.remove();
|
||||
|
||||
|
||||
const inlineTags = new Set(['STRONG', 'EM', 'CODE', 'S', 'DEL', 'INS', 'MARK', 'SUP', 'SUB', 'U', 'SPAN', 'A']);
|
||||
let node = range.commonAncestorContainer;
|
||||
if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1
@@ -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,
|
||||
@@ -160,7 +160,7 @@
|
||||
"@types/node": "18.19.130",
|
||||
"@types/react": "19.1.10",
|
||||
"@types/react-dom": "19.1.7",
|
||||
"@types/react-redux": "7.1.33",
|
||||
"@types/react-redux": "7.1.34",
|
||||
"@types/styled-components": "5.1.36",
|
||||
"async-mutex": "0.5.0",
|
||||
"axios": "^1.7.7",
|
||||
|
||||
@@ -6,7 +6,7 @@ ruby ">= 2.6.10"
|
||||
# Exclude problematic versions of cocoapods and activesupport that causes build failures.
|
||||
gem 'cocoapods', '>= 1.13', '!= 1.15.0', '!= 1.15.1'
|
||||
gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0'
|
||||
gem 'xcodeproj', '< 1.26.0'
|
||||
gem 'xcodeproj', '< 1.27.1'
|
||||
gem 'concurrent-ruby', '< 1.3.4'
|
||||
|
||||
# Ruby 3.4.0 has removed some libraries from the standard library.
|
||||
|
||||
@@ -83,8 +83,8 @@ android {
|
||||
applicationId "net.cozic.joplin"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2097803
|
||||
versionName "3.6.15"
|
||||
versionCode 2097804
|
||||
versionName "3.6.16"
|
||||
|
||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@ const builtInCommandNames = [
|
||||
'setTags',
|
||||
EditorCommandType.ToggleSearch,
|
||||
'hideKeyboard',
|
||||
'-',
|
||||
`editor.${EditorCommandType.GoDocStart}`,
|
||||
`editor.${EditorCommandType.GoDocEnd}`,
|
||||
];
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ const omitFromDefault: string[] = [
|
||||
`editor.${EditorCommandType.DeleteLine}`,
|
||||
`editor.${EditorCommandType.DuplicateLine}`,
|
||||
`editor.${EditorCommandType.SortSelectedLines}`,
|
||||
`editor.${EditorCommandType.GoDocStart}`,
|
||||
`editor.${EditorCommandType.GoDocEnd}`,
|
||||
];
|
||||
|
||||
// The "hide keyboard" button is only needed on iOS, so only show it there by default.
|
||||
|
||||
@@ -168,6 +168,16 @@ const declarations: CommandDeclaration[] = [
|
||||
label: () => _('Link'),
|
||||
iconName: 'material link',
|
||||
},
|
||||
{
|
||||
name: `editor.${EditorCommandType.GoDocStart}`,
|
||||
label: () => _('Go to start of note'),
|
||||
iconName: 'material page-first',
|
||||
},
|
||||
{
|
||||
name: `editor.${EditorCommandType.GoDocEnd}`,
|
||||
label: () => _('Go to end of note'),
|
||||
iconName: 'material page-last',
|
||||
},
|
||||
];
|
||||
|
||||
export default declarations;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1678,9 +1678,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
!note || !note.body.trim() ? null : (
|
||||
<NoteBodyViewer
|
||||
style={this.styles().noteBodyViewer}
|
||||
// Extra bottom padding to make it possible to scroll past the
|
||||
// action button (so that it doesn't overlap the text)
|
||||
paddingBottom={150}
|
||||
paddingBottom={0}
|
||||
noteBody={note.body}
|
||||
noteMarkupLanguage={note.markup_language}
|
||||
noteResources={this.state.noteResources}
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from 'react';
|
||||
import { PrimaryButton, SecondaryButton } from '../buttons';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Audio, InterruptionModeIOS } from 'expo-av';
|
||||
import { AudioQuality, getRecordingPermissionsAsync, IOSOutputFormat, requestRecordingPermissionsAsync, setAudioModeAsync, type RecordingOptions, useAudioRecorder as useExpoAudioRecorder, useAudioRecorderState } from 'expo-audio';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { OnFileSavedCallback, RecorderState } from './types';
|
||||
import { Platform } from 'react-native';
|
||||
@@ -11,7 +11,6 @@ import FsDriverWeb from '../../utils/fs-driver/fs-driver-rn.web';
|
||||
import uuid from '@joplin/lib/uuid';
|
||||
import RecordingControls from './RecordingControls';
|
||||
import { Text } from 'react-native-paper';
|
||||
import { AndroidAudioEncoder, AndroidOutputFormat, IOSAudioQuality, IOSOutputFormat, RecordingOptions } from 'expo-av/build/Audio';
|
||||
import time from '@joplin/lib/time';
|
||||
import { toFileExtension } from '@joplin/lib/mime-utils';
|
||||
import { formatMsToDurationCompat, msleep } from '@joplin/utils/time';
|
||||
@@ -25,23 +24,21 @@ interface Props {
|
||||
|
||||
// Modified from the Expo default recording options to create
|
||||
// .m4a recordings on both Android and iOS (rather than .3gp on Android).
|
||||
const recordingOptions = (): RecordingOptions => ({
|
||||
const recordingOptions: RecordingOptions = {
|
||||
extension: '.m4a',
|
||||
isMeteringEnabled: true,
|
||||
sampleRate: 44100,
|
||||
numberOfChannels: 2,
|
||||
bitRate: 64000,
|
||||
android: {
|
||||
extension: '.m4a',
|
||||
outputFormat: AndroidOutputFormat.MPEG_4,
|
||||
audioEncoder: AndroidAudioEncoder.AAC,
|
||||
sampleRate: 44100,
|
||||
numberOfChannels: 2,
|
||||
bitRate: 64000,
|
||||
outputFormat: 'mpeg4',
|
||||
audioEncoder: 'aac',
|
||||
},
|
||||
ios: {
|
||||
extension: '.m4a',
|
||||
audioQuality: IOSAudioQuality.MIN,
|
||||
audioQuality: AudioQuality.MIN,
|
||||
outputFormat: IOSOutputFormat.MPEG4AAC,
|
||||
sampleRate: 44100,
|
||||
numberOfChannels: 2,
|
||||
bitRate: 64000,
|
||||
linearPCMBitDepth: 16,
|
||||
linearPCMIsBigEndian: false,
|
||||
linearPCMIsFloat: false,
|
||||
@@ -56,14 +53,16 @@ const recordingOptions = (): RecordingOptions => ({
|
||||
].find(type => MediaRecorder.isTypeSupported(type)) ?? 'audio/webm',
|
||||
bitsPerSecond: 128000,
|
||||
} : {},
|
||||
});
|
||||
};
|
||||
|
||||
const getRecordingFileName = (extension: string) => {
|
||||
return `recording-${time.formatDateToLocal(new Date())}${extension}`;
|
||||
};
|
||||
|
||||
const recordingToSaveData = async (recording: Audio.Recording) => {
|
||||
let uri = recording.getURI();
|
||||
const recordingToSaveData = async (recordingUri: string|null) => {
|
||||
if (!recordingUri) throw new Error(_('Unable to access the recording file.'));
|
||||
|
||||
let uri = recordingUri;
|
||||
let type: string|undefined;
|
||||
let fileName;
|
||||
|
||||
@@ -73,7 +72,7 @@ const recordingToSaveData = async (recording: Audio.Recording) => {
|
||||
const fetchResult = await fetch(uri);
|
||||
const blob = await fetchResult.blob();
|
||||
|
||||
type = recordingOptions().web.mimeType;
|
||||
type = recordingOptions.web.mimeType;
|
||||
const extension = `.${toFileExtension(type)}`;
|
||||
fileName = getRecordingFileName(extension);
|
||||
const file = new File([blob], fileName);
|
||||
@@ -82,10 +81,9 @@ const recordingToSaveData = async (recording: Audio.Recording) => {
|
||||
await (shim.fsDriver() as FsDriverWeb).createReadOnlyVirtualFile(path, file);
|
||||
uri = path;
|
||||
} else {
|
||||
const options = recordingOptions();
|
||||
const extension = Platform.select({
|
||||
android: options.android.extension,
|
||||
ios: options.ios.extension,
|
||||
android: recordingOptions.android.extension,
|
||||
ios: recordingOptions.ios.extension,
|
||||
default: '',
|
||||
});
|
||||
fileName = getRecordingFileName(extension);
|
||||
@@ -95,75 +93,72 @@ const recordingToSaveData = async (recording: Audio.Recording) => {
|
||||
};
|
||||
|
||||
const resetAudioMode = async () => {
|
||||
await Audio.setAudioModeAsync({
|
||||
// When enabled, iOS may use the small (phone call) speaker
|
||||
// instead of the default one, so it's disabled when not recording:
|
||||
allowsRecordingIOS: false,
|
||||
playsInSilentModeIOS: false,
|
||||
staysActiveInBackground: false,
|
||||
await setAudioModeAsync({
|
||||
allowsRecording: false,
|
||||
allowsBackgroundRecording: false,
|
||||
playsInSilentMode: false,
|
||||
shouldPlayInBackground: false,
|
||||
});
|
||||
};
|
||||
|
||||
const useAudioRecorder = (onFileSaved: OnFileSavedCallback, onDismiss: ()=> void) => {
|
||||
const [permissionResponse, requestPermissions] = Audio.usePermissions();
|
||||
const [recordingState, setRecordingState] = useState<RecorderState>(RecorderState.Idle);
|
||||
const [error, setError] = useState('');
|
||||
const [duration, setDuration] = useState(0);
|
||||
const recorder = useExpoAudioRecorder(recordingOptions);
|
||||
const recorderStatus = useAudioRecorderState(recorder, 100);
|
||||
const isRecordingRef = useRef(false);
|
||||
|
||||
const recordingRef = useRef<Audio.Recording|null>(null);
|
||||
const onStartRecording = useCallback(async () => {
|
||||
try {
|
||||
setRecordingState(RecorderState.Loading);
|
||||
setError('');
|
||||
|
||||
if (permissionResponse?.status !== 'granted') {
|
||||
const response = await requestPermissions();
|
||||
const permissionResponse = await getRecordingPermissionsAsync();
|
||||
if (permissionResponse.status !== 'granted') {
|
||||
const response = await requestRecordingPermissionsAsync();
|
||||
if (!response.granted) {
|
||||
throw new Error(_('Missing permission to record audio.'));
|
||||
}
|
||||
|
||||
// Work around "This experience is currently in the background, so the audio session could not be activated"
|
||||
// See https://github.com/expo/expo/issues/21782
|
||||
// May be resolved by migrating to expo-audio.
|
||||
await msleep(500);
|
||||
}
|
||||
|
||||
await Audio.setAudioModeAsync({
|
||||
allowsRecordingIOS: true,
|
||||
playsInSilentModeIOS: true,
|
||||
staysActiveInBackground: true,
|
||||
await setAudioModeAsync({
|
||||
allowsRecording: true,
|
||||
allowsBackgroundRecording: true,
|
||||
playsInSilentMode: true,
|
||||
shouldPlayInBackground: true,
|
||||
// Fixes an issue where opening a recording in the iOS audio player
|
||||
// breaks creating new recordings.
|
||||
// See https://github.com/expo/expo/issues/31152#issuecomment-2341811087
|
||||
interruptionModeIOS: InterruptionModeIOS.DoNotMix,
|
||||
interruptionMode: 'doNotMix',
|
||||
});
|
||||
await recorder.prepareToRecordAsync();
|
||||
isRecordingRef.current = true;
|
||||
recorder.record();
|
||||
setRecordingState(RecorderState.Recording);
|
||||
const recording = new Audio.Recording();
|
||||
await recording.prepareToRecordAsync(recordingOptions());
|
||||
recording.setOnRecordingStatusUpdate(status => {
|
||||
setDuration(status.durationMillis);
|
||||
});
|
||||
recordingRef.current = recording;
|
||||
await recording.startAsync();
|
||||
} catch (error) {
|
||||
logger.error('Error starting recording:', error);
|
||||
setError(`Recording error: ${error}`);
|
||||
setRecordingState(RecorderState.Error);
|
||||
|
||||
void recordingRef.current?.stopAndUnloadAsync();
|
||||
recordingRef.current = null;
|
||||
if (isRecordingRef.current) {
|
||||
isRecordingRef.current = false;
|
||||
void recorder.stop();
|
||||
}
|
||||
}
|
||||
}, [permissionResponse, requestPermissions]);
|
||||
}, [recorder]);
|
||||
|
||||
const onStopRecording = useCallback(async () => {
|
||||
const recording = recordingRef.current;
|
||||
recordingRef.current = null;
|
||||
|
||||
try {
|
||||
setRecordingState(RecorderState.Processing);
|
||||
await recording.stopAndUnloadAsync();
|
||||
await recorder.stop();
|
||||
isRecordingRef.current = false;
|
||||
await resetAudioMode();
|
||||
|
||||
const saveEvent = await recordingToSaveData(recording);
|
||||
const saveEvent = await recordingToSaveData(recorder.uri);
|
||||
onFileSaved(saveEvent);
|
||||
onDismiss();
|
||||
} catch (error) {
|
||||
@@ -171,25 +166,35 @@ const useAudioRecorder = (onFileSaved: OnFileSavedCallback, onDismiss: ()=> void
|
||||
setError(`Save error: ${error}`);
|
||||
setRecordingState(RecorderState.Error);
|
||||
}
|
||||
}, [onFileSaved, onDismiss]);
|
||||
}, [onFileSaved, onDismiss, recorder]);
|
||||
|
||||
const onStartStopRecording = useCallback(async () => {
|
||||
if (recordingState === RecorderState.Idle) {
|
||||
await onStartRecording();
|
||||
} else if (recordingState === RecorderState.Recording && recordingRef.current) {
|
||||
} else if (recordingState === RecorderState.Recording) {
|
||||
await onStopRecording();
|
||||
}
|
||||
}, [recordingState, onStartRecording, onStopRecording]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (recordingRef.current) {
|
||||
void recordingRef.current?.stopAndUnloadAsync();
|
||||
recordingRef.current = null;
|
||||
void resetAudioMode();
|
||||
}
|
||||
}, []);
|
||||
if (isRecordingRef.current) {
|
||||
isRecordingRef.current = false;
|
||||
|
||||
return { onStartStopRecording, error, duration, recordingState };
|
||||
const stopRecorderOnCleanup = async () => {
|
||||
try {
|
||||
await recorder.stop();
|
||||
} catch (error) {
|
||||
logger.warn('Error stopping recorder during cleanup:', error);
|
||||
}
|
||||
|
||||
await resetAudioMode();
|
||||
};
|
||||
|
||||
void stopRecorderOnCleanup();
|
||||
}
|
||||
}, [recorder]);
|
||||
|
||||
return { onStartStopRecording, error, duration: recorderStatus.durationMillis, recordingState };
|
||||
};
|
||||
|
||||
const AudioRecordingBanner: React.FC<Props> = props => {
|
||||
|
||||
@@ -408,7 +408,7 @@
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Joplin/Pods-Joplin-frameworks.sh",
|
||||
"${PODS_XCFRAMEWORKS_BUILD_DIR}/OpenSSL-Universal/OpenSSL.framework/OpenSSL",
|
||||
"${PODS_XCFRAMEWORKS_BUILD_DIR}/QuickCrypto/OpenSSL.framework/OpenSSL",
|
||||
"${PODS_XCFRAMEWORKS_BUILD_DIR}/React-Core-prebuilt/React.framework/React",
|
||||
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ReactNativeDependencies/ReactNativeDependencies.framework/ReactNativeDependencies",
|
||||
"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
PODS:
|
||||
- EXAV (16.0.8):
|
||||
- ExpoModulesCore
|
||||
- ReactCommon/turbomodule/core
|
||||
- EXConstants (18.0.13):
|
||||
- ExpoModulesCore
|
||||
- EXImageLoader (6.0.0):
|
||||
@@ -34,6 +31,8 @@ PODS:
|
||||
- Yoga
|
||||
- ExpoAsset (12.0.12):
|
||||
- ExpoModulesCore
|
||||
- ExpoAudio (1.1.1):
|
||||
- ExpoModulesCore
|
||||
- ExpoCamera (17.0.10):
|
||||
- ExpoModulesCore
|
||||
- ZXingObjC/OneD
|
||||
@@ -72,9 +71,9 @@ PODS:
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- FBLazyVector (0.81.6)
|
||||
- hermes-engine (0.81.5):
|
||||
- hermes-engine/Pre-built (= 0.81.5)
|
||||
- hermes-engine/Pre-built (0.81.5)
|
||||
- hermes-engine (0.81.6):
|
||||
- hermes-engine/Pre-built (= 0.81.6)
|
||||
- hermes-engine/Pre-built (0.81.6)
|
||||
- JoplinCommonShareExtension (1.0.0)
|
||||
- JoplinRNShareExtension (1.0.0):
|
||||
- JoplinCommonShareExtension
|
||||
@@ -114,7 +113,30 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- OpenSSL-Universal (3.3.3001)
|
||||
- QuickCrypto (1.0.19):
|
||||
- hermes-engine
|
||||
- NitroModules
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
- React-callinvoker
|
||||
- React-Core
|
||||
- React-Core-prebuilt
|
||||
- React-debug
|
||||
- React-Fabric
|
||||
- React-featureflags
|
||||
- React-graphics
|
||||
- React-ImageManager
|
||||
- React-jsi
|
||||
- React-NativeModulesApple
|
||||
- React-RCTFabric
|
||||
- React-renderercss
|
||||
- React-rendererdebug
|
||||
- React-utils
|
||||
- ReactCodegen
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- RCTDeprecation (0.81.6)
|
||||
- RCTRequired (0.81.6)
|
||||
- RCTTypeSafety (0.81.6):
|
||||
@@ -154,7 +176,7 @@ PODS:
|
||||
- React-utils
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- React-Core-prebuilt (0.81.5):
|
||||
- React-Core-prebuilt (0.81.6):
|
||||
- ReactNativeDependencies
|
||||
- React-Core/CoreModulesHeaders (0.81.6):
|
||||
- hermes-engine
|
||||
@@ -1468,30 +1490,6 @@ PODS:
|
||||
- React-Core
|
||||
- react-native-quick-base64 (2.2.2):
|
||||
- React-Core
|
||||
- react-native-quick-crypto (0.7.17):
|
||||
- hermes-engine
|
||||
- OpenSSL-Universal
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
- React
|
||||
- React-Core
|
||||
- React-Core-prebuilt
|
||||
- React-debug
|
||||
- React-Fabric
|
||||
- React-featureflags
|
||||
- React-graphics
|
||||
- React-ImageManager
|
||||
- React-jsi
|
||||
- React-NativeModulesApple
|
||||
- React-RCTFabric
|
||||
- React-renderercss
|
||||
- React-rendererdebug
|
||||
- React-utils
|
||||
- ReactCodegen
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- react-native-rsa-native (2.0.5):
|
||||
- React
|
||||
- react-native-saf-x (3.6.0):
|
||||
@@ -2067,7 +2065,7 @@ PODS:
|
||||
- React
|
||||
- RNSecureRandom (1.0.1):
|
||||
- React
|
||||
- RNShare (12.2.1):
|
||||
- RNShare (12.2.2):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
@@ -2169,11 +2167,11 @@ PODS:
|
||||
- ZXingObjC/Core
|
||||
|
||||
DEPENDENCIES:
|
||||
- EXAV (from `../node_modules/expo-av/ios`)
|
||||
- EXConstants (from `../node_modules/expo-constants/ios`)
|
||||
- EXImageLoader (from `../node_modules/expo-image-loader/ios`)
|
||||
- Expo (from `../node_modules/expo`)
|
||||
- ExpoAsset (from `../node_modules/expo-asset/ios`)
|
||||
- ExpoAudio (from `../node_modules/expo-audio/ios`)
|
||||
- ExpoCamera (from `../node_modules/expo-camera/ios`)
|
||||
- ExpoFileSystem (from `../node_modules/expo-file-system/ios`)
|
||||
- ExpoFont (from `../node_modules/expo-font/ios`)
|
||||
@@ -2185,6 +2183,7 @@ DEPENDENCIES:
|
||||
- JoplinCommonShareExtension (from `ShareExtension`)
|
||||
- JoplinRNShareExtension (from `ShareExtension`)
|
||||
- NitroModules (from `../node_modules/react-native-nitro-modules`)
|
||||
- QuickCrypto (from `../node_modules/react-native-quick-crypto`)
|
||||
- RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)
|
||||
- RCTRequired (from `../node_modules/react-native/Libraries/Required`)
|
||||
- RCTTypeSafety (from `../node_modules/react-native/Libraries/TypeSafety`)
|
||||
@@ -2226,7 +2225,6 @@ DEPENDENCIES:
|
||||
- react-native-image-picker (from `../node_modules/react-native-image-picker`)
|
||||
- "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)"
|
||||
- react-native-quick-base64 (from `../node_modules/react-native-quick-base64`)
|
||||
- react-native-quick-crypto (from `../node_modules/react-native-quick-crypto`)
|
||||
- react-native-rsa-native (from `../node_modules/react-native-rsa-native`)
|
||||
- "react-native-saf-x (from `../node_modules/@joplin/react-native-saf-x`)"
|
||||
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
|
||||
@@ -2287,14 +2285,11 @@ DEPENDENCIES:
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- libwebp
|
||||
- OpenSSL-Universal
|
||||
- SDWebImage
|
||||
- SDWebImageWebPCoder
|
||||
- ZXingObjC
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
EXAV:
|
||||
:path: "../node_modules/expo-av/ios"
|
||||
EXConstants:
|
||||
:path: "../node_modules/expo-constants/ios"
|
||||
EXImageLoader:
|
||||
@@ -2303,6 +2298,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/expo"
|
||||
ExpoAsset:
|
||||
:path: "../node_modules/expo-asset/ios"
|
||||
ExpoAudio:
|
||||
:path: "../node_modules/expo-audio/ios"
|
||||
ExpoCamera:
|
||||
:path: "../node_modules/expo-camera/ios"
|
||||
ExpoFileSystem:
|
||||
@@ -2326,6 +2323,8 @@ EXTERNAL SOURCES:
|
||||
:path: ShareExtension
|
||||
NitroModules:
|
||||
:path: "../node_modules/react-native-nitro-modules"
|
||||
QuickCrypto:
|
||||
:path: "../node_modules/react-native-quick-crypto"
|
||||
RCTDeprecation:
|
||||
:path: "../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation"
|
||||
RCTRequired:
|
||||
@@ -2406,8 +2405,6 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/@react-native-community/netinfo"
|
||||
react-native-quick-base64:
|
||||
:path: "../node_modules/react-native-quick-base64"
|
||||
react-native-quick-crypto:
|
||||
:path: "../node_modules/react-native-quick-crypto"
|
||||
react-native-rsa-native:
|
||||
:path: "../node_modules/react-native-rsa-native"
|
||||
react-native-saf-x:
|
||||
@@ -2522,11 +2519,11 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/react-native/ReactCommon/yoga"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
EXAV: b60fcf142fae6684d295bc28cd7cfcb3335570ea
|
||||
EXConstants: fce59a631a06c4151602843667f7cfe35f81e271
|
||||
EXImageLoader: 189e3476581efe3ad4d1d3fb4735b7179eb26f05
|
||||
Expo: 04993fbd7b06dc98ffac58da8847298470dc3db1
|
||||
ExpoAsset: f867e55ceb428aab99e1e8c082b5aee7c159ea18
|
||||
ExpoAudio: e4cfe3a2f3317b8487460685385a9867a07fb4fb
|
||||
ExpoCamera: 6a326deb45ba840749652e4c15198317aa78497e
|
||||
ExpoFileSystem: 858a44267a3e6e9057e0888ad7c7cfbf55d52063
|
||||
ExpoFont: 35ac6191ed86bbf56b3ebd2d9154eda9fad5b509
|
||||
@@ -2534,19 +2531,19 @@ SPEC CHECKSUMS:
|
||||
ExpoLocalAuthentication: 8a31808565da7af926dd9b595e98594d8b1553b6
|
||||
ExpoModulesCore: f3da4f1ab5a8375d0beafab763739dbee8446583
|
||||
FBLazyVector: 14ce6e3675cacb2683ad30272f04274a4ee5b67d
|
||||
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
|
||||
hermes-engine: 7219f6e751ad6ec7f3d7ec121830ee34dae40749
|
||||
JoplinCommonShareExtension: a8b60b02704d85a7305627912c0240e94af78db7
|
||||
JoplinRNShareExtension: e158a4b53ee0aa9cd3037a16221dc8adbd6f7860
|
||||
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
||||
NitroModules: 114b4f79e10be9b202723e721d6a54382fa4b599
|
||||
OpenSSL-Universal: 6082b0bf950e5636fe0d78def171184e2b3899c2
|
||||
QuickCrypto: 0708a392535332365b7f4a16a0b229482be9a1e0
|
||||
RCTDeprecation: ff38238d8b6ddfe1fcfeb2718d1c14da9564c1c3
|
||||
RCTRequired: 5916f53ff05efcc2bf095b0de01a0e5b00112a55
|
||||
RCTTypeSafety: bfbd8d69504a7459a65040705fc6ce10806c383b
|
||||
React: 348d1689d8686d034c5b7667dc45de86c6319dd1
|
||||
React-callinvoker: 2c3b664f3482f5bc5560ea1edcbbe69748752f08
|
||||
React-Core: 346787852200a732b187805344b8a350d464e004
|
||||
React-Core-prebuilt: 02f0ad625ddd47463c009c2d0c5dd35c0d982599
|
||||
React-Core-prebuilt: 721ab014acfaff1e4b8fc0d2f7d6f41ea9a706ed
|
||||
React-CoreModules: 7e07391a1082d02c37f846a362f7574ab035933c
|
||||
React-cxxreact: c50d278c785792a077a6b357aaabd9e5d09e9c6f
|
||||
React-debug: 1b91785fec02ea76c793ead23bed1528d96b4262
|
||||
@@ -2580,7 +2577,6 @@ SPEC CHECKSUMS:
|
||||
react-native-image-picker: 48d850454b4a389753053e1d7378b624d3b47d77
|
||||
react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187
|
||||
react-native-quick-base64: 6568199bb2ac8e72ecdfdc73a230fbc5c1d3aac4
|
||||
react-native-quick-crypto: 1a2467cf17acc57dce5bb076a953978b79e3fd11
|
||||
react-native-rsa-native: a7931cdda1f73a8576a46d7f431378c5550f0c38
|
||||
react-native-saf-x: 50d176763ed692b379c190bf55ae7293a3ee09bb
|
||||
react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2
|
||||
@@ -2633,7 +2629,7 @@ SPEC CHECKSUMS:
|
||||
RNLocalize: 44b09911588826d01c5b949e8e3f9ed5fae16b32
|
||||
RNQuickAction: c2c8f379e614428be0babe4d53a575739667744d
|
||||
RNSecureRandom: b64d263529492a6897e236a22a2c4249aa1b53dc
|
||||
RNShare: 0e600372fb35783fe30d413efd28d11de2bf6cf0
|
||||
RNShare: a075abc351f03fd89517bbee912593f299eb8a64
|
||||
RNSVG: cf9ae78f2edf2988242c71a6392d15ff7dd62522
|
||||
SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838
|
||||
SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377
|
||||
|
||||
@@ -76,14 +76,41 @@ jest.mock('@react-native-clipboard/clipboard', () => {
|
||||
return { default: { getString: jest.fn(), setString: jest.fn() } };
|
||||
});
|
||||
|
||||
jest.doMock('expo-audio', () => {
|
||||
return {
|
||||
AudioQuality: {
|
||||
MIN: 'min',
|
||||
},
|
||||
IOSOutputFormat: {
|
||||
MPEG4AAC: 'mpeg4aac',
|
||||
},
|
||||
getRecordingPermissionsAsync: jest.fn(async () => ({
|
||||
status: 'granted',
|
||||
granted: true,
|
||||
})),
|
||||
requestRecordingPermissionsAsync: jest.fn(async () => ({
|
||||
status: 'granted',
|
||||
granted: true,
|
||||
})),
|
||||
setAudioModeAsync: jest.fn(async () => null),
|
||||
useAudioRecorder: jest.fn(() => ({
|
||||
prepareToRecordAsync: jest.fn(async () => null),
|
||||
record: jest.fn(),
|
||||
stop: jest.fn(async () => null),
|
||||
uri: null,
|
||||
})),
|
||||
useAudioRecorderState: jest.fn(() => ({
|
||||
durationMillis: 0,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const emptyMockPackages = [
|
||||
'react-native-share',
|
||||
'react-native-file-viewer',
|
||||
'react-native-image-picker',
|
||||
'@react-native-documents/picker',
|
||||
'@joplin/react-native-saf-x',
|
||||
'expo-av',
|
||||
'expo-av/build/Audio',
|
||||
'expo-image-manipulator',
|
||||
];
|
||||
for (const packageName of emptyMockPackages) {
|
||||
|
||||
@@ -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",
|
||||
@@ -34,7 +35,7 @@
|
||||
"@react-native-community/datetimepicker": "8.5.1",
|
||||
"@react-native-community/geolocation": "3.4.0",
|
||||
"@react-native-community/netinfo": "11.4.1",
|
||||
"@react-native-community/push-notification-ios": "1.11.0",
|
||||
"@react-native-community/push-notification-ios": "1.12.0",
|
||||
"@react-native-documents/picker": "10.1.7",
|
||||
"@react-native-vector-icons/fontawesome5": "patch:@react-native-vector-icons/fontawesome5@npm%3A12.3.0#~/.yarn/patches/@react-native-vector-icons-fontawesome5-npm-12.3.0-a1ca46610f.patch",
|
||||
"@react-native-vector-icons/get-image": "12.3.0",
|
||||
@@ -49,7 +50,7 @@
|
||||
"deprecated-react-native-prop-types": "5.0.0",
|
||||
"events": "3.3.0",
|
||||
"expo": "54.0.31",
|
||||
"expo-av": "16.0.8",
|
||||
"expo-audio": "1.1.1",
|
||||
"expo-camera": "17.0.10",
|
||||
"expo-image-manipulator": "14.0.8",
|
||||
"expo-local-authentication": "17.0.8",
|
||||
@@ -73,11 +74,11 @@
|
||||
"react-native-popup-menu": "0.17.0",
|
||||
"react-native-quick-actions": "0.3.13",
|
||||
"react-native-quick-base64": "2.2.2",
|
||||
"react-native-quick-crypto": "0.7.17",
|
||||
"react-native-quick-crypto": "1.0.19",
|
||||
"react-native-rsa-native": "2.0.5",
|
||||
"react-native-safe-area-context": "5.6.2",
|
||||
"react-native-securerandom": "1.0.1",
|
||||
"react-native-share": "12.2.1",
|
||||
"react-native-share": "12.2.2",
|
||||
"react-native-sqlite-storage": "6.0.1",
|
||||
"react-native-svg": "15.15.1",
|
||||
"react-native-url-polyfill": "2.0.0",
|
||||
@@ -115,8 +116,8 @@
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/node": "18.19.130",
|
||||
"@types/react": "19.1.10",
|
||||
"@types/react-redux": "7.1.33",
|
||||
"@types/serviceworker": "0.0.172",
|
||||
"@types/react-redux": "7.1.34",
|
||||
"@types/serviceworker": "0.0.176",
|
||||
"@types/tar-stream": "3.1.4",
|
||||
"babel-jest": "29.7.0",
|
||||
"babel-loader": "9.1.3",
|
||||
@@ -146,7 +147,7 @@
|
||||
"url-loader": "4.1.1",
|
||||
"webpack": "5.97.1",
|
||||
"webpack-cli": "5.1.4",
|
||||
"webpack-dev-server": "5.2.2"
|
||||
"webpack-dev-server": "5.2.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
@@ -23,6 +23,15 @@ import { AppState as RNAppState, EmitterSubscription, View, Text, Linking, Nativ
|
||||
import getResponsiveValue from './components/getResponsiveValue';
|
||||
import NetInfo, { NetInfoSubscription } from '@react-native-community/netinfo';
|
||||
const DropdownAlert = require('react-native-dropdownalert').default;
|
||||
|
||||
// Mirrors the DropdownAlertData type from react-native-dropdownalert
|
||||
interface DropdownAlertData {
|
||||
type?: string;
|
||||
title?: string;
|
||||
message?: string;
|
||||
interval?: number;
|
||||
resolve?: (_value: DropdownAlertData)=> void;
|
||||
}
|
||||
import SafeAreaView from './components/SafeAreaView';
|
||||
const { connect, Provider } = require('react-redux');
|
||||
import { Provider as PaperProvider, MD3DarkTheme, MD3LightTheme } from 'react-native-paper';
|
||||
@@ -103,7 +112,7 @@ import SamlShared from '@joplin/lib/components/shared/SamlShared';
|
||||
import NoteRevisionViewer from './components/screens/NoteRevisionViewer';
|
||||
import DocumentScanner from './components/screens/DocumentScanner/DocumentScanner';
|
||||
import buildStartupTasks from './utils/buildStartupTasks';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import appReducer from './utils/appReducer';
|
||||
import SyncWizard from './components/SyncWizard/SyncWizard';
|
||||
import Synchronizer from '@joplin/lib/Synchronizer';
|
||||
@@ -111,6 +120,15 @@ import Synchronizer from '@joplin/lib/Synchronizer';
|
||||
const logger = Logger.create('root');
|
||||
const perfLogger = PerformanceLogger.create();
|
||||
|
||||
interface DropdownAlertWrapperProps {
|
||||
alert: (func: (data?: DropdownAlertData)=> Promise<DropdownAlertData>)=> void;
|
||||
}
|
||||
|
||||
const DropdownAlertWrapper = ({ alert }: DropdownAlertWrapperProps) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
return <DropdownAlert alert={alert} translucent alertViewStyle={{ padding: 8, marginTop: insets.top }} />;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
let storeDispatch: any = function(_action: any) {};
|
||||
|
||||
@@ -287,8 +305,7 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
|
||||
private themeChangeListener_: NativeEventSubscription|null = null;
|
||||
private keyboardShowListener_: EmitterSubscription|null = null;
|
||||
private keyboardHideListener_: EmitterSubscription|null = null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
private dropdownAlert_ = (_data: any) => new Promise<any>(res => res);
|
||||
private dropdownAlert_: (data?: DropdownAlertData)=> Promise<DropdownAlertData> = (_data?: DropdownAlertData) => new Promise<DropdownAlertData>(res => res);
|
||||
private callbackUrl: string|null = null;
|
||||
|
||||
private lastSyncStarted_ = false;
|
||||
@@ -586,14 +603,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.');
|
||||
@@ -771,10 +792,9 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
|
||||
<View style={{ flex: 1, backgroundColor: theme.backgroundColor }}>
|
||||
{ shouldShowMainContent && <AppNav screens={appNavInit} dispatch={this.props.dispatch} /> }
|
||||
</View>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied */}
|
||||
<DropdownAlert alert={(func: any) => (this.dropdownAlert_ = func)} />
|
||||
<SyncWizard/>
|
||||
</SafeAreaView>
|
||||
<DropdownAlertWrapper alert={(func) => { this.dropdownAlert_ = func; }} />
|
||||
</View>
|
||||
</SideMenu>
|
||||
<PluginRunnerWebView />
|
||||
|
||||
@@ -29,7 +29,7 @@ const pbkdf2Raw = (password: string, salt: CryptoBuffer, iterations: number, key
|
||||
|
||||
const encryptRaw = (data: CryptoBuffer, algorithm: CipherAlgorithm, key: CryptoBuffer, iv: CryptoBuffer, authTagLength: number, associatedData: CryptoBuffer) => {
|
||||
|
||||
const cipher = QuickCrypto.createCipheriv(algorithm, key, iv, { authTagLength: authTagLength } as CipherGCMOptions) as CipherGCM;
|
||||
const cipher = QuickCrypto.createCipheriv(algorithm, key, iv, { authTagLength: authTagLength } as CipherGCMOptions) as unknown as CipherGCM;
|
||||
|
||||
cipher.setAAD(associatedData, { plaintextLength: Buffer.byteLength(data) });
|
||||
|
||||
@@ -41,7 +41,7 @@ const encryptRaw = (data: CryptoBuffer, algorithm: CipherAlgorithm, key: CryptoB
|
||||
|
||||
const decryptRaw = (data: CryptoBuffer, algorithm: CipherAlgorithm, key: CryptoBuffer, iv: CryptoBuffer, authTagLength: number, associatedData: CryptoBuffer) => {
|
||||
|
||||
const decipher = QuickCrypto.createDecipheriv(algorithm, key, iv, { authTagLength: authTagLength } as CipherGCMOptions) as DecipherGCM;
|
||||
const decipher = QuickCrypto.createDecipheriv(algorithm, key, iv, { authTagLength: authTagLength } as CipherGCMOptions) as unknown as DecipherGCM;
|
||||
|
||||
const authTag = data.subarray(-authTagLength);
|
||||
const encryptedData = data.subarray(0, data.byteLength - authTag.byteLength);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import appReducer from './appReducer';
|
||||
import appDefaultState, { DEFAULT_ROUTE } from './appDefaultState';
|
||||
|
||||
const notesRoute = { type: 'NAV_GO', routeName: 'Notes', folderId: 'folder1' };
|
||||
const settingsRoute = { type: 'NAV_GO', routeName: 'Config' };
|
||||
const deletedFolderId = 'folder1';
|
||||
|
||||
const clearHistory = () => appReducer(appDefaultState, { type: 'NAV_GO', routeName: 'Notes', clearHistory: true });
|
||||
|
||||
// Simulates the exact scenario: navigate to a folder with no prior history, then delete it
|
||||
const makeDeletedRouteWithEmptyHistory = () => {
|
||||
let state = appReducer(appDefaultState, {
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Notes',
|
||||
folderId: deletedFolderId,
|
||||
clearHistory: true,
|
||||
});
|
||||
state = appReducer(state, { type: 'FOLDER_DELETE', id: deletedFolderId });
|
||||
return state;
|
||||
};
|
||||
|
||||
describe('appReducer', () => {
|
||||
test('historyCanGoBack is true after navigating from Notes to Settings', () => {
|
||||
let state = clearHistory();
|
||||
state = appReducer(state, notesRoute);
|
||||
state = appReducer(state, settingsRoute);
|
||||
expect(state.historyCanGoBack).toBe(true);
|
||||
});
|
||||
|
||||
test('historyCanGoBack remains true after navigating away from a deleted route', () => {
|
||||
let state = makeDeletedRouteWithEmptyHistory();
|
||||
|
||||
// Navigate forward (e.g. open Settings)
|
||||
state = appReducer(state, settingsRoute);
|
||||
|
||||
expect(state.historyCanGoBack).toBe(true);
|
||||
});
|
||||
|
||||
test('going back from Settings after folder deletion lands on DEFAULT_ROUTE', () => {
|
||||
let state = makeDeletedRouteWithEmptyHistory();
|
||||
state = appReducer(state, settingsRoute);
|
||||
|
||||
// Go back
|
||||
state = appReducer(state, { type: 'NAV_BACK' });
|
||||
|
||||
expect(state.route).toEqual(DEFAULT_ROUTE);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import reducer from '@joplin/lib/reducer';
|
||||
import { AppState } from './types';
|
||||
import appDefaultState from './appDefaultState';
|
||||
import appDefaultState, { DEFAULT_ROUTE } from './appDefaultState';
|
||||
import fastDeepEqual = require('fast-deep-equal');
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
|
||||
@@ -76,6 +76,10 @@ const appReducer = (state = appDefaultState, action: any) => {
|
||||
if (currentRoute.isDeleted) {
|
||||
// Do not add the item to the history, and remove the last item in the history if that is now the selected item
|
||||
removeLatestFolderIfSelected(navHistory, action);
|
||||
// Push DEFAULT_ROUTE so there's always a valid back target after deletion
|
||||
if (!navHistory.length) {
|
||||
navHistory.push(DEFAULT_ROUTE);
|
||||
}
|
||||
} else if (isDifferentRoute) {
|
||||
navHistory.push(currentRoute);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -174,6 +174,19 @@ const searchExtension = (onEvent: OnEventCallback, settings: EditorSettings): Ex
|
||||
},
|
||||
} : undefined),
|
||||
|
||||
// On mobile, scrolling is handled externally (page-level native scroll), so
|
||||
// .cm-scroller has no internal scroll. A @codemirror/view upgrade (6.35→6.41)
|
||||
// added rect-clipping after each failed scroll attempt, which prevents the
|
||||
// fallback window.scrollBy from firing. Use native element.scrollIntoView to
|
||||
// fix findNext/findPrevious and GoDocEnd.
|
||||
settings.useExternalSearch ? EditorView.scrollHandler.of((view, range) => {
|
||||
const { node } = view.domAtPos(range.head);
|
||||
const el = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement;
|
||||
if (!el) return false;
|
||||
el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
return true;
|
||||
}) : [],
|
||||
|
||||
autoScrollToMatchPlugin,
|
||||
|
||||
EditorState.transactionExtender.of((tr) => {
|
||||
|
||||
@@ -144,4 +144,21 @@ describe('ProseMirror/commands', () => {
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
test('goDocEnd should move the cursor to the end of the document', () => {
|
||||
const editor = createTestEditor({ html: '<p>First</p><p>Last</p>' });
|
||||
|
||||
commands[EditorCommandType.GoDocEnd](editor.state, editor.dispatch, editor);
|
||||
|
||||
expect(editor.state.selection.from).toBe(editor.state.doc.content.size - 1);
|
||||
});
|
||||
|
||||
test('goDocStart should move the cursor to the start of the document', () => {
|
||||
const editor = createTestEditor({ html: '<p>First</p><p>Last</p>' });
|
||||
|
||||
moveCursorToEnd(editor);
|
||||
commands[EditorCommandType.GoDocStart](editor.state, editor.dispatch, editor);
|
||||
|
||||
expect(editor.state.selection.from).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Command, EditorState, Transaction } from 'prosemirror-state';
|
||||
import { Command, EditorState, TextSelection, Transaction } from 'prosemirror-state';
|
||||
import { EditorCommandType } from '../../types';
|
||||
import { redo, undo } from 'prosemirror-history';
|
||||
import { autoJoin, selectAll, setBlockType, toggleMark } from 'prosemirror-commands';
|
||||
@@ -247,8 +247,14 @@ const commands: Record<EditorCommandType, ExtendedCommand|null> = {
|
||||
[EditorCommandType.InsertNewlineAndIndent]: null,
|
||||
[EditorCommandType.SwapLineUp]: null,
|
||||
[EditorCommandType.SwapLineDown]: null,
|
||||
[EditorCommandType.GoDocEnd]: null,
|
||||
[EditorCommandType.GoDocStart]: null,
|
||||
[EditorCommandType.GoDocEnd]: (state, dispatch) => {
|
||||
dispatch(state.tr.setSelection(TextSelection.atEnd(state.doc)).scrollIntoView());
|
||||
return true;
|
||||
},
|
||||
[EditorCommandType.GoDocStart]: (state, dispatch) => {
|
||||
dispatch(state.tr.setSelection(TextSelection.atStart(state.doc)).scrollIntoView());
|
||||
return true;
|
||||
},
|
||||
[EditorCommandType.GoLineStart]: null,
|
||||
[EditorCommandType.GoLineEnd]: null,
|
||||
[EditorCommandType.GoLineUp]: null,
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/node": "18.19.130",
|
||||
"@types/react": "19.1.10",
|
||||
"@types/react-redux": "7.1.33",
|
||||
"@types/react-redux": "7.1.34",
|
||||
"@types/styled-components": "5.1.32",
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
@@ -28,17 +28,17 @@
|
||||
"typescript": "5.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "6.20.0",
|
||||
"@codemirror/commands": "6.10.1",
|
||||
"@codemirror/autocomplete": "6.20.1",
|
||||
"@codemirror/commands": "6.10.3",
|
||||
"@codemirror/lang-html": "6.4.11",
|
||||
"@codemirror/lang-markdown": "6.5.0",
|
||||
"@codemirror/language": "6.10.4",
|
||||
"@codemirror/language": "6.12.3",
|
||||
"@codemirror/language-data": "6.3.1",
|
||||
"@codemirror/legacy-modes": "6.5.2",
|
||||
"@codemirror/lint": "6.9.2",
|
||||
"@codemirror/search": "6.5.8",
|
||||
"@codemirror/state": "6.5.4",
|
||||
"@codemirror/view": "6.35.0",
|
||||
"@codemirror/lint": "6.9.5",
|
||||
"@codemirror/search": "6.6.0",
|
||||
"@codemirror/state": "6.6.0",
|
||||
"@codemirror/view": "6.41.0",
|
||||
"@joplin/fork-uslug": "^2.0.0",
|
||||
"@lezer/common": "1.5.0",
|
||||
"@lezer/highlight": "1.2.3",
|
||||
@@ -49,7 +49,7 @@
|
||||
"prosemirror-commands": "1.7.1",
|
||||
"prosemirror-dropcursor": "1.8.2",
|
||||
"prosemirror-example-setup": "1.2.3",
|
||||
"prosemirror-gapcursor": "1.3.2",
|
||||
"prosemirror-gapcursor": "1.4.0",
|
||||
"prosemirror-history": "1.5.0",
|
||||
"prosemirror-inputrules": "1.5.0",
|
||||
"prosemirror-keymap": "1.2.3",
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -224,17 +224,44 @@ const markdownUtils = {
|
||||
if (!body) return '';
|
||||
const spaceEntities = / /g;
|
||||
body = body.replace(spaceEntities, ' ');
|
||||
const lines = body.trim().split('\n');
|
||||
const title = lines[0].trim();
|
||||
const lines = body.split('\n');
|
||||
let title = '';
|
||||
|
||||
const mdLinkRegex = /!?\[([^\]]+?)\]\(.+?\)/g;
|
||||
const emptyMdLinkRegex = /!?\[\]\((.+?)\)/g;
|
||||
const filterRegex = /^[# \n\t*`-]*/;
|
||||
return title
|
||||
.replace(filterRegex, '')
|
||||
.replace(mdLinkRegex, '$1')
|
||||
.replace(emptyMdLinkRegex, '$1')
|
||||
.substring(0, 80);
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed) {
|
||||
title = trimmed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
title = title.replace(/<\/?(ins|del|mark|sub|sup)>/g, '');
|
||||
title = title.replace(/!?\[([^\]]*)\]\(([^()\n]+(?:\([^()\n]*\)[^()\n]*)*)\)/g, (_match, text, url) => {
|
||||
return text || url;
|
||||
});
|
||||
const formattingPatterns = [
|
||||
/(\*\*\*|___)(.*?)\1/g,
|
||||
/(\*\*|__)(.*?)\1/g,
|
||||
/(\*|_)(.*?)\1/g,
|
||||
/(~~)(.*?)\1/g,
|
||||
/(==)(.*?)\1/g,
|
||||
/(\^)(.*?)\1/g,
|
||||
];
|
||||
|
||||
let prev;
|
||||
do {
|
||||
prev = title;
|
||||
for (const pattern of formattingPatterns) {
|
||||
title = title.replace(pattern, '$2');
|
||||
}
|
||||
} while (title !== prev);
|
||||
|
||||
title = title.replace(/^\s*([-*+]|\d+[.)])\s+(\[[ xX]\]\s+)?/, '');
|
||||
title = title.replace(/^(~{2,}|={2,})$/, '');
|
||||
title = title.replace(/^[#>\-*`\s=]+/, '');
|
||||
title = title.replace(/[#>\-*`\s=]+$/, '');
|
||||
title = title.trim();
|
||||
return title.substring(0, 80);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -115,6 +115,15 @@ describe('markdownUtils', () => {
|
||||
['These are [link1](one), [link2](two) and ', 'These are link1, link2 and link3'],
|
||||
['No description link to [](https://joplinapp.org)', 'No description link to https://joplinapp.org'],
|
||||
[' \n\nThis is a test test.', 'This is a test test.'],
|
||||
['***Example title***', 'Example title'],
|
||||
['==~~Formatted title~~==', 'Formatted title'],
|
||||
['<ins>Important note title</ins>', 'Important note title'],
|
||||
['`Inline code title`', 'Inline code title'],
|
||||
['<ins>~~==***Deeply formatted title***==~~</ins>', 'Deeply formatted title'],
|
||||
['C++ <vector> usage guide', 'C++ <vector> usage guide'],
|
||||
['C# programming basics', 'C# programming basics'],
|
||||
['Understanding a ~ b relationship', 'Understanding a ~ b relationship'],
|
||||
['***~~***', ''],
|
||||
])('should create a default note title from the note body (%j -> %j)', (body, expectedTitle) => {
|
||||
expect(markdownUtils.titleFromBody(body)).toBe(expectedTitle);
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
@@ -1460,7 +1479,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
noteVisiblePanes: { value: ['editor', 'viewer'], type: SettingItemType.Array, storage: SettingStorage.File, isGlobal: true, public: false, appTypes: [AppType.Desktop] },
|
||||
tagHeaderIsExpanded: { value: true, type: SettingItemType.Bool, public: false, appTypes: [AppType.Desktop] },
|
||||
folderHeaderIsExpanded: { value: true, type: SettingItemType.Bool, public: false, appTypes: [AppType.Desktop] },
|
||||
syncReportLogExpanded: { value: false, type: SettingItemType.Bool, public: false, appTypes: [AppType.Desktop] },
|
||||
syncReportIsVisible: { value: false, type: SettingItemType.Bool, public: false, appTypes: [AppType.Desktop] },
|
||||
editor: { value: '', type: SettingItemType.String, subType: 'file_path_and_args', storage: SettingStorage.File, isGlobal: true, public: true, appTypes: [AppType.Cli, AppType.Desktop], label: () => _('Text editor command'), description: () => _('The editor command (may include arguments) that will be used to open a note. If none is provided it will try to auto-detect the default editor.') },
|
||||
'export.pdfPageSize': { value: 'A4', type: SettingItemType.String, advanced: true, storage: SettingStorage.File, isGlobal: true, isEnum: true, public: true, appTypes: [AppType.Desktop], label: () => _('Page size for PDF export'), options: () => {
|
||||
return {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -53,8 +53,8 @@ const defaultKeymapItems = {
|
||||
{ accelerator: 'F1', command: 'help' },
|
||||
{ accelerator: 'Cmd+D', command: 'editor.deleteLine' },
|
||||
{ accelerator: 'Shift+Cmd+D', command: 'editor.duplicateLine' },
|
||||
{ accelerator: 'Cmd+Z', command: 'editor.undo' },
|
||||
{ accelerator: 'Cmd+Y', command: 'editor.redo' },
|
||||
{ accelerator: 'Cmd+Z', command: 'globalUndo' },
|
||||
{ accelerator: 'Cmd+Shift+Z', command: 'globalRedo' },
|
||||
{ accelerator: 'Cmd+[', command: 'editor.indentLess' },
|
||||
{ accelerator: 'Cmd+]', command: 'editor.indentMore' },
|
||||
{ accelerator: 'Cmd+/', command: 'editor.toggleComment' },
|
||||
@@ -109,8 +109,8 @@ const defaultKeymapItems = {
|
||||
{ accelerator: 'F1', command: 'help' },
|
||||
{ accelerator: 'Ctrl+D', command: 'editor.deleteLine' },
|
||||
{ accelerator: 'Shift+Ctrl+D', command: 'editor.duplicateLine' },
|
||||
{ accelerator: 'Ctrl+Z', command: 'editor.undo' },
|
||||
{ accelerator: 'Ctrl+Y', command: 'editor.redo' },
|
||||
{ accelerator: 'Ctrl+Z', command: 'globalUndo' },
|
||||
{ accelerator: 'Ctrl+Y', command: 'globalRedo' },
|
||||
{ accelerator: 'Ctrl+[', command: 'editor.indentLess' },
|
||||
{ accelerator: 'Ctrl+]', command: 'editor.indentMore' },
|
||||
{ accelerator: 'Ctrl+/', command: 'editor.toggleComment' },
|
||||
|
||||
@@ -67,6 +67,16 @@ export async function checkDecryptTestData(data: DecryptTestData, options: Check
|
||||
messages.push(`Failed to decrypt data: Error: ${error}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const decrypted = await EncryptionService.instance().decrypt(data.method, `${data.password}-bad`, data.ciphertext);
|
||||
messages.push('Data could be decrypted with incorrect password');
|
||||
messages.push('Expected:', data.plaintext);
|
||||
messages.push('Got:', decrypted);
|
||||
hasError = true;
|
||||
} catch (error) {
|
||||
messages.push(`Could not decrypt data with an invalid password (${error})`);
|
||||
}
|
||||
|
||||
if (hasError && options.throwOnError) {
|
||||
const label = options.testLabel ? ` (test ${options.testLabel})` : '';
|
||||
throw new Error(`Testing Crypto failed${label}: \n${messages.join('\n')}`);
|
||||
|
||||
@@ -299,6 +299,14 @@ describe('InteropService_Importer_OneNote', () => {
|
||||
expect(normalizeNoteForSnapshot(note2Content)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should import vertically-scaled ink', async () => {
|
||||
const notes = await importNote(`${supportDir}/onenote/scaled_ink.one`);
|
||||
|
||||
const note = notes.find(n => n.title === 'Scaled');
|
||||
expectWithInstructions(note).toBeTruthy();
|
||||
expectWithInstructions(normalizeNoteForSnapshot(note.body)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should support directly importing .one files', async () => {
|
||||
const notes = await importNote(`${supportDir}/onenote/onenote_desktop.one`);
|
||||
|
||||
@@ -368,4 +376,14 @@ describe('InteropService_Importer_OneNote', () => {
|
||||
// The other section should import successfully
|
||||
expect(notes.map(note => note.title).sort()).toEqual(['Test note', 'Test section']);
|
||||
});
|
||||
|
||||
it('should import nested ink', async () => {
|
||||
const notes = await importNote(`${supportDir}/onenote/desktop_missing_ink.one`);
|
||||
expect(
|
||||
notes
|
||||
.filter(note => note.title === 'Ink Missing - only one example missing part')
|
||||
.map(note => normalizeNoteForSnapshot(note.body))
|
||||
.sort(),
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,12 @@ const getOneNoteConverter = (): NativeOneNoteConverter => {
|
||||
}
|
||||
};
|
||||
|
||||
const setEnableUnresponsiveCheck = (enabled: boolean) => {
|
||||
if (shim.isElectron()) {
|
||||
shim.electronBridge().setEnableUnresponsiveCheck(enabled);
|
||||
}
|
||||
};
|
||||
|
||||
// See onenote-converter README.md for more information
|
||||
export default class InteropService_Importer_OneNote extends InteropService_Importer_Base {
|
||||
protected importedNotes: Record<string, NoteEntity> = {};
|
||||
@@ -119,6 +125,11 @@ export default class InteropService_Importer_OneNote extends InteropService_Impo
|
||||
}
|
||||
|
||||
try {
|
||||
// HACK: The OneNote importer currently runs in the renderer process on desktop.
|
||||
// If importing a large file takes a long time, the "unresponsive" dialog can be
|
||||
// shown. Work around this by temporarily disabling the dialog:
|
||||
setEnableUnresponsiveCheck(false);
|
||||
|
||||
await oneNoteConverter(notebookFilePath, resolve(outputDirectory2), notebookBaseDir);
|
||||
} catch (error) {
|
||||
// Forward only the error message. Usually the stack trace points to bytes in the WASM file.
|
||||
@@ -126,6 +137,8 @@ export default class InteropService_Importer_OneNote extends InteropService_Impo
|
||||
// length for auto-creating a forum post:
|
||||
this.options_.onError?.(error.message ?? error);
|
||||
console.error(error);
|
||||
} finally {
|
||||
setEnableUnresponsiveCheck(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+61
-1
@@ -942,6 +942,66 @@ exports[`InteropService_Importer_OneNote should import a simple OneNote notebook
|
||||
|
||||
|
||||
|
||||
</body></html>"
|
||||
`;
|
||||
|
||||
exports[`InteropService_Importer_OneNote should import nested ink 1`] = `
|
||||
[
|
||||
"<!DOCTYPE HTML>
|
||||
<html><head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Ink Missing - only one example missing part</title>
|
||||
|
||||
<style>
|
||||
|
||||
/* (For testing: Removed default CSS) */
|
||||
|
||||
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="title" style="left: 48px; position: absolute; top: 24px;"><div class="container-outline" style="width: 753px;"><div class="outline-element" style="margin-left: 0px;"><span style="font-family: Calibri Light; font-size: 20pt; line-height: 32px;">Ink Missing - only one example missing part</span></div>
|
||||
</div><div class="container-outline" style="width: 753px;"><div class="outline-element" style="margin-left: 0px;"><span style="color: rgb(118,118,118); font-family: Calibri; font-size: 10pt; line-height: 16px;">Mittwoch, 1. April 2026</span></div>
|
||||
<div class="outline-element" style="margin-left: 0px;"><span style="color: rgb(118,118,118); font-family: Calibri; font-size: 10pt; line-height: 16px;">04:25</span></div>
|
||||
</div></div><div class="container-outline" style="left: 277px; position: absolute; top: 485px; width: 474px;"><div class="outline-element" style="margin-left: 0px;"><p><span style="height: 54px; width: 90px;" class="ink-text"><img style="height: 54px; left: 22.9px; pointer-events: none; position: absolute; top: -2.61px; width: 90px;" src=":/id-here"></span><span class="ink-space" style="padding-left: 25px; padding-top: 77px;"></span><span style="height: 86px; width: 283px;" class="ink-text"><img style="height: 86px; left: -2.61px; pointer-events: none; position: absolute; top: 5.78px; width: 283px;" src=":/id-here"></span></p></div>
|
||||
<div class="outline-element" style="margin-left: 0px;"><p><span style="height: 70px; width: 190px;" class="ink-text"><img style="height: 70px; left: -2.61px; pointer-events: none; position: absolute; top: -10.05px; width: 190px;" src=":/id-here"></span></p></div>
|
||||
<div class="outline-element" style="margin-left: 0px;"><p><span style="height: 68px; width: 344px;" class="ink-text"><img style="height: 68px; left: 34.28px; pointer-events: none; position: absolute; top: 7.56px; width: 344px;" src=":/id-here"></span></p></div>
|
||||
</div><div class="container-outline" style="left: 262px; position: absolute; top: 130px; width: 624px;"><div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt; line-height: 17px;"> </p></div>
|
||||
</div><div class="container-outline" style="left: 285px; position: absolute; top: 243px; width: 624px;"><div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt;"> </p></div>
|
||||
</div><div class="container-outline" style="left: 560px; position: absolute; top: 195px; width: 624px;"><div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt; line-height: 17px;"> </p></div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</body></html>",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`InteropService_Importer_OneNote should import vertically-scaled ink 1`] = `
|
||||
"<!DOCTYPE HTML>
|
||||
<html><head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Scaled</title>
|
||||
|
||||
<style>
|
||||
|
||||
/* (For testing: Removed default CSS) */
|
||||
|
||||
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="title" style="left: 48px; position: absolute; top: 24px;"><div class="container-outline" style="width: 624px;"><div class="outline-element" style="margin-left: 0px;"><span style="font-family: Calibri Light; font-size: 20pt; line-height: 32px;">Scaled</span></div>
|
||||
</div><div class="container-outline" style="width: 624px;"><div class="outline-element" style="margin-left: 0px;"><span style="color: rgb(118,118,118); font-family: Calibri; font-size: 10pt; line-height: 16px;">Wednesday, April 15, 2026</span></div>
|
||||
<div class="outline-element" style="margin-left: 0px;"><span style="color: rgb(118,118,118); font-family: Calibri; font-size: 10pt; line-height: 16px;">12:36 AM</span></div>
|
||||
</div></div><img style="height: 492.21px; left: 128.13px; pointer-events: none; position: absolute; top: 144.3px; width: 187.42px;" src=":/id-here">
|
||||
|
||||
|
||||
|
||||
</body></html>"
|
||||
`;
|
||||
|
||||
@@ -1414,7 +1474,7 @@ exports[`InteropService_Importer_OneNote should use default value for EntityGuid
|
||||
</div><div class="container-outline" style="left: 72px; position: absolute; top: 475px; width: 146px;"><div class="outline-element" style="margin-left: 0px;"><p style="color: rgb(91,155,213); font-family: Trebuchet MS; font-size: 12pt; line-height: 19px;">Customer Success Management</p></div>
|
||||
<div class="outline-element" style="margin-left: 0px;"><p style="color: rgb(91,155,213); font-family: Trebuchet MS; font-size: 12pt; line-height: 19px;">and</p></div>
|
||||
<div class="outline-element" style="margin-left: 0px;"><p style="color: rgb(91,155,213); font-family: Trebuchet MS; font-size: 12pt; line-height: 19px;">Training</p></div>
|
||||
</div><img style="height: 170px; left: 57.15px; pointer-events: none; position: absolute; top: 435.1px; width: 310px;" src=":/id-here">
|
||||
</div><img style="height: 173px; left: 57.15px; pointer-events: none; position: absolute; top: 443.15px; width: 310px;" src=":/id-here">
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { toIso639Alpha3 } from '../../locale';
|
||||
import { countryCodeOnly, languageCodeOnly, toIso639Alpha3 } from '../../locale';
|
||||
import Resource from '../../models/Resource';
|
||||
import Setting from '../../models/Setting';
|
||||
import shim from '../../shim';
|
||||
@@ -23,6 +23,24 @@ export const supportedMimeTypes = [
|
||||
'image/x-portable-bitmap',
|
||||
];
|
||||
|
||||
// Tesseract uses its own language codes that don't always match ISO 639-3.
|
||||
// For example, the ISO 639-3 code for Chinese is "zho" but Tesseract uses
|
||||
// "chi_sim" (Simplified) and "chi_tra" (Traditional), and Norwegian Bokmål
|
||||
// is "nob" in ISO 639-3 but "nor" in Tesseract.
|
||||
const iso639ToTesseractOverrides: Record<string, string> = {
|
||||
'nob': 'nor',
|
||||
};
|
||||
|
||||
const localeToTesseractLanguage = (locale: string): string => {
|
||||
const lang = languageCodeOnly(locale);
|
||||
if (lang === 'zh') {
|
||||
const country = countryCodeOnly(locale).toUpperCase();
|
||||
return country === 'TW' ? 'chi_tra' : 'chi_sim';
|
||||
}
|
||||
const alpha3 = toIso639Alpha3(locale);
|
||||
return iso639ToTesseractOverrides[alpha3] || alpha3;
|
||||
};
|
||||
|
||||
const resourceInfo = (resource: ResourceEntity) => {
|
||||
return `${resource.id} (type ${resource.mime})`;
|
||||
};
|
||||
@@ -213,7 +231,7 @@ export default class OcrService {
|
||||
};
|
||||
|
||||
try {
|
||||
const language = toIso639Alpha3(Setting.value('locale'));
|
||||
const language = localeToTesseractLanguage(Setting.value('locale'));
|
||||
const processedResourceIds: string[] = [];
|
||||
|
||||
// Queue all resources for processing
|
||||
|
||||
@@ -320,7 +320,7 @@ export async function createResourcesFromPaths(mediaFiles: DownloadedMediaFile[]
|
||||
const resource = await shim.createResourceFromPath(mediaFile.path);
|
||||
return { ...mediaFile, resource };
|
||||
} catch (error) {
|
||||
logger.warn(`Cannot create resource for ${mediaFile.originalUrl}`, error);
|
||||
logger.info(`Cannot create resource for ${mediaFile.originalUrl}`, error);
|
||||
return { ...mediaFile, resource: null };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -491,7 +491,7 @@ const shim = {
|
||||
},
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
writeImageToFile: (_image: any, _format: any, _filePath: string): void => {
|
||||
writeImageToFile: (_image: any, _format: any, _filePath: string): Promise<void> => {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -30,9 +30,17 @@ function normalizeAndWriteFile(filePath, data) {
|
||||
|
||||
function fileReader(path) {
|
||||
const fd = fs.openSync(path);
|
||||
const size = fs.fstatSync(fd).size;
|
||||
// TODO: When Node v20 is EOL, replace this with the { bigint: true }
|
||||
// parameter variant.
|
||||
const size = BigInt(fs.fstatSync(fd).size);
|
||||
|
||||
return {
|
||||
read: (position, length) => {
|
||||
read: (bigPosition, bigLength) => {
|
||||
// Rust uses u64 for position/length which is transferred to JS as a BigInt.
|
||||
// Convert:
|
||||
const length = Number(bigLength);
|
||||
const position = Number(bigPosition);
|
||||
|
||||
const data = Buffer.alloc(length);
|
||||
const sizeRead = fs.readSync(fd, data, { length, position });
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! OneNote parsing error handling.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::num::TryFromIntError;
|
||||
use std::{io, string};
|
||||
use thiserror::Error;
|
||||
|
||||
@@ -27,7 +28,11 @@ impl From<ErrorKind> for Error {
|
||||
|
||||
impl From<std::io::Error> for Error {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
ErrorKind::from(err).into()
|
||||
if err.kind() == std::io::ErrorKind::UnexpectedEof {
|
||||
ErrorKind::UnexpectedEof(err.to_string().into()).into()
|
||||
} else {
|
||||
ErrorKind::from(err).into()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +48,18 @@ 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<TryFromIntError> for Error {
|
||||
fn from(err: TryFromIntError) -> Self {
|
||||
ErrorKind::from(err).into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<uuid::Error> for Error {
|
||||
fn from(err: uuid::Error) -> Self {
|
||||
ErrorKind::from(err).into()
|
||||
@@ -107,6 +124,12 @@ pub enum ErrorKind {
|
||||
#[error("Failed to resolve: {0}")]
|
||||
ResolutionFailed(Cow<'static, str>),
|
||||
|
||||
#[error("Type conversion failed: {err}")]
|
||||
TypeConversionFailed {
|
||||
#[from]
|
||||
err: TryFromIntError,
|
||||
},
|
||||
|
||||
/// A malformed UUID was encountered
|
||||
#[error("Invalid UUID: {err}")]
|
||||
InvalidUuid {
|
||||
@@ -128,6 +151,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,7 +2,9 @@ use sanitize_filename::{Options as SanitizeOptions, sanitize_with_options};
|
||||
use std::io::{Read, Seek};
|
||||
|
||||
pub type ApiResult<T> = std::result::Result<T, std::io::Error>;
|
||||
pub trait FileHandle: Read + Seek {}
|
||||
pub trait FileHandle: Read + Seek {
|
||||
fn byte_length(&self) -> u64;
|
||||
}
|
||||
|
||||
pub trait FileApiDriver: Send + Sync {
|
||||
fn is_windows(&self) -> bool;
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::ApiResult;
|
||||
use super::FileApiDriver;
|
||||
use super::FileHandle;
|
||||
use std::fs;
|
||||
use std::io::BufReader;
|
||||
use std::path;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -38,7 +39,7 @@ impl FileApiDriver for FileApiDriverImpl {
|
||||
}
|
||||
|
||||
fn open_file(&self, path: &str) -> ApiResult<Box<dyn FileHandle>> {
|
||||
Ok(Box::new(fs::File::open(path)?))
|
||||
Ok(Box::new(BufReader::new(fs::File::open(path)?)))
|
||||
}
|
||||
|
||||
fn write_file(&self, path: &str, data: &[u8]) -> ApiResult<()> {
|
||||
@@ -87,7 +88,11 @@ impl FileApiDriver for FileApiDriverImpl {
|
||||
}
|
||||
}
|
||||
|
||||
impl FileHandle for fs::File {}
|
||||
impl FileHandle for BufReader<fs::File> {
|
||||
fn byte_length(&self) -> u64 {
|
||||
self.get_ref().metadata().map(|m| m.len()).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
|
||||
@@ -48,12 +48,12 @@ extern "C" {
|
||||
#[wasm_bindgen(structural, method, catch)]
|
||||
fn read(
|
||||
this: &JsFileHandle,
|
||||
offset: usize,
|
||||
size: usize,
|
||||
offset: u64,
|
||||
size: u64,
|
||||
) -> std::result::Result<Uint8Array, JsValue>;
|
||||
|
||||
#[wasm_bindgen(structural, method)]
|
||||
fn size(this: &JsFileHandle) -> usize;
|
||||
fn size(this: &JsFileHandle) -> u64;
|
||||
|
||||
#[wasm_bindgen(structural, method, catch)]
|
||||
fn close(this: &JsFileHandle) -> std::result::Result<(), JsValue>;
|
||||
@@ -181,7 +181,7 @@ impl FileApiDriver for FileApiDriverImpl {
|
||||
|
||||
struct SeekableFileHandle {
|
||||
handle: JsFileHandle,
|
||||
offset: usize,
|
||||
offset: u64,
|
||||
}
|
||||
|
||||
impl Read for SeekableFileHandle {
|
||||
@@ -193,12 +193,12 @@ impl Read for SeekableFileHandle {
|
||||
0
|
||||
};
|
||||
|
||||
let maximum_read_size = bytes_remaining.min(out.len());
|
||||
let maximum_read_size = bytes_remaining.min(out.len() as u64);
|
||||
match self.handle.read(self.offset, maximum_read_size) {
|
||||
Ok(data) => {
|
||||
let data = data.to_vec();
|
||||
let size = data.len();
|
||||
self.offset += size;
|
||||
self.offset += size as u64;
|
||||
|
||||
// Verify that handle.read respected the maximum length:
|
||||
if size > out.len() {
|
||||
@@ -228,25 +228,25 @@ impl Seek for SeekableFileHandle {
|
||||
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
|
||||
match pos {
|
||||
SeekFrom::Start(pos) => {
|
||||
self.offset = pos as usize;
|
||||
self.offset = pos;
|
||||
}
|
||||
SeekFrom::Current(offset) => {
|
||||
// Disallow seeking to a negative position
|
||||
if offset < 0 && (-offset) as usize > self.offset {
|
||||
if offset < 0 && offset.unsigned_abs() > self.offset {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"Attempted to seek before the beginning of the file.",
|
||||
));
|
||||
}
|
||||
|
||||
self.offset = (self.offset as i64 + offset) as usize;
|
||||
self.offset = (self.offset as i64 + offset) as u64;
|
||||
}
|
||||
SeekFrom::End(offset) => {
|
||||
self.offset = self.handle.size();
|
||||
self.seek(SeekFrom::Current(offset))?;
|
||||
}
|
||||
}
|
||||
Ok(self.offset as u64)
|
||||
Ok(self.offset)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,4 +261,8 @@ impl Drop for SeekableFileHandle {
|
||||
}
|
||||
}
|
||||
|
||||
impl FileHandle for BufReader<SeekableFileHandle> {}
|
||||
impl FileHandle for BufReader<SeekableFileHandle> {
|
||||
fn byte_length(&self) -> u64 {
|
||||
self.get_ref().handle.size()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +1,182 @@
|
||||
use crate::errors::{ErrorKind, Result};
|
||||
use crate::{
|
||||
FileHandle,
|
||||
errors::{ErrorKind, Result},
|
||||
};
|
||||
use bytes::Buf;
|
||||
use paste::paste;
|
||||
use std::mem;
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
io::{Read, Seek, SeekFrom},
|
||||
mem,
|
||||
rc::Rc,
|
||||
};
|
||||
|
||||
macro_rules! try_get {
|
||||
($this:ident, $typ:tt) => {{
|
||||
if $this.buff.remaining() < mem::size_of::<$typ>() {
|
||||
if $this.remaining() < mem::size_of::<$typ>() as u64 {
|
||||
Err(ErrorKind::UnexpectedEof(format!("Getting {:}", stringify!($typ)).into()).into())
|
||||
} else {
|
||||
Ok(paste! {$this.buff. [< get_ $typ >]()})
|
||||
let mut buff = [0; mem::size_of::<$typ>()];
|
||||
$this.read_exact(&mut buff)?;
|
||||
|
||||
let mut buff_ref: &[u8] = &mut buff;
|
||||
Ok(paste! {buff_ref. [< get_ $typ >]()})
|
||||
}
|
||||
}};
|
||||
|
||||
($this:ident, $typ:tt::$endian:tt) => {{
|
||||
if $this.buff.remaining() < mem::size_of::<$typ>() {
|
||||
if $this.remaining() < mem::size_of::<$typ>() as u64 {
|
||||
Err(ErrorKind::UnexpectedEof(
|
||||
format!("Getting {:} ({:})", stringify!($typ), stringify!($endian)).into(),
|
||||
)
|
||||
.into())
|
||||
} else {
|
||||
Ok(paste! {$this.buff. [< get_ $typ _ $endian >]()})
|
||||
let mut buff = [0; mem::size_of::<$typ>()];
|
||||
$this.read_exact(&mut buff)?;
|
||||
|
||||
let mut buff_ref: &[u8] = &mut buff;
|
||||
Ok(paste! {buff_ref. [< get_ $typ _ $endian >]()})
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
pub struct Reader<'a> {
|
||||
buff: &'a [u8],
|
||||
original: &'a [u8],
|
||||
type ReaderFileHandle = Rc<RefCell<Box<dyn FileHandle>>>;
|
||||
|
||||
enum ReaderData<'a> {
|
||||
/// Wraps a buffer owned by calling logic. This is more efficient for
|
||||
/// small amounts of data.
|
||||
BufferRef { buffer: &'a [u8] },
|
||||
|
||||
/// Wraps a handle to a file. This handles large amounts of data
|
||||
/// that won't necessarily fit into memory.
|
||||
/// Invariant: The internal file handle's offset should match the
|
||||
/// `data_offset` of the main reader.
|
||||
File(ReaderFileHandle),
|
||||
}
|
||||
|
||||
impl<'a> Clone for Reader<'a> {
|
||||
fn clone(&self) -> Self {
|
||||
let mut result = Self::new(self.original);
|
||||
result
|
||||
.advance(self.absolute_offset())
|
||||
.expect("should re-advance to the original's position");
|
||||
result
|
||||
pub struct Reader<'a> {
|
||||
data: ReaderData<'a>,
|
||||
data_len: u64,
|
||||
data_offset: u64,
|
||||
}
|
||||
|
||||
pub struct ReaderOffset(u64);
|
||||
|
||||
impl<'a> Seek for Reader<'a> {
|
||||
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
|
||||
let new_offset = match pos {
|
||||
SeekFrom::Start(n) => n as i64,
|
||||
SeekFrom::Current(n) => self.data_offset as i64 + n,
|
||||
SeekFrom::End(n) => (self.data_len as i64) + n,
|
||||
};
|
||||
|
||||
if new_offset < 0 || new_offset as u64 > self.data_len {
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::UnexpectedEof,
|
||||
format!(
|
||||
"New offset {} is out-of-bounds (data length: {}).",
|
||||
new_offset, self.data_len
|
||||
),
|
||||
))
|
||||
} else {
|
||||
self.data_offset = new_offset as u64;
|
||||
|
||||
// Sync the internal file with the new offset. This is done rather than seek the file
|
||||
// directly to avoid inconsistency if e.g. the file resizes and we're seeking from the end.
|
||||
if let ReaderData::File(f) = &mut self.data {
|
||||
f.borrow_mut().seek(SeekFrom::Start(self.data_offset))?;
|
||||
}
|
||||
|
||||
Ok(self.data_offset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
pub fn new(data: &'a [u8]) -> Reader<'a> {
|
||||
pub fn new(buffer: &'a [u8]) -> Self {
|
||||
Reader {
|
||||
buff: data,
|
||||
original: data,
|
||||
data_len: buffer.len() as u64,
|
||||
data_offset: 0,
|
||||
data: ReaderData::BufferRef { buffer },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read(&mut self, cnt: usize) -> Result<&'a [u8]> {
|
||||
if self.remaining() < cnt {
|
||||
return Err(ErrorKind::UnexpectedEof("Unexpected EOF (Reader.read)".into()).into());
|
||||
pub fn read(&mut self, count: usize) -> Result<Vec<u8>> {
|
||||
let mut buff = vec![0; count];
|
||||
self.read_exact(&mut buff)?;
|
||||
Ok(buff)
|
||||
}
|
||||
|
||||
fn read_exact(&mut self, output: &mut [u8]) -> Result<()> {
|
||||
let count = output.len();
|
||||
if self.remaining() < count as u64 {
|
||||
return Err(
|
||||
ErrorKind::UnexpectedEof("Unexpected EOF (Reader.read_exact)".into()).into(),
|
||||
);
|
||||
}
|
||||
|
||||
let data = &self.buff[0..cnt];
|
||||
self.buff.advance(cnt);
|
||||
match &mut self.data {
|
||||
ReaderData::BufferRef { buffer } => {
|
||||
let start = self.data_offset as usize;
|
||||
(&buffer[start..start + count]).copy_to_slice(output);
|
||||
}
|
||||
ReaderData::File(file) => {
|
||||
file.borrow_mut().read_exact(output)?;
|
||||
}
|
||||
};
|
||||
self.data_offset += count as u64;
|
||||
|
||||
Ok(data)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn bytes(&self) -> &[u8] {
|
||||
self.buff.chunk()
|
||||
pub fn peek_u8(&mut self) -> Result<Option<u8>> {
|
||||
match &mut self.data {
|
||||
ReaderData::BufferRef { buffer, .. } => {
|
||||
Ok(buffer.get(self.data_offset as usize).copied())
|
||||
}
|
||||
ReaderData::File(file) => {
|
||||
let mut file = file.borrow_mut();
|
||||
let mut buf = [0u8];
|
||||
let read_result = file.read(&mut buf);
|
||||
// Reset the original position
|
||||
file.seek(SeekFrom::Start(self.data_offset))?;
|
||||
|
||||
match read_result {
|
||||
Ok(size) => Ok(if size < 1 { None } else { Some(buf[0]) }),
|
||||
Err(error) => Err(error)?,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remaining(&self) -> usize {
|
||||
self.buff.remaining()
|
||||
pub fn as_data_ref(&mut self, size: usize) -> Result<ReaderDataRef> {
|
||||
if self.remaining() < size as u64 {
|
||||
return Err(
|
||||
ErrorKind::UnexpectedEof("Unexpected EOF (Reader.as_data_ref)".into()).into(),
|
||||
);
|
||||
}
|
||||
|
||||
match &mut self.data {
|
||||
ReaderData::BufferRef { buffer } => {
|
||||
let start = self.data_offset as usize;
|
||||
// Cloning needs to be done early with BufferRef, since we don't own the original
|
||||
// data. Large data should generally use `ReaderData::File`.
|
||||
Ok(ReaderDataRef::Vec(buffer[start..start + size].to_vec()))
|
||||
}
|
||||
ReaderData::File(file) => Ok(ReaderDataRef::FilePointer {
|
||||
file: file.clone(),
|
||||
offset: self.data_offset,
|
||||
size,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn advance(&mut self, count: usize) -> Result<()> {
|
||||
pub fn remaining(&self) -> u64 {
|
||||
assert!(self.data_len >= self.data_offset);
|
||||
self.data_len - self.data_offset
|
||||
}
|
||||
|
||||
pub fn advance(&mut self, count: u64) -> Result<()> {
|
||||
if self.remaining() < count {
|
||||
return Err(ErrorKind::UnexpectedEof(
|
||||
format!(
|
||||
@@ -79,41 +189,23 @@ impl<'a> Reader<'a> {
|
||||
.into());
|
||||
}
|
||||
|
||||
self.buff.advance(count);
|
||||
assert!(count < i64::MAX as u64);
|
||||
self.seek(SeekFrom::Current(count as i64))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn absolute_offset(&self) -> usize {
|
||||
// Use pointer arithmetic (in a way similar to the [subslice offset](https://docs.rs/crate/subslice-offset/latest/source/src/lib.rs)
|
||||
// crate and [this StackOverflow post](https://stackoverflow.com/questions/50781561/how-to-find-the-starting-offset-of-a-string-slice-of-another-string/50781657))
|
||||
// to calculate the offset.
|
||||
let offset = (self.buff.as_ptr() as usize) - (self.original.as_ptr() as usize);
|
||||
if offset > self.original.len() {
|
||||
panic!("self.buff must be a subslice of self.original!");
|
||||
}
|
||||
|
||||
offset
|
||||
pub fn save_position(&self) -> ReaderOffset {
|
||||
ReaderOffset(self.data_offset)
|
||||
}
|
||||
|
||||
pub fn with_updated_bounds(&self, start: usize, end: usize) -> Result<Reader<'a>> {
|
||||
if start > self.original.len() {
|
||||
return Err(ErrorKind::UnexpectedEof(
|
||||
"Reader.with_updated_bounds: start is out of bounds".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if end > self.original.len() {
|
||||
return Err(ErrorKind::UnexpectedEof(
|
||||
"Reader.with_updated_bounds: end is out of bounds".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
pub fn restore_position(&mut self, offset: ReaderOffset) -> Result<()> {
|
||||
self.seek(SeekFrom::Start(offset.0))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Ok(Reader {
|
||||
buff: &self.original[start..end],
|
||||
original: self.original,
|
||||
})
|
||||
pub fn offset(&self) -> u64 {
|
||||
self.data_offset
|
||||
}
|
||||
|
||||
pub fn get_u8(&mut self) -> Result<u8> {
|
||||
@@ -141,6 +233,61 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> TryFrom<Box<dyn FileHandle>> for Reader<'a> {
|
||||
type Error = crate::errors::Error;
|
||||
|
||||
fn try_from(mut handle: Box<dyn FileHandle>) -> Result<Self> {
|
||||
let initial_offset = handle.seek(SeekFrom::Current(0))?;
|
||||
Ok(Self {
|
||||
data_len: handle.byte_length(),
|
||||
data_offset: initial_offset,
|
||||
data: ReaderData::File(Rc::new(RefCell::new(handle))),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a [u8]> for Reader<'a> {
|
||||
fn from(value: &'a [u8]) -> Self {
|
||||
Self {
|
||||
data_len: value.len() as u64,
|
||||
data_offset: 0,
|
||||
data: ReaderData::BufferRef { buffer: value },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum ReaderDataRef {
|
||||
Vec(Vec<u8>),
|
||||
FilePointer {
|
||||
file: ReaderFileHandle,
|
||||
offset: u64,
|
||||
size: usize,
|
||||
},
|
||||
}
|
||||
|
||||
impl ReaderDataRef {
|
||||
pub fn bytes(&self) -> Result<Vec<u8>> {
|
||||
match self {
|
||||
ReaderDataRef::Vec(slice) => Ok(slice.clone()),
|
||||
ReaderDataRef::FilePointer { file, offset, size } => {
|
||||
let mut file = file.borrow_mut();
|
||||
let original_offset = file.seek(SeekFrom::Current(0))?;
|
||||
let read_result = (|| {
|
||||
file.seek(SeekFrom::Start(*offset))?;
|
||||
|
||||
let mut result = vec![0; *size];
|
||||
file.read_exact(&mut result)?;
|
||||
|
||||
Ok(result)
|
||||
})();
|
||||
file.seek(SeekFrom::Start(original_offset))?;
|
||||
|
||||
read_result
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
@@ -148,27 +295,23 @@ mod test {
|
||||
#[test]
|
||||
fn with_start_index_should_seek() {
|
||||
let data: [u8; 8] = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||
let mut reader = Reader::new(&data);
|
||||
let mut reader = Reader::from(&data as &[u8]);
|
||||
assert_eq!(reader.get_u8().unwrap(), 1);
|
||||
assert_eq!(reader.get_u8().unwrap(), 2);
|
||||
assert_eq!(reader.get_u8().unwrap(), 3);
|
||||
{
|
||||
let mut reader = reader.with_updated_bounds(0, 8).unwrap();
|
||||
assert_eq!(reader.get_u8().unwrap(), 1);
|
||||
assert_eq!(reader.get_u8().unwrap(), 2);
|
||||
assert_eq!(reader.get_u8().unwrap(), 3);
|
||||
assert_eq!(reader.get_u8().unwrap(), 4);
|
||||
let mut reader = reader.with_updated_bounds(1, 7).unwrap();
|
||||
assert_eq!(reader.get_u8().unwrap(), 2);
|
||||
assert_eq!(reader.get_u8().unwrap(), 3);
|
||||
let mut reader = reader.with_updated_bounds(1, 7).unwrap();
|
||||
assert_eq!(reader.get_u8().unwrap(), 2);
|
||||
assert_eq!(reader.get_u8().unwrap(), 3);
|
||||
let reader = reader.with_updated_bounds(5, 7).unwrap();
|
||||
assert_eq!(reader.remaining(), 2);
|
||||
let reader = reader.with_updated_bounds(6, 6).unwrap();
|
||||
assert_eq!(reader.remaining(), 0);
|
||||
}
|
||||
reader.seek(SeekFrom::Start(0)).unwrap();
|
||||
assert_eq!(reader.get_u8().unwrap(), 1);
|
||||
assert_eq!(reader.get_u8().unwrap(), 2);
|
||||
assert_eq!(reader.get_u8().unwrap(), 3);
|
||||
assert_eq!(reader.get_u8().unwrap(), 4);
|
||||
|
||||
reader.seek_relative(-3).unwrap();
|
||||
assert_eq!(reader.get_u8().unwrap(), 2);
|
||||
assert_eq!(reader.get_u8().unwrap(), 3);
|
||||
|
||||
reader.seek_relative(-2).unwrap();
|
||||
assert_eq!(reader.get_u8().unwrap(), 2);
|
||||
assert_eq!(reader.get_u8().unwrap(), 3);
|
||||
assert_eq!(reader.get_u8().unwrap(), 4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,3 +33,6 @@ features = [
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "lib"]
|
||||
|
||||
[[bin]]
|
||||
name = "inspect"
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
use parser::Parser;
|
||||
use parser_utils::errors::Error;
|
||||
use std::{
|
||||
env::{self, Args},
|
||||
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;
|
||||
|
||||
let mut parser = Parser::new();
|
||||
if config.output_mode == OutputMode::Section {
|
||||
let parsed_section = match parser.parse_section(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(input_path_string) {
|
||||
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: String,
|
||||
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,
|
||||
output_mode,
|
||||
program_name,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ pub(crate) struct BinaryItem(Vec<u8>);
|
||||
impl BinaryItem {
|
||||
pub(crate) fn parse(reader: Reader) -> Result<BinaryItem> {
|
||||
let size = CompactU64::parse(reader)?.value();
|
||||
let data = reader.read(size as usize)?.to_vec();
|
||||
let data = reader.read(size.try_into()?)?;
|
||||
|
||||
Ok(BinaryItem(data))
|
||||
}
|
||||
@@ -25,6 +25,9 @@ impl BinaryItem {
|
||||
|
||||
impl From<BinaryItem> for FileBlob {
|
||||
fn from(value: BinaryItem) -> Self {
|
||||
value.0.into()
|
||||
let size = value.0.len();
|
||||
let data = value.0;
|
||||
|
||||
FileBlob::new(Box::new(data), size)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,7 @@ impl ObjectHeader {
|
||||
/// Parse a 16-bit or 32-bit stream object header.
|
||||
pub(crate) fn parse(reader: Reader) -> Result<ObjectHeader> {
|
||||
let header_type = reader
|
||||
.bytes()
|
||||
.first()
|
||||
.peek_u8()?
|
||||
.ok_or(ErrorKind::UnexpectedEof("Reading ObjectHeader".into()))?;
|
||||
|
||||
match header_type & 0b11 {
|
||||
@@ -199,7 +198,7 @@ impl ObjectHeader {
|
||||
}
|
||||
|
||||
pub(crate) fn has_end_8(reader: Reader, object_type: ObjectType) -> Result<bool> {
|
||||
let data = reader.bytes().first().ok_or(ErrorKind::UnexpectedEof(
|
||||
let data = reader.peek_u8()?.ok_or(ErrorKind::UnexpectedEof(
|
||||
"Reading ObjectHeader.has_end_8".into(),
|
||||
))?;
|
||||
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ impl DataElement {
|
||||
let offset = CompactU64::parse(reader)?.value();
|
||||
let length = CompactU64::parse(reader)?.value();
|
||||
|
||||
let data = reader.read(size as usize)?.to_vec();
|
||||
let data = reader.read(size as usize)?;
|
||||
|
||||
let chunk_reference = DataElementFragmentChunkReference { offset, length };
|
||||
let fragment = DataElementFragment {
|
||||
|
||||
@@ -22,7 +22,7 @@ impl ObjectDataBlob {
|
||||
|
||||
impl fmt::Debug for ObjectDataBlob {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "ObjectDataBlob({} bytes)", self.0.as_ref().len())
|
||||
write!(f, "ObjectDataBlob({} bytes)", self.0.len())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ mod one;
|
||||
mod onenote;
|
||||
mod onestore;
|
||||
mod shared;
|
||||
mod utils;
|
||||
|
||||
pub use onenote::Parser;
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::io::{Seek, SeekFrom};
|
||||
|
||||
use parser_utils::{errors::ErrorKind, errors::Result, parse::Parse};
|
||||
|
||||
/// See [\[MS-ONESTORE\] 2.2.4](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-onestore/0d86b13d-d58c-44e8-b931-4728b9d39a4b)
|
||||
@@ -7,10 +9,7 @@ pub trait FileChunkReference {
|
||||
fn data_location(&self) -> usize;
|
||||
fn data_size(&self) -> usize;
|
||||
|
||||
fn resolve_to_reader<'a>(
|
||||
&self,
|
||||
original_reader: &parser_utils::reader::Reader<'a>,
|
||||
) -> Result<parser_utils::reader::Reader<'a>> {
|
||||
fn seek_reader_to(&self, reader: &mut parser_utils::reader::Reader) -> Result<()> {
|
||||
if self.is_fcr_nil() {
|
||||
return Err(ErrorKind::ResolutionFailed(
|
||||
"Failed to resolve node reference -- is nil".into(),
|
||||
@@ -18,10 +17,8 @@ pub trait FileChunkReference {
|
||||
.into());
|
||||
}
|
||||
|
||||
original_reader.with_updated_bounds(
|
||||
self.data_location(),
|
||||
self.data_location() + self.data_size(),
|
||||
)
|
||||
reader.seek(SeekFrom::Start(self.data_location() as u64))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,8 +56,15 @@ impl FileNode {
|
||||
)?),
|
||||
2 => FileNodeDataRef::ElementList({
|
||||
let list_ref = FileNodeChunkReference::parse(reader, stp_format, cb_format)?;
|
||||
let mut resolved_reader = list_ref.resolve_to_reader(reader)?;
|
||||
FileNodeList::parse(&mut resolved_reader, context, list_ref.data_size())
|
||||
|
||||
let reader_offset = reader.save_position();
|
||||
let result = {
|
||||
list_ref.seek_reader_to(reader)?;
|
||||
FileNodeList::parse(reader, context, list_ref.data_size())
|
||||
};
|
||||
reader.restore_position(reader_offset)?;
|
||||
|
||||
result
|
||||
}?),
|
||||
_ => FileNodeDataRef::InvalidData,
|
||||
};
|
||||
@@ -162,7 +169,7 @@ impl FileNode {
|
||||
0 => FileNodeData::Null,
|
||||
other => {
|
||||
log_warn!("Unknown node type: {:#0x}, size {}", other, size);
|
||||
let size_used = remaining_0 - remaining_1;
|
||||
let size_used = (remaining_0 - remaining_1) as usize;
|
||||
assert!(size_used <= size);
|
||||
let remaining_size = size - size_used;
|
||||
FileNodeData::UnknownNode(UnknownNode::parse(reader, remaining_size)?)
|
||||
@@ -170,7 +177,7 @@ impl FileNode {
|
||||
};
|
||||
|
||||
let remaining_2 = reader.remaining();
|
||||
let actual_size = remaining_0 - remaining_2;
|
||||
let actual_size = (remaining_0 - remaining_2) as usize;
|
||||
|
||||
let node = Self {
|
||||
node_type_id: node_id,
|
||||
@@ -452,9 +459,14 @@ impl<RefSize: Parse> ParseWithRef for ObjectDeclarationWithSizedRefCount<RefSize
|
||||
fn read_property_set(reader: Reader, property_set_ref: &FileNodeDataRef) -> Result<ObjectPropSet> {
|
||||
match property_set_ref {
|
||||
FileNodeDataRef::SingleElement(data_ref) => {
|
||||
let mut prop_set_reader = data_ref.resolve_to_reader(reader)?;
|
||||
let prop_set = ObjectPropSet::parse(&mut prop_set_reader)?;
|
||||
Ok(prop_set)
|
||||
let reader_offset = reader.save_position();
|
||||
let prop_set = {
|
||||
data_ref.seek_reader_to(reader)?;
|
||||
ObjectPropSet::parse(reader)
|
||||
};
|
||||
reader.restore_position(reader_offset)?;
|
||||
|
||||
prop_set
|
||||
}
|
||||
FileNodeDataRef::ElementList(_) => Err(ErrorKind::MalformedOneStoreData(
|
||||
"Expected a single element (reading PropertySet)".into(),
|
||||
@@ -539,7 +551,7 @@ pub struct RootObjectReference3FND {
|
||||
pub struct RevisionRoleDeclarationFND {
|
||||
pub rid: ExGuid,
|
||||
/// "should be 0x01"
|
||||
revision_role: u32,
|
||||
pub revision_role: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Parse)]
|
||||
@@ -564,7 +576,7 @@ impl Parse for StringInStorageBuffer {
|
||||
let characer_count = reader.get_u32()? as usize;
|
||||
let string_size = characer_count * 2; // 2 bytes per character
|
||||
let data = reader.read(string_size)?;
|
||||
let data = data.utf16_to_string()?;
|
||||
let data = (&data as &[u8]).utf16_to_string()?;
|
||||
Ok(Self {
|
||||
cch: characer_count,
|
||||
data,
|
||||
@@ -654,10 +666,14 @@ impl ParseWithRef for ObjectInfoDependencyOverridesFND {
|
||||
fn parse(reader: parser_utils::Reader, obj_ref: &FileNodeDataRef) -> Result<Self> {
|
||||
if let FileNodeDataRef::SingleElement(obj_ref) = obj_ref {
|
||||
if !obj_ref.is_fcr_nil() {
|
||||
let data = ObjectInfoDependencyOverrideData::parse(
|
||||
&mut obj_ref.resolve_to_reader(reader)?,
|
||||
)?;
|
||||
Ok(Self { data })
|
||||
let reader_offset = reader.save_position();
|
||||
let data = {
|
||||
obj_ref.seek_reader_to(reader)?;
|
||||
ObjectInfoDependencyOverrideData::parse(reader)
|
||||
};
|
||||
reader.restore_position(reader_offset)?;
|
||||
|
||||
Ok(Self { data: data? })
|
||||
} else {
|
||||
Ok(Self {
|
||||
data: ObjectInfoDependencyOverrideData::parse(reader)?,
|
||||
@@ -705,14 +721,15 @@ pub struct FileData(pub FileBlob);
|
||||
|
||||
impl Debug for FileData {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "FileData(size={:} KiB)", self.0.as_ref().len() / 1024)
|
||||
write!(f, "FileData(size={:} KiB)", self.0.len() / 1024)
|
||||
}
|
||||
}
|
||||
|
||||
impl ParseWithCount for FileData {
|
||||
fn parse(reader: parser_utils::Reader, size: usize) -> Result<Self> {
|
||||
let data = reader.read(size)?.to_vec();
|
||||
Ok(FileData(data.into()))
|
||||
let data_ref = FileBlob::new(Box::new(reader.as_data_ref(size)?), size);
|
||||
reader.advance(size as u64)?;
|
||||
Ok(FileData(data_ref))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -727,9 +744,15 @@ impl ParseWithRef for FileDataStoreObjectReferenceFND {
|
||||
fn parse(reader: parser_utils::Reader, data_ref: &FileNodeDataRef) -> Result<Self> {
|
||||
let guid = Guid::parse(reader)?;
|
||||
if let FileNodeDataRef::SingleElement(data_ref) = data_ref {
|
||||
let mut reader = data_ref.resolve_to_reader(reader)?;
|
||||
let reader_offset = reader.save_position();
|
||||
let target = {
|
||||
data_ref.seek_reader_to(reader)?;
|
||||
FileDataStoreObject::parse(reader)
|
||||
};
|
||||
reader.restore_position(reader_offset)?;
|
||||
|
||||
Ok(Self {
|
||||
target: FileDataStoreObject::parse(&mut reader)?,
|
||||
target: target?,
|
||||
guid,
|
||||
})
|
||||
} else {
|
||||
@@ -945,7 +968,7 @@ pub struct UnknownNode {}
|
||||
|
||||
impl ParseWithCount for UnknownNode {
|
||||
fn parse(reader: Reader, size: usize) -> Result<Self> {
|
||||
reader.advance(size)?;
|
||||
reader.advance(size as u64)?;
|
||||
Ok(UnknownNode {})
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -20,9 +20,9 @@ impl FileNodeList {
|
||||
let mut next_fragment_ref =
|
||||
builder.add_fragment(FileNodeListFragment::parse(reader, context, size)?)?;
|
||||
while !next_fragment_ref.is_fcr_nil() && !next_fragment_ref.is_fcr_zero() {
|
||||
let mut reader = next_fragment_ref.resolve_to_reader(reader)?;
|
||||
next_fragment_ref.seek_reader_to(reader)?;
|
||||
let fragment =
|
||||
FileNodeListFragment::parse(&mut reader, context, next_fragment_ref.cb as usize)?;
|
||||
FileNodeListFragment::parse(reader, context, next_fragment_ref.cb as usize)?;
|
||||
next_fragment_ref = builder.add_fragment(fragment)?;
|
||||
}
|
||||
Ok(Self {
|
||||
|
||||
+2
-2
@@ -49,13 +49,13 @@ impl FileNodeListFragment {
|
||||
file_nodes.push(file_node);
|
||||
}
|
||||
|
||||
assert_eq!(remaining_0 - reader.remaining(), file_node_size);
|
||||
assert_eq!(remaining_0 - reader.remaining(), file_node_size as u64);
|
||||
}
|
||||
|
||||
context.update_remaining_nodes_in_fragment(&header, maximum_node_count);
|
||||
|
||||
let padding_length = size - 36 - file_node_size;
|
||||
reader.advance(padding_length)?;
|
||||
reader.advance(padding_length as u64)?;
|
||||
|
||||
let next_fragment = FileChunkReference64x32::parse(reader)?;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+9
-2
@@ -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()
|
||||
|
||||
@@ -77,8 +77,8 @@ impl Parse for OneStoreFile {
|
||||
let mut free_chunk_list = Vec::new();
|
||||
let mut free_chunk_ref = header.fcr_free_chunk_list.clone();
|
||||
while !free_chunk_ref.is_fcr_nil() && !free_chunk_ref.is_fcr_zero() {
|
||||
let mut reader = free_chunk_ref.resolve_to_reader(reader)?;
|
||||
let fragment = FreeChunkListFragment::parse(&mut reader, free_chunk_ref.cb.into())?;
|
||||
free_chunk_ref.seek_reader_to(reader)?;
|
||||
let fragment = FreeChunkListFragment::parse(reader, free_chunk_ref.cb.into())?;
|
||||
free_chunk_ref = fragment.fcr_next_chunk.clone();
|
||||
free_chunk_list.push(fragment);
|
||||
}
|
||||
@@ -86,10 +86,9 @@ impl Parse for OneStoreFile {
|
||||
let mut transaction_log = Vec::new();
|
||||
let mut transaction_log_ref = header.fcr_transaction_log.clone();
|
||||
loop {
|
||||
let mut reader = transaction_log_ref.resolve_to_reader(reader)?;
|
||||
transaction_log_ref.seek_reader_to(reader)?;
|
||||
|
||||
let fragment =
|
||||
TransactionLogFragment::parse(&mut reader, transaction_log_ref.cb as usize)?;
|
||||
let fragment = TransactionLogFragment::parse(reader, transaction_log_ref.cb as usize)?;
|
||||
transaction_log_ref = fragment.next_fragment.clone();
|
||||
transaction_log.push(fragment);
|
||||
|
||||
@@ -104,9 +103,9 @@ impl Parse for OneStoreFile {
|
||||
let mut hashed_chunk_list = Vec::new();
|
||||
let mut hash_chunk_ref = header.fcr_hashed_chunk_list.clone();
|
||||
while !hash_chunk_ref.is_fcr_nil() && !hash_chunk_ref.is_fcr_zero() {
|
||||
let mut reader = hash_chunk_ref.resolve_to_reader(reader)?;
|
||||
hash_chunk_ref.seek_reader_to(reader)?;
|
||||
let fragment = FileNodeListFragment::parse(
|
||||
&mut reader,
|
||||
reader,
|
||||
&mut parse_context,
|
||||
hash_chunk_ref.cb as usize,
|
||||
)?;
|
||||
@@ -117,12 +116,8 @@ impl Parse for OneStoreFile {
|
||||
let file_node_list_root = &header.fcr_file_node_list_root;
|
||||
let raw_file_node_list =
|
||||
if !file_node_list_root.is_fcr_nil() && !file_node_list_root.is_fcr_zero() {
|
||||
let mut reader = file_node_list_root.resolve_to_reader(reader)?;
|
||||
FileNodeList::parse(
|
||||
&mut reader,
|
||||
&mut parse_context,
|
||||
file_node_list_root.cb as usize,
|
||||
)?
|
||||
file_node_list_root.seek_reader_to(reader)?;
|
||||
FileNodeList::parse(reader, &mut parse_context, file_node_list_root.cb as usize)?
|
||||
} else {
|
||||
FileNodeList::default()
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ pub(crate) struct Data {
|
||||
pub(crate) offset_from_parent_vert: Option<f32>,
|
||||
pub(crate) last_modified: Option<Time>,
|
||||
pub(crate) ink_data: Option<ExGuid>,
|
||||
pub(crate) children: Option<Vec<ExGuid>>,
|
||||
pub(crate) ink_scaling_x: Option<f32>,
|
||||
pub(crate) ink_scaling_y: Option<f32>,
|
||||
}
|
||||
@@ -26,14 +27,16 @@ pub(crate) fn parse(object: &Object) -> Result<Data> {
|
||||
let offset_from_parent_horiz = simple::parse_f32(PropertyType::OffsetFromParentHoriz, object)?;
|
||||
let offset_from_parent_vert = simple::parse_f32(PropertyType::OffsetFromParentVert, object)?;
|
||||
let ink_data = ObjectReference::parse(PropertyType::InkData, object)?;
|
||||
let children = ObjectReference::parse_vec(PropertyType::ContentChildNodes, object)?;
|
||||
let ink_scaling_x = simple::parse_f32(PropertyType::InkScalingX, object)?;
|
||||
let ink_scaling_y = simple::parse_f32(PropertyType::InkScalingX, object)?;
|
||||
let ink_scaling_y = simple::parse_f32(PropertyType::InkScalingY, object)?;
|
||||
|
||||
let data = Data {
|
||||
offset_from_parent_horiz,
|
||||
offset_from_parent_vert,
|
||||
last_modified,
|
||||
ink_data,
|
||||
children,
|
||||
ink_scaling_x,
|
||||
ink_scaling_y,
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ use parser_utils::errors::{ErrorKind, Result};
|
||||
/// See [\[MS-ONE\] 2.2.32].
|
||||
///
|
||||
/// [\[MS-ONE\] 2.2.32]: https://docs.microsoft.com/en-us/openspecs/office_file_formats/ms-one/a665b5ad-ff40-4c0c-9e42-4b707254dc3f
|
||||
#[derive(Clone, PartialEq, PartialOrd, Debug)]
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct EmbeddedFile {
|
||||
pub(crate) filename: String,
|
||||
pub(crate) file_type: FileType,
|
||||
@@ -46,8 +46,8 @@ impl EmbeddedFile {
|
||||
}
|
||||
|
||||
/// The file's binary data.
|
||||
pub fn data(&self) -> &[u8] {
|
||||
self.data.as_ref()
|
||||
pub fn data(&self) -> Result<Vec<u8>> {
|
||||
self.data.load()
|
||||
}
|
||||
|
||||
/// The max width of the embedded file's icon in half-inch increments.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user