Compare commits

..
Author SHA1 Message Date
Laurent Cozic 22dd50bdef update 2026-02-08 18:30:32 +00:00
Laurent Cozic 97a18f722d Chore: Convert WebDavAPI.js to TypeScript (#14291) 2026-02-06 13:28:35 +00:00
luzpaz 554e6efaab Doc: fixed a few dev-facing typos (#14279) 2026-02-05 00:11:51 +00:00
Joplin Bot cd7af20bc1 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-02-04 19:02:12 +00:00
renovate[bot] 4346616cae Update dependency sharp to v0.34.5 (#14264)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-04 18:02:14 +00:00
Laurent Cozic ef646adafa Doc: Added mobile performance analysis report 2026-02-04 15:21:52 +00:00
Laurent Cozic 4ce47807b1 iOS 13.6.1 2026-02-04 15:13:08 +00:00
Laurent Cozic 9b0bc4d600 Chore: Limit build cleaning function to just Android 2026-02-04 15:13:08 +00:00
renovate[bot] 91b8e4d34d Update dependency style-to-js to v1.1.19 (#14244)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-04 14:51:04 +00:00
renovate[bot] 9e9bd662dc Update dependency nan to v2.23.1 (#14263)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-04 14:48:21 +00:00
Laurent Cozic defbbd5d72 Android 3.6.12 2026-02-04 14:45:02 +00:00
Laurent Cozic b2c9dd40dc Chore: Fixed Android release script 2026-02-04 14:43:56 +00:00
Laurent Cozic a9049111e4 Chore: Add a marker to tell when the application is ready on mobile 2026-02-04 12:40:51 +00:00
renovate[bot] 55f642c625 Update dependency @types/serviceworker to v0.0.165 (#14250)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-04 11:29:26 +00:00
Henry Heino dee9ec3495 Chore: Testing: Give the plugin panel test more time to pass (#14199) 2026-02-04 10:10:01 +00:00
Henry Heino 97bf020150 Chore: Mobile: Migrate folder screen to TypeScript (#14200) 2026-02-04 10:09:35 +00:00
Henry Heino 2cb2680a5a All: Sync: Make resource processing in read-only shares more reliable (#14204) 2026-02-04 10:08:29 +00:00
Henry Heino 242c6ec3b8 Chore: Sync fuzzer: Don't attempt to publish read-only notes (#14205) 2026-02-04 10:08:14 +00:00
Henry Heino ed0b1ae390 Desktop: Fixes #14216: Fix undo/redo menu items in the Rich Text and Markdown editors (#14218) 2026-02-04 10:07:34 +00:00
Henry Heino de29e4ff92 Docs: Update SECURITY.md to suggest using GitHub private vulnerability reporting (#14221) 2026-02-04 10:03:36 +00:00
Henry Heino 9d96e31b83 Desktop: Fixes #14210: OneNote importer: Skip importing ink when ID lookup fails (#14230) 2026-02-04 10:03:28 +00:00
Henry Heino aad460e9a1 Chore: Fixes #14222: Retry failing editor test in CI (#14231) 2026-02-04 10:03:11 +00:00
Henry Heino 00248a9177 Mobile: Upgrade to React Native 0.81 (#14232) 2026-02-04 10:03:02 +00:00
Henry HeinoandHimanshu af2926b634 Desktop: OneNote import: Fix onepkg import stops after the first section fails to import (#14246)
Co-authored-by: Himanshu <h-jangra@users.noreply.github.com>
2026-02-04 09:59:17 +00:00
Henry Heino 916ed9bbfb Chore: Desktop: Fix warning logged when opening the note viewer (#14247) 2026-02-04 09:59:01 +00:00
Laurent Cozic b32015864e All: Add support for FrontMatter block rendering in notes (#14256) 2026-02-04 09:58:12 +00:00
Henry Heino 8939ef1c19 Desktop,Mobile: Resolves #9745: Markdown: Allow specifying the start/end of audio, videos, and PDFs (#14257) 2026-02-04 09:56:05 +00:00
Henry Heino 31bba39ae9 Chore: Fix HTML to Markdown conversion fails for certain rendered code block HTML (#14259) 2026-02-04 09:55:50 +00:00
Henry Heino 9dc49f0c24 Chore: Sync fuzzer: Fix "moveFolderToToplevel" command (#14261) 2026-02-04 09:55:03 +00:00
714 changed files with 5497 additions and 3262 deletions

No files matched your search

+15
View File
@@ -92,6 +92,7 @@ readme/
packages/react-native-vosk/lib/
packages/lib/countable/Countable.js
packages/onenote-converter/renderer/pkg/*
packages/whisper-voice-typing/lib/
# AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD
packages/app-cli/app/LinkSelector.js
@@ -468,6 +469,8 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/deleteFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/duplicateNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/editAlarm.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/exportPdf.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/globalRedo.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/globalUndo.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/gotoAnything.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/hideModalMessage.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/importFrom.js
@@ -511,6 +514,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleNotesSortOrderR
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/togglePerFolderSortOrder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleSideBar.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleVisiblePanes.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/utils/canUseNativeUndo.js
packages/app-desktop/gui/WindowCommandsAndDialogs/types.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/appDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/showFolderPicker.js
@@ -691,6 +695,7 @@ packages/app-mobile/components/FeedbackBanner.js
packages/app-mobile/components/FolderPicker.js
packages/app-mobile/components/Icon.js
packages/app-mobile/components/IconButton.js
packages/app-mobile/components/KeyboardAvoidingView.js
packages/app-mobile/components/Modal.js
packages/app-mobile/components/ModalDialog.js
packages/app-mobile/components/NestableFlatList.js
@@ -869,6 +874,7 @@ packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js
packages/app-mobile/components/screens/dropbox-login.js
packages/app-mobile/components/screens/encryption-config.test.js
packages/app-mobile/components/screens/encryption-config.js
packages/app-mobile/components/screens/folder.js
packages/app-mobile/components/screens/status.js
packages/app-mobile/components/screens/tags.js
packages/app-mobile/components/side-menu-content.js
@@ -969,6 +975,7 @@ packages/app-mobile/utils/hooks/useSafeAreaPadding.js
packages/app-mobile/utils/image/fileToImage.web.js
packages/app-mobile/utils/image/getImageDimensions.js
packages/app-mobile/utils/image/resizeImage.js
packages/app-mobile/utils/initReact.js
packages/app-mobile/utils/initializeCommandService.js
packages/app-mobile/utils/ipc/RNToWebViewMessenger.js
packages/app-mobile/utils/ipc/WebViewToRNMessenger.js
@@ -1045,6 +1052,8 @@ packages/editor/CodeMirror/extensions/links/utils/getUrlAtPosition.js
packages/editor/CodeMirror/extensions/links/utils/openLink.js
packages/editor/CodeMirror/extensions/markdownDecorationExtension.test.js
packages/editor/CodeMirror/extensions/markdownDecorationExtension.js
packages/editor/CodeMirror/extensions/markdownFrontMatterExtension.test.js
packages/editor/CodeMirror/extensions/markdownFrontMatterExtension.js
packages/editor/CodeMirror/extensions/markdownHighlightExtension.test.js
packages/editor/CodeMirror/extensions/markdownHighlightExtension.js
packages/editor/CodeMirror/extensions/markdownMathExtension.test.js
@@ -1247,6 +1256,7 @@ packages/lib/SyncTargetOneDrive.js
packages/lib/SyncTargetRegistry.js
packages/lib/Synchronizer.js
packages/lib/TaskQueue.js
packages/lib/WebDavApi.js
packages/lib/WelcomeUtils.js
packages/lib/array.js
packages/lib/callbackUrlUtils.test.js
@@ -1707,6 +1717,7 @@ packages/lib/testing/share/mockShareService.js
packages/lib/testing/syncTargetUtils.js
packages/lib/testing/test-utils-synchronizer.js
packages/lib/testing/test-utils.js
packages/lib/testing/waitFor.js
packages/lib/theme.js
packages/lib/themes/aritimDark.js
packages/lib/themes/dark.js
@@ -1811,6 +1822,8 @@ packages/renderer/MdToHtml/rules/code_inline.js
packages/renderer/MdToHtml/rules/externalEmbed.js
packages/renderer/MdToHtml/rules/fence.js
packages/renderer/MdToHtml/rules/fountain.js
packages/renderer/MdToHtml/rules/frontmatter.test.js
packages/renderer/MdToHtml/rules/frontmatter.js
packages/renderer/MdToHtml/rules/highlight_keywords.js
packages/renderer/MdToHtml/rules/html_image.js
packages/renderer/MdToHtml/rules/image.js
@@ -1938,4 +1951,6 @@ packages/tools/website/utils/pressCarousel.js
packages/tools/website/utils/processTranslations.js
packages/tools/website/utils/render.js
packages/tools/website/utils/types.js
packages/whisper-voice-typing/src/index.js
packages/whisper-voice-typing/src/specs/Whisper.nitro.js
# AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD
+14
View File
@@ -442,6 +442,8 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/deleteFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/duplicateNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/editAlarm.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/exportPdf.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/globalRedo.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/globalUndo.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/gotoAnything.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/hideModalMessage.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/importFrom.js
@@ -485,6 +487,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleNotesSortOrderR
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/togglePerFolderSortOrder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleSideBar.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleVisiblePanes.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/utils/canUseNativeUndo.js
packages/app-desktop/gui/WindowCommandsAndDialogs/types.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/appDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/showFolderPicker.js
@@ -665,6 +668,7 @@ packages/app-mobile/components/FeedbackBanner.js
packages/app-mobile/components/FolderPicker.js
packages/app-mobile/components/Icon.js
packages/app-mobile/components/IconButton.js
packages/app-mobile/components/KeyboardAvoidingView.js
packages/app-mobile/components/Modal.js
packages/app-mobile/components/ModalDialog.js
packages/app-mobile/components/NestableFlatList.js
@@ -843,6 +847,7 @@ packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js
packages/app-mobile/components/screens/dropbox-login.js
packages/app-mobile/components/screens/encryption-config.test.js
packages/app-mobile/components/screens/encryption-config.js
packages/app-mobile/components/screens/folder.js
packages/app-mobile/components/screens/status.js
packages/app-mobile/components/screens/tags.js
packages/app-mobile/components/side-menu-content.js
@@ -943,6 +948,7 @@ packages/app-mobile/utils/hooks/useSafeAreaPadding.js
packages/app-mobile/utils/image/fileToImage.web.js
packages/app-mobile/utils/image/getImageDimensions.js
packages/app-mobile/utils/image/resizeImage.js
packages/app-mobile/utils/initReact.js
packages/app-mobile/utils/initializeCommandService.js
packages/app-mobile/utils/ipc/RNToWebViewMessenger.js
packages/app-mobile/utils/ipc/WebViewToRNMessenger.js
@@ -1019,6 +1025,8 @@ packages/editor/CodeMirror/extensions/links/utils/getUrlAtPosition.js
packages/editor/CodeMirror/extensions/links/utils/openLink.js
packages/editor/CodeMirror/extensions/markdownDecorationExtension.test.js
packages/editor/CodeMirror/extensions/markdownDecorationExtension.js
packages/editor/CodeMirror/extensions/markdownFrontMatterExtension.test.js
packages/editor/CodeMirror/extensions/markdownFrontMatterExtension.js
packages/editor/CodeMirror/extensions/markdownHighlightExtension.test.js
packages/editor/CodeMirror/extensions/markdownHighlightExtension.js
packages/editor/CodeMirror/extensions/markdownMathExtension.test.js
@@ -1221,6 +1229,7 @@ packages/lib/SyncTargetOneDrive.js
packages/lib/SyncTargetRegistry.js
packages/lib/Synchronizer.js
packages/lib/TaskQueue.js
packages/lib/WebDavApi.js
packages/lib/WelcomeUtils.js
packages/lib/array.js
packages/lib/callbackUrlUtils.test.js
@@ -1681,6 +1690,7 @@ packages/lib/testing/share/mockShareService.js
packages/lib/testing/syncTargetUtils.js
packages/lib/testing/test-utils-synchronizer.js
packages/lib/testing/test-utils.js
packages/lib/testing/waitFor.js
packages/lib/theme.js
packages/lib/themes/aritimDark.js
packages/lib/themes/dark.js
@@ -1785,6 +1795,8 @@ packages/renderer/MdToHtml/rules/code_inline.js
packages/renderer/MdToHtml/rules/externalEmbed.js
packages/renderer/MdToHtml/rules/fence.js
packages/renderer/MdToHtml/rules/fountain.js
packages/renderer/MdToHtml/rules/frontmatter.test.js
packages/renderer/MdToHtml/rules/frontmatter.js
packages/renderer/MdToHtml/rules/highlight_keywords.js
packages/renderer/MdToHtml/rules/html_image.js
packages/renderer/MdToHtml/rules/image.js
@@ -1912,5 +1924,7 @@ packages/tools/website/utils/pressCarousel.js
packages/tools/website/utils/processTranslations.js
packages/tools/website/utils/render.js
packages/tools/website/utils/types.js
packages/whisper-voice-typing/src/index.js
packages/whisper-voice-typing/src/specs/Whisper.nitro.js
# AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD
+1
View File
@@ -5,6 +5,7 @@
"exceptions": [
"@joplin/editor",
"@joplin/fork-htmlparser2",
"@joplin/whisper-voice-typing",
"@joplin/fork-sax",
"@joplin/fork-uslug",
"@joplin/htmlpack",
+1 -1
View File
@@ -6,7 +6,7 @@ Only the latest version is supported with security updates.
## Reporting a Vulnerability
Please [contact support](https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/AdresseSupport.png) **with a proof of concept** that shows the security vulnerability. Please do not contact us without this proof of concept, as we cannot fix anything without this.
Please report vulnerabilities [through private vulnerability reporting](https://github.com/laurent22/joplin/security/advisories/new) **with a proof of concept** that shows the security vulnerability. Please do not contact us without this proof of concept, as we cannot fix anything without this.
For general opinions on what makes an app more or less secure, please use the forum.
+1 -1
View File
@@ -33,7 +33,7 @@
"/packages/app-desktop/build/",
"/packages/app-desktop/utils/checkForUpdatesUtilsTestData.ts",
"/packages/app-desktop/vendor/",
"/packages/app-mobile/android/vendor/",
"/packages/whisper-voice-typing/vendor/",
"/packages/app-mobile/ios/Pods/",
"/packages/app-mobile/lib/rnInjectedJs",
"/packages/app-mobile/pluginAssets",
+2 -1
View File
@@ -1,6 +1,7 @@
import ShareService from '@joplin/lib/services/share/ShareService';
import mockShareService from '@joplin/lib/testing/share/mockShareService';
import { createFolderTree, setupDatabaseAndSynchronizer, switchClient, waitFor } from '@joplin/lib/testing/test-utils';
import { createFolderTree, setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import waitFor from '@joplin/lib/testing/waitFor';
import { setupApplication, setupCommandForTesting } from './utils/testUtils';
import Note from '@joplin/lib/models/Note';
import Folder from '@joplin/lib/models/Folder';
@@ -1,6 +1,7 @@
import ShareService from '@joplin/lib/services/share/ShareService';
import mockShareService from '@joplin/lib/testing/share/mockShareService';
import { setupDatabaseAndSynchronizer, switchClient, waitFor } from '@joplin/lib/testing/test-utils';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import waitFor from '@joplin/lib/testing/waitFor';
import { setupApplication, setupCommandForTesting } from './utils/testUtils';
import Note from '@joplin/lib/models/Note';
import Folder from '@joplin/lib/models/Folder';
+1 -1
View File
@@ -57,7 +57,7 @@
"proper-lockfile": "4.1.2",
"redux": "4.2.1",
"server-destroy": "1.0.1",
"sharp": "0.34.4",
"sharp": "0.34.5",
"sprintf-js": "1.1.3",
"sqlite3": "5.1.6",
"string-padding": "1.0.2",
+17 -1
View File
@@ -12,6 +12,7 @@ function newTestMdToHtml(options: any = null) {
ResourceModel: {
isResourceUrl: isResourceUrl,
urlToId: resourceUrlToId,
fullPath: () => '/some/path/here',
},
fsDriver: shim.fsDriver(),
...options,
@@ -56,6 +57,21 @@ describe('MdToHtml', () => {
mdToHtmlOptions.mapsToLine = true;
} else if (mdFilename.startsWith('resource_')) {
mdToHtmlOptions.resources = {};
} else if (mdFilename.startsWith('pdf_')) {
mdToHtmlOptions.resources = {
'00000000000000000000000000000001': {
item: { mime: 'application/pdf' },
localState: { },
},
};
mdToHtmlOptions.pdfViewerEnabled = true;
} else if (mdFilename.startsWith('video_')) {
mdToHtmlOptions.resources = {
'00000000000000000000000000000001': {
item: { mime: 'video/mp4' },
localState: { },
},
};
}
const markdown = await shim.fsDriver().readFile(mdFilePath);
@@ -86,7 +102,7 @@ describe('MdToHtml', () => {
// eslint-disable-next-line no-console
console.info(msg.join('\n'));
expect(false).toBe(true);
expect(actualHtml).toBe(expectedHtml);
// return;
} else {
expect(true).toBe(true);
@@ -0,0 +1,11 @@
<div class="joplin-editable">
<!-- Regression test: Historically, text nodes before the first "joplin-source" block caused
conversion to fail. -->
A text node!
<pre class="joplin-source" data-joplin-language="test" data-joplin-source-open="```&#10;" data-joplin-source-close="&#10;```">
Test!
</pre>
<div class="joplin-rendered">
<p>Test content</p>
</div>
</div>
@@ -0,0 +1,4 @@
```
Test!
```
@@ -0,0 +1,4 @@
<p>Embed without starting page:</p>
<p><a data-from-md data-resource-id='00000000000000000000000000000001' type='application/pdf' href='#' onclick='postMessage(&quot;joplin://00000000000000000000000000000001&quot;, { resourceId: &quot;00000000000000000000000000000001&quot; }); return false;'><span class="resource-icon fa-file-pdf"></span>pdf</a><object data="file:///some/path/here" class="media-player media-pdf" type="application/pdf"></object></p>
<p>Embed with starting page:</p>
<p><a data-from-md data-resource-id='00000000000000000000000000000001' type='application/pdf' href='#' onclick='postMessage(&quot;joplin://00000000000000000000000000000001#page=1&quot;, { resourceId: &quot;00000000000000000000000000000001&quot; }); return false;'><span class="resource-icon fa-file-pdf"></span>pdf</a><object data="file:///some/path/here#page=1" class="media-player media-pdf" type="application/pdf"></object></p>
@@ -0,0 +1,8 @@
Embed without starting page:
[pdf](:/00000000000000000000000000000001)
Embed with starting page:
[pdf](:/00000000000000000000000000000001#page=1)
@@ -0,0 +1,10 @@
<p><a data-from-md data-resource-id='00000000000000000000000000000001' type='video/mp4' href='#' onclick='postMessage(&quot;joplin://00000000000000000000000000000001#t=1,2&quot;, { resourceId: &quot;00000000000000000000000000000001&quot; }); return false;'><span class="resource-icon fa-file-video"></span>video, with start/end time</a>
<video class="media-player media-video" controls>
<source src="file:///some/path/here#t=1,2" type="video/mp4">
</video>
</p>
<p><a data-from-md data-resource-id='00000000000000000000000000000001' type='video/mp4' href='#' onclick='postMessage(&quot;joplin://00000000000000000000000000000001&quot;, { resourceId: &quot;00000000000000000000000000000001&quot; }); return false;'><span class="resource-icon fa-file-video"></span>video, without start/end time</a>
<video class="media-player media-video" controls>
<source src="file:///some/path/here" type="video/mp4">
</video>
</p>
@@ -0,0 +1,4 @@
[video, with start/end time](:/00000000000000000000000000000001#t=1,2)
[video, without start/end time](:/00000000000000000000000000000001)
+2 -11
View File
@@ -693,17 +693,8 @@ function useMenu(props: Props) {
menuItemDic.pasteAsText,
menuItemDic.textSelectAll,
separator(),
// Using the generic "undo"/"redo" roles mean the menu
// item will work in every text fields, whether it's the
// editor or a regular text field.
{
role: 'undo',
label: _('Undo'),
},
{
role: 'redo',
label: _('Redo'),
},
menuItemDic.globalUndo,
menuItemDic.globalRedo,
separator(),
menuItemDic.textBold,
menuItemDic.textItalic,
@@ -294,6 +294,13 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
window.requestAnimationFrame(() => editor.undoManager.add());
},
pasteAsText: () => editor.fire(TinyMceEditorEvents.PasteAsText),
'editor.undo': () => {
editor.undoManager.undo();
},
'editor.redo': () => {
editor.undoManager.redo();
},
};
if (additionalCommands[cmd.name]) {
@@ -51,6 +51,15 @@ function newBlockSource(language = '', content = '', previousSource: SourceInfo
} else {
fence = '$$';
}
} else if (language === 'frontmatter') {
// Frontmatter uses --- delimiters instead of code fences
return {
openCharacters: '---\n',
closeCharacters: '\n---\n',
content: content,
node: null,
language: language,
};
}
const fenceLanguage = language === 'katex' ? '' : language;
+13 -3
View File
@@ -58,6 +58,17 @@ const usePluginMessageResponder = (webviewRef: RefObject<HTMLIFrameElement>) =>
}, [webviewRef, windowId]);
};
const useAllowAttribute = () => {
// Specifies what content in the note viewer can do. See
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLIFrameElement/allow
// allow=fullscreen: Required to allow the user to fullscreen videos.
return [
'clipboard-write', 'fullscreen', 'autoplay', 'local-fonts', 'encrypted-media',
].map(
attr => `${attr} joplin-content://note-viewer/`,
).join('; ');
};
const NoteTextViewer = forwardRef((props: Props, ref: ForwardedRef<NoteViewerControl>) => {
const [webview, setWebview] = useState<HTMLIFrameElement|null>(null);
const webviewRef = useRef<HTMLIFrameElement|null>(null);
@@ -233,14 +244,13 @@ const NoteTextViewer = forwardRef((props: Props, ref: ForwardedRef<NoteViewerCon
return { border: 'none', ...props.viewerStyle };
}, [props.viewerStyle]);
// allow=fullscreen: Required to allow the user to fullscreen videos.
const allow = useAllowAttribute();
return (
<iframe
className="noteTextViewer"
ref={setWebview}
style={viewerStyle}
allow='clipboard-write=(self) fullscreen=(self) autoplay=(self) local-fonts=(self) encrypted-media=(self)'
allowFullScreen={true}
allow={allow}
aria-label={_('Note viewer')}
src={`joplin-content://note-viewer/${toForwardSlashes(getAssetPath('gui/note-viewer/index.html'))}`}
></iframe>
@@ -0,0 +1,24 @@
import CommandService, { CommandContext, CommandDeclaration, CommandRuntime } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import { WindowControl } from '../utils/useWindowControl';
import bridge from '../../../services/bridge';
import canUseNativeUndo from './utils/canUseNativeUndo';
export const declaration: CommandDeclaration = {
name: 'globalRedo',
label: () => _('Redo'),
};
export const runtime = (control: WindowControl): CommandRuntime => {
return {
execute: async (_context: CommandContext) => {
if (canUseNativeUndo(control)) {
bridge().activeWindow().webContents.redo();
} else {
await CommandService.instance().execute('editor.redo');
}
},
enabledCondition: '',
};
};
@@ -0,0 +1,27 @@
import CommandService, { CommandContext, CommandDeclaration, CommandRuntime } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import { WindowControl } from '../utils/useWindowControl';
import bridge from '../../../services/bridge';
import canUseNativeUndo from './utils/canUseNativeUndo';
export const declaration: CommandDeclaration = {
name: 'globalUndo',
label: () => _('Undo'),
};
export const runtime = (control: WindowControl): CommandRuntime => {
return {
execute: async (_context: CommandContext) => {
// As of January 2026, webContents.undo() doesn't work properly in more complex
// edit controls (e.g. CodeMirror or TinyMCE). Only use it when a more simple input
// has focus:
if (canUseNativeUndo(control)) {
bridge().activeWindow().webContents.undo();
} else {
await CommandService.instance().execute('editor.undo');
}
},
enabledCondition: '',
};
};
@@ -5,6 +5,8 @@ import * as deleteFolder from './deleteFolder';
import * as duplicateNote from './duplicateNote';
import * as editAlarm from './editAlarm';
import * as exportPdf from './exportPdf';
import * as globalRedo from './globalRedo';
import * as globalUndo from './globalUndo';
import * as gotoAnything from './gotoAnything';
import * as hideModalMessage from './hideModalMessage';
import * as importFrom from './importFrom';
@@ -54,6 +56,8 @@ const index: any[] = [
duplicateNote,
editAlarm,
exportPdf,
globalRedo,
globalUndo,
gotoAnything,
hideModalMessage,
importFrom,
@@ -0,0 +1,11 @@
import { WindowControl } from '../../utils/useWindowControl';
// CodeMirror and TinyMCE both have trouble with native Electron
// undo/redo.
// See https://github.com/laurent22/joplin/issues/14216
const canUseNativeUndo = (control: WindowControl) => {
const dom = control.getFocusedDocument();
return !dom.activeElement.closest('.CodeMirror, div.joplin-tinymce');
};
export default canUseNativeUndo;
@@ -21,7 +21,7 @@ const useWindowCommands = ({ documentRef, customCss, plugins, editorNoteStatuses
editorNoteStatuses: editorNoteStatuses,
plugins: plugins,
});
const windowControl = useWindowControl(setDialogState, onPrintCallback);
const windowControl = useWindowControl(setDialogState, onPrintCallback, documentRef);
// This effect needs to run as soon as possible. Certain components may fail to load if window
// commands are not registered on their first render.
@@ -1,5 +1,5 @@
import * as React from 'react';
import { useMemo, useRef } from 'react';
import { RefObject, useMemo, useRef } from 'react';
import { DialogState } from '../types';
import { PrintCallback } from './usePrintToCallback';
import { _ } from '@joplin/lib/locale';
@@ -23,10 +23,11 @@ export interface WindowControl {
showPrompt: <T>(options: PromptOptions<T>)=> Promise<T>;
printTo: PrintCallback;
announcePanelVisibility(panelName: string, visible: boolean): void;
getFocusedDocument(): Document;
}
export type OnSetDialogState = React.Dispatch<React.SetStateAction<DialogState>>;
const useWindowControl = (setDialogState: OnSetDialogState, onPrint: PrintCallback) => {
const useWindowControl = (setDialogState: OnSetDialogState, onPrint: PrintCallback, windowDomRef: RefObject<Document>) => {
// Use refs to avoid reloading the output where possible -- reloading the window control
// may mean reloading all main window commands.
const onPrintRef = useRef(onPrint);
@@ -67,9 +68,12 @@ const useWindowControl = (setDialogState: OnSetDialogState, onPrint: PrintCallba
});
});
},
getFocusedDocument: () => {
return windowDomRef.current;
},
};
return control;
}, [setDialogState]);
}, [setDialogState, windowDomRef]);
};
export default useWindowControl;
+9 -6
View File
@@ -50,13 +50,16 @@ export default function() {
'editor.duplicateLine',
'openSecondaryAppInstance',
'openPrimaryAppInstance',
// We cannot put the undo/redo commands in the menu because they are
// editor-specific commands. If we put them there it will break the
// undo/redo in regular text fields.
// https://github.com/laurent22/joplin/issues/6214
// 'editor.undo',
// 'editor.redo',
// We cannot put the editor.undo/editor.redo commands in the menu because they are
// editor-specific commands. If we put them there it will break the undo/redo in
// regular text fields (https://github.com/laurent22/joplin/issues/6214).
// However, the native Electron undo/redo doesn't work well in TinyMCE/CodeMirror.
// As a workaround, use these commands that switch between editor.undo and native Electron
// undo/redo depending on the type of selected editor:
'globalUndo',
'globalRedo',
'editor.indentLess',
'editor.indentMore',
'editor.toggleComment',
@@ -381,5 +381,24 @@ test.describe('markdownEditor', () => {
await goToAnything.runCommand(electronApp, 'textPaste');
await noteEditor.expectToHaveText(/^Test \(new content!\)[\n]+/);
});
test('the undo and redo menu items should work', async ({ mainWindow, electronApp }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.waitFor();
await mainScreen.createNewNote('Test undo/redo');
const noteEditor = mainScreen.noteEditor;
await noteEditor.focusCodeMirrorEditor();
await mainWindow.keyboard.type('A');
await noteEditor.expectToHaveText('A');
await activateMainMenuItem(electronApp, 'Undo');
await noteEditor.expectToHaveText('\n');
await activateMainMenuItem(electronApp, 'Redo');
await noteEditor.expectToHaveText('A');
});
});
@@ -23,7 +23,7 @@ const waitFor = async (condition) => {
setTimeout(() => resolve(), 100);
});
};
for (let i = 0; i < 100; i++) {
for (let i = 0; i < 500; i++) {
if (await condition()) {
return;
}
+1 -1
View File
@@ -185,7 +185,7 @@
"md5": "2.3.0",
"moment": "2.30.1",
"mustache": "4.2.0",
"nan": "2.23.0",
"nan": "2.23.1",
"node-notifier": "10.0.1",
"node-rsa": "1.1.1",
"pdfjs-dist": "3.11.174",
+22 -37
View File
@@ -2,11 +2,24 @@ apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react"
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
/**
* This is the configuration block to customize your React Native Android app.
* By default you don't need to apply any configuration, just uncomment the lines you need.
*/
react {
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean()
// (Disabled) Use Expo CLI to bundle the app, this ensures the Metro config
// works correctly with Expo projects.
// cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
// bundleCommand = "export:embed"
/* Folders */
// The root of your project, i.e. where "package.json" lives. Default is '../..'
// root = file("../..")
@@ -55,31 +68,12 @@ react {
}
/**
* Set this to true to Run Proguard on Release builds to minify the Java bytecode.
* Set this to true in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization).
*/
def enableProguardInReleaseBuilds = false
/**
* The preferred build flavor of JavaScriptCore (JSC)
*
* For example, to use the international variant, you can use:
* `def jscFlavor = io.github.react-native-community:jsc-android-intl:2026004.+`
*
* The international variant includes ICU i18n library and necessary data
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
* give correct results when using with locales other than en-US. Note that
* this variant is about 6MiB larger per architecture than default.
*/
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
android {
externalNativeBuild {
cmake {
path file('src/main/cpp/CMakeLists.txt')
version '3.22.1'
}
}
ndkVersion rootProject.ext.ndkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk rootProject.ext.compileSdkVersion
@@ -89,21 +83,17 @@ android {
applicationId "net.cozic.joplin"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2097788
versionName "3.6.0"
versionCode 2097800
versionName "3.6.12"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
ndk {
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
}
// Needed to fix: The number of method references in a .dex file cannot exceed 64K
multiDexEnabled true
externalNativeBuild {
cmake {
cppFlags '-DCMAKE_BUILD_TYPE=Release'
// For 16 KB pages. This should be removable after upgrading to NDK r28
arguments "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON"
}
}
}
signingConfigs {
debug {
@@ -129,7 +119,7 @@ android {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.release
minifyEnabled enableProguardInReleaseBuilds
minifyEnabled enableMinifyInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
}
profileable {
@@ -149,10 +139,5 @@ android {
dependencies {
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android")
} else {
implementation jscFlavor
}
implementation("com.facebook.react:hermes-android")
}
+4
View File
@@ -8,3 +8,7 @@
# http://developer.android.com/guide/developing/tools/proguard.html
# Add any project specific keep options here:
# Keep classes referenced by JNI
# (see https://developer.android.com/topic/performance/app-optimization/add-keep-rules)
-keep class com.margelo.nitro.whispervoicetyping.AudioRecorder
@@ -44,6 +44,7 @@
android:requestLegacyExternalStorage="true"
android:resizeableActivity="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="${usesCleartextTraffic}"
android:supportsRtl="true">
<!-- Enable profiling in release builds (Android 10+) -->
@@ -1,65 +0,0 @@
# For more information about using CMake with Android Studio, read the
# documentation: https://d.android.com/studio/projects/add-native-code.html.
# For more examples on how to use CMake, see https://github.com/android/ndk-samples.
# Sets the minimum CMake version required for this project.
cmake_minimum_required(VERSION 3.22.1)
# Declares the project name. The project name can be accessed via ${ PROJECT_NAME},
# Since this is the top level CMakeLists.txt, the project name is also accessible
# with ${CMAKE_PROJECT_NAME} (both CMake variables are in-sync within the top level
# build script scope).
project("joplin")
# Creates and names a library, sets it as either STATIC
# or SHARED, and provides the relative paths to its source code.
# You can define multiple libraries, and CMake builds them for you.
# Gradle automatically packages shared libraries with your APK.
#
# In this top level CMakeLists.txt, ${CMAKE_PROJECT_NAME} is used to define
# the target library name; in the sub-module's CMakeLists.txt, ${PROJECT_NAME}
# is preferred for the same purpose.
#
# In order to load a library into your app from Java/Kotlin, you must call
# System.loadLibrary() and pass the name of the library defined here;
# for GameActivity/NativeActivity derived applications, the same library name must be
# used in the AndroidManifest.xml file.
add_library(${CMAKE_PROJECT_NAME} SHARED
# List C/C++ source files with relative paths to this CMakeLists.txt.
whisperWrapper.cpp
utils/WhisperSession.cpp
utils/findLongestSilence.cpp
utils/findLongestSilence_test.cpp
)
set(WHISPER_LIB_DIR ${CMAKE_SOURCE_DIR}/../../../../vendor/whisper.cpp)
# Based on the Whisper.cpp Android example:
set(SHARED_FLAGS "-O3 ")
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} ${SHARED_FLAGS} ")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} ${SHARED_FLAGS} -fvisibility=hidden -fvisibility-inlines-hidden -ffunction-sections -fdata-sections")
# Whisper: See https://stackoverflow.com/a/76290722
add_subdirectory(${WHISPER_LIB_DIR} ./whisper)
# Directories for header files
target_include_directories(
${CMAKE_PROJECT_NAME}
PUBLIC
${PROJECT_BASE_DIR}/shared
${WHISPER_LIB_DIR}/include
)
# Specifies libraries CMake should link to your target library. You
# can link libraries from various origins, such as libraries defined in this
# build script, prebuilt third-party libraries, or Android system libraries.
target_link_libraries(${CMAKE_PROJECT_NAME}
whisper
# List libraries link to the target library
android
log
)
@@ -1,151 +0,0 @@
// Write C++ code here.
//
// Do not forget to dynamically load the C++ library into your application.
//
// For instance,
//
// In MainActivity.java:
// static {
// System.loadLibrary("joplin");
// }
//
// Or, in MainActivity.kt:
// companion object {
// init {
// System.loadLibrary("joplin")
// }
// }
#include <jni.h>
#include <memory>
#include <string>
#include <sstream>
#include <android/log.h>
#include "whisper.h"
#include "utils/WhisperSession.h"
#include "utils/androidUtil.h"
#include "utils/findLongestSilence_test.h"
void log_android(enum ggml_log_level level, const char* message, void* user_data) {
android_LogPriority priority = level == 4 ? ANDROID_LOG_ERROR : ANDROID_LOG_INFO;
__android_log_print(priority, "Whisper::JNI::cpp", "%s", message);
}
jstring stringToJava(JNIEnv *env, const std::string& source) {
return env->NewStringUTF(source.c_str());
}
std::string stringToCXX(JNIEnv *env, jstring jString) {
const char *jStringChars = env->GetStringUTFChars(jString, nullptr);
std::string result { jStringChars };
env->ReleaseStringUTFChars(jString, jStringChars);
return result;
}
void throwException(JNIEnv *env, const std::string& message) {
jclass errorClass = env->FindClass("java/lang/Exception");
env->ThrowNew(errorClass, message.c_str());
}
extern "C"
JNIEXPORT jlong JNICALL
Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_init(
JNIEnv *env,
jobject thiz,
jstring modelPath,
jstring language,
jstring prompt,
jboolean useShortAudioContext
) {
whisper_log_set(log_android, nullptr);
try {
auto *pSession = new WhisperSession(
stringToCXX(env, modelPath), stringToCXX(env, language), stringToCXX(env, prompt), useShortAudioContext
);
return (jlong) pSession;
} catch (const std::exception& exception) {
LOGW("Failed to init whisper: %s", exception.what());
throwException(env, exception.what());
return 0;
}
}
extern "C"
JNIEXPORT void JNICALL
Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_free(JNIEnv *env, jobject thiz,
jlong pointer) {
delete reinterpret_cast<WhisperSession *>(pointer);
}
extern "C"
JNIEXPORT void JNICALL
Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_addAudio(JNIEnv *env,
jobject thiz,
jlong pointer,
jfloatArray audio_data) {
auto *pSession = reinterpret_cast<WhisperSession *> (pointer);
jfloat *pAudioData = env->GetFloatArrayElements(audio_data, nullptr);
jsize lenAudioData = env->GetArrayLength(audio_data);
std::string result;
try {
pSession->addAudio(pAudioData, lenAudioData);
} catch (const std::exception& exception) {
LOGW("Failed to add to audio buffer: %s", exception.what());
throwException(env, exception.what());
}
// JNI_ABORT: "free the buffer without copying back the possible changes", pass 0 to copy
// changes (there should be no changes)
env->ReleaseFloatArrayElements(audio_data, pAudioData, JNI_ABORT);
}
extern "C"
JNIEXPORT jstring JNICALL
Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_transcribeNextChunk(JNIEnv *env,
jobject thiz,
jlong pointer) {
auto *pSession = reinterpret_cast<WhisperSession *> (pointer);
std::string result;
try {
result = pSession->transcribeNextChunk();
} catch (const std::exception& exception) {
LOGW("Failed to run whisper: %s", exception.what());
throwException(env, exception.what());
return nullptr;
}
return stringToJava(env, result);
}
extern "C"
JNIEXPORT jstring JNICALL
Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_transcribeRemaining(JNIEnv *env,
jobject thiz,
jlong pointer) {
auto *pSession = reinterpret_cast<WhisperSession *> (pointer);
std::string result;
try {
result = pSession->transcribeAll();
} catch (const std::exception& exception) {
LOGW("Failed to run whisper: %s", exception.what());
throwException(env, exception.what());
return nullptr;
}
return stringToJava(env, result);
}
extern "C"
JNIEXPORT void JNICALL
Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_runTests(JNIEnv *env, jobject thiz) {
try {
findLongestSilence_test();
} catch (const std::exception& exception) {
LOGW("Failed to run tests: %s", exception.what());
throwException(env, exception.what());
}
}
@@ -6,37 +6,36 @@ import expo.modules.ReactNativeHostWrapper
import android.app.Application
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.ReactHost
import com.facebook.react.ReactNativeHost
import com.facebook.react.ReactPackage
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
import com.facebook.react.common.ReleaseLevel
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.react.soloader.OpenSourceMergedSoMapping
import com.facebook.soloader.SoLoader
import net.cozic.joplin.audio.SpeechToTextPackage
import net.cozic.joplin.versioninfo.SystemVersionInformationPackage
import net.cozic.joplin.share.SharePackage
import net.cozic.joplin.ssl.SslPackage
class MainApplication : Application(), ReactApplication {
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(this, object : DefaultReactNativeHost(this) {
override fun getPackages(): List<ReactPackage> =
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
this,
object : DefaultReactNativeHost(this) {
override fun getPackages(): List<ReactPackage> =
PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example:
add(SharePackage())
add(SslPackage())
add(SystemVersionInformationPackage())
add(SpeechToTextPackage())
}
override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"
override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED
})
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
})
override val reactHost: ReactHost
get() = ReactNativeHostWrapper.createReactHost(this.applicationContext, reactNativeHost)
@@ -44,16 +43,17 @@ class MainApplication : Application(), ReactApplication {
override fun onCreate() {
super.onCreate()
SoLoader.init(this, OpenSourceMergedSoMapping)
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
// If you opted-in for the New Architecture, we load the native entry point for this app.
load()
try {
DefaultNewArchitectureEntryPoint.releaseLevel = ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
} catch (e: IllegalArgumentException) {
DefaultNewArchitectureEntryPoint.releaseLevel = ReleaseLevel.STABLE
}
ApplicationLifecycleDispatcher.onApplicationCreate(this)
}
loadReactNative(this)
ApplicationLifecycleDispatcher.onApplicationCreate(this)
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
}
}
@@ -1,5 +0,0 @@
package net.cozic.joplin.audio
class InvalidSessionIdException(id: Int) : IllegalArgumentException("Invalid session ID $id") {
}
@@ -1,64 +0,0 @@
package net.cozic.joplin.audio
import java.io.Closeable
class NativeWhisperLib(
modelPath: String,
languageCode: String,
prompt: String,
shortAudioContext: Boolean,
) : Closeable {
companion object {
init {
System.loadLibrary("joplin")
}
external fun runTests(): Unit;
// TODO: The example whisper.cpp project transfers pointers as Longs to the Kotlin code.
// This seems unsafe. Try changing how this is managed.
private external fun init(modelPath: String, languageCode: String, prompt: String, shortAudioContext: Boolean): Long;
private external fun free(pointer: Long): Unit;
private external fun addAudio(pointer: Long, audioData: FloatArray): Unit;
private external fun transcribeNextChunk(pointer: Long): String;
private external fun transcribeRemaining(pointer: Long): String;
}
private var closed = false
private val pointer: Long = init(modelPath, languageCode, prompt, shortAudioContext)
fun addAudio(audioData: FloatArray) {
if (closed) {
throw Exception("Cannot add audio data to a closed session")
}
Companion.addAudio(pointer, audioData)
}
fun transcribeNextChunk(): String {
if (closed) {
throw Exception("Cannot transcribe using a closed session")
}
return Companion.transcribeNextChunk(pointer)
}
fun transcribeRemaining(): String {
if (closed) {
throw Exception("Cannot transcribeAll using a closed session")
}
return Companion.transcribeRemaining(pointer)
}
override fun close() {
if (closed) {
throw Exception("Cannot close a whisper session twice")
}
closed = true
free(pointer)
}
}
@@ -1,62 +0,0 @@
package net.cozic.joplin.audio
import android.content.Context
import android.util.Log
import java.io.Closeable
class SpeechToTextConverter(
modelPath: String,
locale: String,
prompt: String,
useShortAudioCtx: Boolean,
recorderFactory: AudioRecorderFactory,
context: Context,
) : Closeable {
private val recorder = recorderFactory(context)
private val languageCode = Regex("_.*").replace(locale, "")
private var whisper = NativeWhisperLib(
modelPath,
languageCode,
prompt,
useShortAudioCtx,
)
fun start() {
recorder.start()
}
private fun convert(data: FloatArray): String {
Log.d("Whisper", "Pre-transcribe data of size ${data.size}")
whisper.addAudio(data)
val result = whisper.transcribeNextChunk()
Log.d("Whisper", "Post transcribe. Got $result")
return result;
}
fun dropFirstSeconds(seconds: Double) {
Log.i("Whisper", "Drop first seconds $seconds")
recorder.dropFirstSeconds(seconds)
}
val bufferLengthSeconds: Double get() = recorder.bufferLengthSeconds
fun convertNext(seconds: Double): String {
val buffer = recorder.pullNextSeconds(seconds)
val result = convert(buffer)
dropFirstSeconds(seconds)
return result
}
// Converts as many seconds of buffered data as possible, without waiting
fun convertRemaining(): String {
val buffer = recorder.pullAvailable()
whisper.addAudio(buffer)
return whisper.transcribeRemaining()
}
override fun close() {
Log.d("Whisper", "Close")
recorder.close()
whisper.close()
}
}
@@ -1,87 +0,0 @@
package net.cozic.joplin.audio
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.LifecycleEventListener
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.uimanager.ViewManager
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
class SpeechToTextPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf<NativeModule>(SpeechToTextModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
class SpeechToTextModule(
private var context: ReactApplicationContext,
) : ReactContextBaseJavaModule(context), LifecycleEventListener {
private val executorService: ExecutorService = Executors.newFixedThreadPool(1)
private val sessionManager = SpeechToTextSessionManager(executorService)
override fun getName() = "SpeechToTextModule"
override fun onHostResume() { }
override fun onHostPause() { }
override fun onHostDestroy() { }
@ReactMethod
fun runTests(promise: Promise) {
try {
NativeWhisperLib.runTests()
promise.resolve(true)
} catch (exception: Throwable) {
promise.reject(exception)
}
}
@ReactMethod
fun openSession(modelPath: String, locale: String, prompt: String, useShortAudioCtx: Boolean, promise: Promise) {
val appContext = context.applicationContext
try {
val sessionId = sessionManager.openSession(modelPath, locale, prompt, useShortAudioCtx, appContext)
promise.resolve(sessionId)
} catch (exception: Throwable) {
promise.reject(exception)
}
}
@ReactMethod
fun startRecording(sessionId: Int, promise: Promise) {
sessionManager.startRecording(sessionId, promise)
}
@ReactMethod
fun getBufferLengthSeconds(sessionId: Int, promise: Promise) {
sessionManager.getBufferLengthSeconds(sessionId, promise)
}
@ReactMethod
fun dropFirstSeconds(sessionId: Int, duration: Double, promise: Promise) {
sessionManager.dropFirstSeconds(sessionId, duration, promise)
}
@ReactMethod
fun convertNext(sessionId: Int, duration: Double, promise: Promise) {
sessionManager.convertNext(sessionId, duration, promise)
}
@ReactMethod
fun convertAvailable(sessionId: Int, promise: Promise) {
sessionManager.convertAvailable(sessionId, promise)
}
@ReactMethod
fun closeSession(sessionId: Int, promise: Promise) {
sessionManager.closeSession(sessionId, promise)
}
}
}
@@ -1,111 +0,0 @@
package net.cozic.joplin.audio
import android.content.Context
import com.facebook.react.bridge.Promise
import java.util.concurrent.Executor
import java.util.concurrent.locks.ReentrantLock
class SpeechToTextSession (
val converter: SpeechToTextConverter
) {
val mutex = ReentrantLock()
}
class SpeechToTextSessionManager(
private var executor: Executor,
) {
private val sessions: MutableMap<Int, SpeechToTextSession> = mutableMapOf()
private var nextSessionId: Int = 0
fun openSession(
modelPath: String,
locale: String,
prompt: String,
useShortAudioCtx: Boolean,
context: Context,
): Int {
val sessionId = nextSessionId++
sessions[sessionId] = SpeechToTextSession(
SpeechToTextConverter(
modelPath, locale, prompt, useShortAudioCtx, recorderFactory = AudioRecorder.factory, context,
)
)
return sessionId
}
private fun getSession(id: Int): SpeechToTextSession {
return sessions[id] ?: throw InvalidSessionIdException(id)
}
private fun concurrentWithSession(
id: Int,
callback: (session: SpeechToTextSession)->Unit,
) {
executor.execute {
val session = getSession(id)
session.mutex.lock()
try {
callback(session)
} finally {
session.mutex.unlock()
}
}
}
private fun concurrentWithSession(
id: Int,
onError: (error: Throwable)->Unit,
callback: (session: SpeechToTextSession)->Unit,
) {
return concurrentWithSession(id) { session ->
try {
callback(session)
} catch (error: Throwable) {
onError(error)
}
}
}
fun startRecording(sessionId: Int, promise: Promise) {
this.concurrentWithSession(sessionId, promise::reject) { session ->
session.converter.start()
promise.resolve(null)
}
}
// Left-shifts the recording buffer by [duration] seconds
fun dropFirstSeconds(sessionId: Int, duration: Double, promise: Promise) {
this.concurrentWithSession(sessionId, promise::reject) { session ->
session.converter.dropFirstSeconds(duration)
promise.resolve(sessionId)
}
}
fun getBufferLengthSeconds(sessionId: Int, promise: Promise) {
this.concurrentWithSession(sessionId, promise::reject) { session ->
promise.resolve(session.converter.bufferLengthSeconds)
}
}
// Waits for the next [duration] seconds to become available, then converts
fun convertNext(sessionId: Int, duration: Double, promise: Promise) {
this.concurrentWithSession(sessionId, promise::reject) { session ->
val result = session.converter.convertNext(duration)
promise.resolve(result)
}
}
// Converts all available recorded data
fun convertAvailable(sessionId: Int, promise: Promise) {
this.concurrentWithSession(sessionId, promise::reject) { session ->
val result = session.converter.convertRemaining()
promise.resolve(result)
}
}
fun closeSession(sessionId: Int, promise: Promise) {
this.concurrentWithSession(sessionId) { session ->
session.converter.close()
promise.resolve(null)
}
}
}
+4 -4
View File
@@ -2,14 +2,14 @@
buildscript {
ext {
buildToolsVersion = "35.0.0"
buildToolsVersion = "36.0.0"
minSdkVersion = 24
compileSdkVersion = 35
targetSdkVersion = 35
compileSdkVersion = 36
targetSdkVersion = 36
ndkVersion = "27.1.12297006"
kotlinVersion = "2.0.21"
kotlinVersion = "2.1.20"
}
repositories {
google()
@@ -16,7 +16,7 @@ org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryEr
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
@@ -34,12 +34,17 @@ reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
# your application. You should enable this flag either if you want
# to write custom TurboModules/Fabric components OR use libraries that
# are providing them.
newArchEnabled=false
newArchEnabled=true
# Use this property to enable or disable the Hermes JS engine.
# If set to false, you will be using JSC instead.
hermesEnabled=true
# Use this property to enable edge-to-edge display support.
# This allows your app to draw behind system bars for an immersive UI.
# Note: Only works with ReactActivity and should not be used with custom Activity.
edgeToEdgeEnabled=true
# To fix this error:
#
# > Failed to transform bcprov-jdk15on-1.68.jar
Binary file not shown.
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
+2 -2
View File
@@ -114,7 +114,7 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
@@ -213,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
+2 -2
View File
@@ -70,11 +70,11 @@ goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
@@ -1,9 +0,0 @@
whisper.cpp/.gitmodules
whisper.cpp/scripts/
whisper.cpp/samples/
whisper.cpp/tests/
whisper.cpp/models/
whisper.cpp/examples/
whisper.cpp/.*/
whisper.cpp/bindings/
whisper.cpp/**/*.Dockerfile
+5 -2
View File
@@ -1,4 +1,7 @@
{
"name": "Joplin",
"displayName": "Joplin"
}
"displayName": "Joplin",
"plugins": [
"@react-native-community/datetimepicker"
]
}
@@ -44,7 +44,7 @@ const useStyles = (theme: ThemeStyle) => {
},
contentContainer: {
padding: 20,
paddingBottom: 14,
paddingBottom: 14 + safeAreaPadding.paddingBottom,
gap: 8,
flexDirection: 'row',
flexWrap: 'wrap',
@@ -13,6 +13,7 @@ import shim from '@joplin/lib/shim';
import Logger from '@joplin/utils/Logger';
import { Props, WebViewControl } from './types';
import useCss from './utils/useCss';
import { Platform } from 'react-native';
const logger = Logger.create('ExtendedWebView');
@@ -141,7 +142,8 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
onLoadEnd={props.onLoadEnd}
onContentProcessDidTerminate={refreshWebViewAfterCrash}
onRenderProcessGone={refreshWebViewAfterCrash}
decelerationRate='normal'
// See https://github.com/react-native-webview/react-native-webview/issues/3814
decelerationRate={Platform.OS === 'ios' ? 'normal' : undefined}
/>
);
};
@@ -2,7 +2,8 @@ import * as React from 'react';
import { Store } from 'redux';
import { AppState } from '../utils/types';
import TestProviderStack from './testing/TestProviderStack';
import { switchClient, setupDatabase, mockMobilePlatform, mockFetch, waitFor } from '@joplin/lib/testing/test-utils';
import { switchClient, setupDatabase, mockMobilePlatform, mockFetch } from '@joplin/lib/testing/test-utils';
import waitFor from '@joplin/lib/testing/waitFor';
import createMockReduxStore from '../utils/testing/createMockReduxStore';
import setupGlobalStore from '../utils/testing/setupGlobalStore';
import { act, fireEvent, render, screen } from '@testing-library/react-native';
@@ -0,0 +1,26 @@
import * as React from 'react';
import { KeyboardAvoidingViewProps, KeyboardAvoidingView as NativeKeyboardAvoidingView } from 'react-native';
import useKeyboardState from '../utils/hooks/useKeyboardState';
interface Props extends KeyboardAvoidingViewProps {}
const KeyboardAvoidingView: React.FC<Props> = ({ enabled, children, ...forwardedProps }) => {
const keyboardState = useKeyboardState();
enabled &&= (
// When the floating keyboard is enabled, the KeyboardAvoidingView can have a very small
// height. Don't use the KeyboardAvoidingView when the floating keyboard is enabled.
// See https://github.com/facebook/react-native/issues/29473
!keyboardState.isFloatingKeyboard
);
return <NativeKeyboardAvoidingView
behavior='padding'
{...forwardedProps}
enabled={enabled}
>
{children}
</NativeKeyboardAvoidingView>;
};
export default KeyboardAvoidingView;
+3 -2
View File
@@ -1,12 +1,13 @@
import * as React from 'react';
import { RefObject, useCallback, useMemo, useRef, useState } from 'react';
import { GestureResponderEvent, KeyboardAvoidingView, Modal, ModalProps, Platform, Pressable, ScrollView, ScrollViewProps, StyleSheet, View, ViewStyle } from 'react-native';
import { GestureResponderEvent, Modal, ModalProps, Platform, Pressable, ScrollView, ScrollViewProps, StyleSheet, View, ViewStyle } from 'react-native';
import FocusControl from './accessibility/FocusControl/FocusControl';
import { msleep, Second } from '@joplin/utils/time';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import { ModalState } from './accessibility/FocusControl/types';
import useSafeAreaPadding from '../utils/hooks/useSafeAreaPadding';
import { _ } from '@joplin/lib/locale';
import KeyboardAvoidingView from './KeyboardAvoidingView';
export interface ModalElementProps extends ModalProps {
children: React.ReactNode;
@@ -175,7 +176,7 @@ const ModalElement: React.FC<ModalElementProps> = ({
{...modalProps}
>
{scrollOverflow ? (
<KeyboardAvoidingView behavior='padding' style={styles.keyboardAvoidingView}>
<KeyboardAvoidingView style={styles.keyboardAvoidingView} enabled={true}>
<ScrollView
{...extraScrollViewProps}
style={[styles.modalScrollView, extraScrollViewProps.style]}
@@ -61,7 +61,7 @@ const ProfileListItem: React.FC<ProfileItemProps> = ({ profile, profileConfig, s
}
};
const switchProfileMessage = _('To switch the profile, the app is going to close and you will need to restart it.');
const switchProfileMessage = _('To switch the profile, the app is going to restart.');
if (shim.mobilePlatform() === 'web') {
if (confirm(switchProfileMessage)) {
void doIt();
@@ -688,8 +688,8 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
const menuComp =
!menuOptions.length || !showContextMenuButton ? null : (
<Menu themeId={this.props.themeId} options={menuOptions}>
<View style={contextMenuStyle} accessibilityLabel={_('Actions')}>
<Icon name="ionicon ellipsis-vertical" style={this.styles().contextMenuTrigger} accessibilityLabel={null}/>
<View style={contextMenuStyle}>
<Icon name="ionicon ellipsis-vertical" style={this.styles().contextMenuTrigger} accessibilityLabel={_('Actions')}/>
</View>
</Menu>
);
+12 -2
View File
@@ -7,6 +7,7 @@ import AccessibleView from './accessibility/AccessibleView';
import { _ } from '@joplin/lib/locale';
import useReduceMotionEnabled from '../utils/hooks/useReduceMotionEnabled';
import { themeStyle } from './global-style';
import useSafeAreaPadding from '../utils/hooks/useSafeAreaPadding';
export enum SideMenuPosition {
Left = 'left',
@@ -40,6 +41,8 @@ interface UseStylesProps {
const useStyles = ({ themeId, isLeftMenu, menuWidth, menuOpenFraction }: UseStylesProps) => {
const { height: windowHeight, width: windowWidth } = useWindowDimensions();
const safeAreaInsets = useSafeAreaPadding();
return useMemo(() => {
const theme = themeStyle(themeId);
return StyleSheet.create({
@@ -53,7 +56,7 @@ const useStyles = ({ themeId, isLeftMenu, menuWidth, menuOpenFraction }: UseStyl
contentOuterWrapper: {
flexGrow: 1,
flexShrink: 1,
width: windowWidth,
width: '100%',
height: windowHeight,
transform: [{
translateX: menuOpenFraction.interpolate({
@@ -71,11 +74,18 @@ const useStyles = ({ themeId, isLeftMenu, menuWidth, menuOpenFraction }: UseStyl
flexShrink: 1,
},
menuWrapper: {
backgroundColor: theme.backgroundColor,
position: 'absolute',
top: 0,
bottom: 0,
width: menuWidth,
paddingLeft: isLeftMenu ? safeAreaInsets.paddingLeft : 0,
paddingRight: isLeftMenu ? 0 : safeAreaInsets.paddingRight,
paddingTop: safeAreaInsets.paddingTop,
paddingBottom: safeAreaInsets.paddingBottom,
// In React Native, RTL replaces `left` with `right` and `right` with `left`.
// As such, we need to reverse the normal direction in RTL mode.
...(isLeftMenu === !I18nManager.isRTL ? {
@@ -107,7 +117,7 @@ const useStyles = ({ themeId, isLeftMenu, menuWidth, menuOpenFraction }: UseStyl
width: windowWidth,
},
});
}, [themeId, isLeftMenu, windowWidth, windowHeight, menuWidth, menuOpenFraction]);
}, [themeId, isLeftMenu, windowWidth, windowHeight, menuWidth, menuOpenFraction, safeAreaInsets]);
};
interface UseAnimationsProps {
+22 -12
View File
@@ -2,13 +2,16 @@ import * as React from 'react';
import { connect } from 'react-redux';
import NotesScreen from './screens/Notes/Notes';
import SearchScreen from './screens/SearchScreen';
import { KeyboardAvoidingView, Platform, View } from 'react-native';
import { Platform, View, StyleSheet } from 'react-native';
import { AppState } from '../utils/types';
import { themeStyle } from './global-style';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import useKeyboardState from '../utils/hooks/useKeyboardState';
import usePrevious from '@joplin/lib/hooks/usePrevious';
import FeedbackBanner from './FeedbackBanner';
import { Theme } from '@joplin/lib/themes/type';
import { useMemo } from 'react';
import KeyboardAvoidingView from './KeyboardAvoidingView';
interface Props {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -20,6 +23,15 @@ interface Props {
themeId: number;
}
const useStyles = (theme: Theme) => {
return useMemo(() => {
return StyleSheet.create({
keyboardAvoidingView: { flex: 1, backgroundColor: theme.backgroundColor },
});
}, [theme]);
};
const AppNavComponent: React.FC<Props> = (props) => {
const keyboardState = useKeyboardState();
const safeAreaPadding = useSafeAreaInsets();
@@ -50,20 +62,18 @@ const AppNavComponent: React.FC<Props> = (props) => {
const searchScreenLoaded = searchScreenVisible || (previousRouteName === 'Search' && route.routeName === 'Note');
const theme = themeStyle(props.themeId);
const style = { flex: 1, backgroundColor: theme.backgroundColor };
// When the floating keyboard is enabled, the KeyboardAvoidingView can have a very small
// height. Don't use the KeyboardAvoidingView when the floating keyboard is enabled.
// See https://github.com/facebook/react-native/issues/29473
const keyboardAvoidingViewEnabled = !keyboardState.isFloatingKeyboard;
const autocompletionBarPadding = Platform.OS === 'ios' && keyboardState.keyboardVisible ? safeAreaPadding.top : 0;
const styles = useStyles(theme);
const autocompletionBarPadding = keyboardState.keyboardVisible ? safeAreaPadding.top : 0;
return (
<KeyboardAvoidingView
enabled={keyboardAvoidingViewEnabled}
behavior={Platform.OS === 'ios' ? 'padding' : null}
style={style}
style={styles.keyboardAvoidingView}
enabled={
// Workaround: On Android 15 and 16, the main app content seems to auto-resize when the keyboard is shown.
// On earlier Android versions (and in modals), this does not seem to be the case.
(Platform.OS === 'android' && Platform.Version < 35)
|| Platform.OS === 'ios'
}
>
<NotesScreen visible={notesScreenVisible} />
{searchScreenLoaded && <SearchScreen visible={searchScreenVisible} />}
@@ -16,6 +16,7 @@ import usePrevious from '@joplin/lib/hooks/usePrevious';
import PlatformImplementation from '../../services/plugins/PlatformImplementation';
import AccessibleView from '../accessibility/AccessibleView';
import useOnDevPluginsUpdated from './utils/useOnDevPluginsUpdated';
import { ViewStyle } from 'react-native';
const logger = Logger.create('PluginRunnerWebView');
@@ -98,6 +99,17 @@ interface Props {
themeId: number;
}
// The WebView needs to have a non-zero size to be rendered by
// newer React Native versions. This style makes it visually hidden.
const hiddenStyle: ViewStyle = {
width: 1,
height: 1,
opacity: 0,
position: 'absolute',
top: 0,
zIndex: -1,
};
const PluginRunnerWebViewComponent: React.FC<Props> = props => {
const webviewRef = useRef<WebViewControl>(null);
@@ -189,7 +201,7 @@ const PluginRunnerWebViewComponent: React.FC<Props> = props => {
};
return (
<AccessibleView style={{ display: 'none' }} inert={true}>
<AccessibleView style={hiddenStyle} inert={true}>
{renderWebView()}
</AccessibleView>
);
@@ -5,7 +5,7 @@ import { act, fireEvent, render, screen, userEvent, waitFor } from '../../../uti
import NoteScreen from './Note';
import { setupDatabaseAndSynchronizer, switchClient, simulateReadOnlyShareEnv, supportDir, synchronizerStart, resourceFetcher, runWithFakeTimers } from '@joplin/lib/testing/test-utils';
import { waitFor as waitForWithRealTimers } from '@joplin/lib/testing/test-utils';
import waitForWithRealTimers from '@joplin/lib/testing/waitFor';
import Note from '@joplin/lib/models/Note';
import { AppState } from '../../../utils/types';
import { Store } from 'redux';
@@ -1,30 +1,43 @@
const React = require('react');
import * as React from 'react';
const { View, StyleSheet } = require('react-native');
const { connect } = require('react-redux');
const Folder = require('@joplin/lib/models/Folder').default;
const BaseModel = require('@joplin/lib/BaseModel').default;
const { ScreenHeader } = require('../ScreenHeader');
const { BaseScreenComponent } = require('../base-screen');
const shim = require('@joplin/lib/shim').default;
const { _ } = require('@joplin/lib/locale');
const { default: FolderPicker } = require('../FolderPicker');
const TextInput = require('../TextInput').default;
import { View, StyleSheet } from 'react-native';
import { connect } from 'react-redux';
import Folder from '@joplin/lib/models/Folder';
import BaseModel from '@joplin/lib/BaseModel';
import { ScreenHeader } from '../ScreenHeader';
import { BaseScreenComponent } from '../base-screen';
import shim from '@joplin/lib/shim';
import { _ } from '@joplin/lib/locale';
import FolderPicker from '../FolderPicker';
import TextInput from '../TextInput';
import { FolderEntity } from '@joplin/lib/services/database/types';
import { AppState } from '../../utils/types';
import { Dispatch } from 'redux';
class FolderScreenComponent extends BaseScreenComponent {
static navigationOptions() {
return { header: null };
}
interface Props {
folderId: string;
selectedFolderId: string;
themeId: number;
folders: FolderEntity[];
dispatch: Dispatch;
}
constructor() {
super();
interface State {
folder: FolderEntity;
lastSavedFolder: FolderEntity|null;
}
class FolderScreenComponent extends BaseScreenComponent<Props, State> {
public constructor(props: Props) {
super(props);
this.state = {
folder: Folder.new(),
lastSavedFolder: null,
};
}
UNSAFE_componentWillMount() {
public override UNSAFE_componentWillMount() {
if (!this.props.folderId) {
const folder = Folder.new();
this.setState({
@@ -33,7 +46,7 @@ class FolderScreenComponent extends BaseScreenComponent {
});
} else {
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
Folder.load(this.props.folderId).then(folder => {
void Folder.load(this.props.folderId).then(folder => {
this.setState({
folder: folder,
lastSavedFolder: { ...folder },
@@ -42,38 +55,40 @@ class FolderScreenComponent extends BaseScreenComponent {
}
}
isModified() {
private isModified() {
if (!this.state.folder || !this.state.lastSavedFolder) return false;
const diff = BaseModel.diffObjects(this.state.folder, this.state.lastSavedFolder);
delete diff.type_;
return !!Object.getOwnPropertyNames(diff).length;
}
folderComponent_change(propName, propValue) {
private folderComponent_change(propName: keyof FolderEntity, propValue: string) {
this.setState((prevState) => {
const folder = { ...prevState.folder };
folder[propName] = propValue;
const folder = {
...prevState.folder,
[propName]: propValue,
};
return { folder: folder };
});
}
title_changeText(text) {
private title_changeText(text: string) {
this.folderComponent_change('title', text);
}
parent_changeValue(parent) {
private parent_changeValue(parent: string) {
this.folderComponent_change('parent_id', parent);
}
async saveFolderButton_press() {
private async saveFolderButton_press() {
let folder = { ...this.state.folder };
try {
if (folder.id && !(await Folder.canNestUnder(folder.id, folder.parent_id))) throw new Error(_('Cannot move notebook to this location'));
folder = await Folder.save(folder, { userSideValidation: true });
} catch (error) {
shim.showErrorDialog(_('The notebook could not be saved: %s', error.message));
void shim.showErrorDialog(_('The notebook could not be saved: %s', error.message));
return;
}
@@ -89,7 +104,7 @@ class FolderScreenComponent extends BaseScreenComponent {
});
}
render() {
public override render() {
const saveButtonDisabled = !this.isModified() || !this.state.folder.title;
return (
@@ -101,7 +116,7 @@ class FolderScreenComponent extends BaseScreenComponent {
autoFocus={true}
value={this.state.folder.title}
onChangeText={text => this.title_changeText(text)}
disabled={this.state.folder.encryption_applied}
editable={!this.state.folder.encryption_applied}
/>
<View style={styles.folderPickerContainer}>
<FolderPicker
@@ -120,7 +135,7 @@ class FolderScreenComponent extends BaseScreenComponent {
}
}
const FolderScreen = connect(state => {
export default connect((state: AppState) => {
return {
folderId: state.selectedFolderId,
themeId: state.settings.theme,
@@ -138,4 +153,3 @@ const styles = StyleSheet.create({
},
});
module.exports = { FolderScreen };
+2 -4
View File
@@ -6,16 +6,14 @@
// So there's basically still a one way flux: React => SQLite => Redux => React
import './utils/initReact';
import './utils/polyfills';
import Root from './root';
import { LogBox } from 'react-native';
import { registerRootComponent } from 'expo';
// Allows loading image assets. See https://github.com/expo/expo/issues/31240
import 'expo-asset';
import shim from '@joplin/lib/shim';
shim.setReact(require('react'));
const Root = require('./root').default;
// Seems JavaScript developers love adding warnings everywhere, even when these warnings can't be fixed
// or don't really matter. Because we want important warnings to actually be fixed, we disable
+1
View File
@@ -1,4 +1,5 @@
import './utils/polyfills';
import './utils/initReact';
import { AppRegistry } from 'react-native';
import Root from './root';
import Setting from '@joplin/lib/models/Setting';
@@ -35,6 +35,8 @@
</dict>
<key>NSLocationWhenInUseUsageDescription</key>
<string></string>
<key>RCTNewArchEnabled</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
@@ -321,6 +321,8 @@
files = (
);
inputPaths = (
"$(SRCROOT)/.xcode.env",
"$(SRCROOT)/.xcode.env.local",
);
name = "Bundle React Native code and images";
outputPaths = (
@@ -339,13 +341,12 @@
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RCT-Folly/RCT-Folly_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNDeviceInfo/RNDeviceInfoPrivacyInfo.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/boost/boost_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/glog/glog_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ReactNativeFs/RNFS_PrivacyInfo.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/react-native-image-picker/RNImagePickerPrivacyInfo.bundle",
"${PODS_ROOT}/../../node_modules/@react-native-vector-icons/fontawesome5/fonts/FontAwesome5_Brands.ttf",
"${PODS_ROOT}/../../node_modules/@react-native-vector-icons/fontawesome5/fonts/FontAwesome5_Regular.ttf",
@@ -359,13 +360,12 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCT-Folly_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNDeviceInfoPrivacyInfo.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/boost_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/glog_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNFS_PrivacyInfo.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNImagePickerPrivacyInfo.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Brands.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Regular.ttf",
@@ -409,11 +409,15 @@
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}/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",
);
name = "[CP] Embed Pods Frameworks";
outputPaths = (
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/OpenSSL.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ReactNativeDependencies.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
);
runOnlyForDeploymentPostprocessing = 0;
@@ -452,11 +456,16 @@
inputFileListPaths = (
);
inputPaths = (
"$(SRCROOT)/.xcode.env",
"$(SRCROOT)/.xcode.env.local",
"$(SRCROOT)/Joplin/Joplin.entitlements",
"$(SRCROOT)/Pods/Target Support Files/Pods-Joplin/expo-configure-project.sh",
);
name = "[Expo] Configure project";
outputFileListPaths = (
);
outputPaths = (
"$(SRCROOT)/Pods/Target Support Files/Pods-Joplin/ExpoModulesProvider.swift",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
@@ -511,16 +520,16 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
CURRENT_PROJECT_VERSION = 149;
CURRENT_PROJECT_VERSION = 150;
DEVELOPMENT_TEAM = A9BXAFS6CT;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Joplin/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
IPHONEOS_DEPLOYMENT_TARGET = 16.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 13.6.0;
MARKETING_VERSION = 13.6.1;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -535,6 +544,7 @@
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TVOS_DEPLOYMENT_TARGET = 16.6;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
@@ -546,15 +556,15 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
CURRENT_PROJECT_VERSION = 149;
CURRENT_PROJECT_VERSION = 150;
DEVELOPMENT_TEAM = A9BXAFS6CT;
INFOPLIST_FILE = Joplin/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
IPHONEOS_DEPLOYMENT_TARGET = 16.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 13.6.0;
MARKETING_VERSION = 13.6.1;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -568,6 +578,7 @@
SWIFT_OBJC_BRIDGING_HEADER = "Joplin-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TVOS_DEPLOYMENT_TARGET = 16.6;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
@@ -623,7 +634,7 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
HEADER_SEARCH_PATHS = "$(SRCROOT)/../node_modules/react-native/Libraries/LinkingIOS\n\n$(SRCROOT)/../node_modules/react-native/Libraries/LinkingIOS\n\n$(SRCROOT)/../node_modules/react-native/Libraries/LinkingIOS\n\n$(SRCROOT)/../node_modules/react-native/Libraries/LinkingIOS\n\n";
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
IPHONEOS_DEPLOYMENT_TARGET = 16.1;
LD = "";
LDPLUSPLUS = "";
LD_RUNPATH_SEARCH_PATHS = (
@@ -653,6 +664,7 @@
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
USE_HERMES = true;
};
name = Debug;
@@ -701,7 +713,7 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
HEADER_SEARCH_PATHS = "$(SRCROOT)/../node_modules/react-native/Libraries/LinkingIOS\n\n$(SRCROOT)/../node_modules/react-native/Libraries/LinkingIOS\n\n$(SRCROOT)/../node_modules/react-native/Libraries/LinkingIOS\n\n$(SRCROOT)/../node_modules/react-native/Libraries/LinkingIOS\n\n";
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
IPHONEOS_DEPLOYMENT_TARGET = 16.1;
LD = "";
LDPLUSPLUS = "";
LD_RUNPATH_SEARCH_PATHS = (
@@ -729,6 +741,7 @@
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
USE_HERMES = true;
VALIDATE_PRODUCT = YES;
};
@@ -747,18 +760,18 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 149;
CURRENT_PROJECT_VERSION = 150;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = A9BXAFS6CT;
GCC_C_LANGUAGE_STANDARD = gnu11;
INFOPLIST_FILE = ShareExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
IPHONEOS_DEPLOYMENT_TARGET = 16.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 13.6.0;
MARKETING_VERSION = 13.6.1;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
@@ -790,18 +803,18 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 149;
CURRENT_PROJECT_VERSION = 150;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = A9BXAFS6CT;
GCC_C_LANGUAGE_STANDARD = gnu11;
INFOPLIST_FILE = ShareExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
IPHONEOS_DEPLOYMENT_TARGET = 16.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 13.6.0;
MARKETING_VERSION = 13.6.1;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
"$(inherited)",
+12 -11
View File
@@ -44,16 +44,15 @@
<false/>
<key>NSAllowsLocalNetworking</key>
<true/>
<!-- Left over from before upgrading from RN 0.71, 0.73 -->
<key>NSExceptionDomains</key>
<dict>
<key>localhost</key>
<key>api.joplincloud.local</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
<key>api.joplincloud.local</key>
<key>localhost</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
@@ -62,18 +61,22 @@
</dict>
<key>NSCameraUsageDescription</key>
<string>To allow attaching a photo to a note</string>
<key>NSFaceIDUsageDescription</key>
<string>$(PRODUCT_NAME) requires FaceID access to secure access to the application</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>To add geo-location information to a note. Can be disabled in app.</string>
<key>NSLocationAlwaysUsageDescription</key>
<string>To add geo-location information to a note. Can be disabled in app.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>To add geo-location information to a note. Can be disabled in app.</string>
<key>NSMicrophoneUsageDescription</key>
<string>To allow attaching voice recordings to a note</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>The images will be displayed on your notes.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>To allow attaching images to a note</string>
<key>NSMicrophoneUsageDescription</key>
<string>To allow attaching voice recordings to a note</string>
<key>RCTNewArchEnabled</key>
<true/>
<key>UIAppFonts</key>
<array>
<string>AntDesign.ttf</string>
@@ -86,6 +89,10 @@
<string>MaterialDesignIcons.ttf</string>
<string>MaterialCommunityIcons.ttf</string>
</array>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
@@ -109,11 +116,5 @@
<string>Automatic</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>NSFaceIDUsageDescription</key>
<string>$(PRODUCT_NAME) requires FaceID access to secure access to the application</string>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
</dict>
</plist>
+29 -11
View File
@@ -5,23 +5,33 @@ require File.join(File.dirname(`node --print "require.resolve('react-native/pack
require 'json'
podfile_properties = JSON.parse(File.read(File.join(__dir__, 'Podfile.properties.json'))) rescue {}
ENV['RCT_NEW_ARCH_ENABLED'] = '0' if podfile_properties['newArchEnabled'] == 'false'
ENV['EX_DEV_CLIENT_NETWORK_INSPECTOR'] = podfile_properties['EX_DEV_CLIENT_NETWORK_INSPECTOR']
def ccache_enabled?(podfile_properties)
# Environment variable takes precedence
return ENV['USE_CCACHE'] == '1' if ENV['USE_CCACHE']
# Fall back to Podfile properties
podfile_properties['apple.ccacheEnabled'] == 'true'
end
platform :ios, podfile_properties['ios.deploymentTarget'] || '15.1'
install! 'cocoapods',
:deterministic_uuids => false
ENV['RCT_NEW_ARCH_ENABLED'] ||= '0' if podfile_properties['newArchEnabled'] == 'false'
ENV['EX_DEV_CLIENT_NETWORK_INSPECTOR'] ||= podfile_properties['EX_DEV_CLIENT_NETWORK_INSPECTOR']
ENV['RCT_USE_RN_DEP'] ||= '1' if podfile_properties['ios.buildReactNativeFromSource'] != 'true' && podfile_properties['newArchEnabled'] != 'false'
ENV['RCT_USE_PREBUILT_RNCORE'] ||= '1' if podfile_properties['ios.buildReactNativeFromSource'] != 'true' && podfile_properties['newArchEnabled'] != 'false'
platform :ios, podfile_properties['ios.deploymentTarget'] || '16.1'
prepare_react_native_project!
target 'Joplin' do
use_expo_modules!
if ENV['EXPO_USE_COMMUNITY_AUTOLINKING'] == '1'
if ENV['EXPO_USE_COMMUNITY_AUTOLINKING'] != '0'
config_command = ['node', '-e', "process.argv=['', '', 'config'];require('@react-native-community/cli').run()"];
else
config_command = [
'npx',
'node',
'--no-warnings',
'--eval',
'require(\'expo/bin/autolinking\')',
'expo-modules-autolinking',
'react-native-config',
'--json',
@@ -35,13 +45,14 @@ target 'Joplin' do
use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks']
use_frameworks! :linkage => ENV['USE_FRAMEWORKS'].to_sym if ENV['USE_FRAMEWORKS']
use_react_native!(
:path => config[:reactNativePath],
:hermes_enabled => podfile_properties['expo.jsEngine'] == nil || podfile_properties['expo.jsEngine'] == 'hermes',
# An absolute path to your application root.
:app_path => "#{Pod::Config.instance.installation_root}/.."
:app_path => "#{Pod::Config.instance.installation_root}/..",
:privacy_file_aggregation_enabled => podfile_properties['apple.privacyManifestAggregationEnabled'] != 'false',
)
pod 'JoplinRNShareExtension', :path => 'ShareExtension'
post_install do |installer|
@@ -50,8 +61,15 @@ target 'Joplin' do
installer,
config[:reactNativePath],
:mac_catalyst_enabled => false,
# :ccache_enabled => true
:ccache_enabled => ccache_enabled?(podfile_properties),
)
# Force all pods to use the same deployment target
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = podfile_properties['ios.deploymentTarget'] || '16.1'
end
end
end
end
File diff suppressed because it is too large. Load diff
@@ -1,3 +1,4 @@
{
"newArchEnabled": "false"
"newArchEnabled": "true",
"ios.deploymentTarget": "16.1"
}
@@ -43,5 +43,7 @@
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
</dict>
<key>RCTNewArchEnabled</key>
<true/>
</dict>
</plist>
+2 -1
View File
@@ -84,6 +84,7 @@ const emptyMockPackages = [
'@joplin/react-native-saf-x',
'expo-av',
'expo-av/build/Audio',
'expo-image-manipulator',
];
for (const packageName of emptyMockPackages) {
jest.doMock(packageName, () => {
@@ -130,7 +131,7 @@ mockIconLibrary('@react-native-vector-icons/fontawesome5', 'FontAwesome5');
// Use a temporary folder instead.
const tempDirectoryPath = path.join(tmpdir(), `appmobile-test-${uuid.createNano()}`);
jest.doMock('react-native-fs', () => {
jest.doMock('@dr.pogodin/react-native-fs', () => {
return {
CachesDirectoryPath: tempDirectoryPath,
};
+1
View File
@@ -23,6 +23,7 @@ const localPackages = {
'@joplin/tools': path.resolve(__dirname, '../tools/'),
'@joplin/utils': path.resolve(__dirname, '../utils/'),
'@joplin/fork-htmlparser2': path.resolve(__dirname, '../fork-htmlparser2/'),
'@joplin/whisper-voice-typing': path.resolve(__dirname, '../whisper-voice-typing/'),
'@joplin/fork-uslug': path.resolve(__dirname, '../fork-uslug/'),
'@joplin/react-native-saf-x': path.resolve(__dirname, '../react-native-saf-x/'),
'@joplin/react-native-alarm-notification': path.resolve(__dirname, '../react-native-alarm-notification/'),
+25 -24
View File
@@ -21,13 +21,14 @@
"postinstall": "jetify"
},
"dependencies": {
"@bam.tech/react-native-image-resizer": "3.0.11",
"@dr.pogodin/react-native-fs": "2.36.2",
"@joplin/editor": "~3.6",
"@joplin/lib": "~3.6",
"@joplin/react-native-alarm-notification": "~3.6",
"@joplin/react-native-saf-x": "~3.6",
"@joplin/renderer": "~3.6",
"@joplin/utils": "~3.6",
"@joplin/whisper-voice-typing": "~3.6",
"@js-draw/material-icons": "1.33.0",
"@react-native-clipboard/clipboard": "1.16.3",
"@react-native-community/datetimepicker": "8.4.7",
@@ -47,40 +48,41 @@
"crypto-browserify": "3.12.1",
"deprecated-react-native-prop-types": "5.0.0",
"events": "3.3.0",
"expo": "53.0.23",
"expo-av": "15.1.7",
"expo-camera": "16.1.11",
"expo-local-authentication": "16.0.5",
"expo": "54.0.31",
"expo-av": "16.0.8",
"expo-camera": "17.0.10",
"expo-image-manipulator": "14.0.8",
"expo-local-authentication": "17.0.8",
"js-draw": "1.33.0",
"lodash": "4.17.21",
"md5": "2.3.0",
"path-browserify": "1.0.1",
"prop-types": "15.8.1",
"punycode": "2.3.1",
"react": "19.0.0",
"react-native": "0.79.2",
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-device-info": "14.1.1",
"react-native-dropdownalert": "5.2.0",
"react-native-exit-app": "2.0.0",
"react-native-file-viewer": "2.1.5",
"react-native-fs": "2.20.0",
"react-native-get-random-values": "1.11.0",
"react-native-image-picker": "8.2.1",
"react-native-localize": "3.5.4",
"react-native-modal-datetime-picker": "18.0.0",
"react-native-nitro-modules": "0.33.2",
"react-native-paper": "5.14.5",
"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-rsa-native": "2.0.5",
"react-native-safe-area-context": "5.6.2",
"react-native-securerandom": "1.0.1",
"react-native-share": "12.2.0",
"react-native-sqlite-storage": "6.0.1",
"react-native-svg": "15.13.0",
"react-native-svg": "15.12.1",
"react-native-url-polyfill": "2.0.0",
"react-native-version-info": "1.1.1",
"react-native-webview": "13.16.0",
"react-native-webview": "13.15.0",
"react-native-zip-archive": "7.0.2",
"react-redux": "8.1.3",
"redux": "4.2.1",
@@ -101,20 +103,20 @@
"@joplin/turndown": "~4.0.80",
"@joplin/turndown-plugin-gfm": "~1.0.62",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.0",
"@react-native-community/cli": "16.0.3",
"@react-native-community/cli-platform-android": "16.0.3",
"@react-native-community/cli-platform-ios": "16.0.3",
"@react-native/babel-preset": "0.80.1",
"@react-native/metro-config": "0.79.5",
"@react-native/typescript-config": "0.80.2",
"@react-native-community/cli": "20.0.0",
"@react-native-community/cli-platform-android": "20.0.0",
"@react-native-community/cli-platform-ios": "20.0.0",
"@react-native/babel-preset": "0.81.5",
"@react-native/metro-config": "0.81.5",
"@react-native/typescript-config": "0.81.5",
"@sqlite.org/sqlite-wasm": "3.46.0-build2",
"@testing-library/react-native": "13.2.0",
"@types/fs-extra": "11.0.4",
"@types/jest": "29.5.14",
"@types/node": "18.19.130",
"@types/react": "19.0.14",
"@types/react": "19.1.10",
"@types/react-redux": "7.1.33",
"@types/serviceworker": "0.0.164",
"@types/serviceworker": "0.0.165",
"@types/tar-stream": "3.1.4",
"babel-jest": "29.7.0",
"babel-loader": "9.1.3",
@@ -130,11 +132,11 @@
"jsdom": "26.1.0",
"nodemon": "3.1.10",
"punycode": "2.3.1",
"react-dom": "19.0.0",
"react-dom": "19.1.0",
"react-native-web": "0.21.2",
"react-refresh": "0.18.0",
"react-test-renderer": "19.0.0",
"sharp": "0.34.4",
"react-test-renderer": "19.1.0",
"sharp": "0.34.5",
"sqlite3": "5.1.6",
"timers-browserify": "2.0.12",
"ts-jest": "29.4.1",
@@ -147,7 +149,7 @@
"webpack-dev-server": "5.2.2"
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"expo": {
"autolinking": {
@@ -157,7 +159,6 @@
},
"install": {
"exclude": [
"react-native@~0.76.6",
"react-native-reanimated@~3.16.1",
"react-native-gesture-handler@~2.20.0",
"react-native-screens@~4.4.0",
+7 -6
View File
@@ -1,8 +1,5 @@
import * as React from 'react';
import shim from '@joplin/lib/shim';
import PerformanceLogger from '@joplin/lib/PerformanceLogger';
shim.setReact(React);
PerformanceLogger.onAppStartBegin();
import setupQuickActions from './setupQuickActions';
@@ -38,7 +35,7 @@ import Folder from '@joplin/lib/models/Folder';
import NotesScreen from './components/screens/Notes/Notes';
import TagsScreen from './components/screens/tags';
import ConfigScreen from './components/screens/ConfigScreen/ConfigScreen';
const { FolderScreen } = require('./components/screens/folder.js');
import FolderScreen from './components/screens/folder';
import LogScreen from './components/screens/LogScreen';
import StatusScreen from './components/screens/status';
import SearchScreen from './components/screens/SearchScreen';
@@ -445,6 +442,10 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
state: 'ready',
});
setTimeout(() => {
perfLogger.mark('Application is ready');
}, 50);
// setTimeout(() => {
// this.props.dispatch({
// type: 'NAV_GO',
@@ -695,12 +696,12 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
let disableSideMenuGestures = this.props.disableSideMenuGestures;
if (this.props.routeName === 'Note') {
sideMenuContent = <SafeAreaView style={{ flex: 1, backgroundColor: theme.backgroundColor }}><SideMenuContentNote options={this.props.noteSideMenuOptions}/></SafeAreaView>;
sideMenuContent = <SideMenuContentNote options={this.props.noteSideMenuOptions}/>;
menuPosition = SideMenuPosition.Right;
} else if (this.props.routeName === 'Config') {
disableSideMenuGestures = true;
} else {
sideMenuContent = <SafeAreaView style={{ flex: 1, backgroundColor: theme.backgroundColor }}><SideMenuContent/></SafeAreaView>;
sideMenuContent = <SideMenuContent/>;
}
const appNavInit = {
+4 -1
View File
@@ -71,7 +71,10 @@ const crypto: Crypto = {
digest: async (algorithm: Digest, data: Uint8Array) => {
const hash = QuickCrypto.createHash(digestNameMap[algorithm]);
hash.update(data);
hash.update(
// Cast: hash.update accepts TypedArrays, despite its declared types
data as unknown as ArrayBuffer,
);
return hash.digest();
},
@@ -3,35 +3,29 @@ import whisper from './whisper';
import { dirname, join } from 'path';
import { exists, mkdir, remove, writeFile } from 'fs-extra';
import Setting from '@joplin/lib/models/Setting';
import { NativeModules } from 'react-native';
const SpeechToTextModule = NativeModules.SpeechToTextModule;
jest.mock('react-native', () => {
const reactNative = jest.requireActual('react-native');
const { testing__lastPrompt } = require('@joplin/whisper-voice-typing');
jest.mock('@joplin/whisper-voice-typing', () => {
let lastPrompt: string|null = null;
// Set properties on reactNative rather than creating a new object with
// {...reactNative, ...}. Creating a new object triggers deprecation warnings.
// See https://github.com/facebook/react-native/issues/28839.
reactNative.NativeModules.SpeechToTextModule = {
convertNext: () => 'Test. This is test output. Test!',
runTests: ()=> {},
openSession: jest.fn((_path, _locale, prompt) => {
lastPrompt = prompt;
const someId = 1234;
return someId;
return {
runTests: ()=> {},
openSession: jest.fn((options) => {
lastPrompt = options.prompt;
return {
open: jest.fn(),
close: jest.fn(),
convertNext: jest.fn(() => 'Test. This is test output. Test!'),
convertAvailable: jest.fn(() => ''),
};
}),
closeSession: jest.fn(),
startRecording: jest.fn(),
convertAvailable: jest.fn(() => ''),
test: ()=>{},
testing__lastPrompt: () => {
return lastPrompt;
},
};
return reactNative;
});
interface ModelConfig {
@@ -135,6 +129,6 @@ describe('whisper', () => {
});
await session.start();
expect(SpeechToTextModule.testing__lastPrompt()).toBe(expectedPrompt);
expect(testing__lastPrompt()).toBe(expectedPrompt);
});
});
@@ -3,14 +3,13 @@ import shim from '@joplin/lib/shim';
import Logger from '@joplin/utils/Logger';
import { rtrimSlashes } from '@joplin/utils/path';
import { dirname, join } from 'path';
import { NativeModules } from 'react-native';
import { openSession, test as testWhisper, Session as WhisperSession } from '@joplin/whisper-voice-typing';
import { SpeechToTextCallbacks, VoiceTypingProvider, VoiceTypingSession } from './VoiceTyping';
import { languageCodeOnly, stringByLocale } from '@joplin/lib/locale';
import { Platform } from 'react-native';
const logger = Logger.create('voiceTyping/whisper');
const { SpeechToTextModule } = NativeModules;
class WhisperConfig {
public prompts: Map<string, string> = new Map();
public supportsShortAudioCtx = false;
@@ -86,7 +85,7 @@ class Whisper implements VoiceTypingSession {
private isFirstParagraph = true;
public constructor(
private sessionId: number|null,
private session: WhisperSession|null,
private callbacks: SpeechToTextCallbacks,
private config: WhisperConfig,
) {
@@ -124,18 +123,18 @@ class Whisper implements VoiceTypingSession {
}
public async start() {
if (this.sessionId === null) {
if (this.session === null) {
throw new Error('Session closed.');
}
try {
logger.debug('starting recorder');
await SpeechToTextModule.startRecording(this.sessionId);
await this.session.open();
logger.debug('recorder started');
const loopStartCounter = this.closeCounter;
while (this.closeCounter === loopStartCounter && this.sessionId !== null) {
while (this.closeCounter === loopStartCounter && this.session !== null) {
logger.debug('reading block');
const data: string = await SpeechToTextModule.convertNext(this.sessionId, 4);
const data: string = await this.session.convertNext(4);
this.onDataFinalize(data);
logger.debug('done reading block. Length', data?.length);
@@ -148,13 +147,13 @@ class Whisper implements VoiceTypingSession {
}
public async stop() {
if (this.sessionId === null) {
if (this.session === null) {
logger.debug('Session already closed.');
return;
}
try {
const data: string = await SpeechToTextModule.convertAvailable(this.sessionId);
const data: string = await this.session.convertNext(null);
this.onDataFinalize(data);
} catch (error) {
logger.error('Error stopping session: ', error);
@@ -164,17 +163,17 @@ class Whisper implements VoiceTypingSession {
}
public cancel() {
if (this.sessionId === null) {
if (this.session === null) {
logger.debug('No session to cancel.');
return;
return Promise.resolve();
}
logger.info('Closing session...');
const sessionId = this.sessionId;
this.sessionId = null;
this.session.close();
this.session = null;
this.closeCounter ++;
return SpeechToTextModule.closeSession(sessionId);
return Promise.resolve();
}
}
@@ -213,7 +212,7 @@ const modelLocalFilepath = () => {
};
const whisper: VoiceTypingProvider = {
supported: () => !!SpeechToTextModule && Setting.value('buildFlag.voiceTypingEnabled'),
supported: () => Platform.OS === 'android' && Setting.value('buildFlag.voiceTypingEnabled'),
modelLocalFilepath: modelLocalFilepath,
getDownloadUrl: (locale) => {
const lang = languageCodeOnly(locale).toLowerCase();
@@ -251,7 +250,7 @@ const whisper: VoiceTypingProvider = {
if (Setting.value('env') === Env.Dev) {
try {
await SpeechToTextModule.runTests();
await testWhisper();
} catch (error) {
logger.error('Testing error', error);
await shim.showErrorDialog(`Test failure: ${error}`);
@@ -268,10 +267,10 @@ const whisper: VoiceTypingProvider = {
}
logger.debug('Starting whisper session', config.supportsShortAudioCtx ? '(short audio context)' : '');
const sessionId = await SpeechToTextModule.openSession(
modelPath, locale, getPrompt(locale, config.prompts), config.supportsShortAudioCtx,
);
return new Whisper(sessionId, callbacks, config);
const session = openSession({
modelPath, locale, prompt: getPrompt(locale, config.prompts), shortAudioContext: config.supportsShortAudioCtx,
});
return new Whisper(session, callbacks, config);
},
modelName: 'whisper',
};
+52 -5
View File
@@ -1,15 +1,62 @@
// We don't make that a gulp task because we might want to run it before
// gulp has been installed.
// We don't make that a gulp task because we might want to run it before gulp has been installed.
// For now this script is specifically for Android, as it's the build that causes the most problems.
// We also don't want to clear the iOS data whenever we build the Android app.
const fs = require('fs');
function deleteMatchingDirs(rootDir, predicate) {
let entries;
try {
entries = fs.readdirSync(rootDir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const fullPath = `${rootDir}/${entry.name}`;
if (predicate(fullPath)) {
fs.rmSync(fullPath, { recursive: true, force: true });
} else {
deleteMatchingDirs(fullPath, predicate);
}
}
}
function main() {
const mobileDir = `${__dirname}/..`;
// Standard Android build artefacts
fs.rmSync(`${mobileDir}/android/.gradle`, { recursive: true, force: true });
fs.rmSync(`${mobileDir}/android/app/build`, { recursive: true, force: true });
fs.rmSync(`${mobileDir}/android/build`, { recursive: true, force: true });
fs.rmSync(`${mobileDir}/ios/Pods`, { recursive: true, force: true });
console.info('To clean the Android build, in some rare cases you might also need to clear the cache in ~/.android and ~/.gradle');
fs.rmSync(`${mobileDir}/android/app/build`, { recursive: true, force: true });
fs.rmSync(`${mobileDir}/android/.cxx`, { recursive: true, force: true });
fs.rmSync(`${mobileDir}/android/app/.cxx`, { recursive: true, force: true });
// React Native generated files that frequently break CMake/autolinking
fs.rmSync(`${mobileDir}/android/app/build/generated/autolinking`, { recursive: true, force: true });
fs.rmSync(`${mobileDir}/android/app/build/generated/source/codegen`, { recursive: true, force: true });
// iOS
// fs.rmSync(`${mobileDir}/ios/Pods`, { recursive: true, force: true });
// Delete all native module Android build artefacts
// Equivalent to:
// find . -path "*/node_modules/*/android/build" -type d -exec rm -rf {} + || true
// find . -path "*/node_modules/*/android/.cxx" -type d -exec rm -rf {} + || true
deleteMatchingDirs(mobileDir, p =>
p.includes('/node_modules/') && (
p.endsWith('/android/build') ||
p.endsWith('/android/.cxx')
),
);
console.info(
'Mobile app build artefacts cleaned.\n' +
'If you still see build issues, you may also need to clear ~/.gradle/caches and ~/.android.',
);
}
try {
@@ -1,6 +1,6 @@
import FsDriverBase, { ReadDirStatsOptions } from '@joplin/lib/fs-driver-base';
const RNFetchBlob = require('rn-fetch-blob').default;
import * as RNFS from 'react-native-fs';
import * as RNFS from '@dr.pogodin/react-native-fs';
import RNSAF, { DocumentFileDetail, openDocumentTree } from '@joplin/react-native-saf-x';
import { Platform } from 'react-native';
import tarCreate from './tarCreate';
@@ -310,7 +310,7 @@ export class WorkerApi {
at = writer.getSize();
}
write = (data: ArrayBufferLike) => writer.write(data, { at });
write = (data: BufferSource) => writer.write(data, { at });
close = () => writer.close();
} catch (error) {
// In some cases, createSyncAccessHandle isn't available. In other cases,
@@ -318,7 +318,7 @@ export class WorkerApi {
logger.warn('Failed to createSyncAccessHandle', error);
const writer = await handle.createWritable({ keepExistingData: options?.keepExistingData });
write = (data: ArrayBufferLike) => writer.write(data);
write = (data: BufferSource) => writer.write(data);
close = () => writer.close();
}
@@ -1,10 +1,13 @@
import { Size } from '@joplin/utils/types';
import { fileUriToPath } from '@joplin/utils/url';
import { Image as NativeImage, Platform } from 'react-native';
import fileToImage from './fileToImage.web';
interface ImageDimensions {
width: number;
height: number;
}
const getImageDimensions = async (uri: string): Promise<Size> => {
const getImageDimensions = async (uri: string): Promise<ImageDimensions> => {
if (uri.startsWith('/')) {
uri = `file://${uri}`;
}
+34 -18
View File
@@ -1,8 +1,9 @@
import shim from '@joplin/lib/shim';
import Logger from '@joplin/utils/Logger';
import ImageResizer from '@bam.tech/react-native-image-resizer';
import { ImageManipulator, SaveFormat } from 'expo-image-manipulator';
import fileToImage from './fileToImage.web';
import FsDriverWeb from '../fs-driver/fs-driver-rn.web';
import getImageDimensions from './getImageDimensions';
const logger = Logger.create('resizeImage');
@@ -18,17 +19,23 @@ interface Options {
}
const resizeImage = async (options: Options) => {
type Sized = { width: number; height: number };
const computeScale = (image: Sized) => {
// Choose a scale factor such that the resized image fits within a
// maxWidth x maxHeight box.
const scale = Math.min(
options.maxWidth / image.width,
options.maxHeight / image.height,
);
return scale;
};
if (shim.mobilePlatform() === 'web') {
const image = await fileToImage(options.inputPath);
try {
const canvas = document.createElement('canvas');
// Choose a scale factor such that the resized image fits within a
// maxWidth x maxHeight box.
const scale = Math.min(
options.maxWidth / image.image.width,
options.maxHeight / image.image.height,
);
const scale = computeScale(image.image);
canvas.width = image.image.width * scale;
canvas.height = image.image.height * scale;
@@ -54,18 +61,27 @@ const resizeImage = async (options: Options) => {
image.free();
}
} else {
const resizedImage = await ImageResizer.createResizedImage(
options.inputPath,
options.maxWidth,
options.maxHeight,
options.format,
options.quality, // quality
undefined, // rotation
undefined, // outputPath
true, // keep metadata
);
const originalSize = await getImageDimensions(options.inputPath);
logger.debug('Processing image with size', originalSize.width, 'x', originalSize.height);
const resizedImagePath = resizedImage.uri;
let context = ImageManipulator.manipulate(options.inputPath);
// Only rescale the image if it's bigger than the maximum size:
if (originalSize.width > options.maxWidth || originalSize.height > options.maxHeight) {
const scale = computeScale(originalSize);
context = context.resize({
width: originalSize.width * scale,
height: originalSize.height * scale,
});
}
const final = await context.renderAsync();
const saved = await final.saveAsync({
format: options.format === 'PNG' ? SaveFormat.PNG : SaveFormat.JPEG,
compress: options.quality,
});
const resizedImagePath = saved.uri;
logger.info('Resized image ', resizedImagePath);
logger.info(`Moving ${resizedImagePath} => ${options.outputPath}`);
+7
View File
@@ -0,0 +1,7 @@
import shim from '@joplin/lib/shim';
// .setReact needs to be called very early in the application startup process.
// This file can be imported to ensure that .setReact has been called prior to
// all other import/requires.
const React = require('react');
shim.setReact(React);
@@ -7,7 +7,7 @@ import { generateSecureRandom } from 'react-native-securerandom';
import FsDriverRN from '../fs-driver/fs-driver-rn';
import { Linking, Platform } from 'react-native';
import crypto from '../../services/e2ee/crypto';
const RNExitApp = require('react-native-exit-app').default;
import { reloadAppAsync } from 'expo';
export default function shimInit() {
shim.Geolocation = GeolocationReact;
@@ -178,7 +178,7 @@ export default function shimInit() {
};
shim.restartApp = () => {
RNExitApp.exitApp();
void reloadAppAsync();
};
shimInitShared();
@@ -79,6 +79,7 @@ const buildSharedConfig = (hotReload: boolean): webpack.Configuration => {
'@react-native-documents/picker': emptyLibraryMock,
'react-native-exit-app': emptyLibraryMock,
'expo-camera': emptyLibraryMock,
'react-native-nitro-modules': emptyLibraryMock,
'react-native-vector-icons/MaterialCommunityIcons': throwOnLoadLibraryMock,
// Workaround for applying serviceworker types to a single file.
@@ -7,6 +7,7 @@ import { deleteMarkupBackward, markdown, markdownLanguage } from '@codemirror/la
import { GFM as GitHubFlavoredMarkdownExtension } from '@lezer/markdown';
import markdownMathExtension from './extensions/markdownMathExtension';
import markdownHighlightExtension from './extensions/markdownHighlightExtension';
import markdownFrontMatterExtension from './extensions/markdownFrontMatterExtension';
import lookUpLanguage from './utils/markdown/codeBlockLanguages/lookUpLanguage';
import { html } from '@codemirror/lang-html';
import { defaultKeymap, emacsStyleKeymap } from '@codemirror/commands';
@@ -30,6 +31,9 @@ const configFromSettings = (settings: EditorSettings, context: RenderedContentCo
extensions: [
GitHubFlavoredMarkdownExtension,
// FrontMatter support (YAML blocks at start of document)
markdownFrontMatterExtension,
settings.markdownMarkEnabled ? markdownHighlightExtension : [],
// Don't highlight KaTeX if the user disabled it
@@ -32,6 +32,10 @@ const mathBlockDecoration = Decoration.line({
attributes: { class: 'cm-mathBlock', ...noSpellCheckAttrs },
});
const frontMatterDecoration = Decoration.line({
attributes: { class: 'cm-frontMatter', ...noSpellCheckAttrs },
});
const inlineMathDecoration = Decoration.mark({
attributes: { class: 'cm-inlineMath', ...noSpellCheckAttrs },
});
@@ -116,6 +120,7 @@ const nodeNameToLineDecoration: Record<string, Decoration> = {
'FencedCode': codeBlockDecoration,
'CodeBlock': codeBlockDecoration,
'BlockMath': mathBlockDecoration,
'FrontMatter': frontMatterDecoration,
'Blockquote': blockQuoteDecoration,
'OrderedList': orderedListDecoration,
'BulletList': unorderedListDecoration,
@@ -152,6 +157,7 @@ const multilineNodes = {
'FencedCode': true,
'CodeBlock': true,
'BlockMath': true,
'FrontMatter': true,
'Blockquote': true,
'OrderedList': true,
'BulletList': true,
@@ -0,0 +1,105 @@
import { EditorSelection, EditorState } from '@codemirror/state';
import { frontMatterTagName, frontMatterContentTagName, frontMatterMarkerTagName } from './markdownFrontMatterExtension';
import createTestEditor from '../testing/createTestEditor';
import findNodesWithName from '../testing/findNodesWithName';
// Creates an EditorState with FrontMatter and markdown extensions
const createEditorState = async (initialText: string, expectedTags: string[]): Promise<EditorState> => {
return (await createTestEditor(initialText, EditorSelection.cursor(0), expectedTags)).state;
};
describe('MarkdownFrontMatterExtension', () => {
jest.retryTimes(2);
it('should parse a basic FrontMatter block at the start of the document', async () => {
const documentText = '---\ntitle: Test\n---\n\n# Heading';
const editor = await createEditorState(documentText, [frontMatterTagName, 'ATXHeading1']);
const frontMatterNodes = findNodesWithName(editor, frontMatterTagName);
expect(frontMatterNodes.length).toBe(1);
expect(frontMatterNodes[0].from).toBe(0);
expect(frontMatterNodes[0].to).toBe('---\ntitle: Test\n---'.length);
});
it('should parse FrontMatter with multiple properties', async () => {
const frontMatter = '---\ntitle: Test\ndate: 2024-01-01\ntags: [one, two]\n---';
const documentText = `${frontMatter}\n\nContent here.`;
const editor = await createEditorState(documentText, [frontMatterTagName]);
const frontMatterNodes = findNodesWithName(editor, frontMatterTagName);
expect(frontMatterNodes.length).toBe(1);
expect(frontMatterNodes[0].from).toBe(0);
expect(frontMatterNodes[0].to).toBe(frontMatter.length);
});
it('should not parse FrontMatter if not at document start', async () => {
const documentText = 'Some text\n\n---\ntitle: Test\n---';
const editor = await createEditorState(documentText, ['Paragraph']);
const frontMatterNodes = findNodesWithName(editor, frontMatterTagName);
// Should not be recognized as FrontMatter since it's not at the start
expect(frontMatterNodes.length).toBe(0);
});
it('should not parse FrontMatter without closing delimiter', async () => {
// Test document with --- at start but no closing delimiter
// This should be parsed as a horizontal rule followed by content
const documentText = '# Heading\n\n---\ntitle: Test';
const editor = await createEditorState(documentText, ['ATXHeading1', 'HorizontalRule']);
const frontMatterNodes = findNodesWithName(editor, frontMatterTagName);
// FrontMatter only works at the very start of the document, so this should not be recognized
expect(frontMatterNodes.length).toBe(0);
});
it('should handle empty FrontMatter block', async () => {
const documentText = '---\n---\n\n# Heading';
const editor = await createEditorState(documentText, [frontMatterTagName, 'ATXHeading1']);
const frontMatterNodes = findNodesWithName(editor, frontMatterTagName);
expect(frontMatterNodes.length).toBe(1);
expect(frontMatterNodes[0].from).toBe(0);
expect(frontMatterNodes[0].to).toBe('---\n---'.length);
});
it('should have FrontMatterContent as child node', async () => {
const documentText = '---\nkey: value\n---';
const editor = await createEditorState(documentText, [frontMatterTagName]);
const frontMatterNodes = findNodesWithName(editor, frontMatterTagName);
const contentNodes = findNodesWithName(editor, frontMatterContentTagName);
expect(frontMatterNodes.length).toBe(1);
// Content node may be replaced by YAML parser, but if not, it should exist
// The presence depends on whether YAML language was loaded
expect(contentNodes.length).toBeGreaterThanOrEqual(0);
});
it('should not confuse horizontal rules with FrontMatter', async () => {
const documentText = '# Title\n\n---\n\nSome text';
const editor = await createEditorState(documentText, ['ATXHeading1', 'HorizontalRule']);
const frontMatterNodes = findNodesWithName(editor, frontMatterTagName);
const hrNodes = findNodesWithName(editor, 'HorizontalRule');
expect(frontMatterNodes.length).toBe(0);
expect(hrNodes.length).toBe(1);
});
it('should create FrontMatterMarker nodes for the delimiters', async () => {
const documentText = '---\ntitle: Test\n---\n\n# Heading';
const editor = await createEditorState(documentText, [frontMatterTagName, frontMatterMarkerTagName]);
const markerNodes = findNodesWithName(editor, frontMatterMarkerTagName);
// Should have two markers: opening and closing ---
expect(markerNodes.length).toBe(2);
// Opening marker
expect(markerNodes[0].from).toBe(0);
expect(markerNodes[0].to).toBe(3); // '---'.length
// Closing marker
expect(markerNodes[1].from).toBe('---\ntitle: Test\n'.length);
expect(markerNodes[1].to).toBe('---\ntitle: Test\n---'.length);
});
});
@@ -0,0 +1,128 @@
// Extension for parsing and highlighting YAML FrontMatter blocks at the start of a document.
//
// A FrontMatter block is delimited by --- at the very start of the document:
// ---
// title: My Document
// date: 2024-01-01
// ---
import { Tag } from '@lezer/highlight';
import { parseMixed, SyntaxNodeRef, Input, NestedParse, ParseWrapper } from '@lezer/common';
import { MarkdownConfig, BlockContext, Line, LeafBlock, MarkdownExtension } from '@lezer/markdown';
import { StreamLanguage } from '@codemirror/language';
import { yaml } from '@codemirror/legacy-modes/mode/yaml';
export const frontMatterTagName = 'FrontMatter';
export const frontMatterContentTagName = 'FrontMatterContent';
export const frontMatterMarkerTagName = 'FrontMatterMarker';
export const frontMatterTag = Tag.define();
// Create the YAML language parser using the legacy mode
const yamlLanguage = StreamLanguage.define(yaml);
// Wraps a YAML parser for the FrontMatter content.
// This replaces [nodeTag] from the syntax tree with a region handled by the YAML parser.
const wrappedYamlParser = (nodeTag: string): ParseWrapper => {
return parseMixed((node: SyntaxNodeRef, _input: Input): NestedParse => {
if (node.name !== nodeTag) {
return null;
}
return {
parser: yamlLanguage.parser,
};
});
};
// Regex to match the FrontMatter delimiter (--- at start of line)
const frontMatterDelimiterRegex = /^---\s*$/;
const frontMatterConfig: MarkdownConfig = {
defineNodes: [
{
name: frontMatterTagName,
style: frontMatterTag,
},
{
name: frontMatterContentTagName,
},
{
name: frontMatterMarkerTagName,
style: frontMatterTag,
},
],
parseBlock: [{
name: frontMatterTagName,
before: 'HorizontalRule',
parse(cx: BlockContext, line: Line): boolean {
// FrontMatter must be at the very start of the document
if (cx.lineStart !== 0) {
return false;
}
// Check if the first line is ---
if (!frontMatterDelimiterRegex.test(line.text)) {
return false;
}
// Store the opening delimiter position
const openingMarkerStart = cx.lineStart;
const openingMarkerEnd = cx.lineStart + line.text.length;
const contentStart = openingMarkerEnd + 1; // Start after the opening --- and newline
let foundEnd = false;
// Consume lines until we find the closing ---
while (cx.nextLine()) {
if (frontMatterDelimiterRegex.test(line.text)) {
foundEnd = true;
break;
}
}
if (!foundEnd) {
// No closing delimiter found - not a valid FrontMatter block
return false;
}
// The content is between the two --- delimiters
const contentEnd = cx.lineStart; // Start of the closing --- line
// Closing delimiter positions
const closingMarkerStart = cx.lineStart;
const closingMarkerEnd = cx.lineStart + line.text.length;
// Create marker elements for the --- delimiters
const openingMarkerElem = cx.elt(frontMatterMarkerTagName, openingMarkerStart, openingMarkerEnd);
const closingMarkerElem = cx.elt(frontMatterMarkerTagName, closingMarkerStart, closingMarkerEnd);
// Create the content element (the YAML content between delimiters)
const contentElem = cx.elt(frontMatterContentTagName, contentStart, contentEnd);
// Create the container element spanning from start of first --- to end of last ---
const containerElement = cx.elt(
frontMatterTagName,
0, // Start at document beginning
closingMarkerEnd, // End after closing ---
[openingMarkerElem, contentElem, closingMarkerElem],
);
cx.addElement(containerElement);
// Move past the closing delimiter
cx.nextLine();
return true;
},
// FrontMatter blocks can end leaf blocks like paragraphs
endLeaf(_cx: BlockContext, line: Line, _leaf: LeafBlock): boolean {
return frontMatterDelimiterRegex.test(line.text);
},
}],
wrap: wrappedYamlParser(frontMatterContentTagName),
};
const markdownFrontMatterExtension: MarkdownExtension = [frontMatterConfig];
export default markdownFrontMatterExtension;
@@ -26,6 +26,9 @@ class DividerWidget extends WidgetType {
const dividerLineMark = Decoration.line({ class: dividerLineClassName });
// Node names that should be rendered as dividers
const dividerNodeNames = ['HorizontalRule', 'FrontMatterMarker'];
const replaceDividers = [
EditorView.theme({
[`& .cm-line.${dividerLineClassName}`]: {
@@ -47,7 +50,7 @@ const replaceDividers = [
}),
makeInlineReplaceExtension({
createDecoration: (node) => {
if (node.name === 'HorizontalRule') {
if (dividerNodeNames.includes(node.name)) {
return new DividerWidget();
}
return null;
@@ -55,7 +58,7 @@ const replaceDividers = [
}),
makeInlineReplaceExtension({
createDecoration: (node) => {
if (node.name === 'HorizontalRule') {
if (dividerNodeNames.includes(node.name)) {
return dividerLineMark;
}
return null;
@@ -1,6 +1,7 @@
import { EditorSelection } from '@codemirror/state';
import createTestEditor from '../../testing/createTestEditor';
import replaceInlineHtml from './replaceInlineHtml';
import waitFor from '@joplin/lib/testing/waitFor';
const createEditor = async (initialMarkdown: string, expectedTags: string[] = ['HTMLTag']) => {
const editor = await createTestEditor(
@@ -27,6 +28,9 @@ describe('replaceInlineHtml', () => {
// Add additional newlines: Ensure that the cursor isn't initially on the same line as the content to be rendered:
const editor = await createEditor(`\n\n${markdown}\n\n`, initialSyntaxTags);
expect(editor.contentDOM.querySelector(expectedTagsQuery)).toBeTruthy();
// Retry on failure to handle the case where the syntax tree is slow:
await waitFor(() => {
expect(editor.contentDOM.querySelector(expectedTagsQuery)).toBeTruthy();
});
});
});
@@ -7,6 +7,7 @@ import forceFullParse from './forceFullParse';
import loadLanguages from './loadLanguages';
import markdownMathExtension from '../extensions/markdownMathExtension';
import markdownHighlightExtension from '../extensions/markdownHighlightExtension';
import markdownFrontMatterExtension from '../extensions/markdownFrontMatterExtension';
// Creates and returns a minimal editor with markdown extensions. Waits to return the editor
// until all syntax tree tags in `expectedSyntaxTreeTags` exist.
@@ -26,7 +27,7 @@ const createTestEditor = async (
selection: EditorSelection.create(initialSelection),
extensions: [
markdown({
extensions: [markdownMathExtension, markdownHighlightExtension, GithubFlavoredMarkdownExt],
extensions: [markdownMathExtension, markdownHighlightExtension, markdownFrontMatterExtension, GithubFlavoredMarkdownExt],
addKeymap: addMarkdownKeymap,
}),
indentUnit.of('\t'),
+1 -1
View File
@@ -3,7 +3,7 @@ const { _ } = require('./locale');
const Setting = require('./models/Setting').default;
const { FileApi } = require('./file-api.js');
const Synchronizer = require('./Synchronizer').default;
const WebDavApi = require('./WebDavApi');
const WebDavApi = require('./WebDavApi').default;
const { FileApiDriverWebDav } = require('./file-api-driver-webdav');
const checkProviderIsSupported = require('./utils/webDAVUtils').default;
@@ -1,12 +1,13 @@
const Logger = require('@joplin/utils/Logger').default;
const shim = require('./shim').default;
const parseXmlString = require('xml2js').parseString;
const JoplinError = require('./JoplinError').default;
const URL = require('url-parse');
const { _ } = require('./locale');
const { rtrimSlashes } = require('./path-utils');
import Logger from '@joplin/utils/Logger';
import shim from './shim';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const parseXmlString: (xml: string, options: any, callback: (error: Error | null, result: any)=> void)=> void = require('xml2js').parseString;
import JoplinError from './JoplinError';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const URL: any = require('url-parse');
import { _ } from './locale';
import { rtrimSlashes, ltrimSlashes } from './path-utils';
const base64 = require('base-64');
const { ltrimSlashes } = require('./path-utils');
// Note that the d: namespace (the DAV namespace) is specific to Nextcloud. The RFC for example uses "D:" however
// we make all the tags and attributes lowercase so we handle both the Nextcloud style and RFC. Hopefully other
@@ -14,20 +15,56 @@ const { ltrimSlashes } = require('./path-utils');
// example to convert a custom namespace to "d:" so that it can be used by the rest of the code.
// In general, we should only deal with things in "d:", which is the standard DAV namespace.
interface WebDavApiOptions {
baseUrl(): string;
username(): string;
password(): string;
ignoreTlsErrors?(): boolean;
}
interface LoggedRequest {
timestamp: number;
request: string;
response: string;
}
interface RequestInfo {
url: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
options: any;
}
interface ExecOptions {
responseFormat?: 'json' | 'text';
target?: 'string' | 'file';
source?: 'file';
path?: string;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
type JsonValue = any;
class WebDavApi {
constructor(options) {
private logger_: Logger;
private options_: WebDavApiOptions;
private lastRequests_: LoggedRequest[];
public constructor(options: WebDavApiOptions) {
this.logger_ = new Logger();
this.options_ = options;
this.lastRequests_ = [];
// Prevent unused method warning - this method is kept for debugging
void this._requestToCurl;
}
logRequest_(request, responseText) {
private logRequest_(request: RequestInfo, responseText: string) {
if (this.lastRequests_.length > 10) this.lastRequests_.splice(0, 1);
const serializeRequest = (r) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const serializeRequest = (r: RequestInfo): string => {
const options = { ...r.options };
if (typeof options.body === 'string') options.body = options.body.substr(0, 4096);
const output = [];
const output: string[] = [];
output.push(options.method ? options.method : 'GET');
output.push(r.url);
options.headers = { ...options.headers };
@@ -45,23 +82,23 @@ class WebDavApi {
});
}
lastRequests() {
public lastRequests() {
return this.lastRequests_;
}
clearLastRequests() {
public clearLastRequests() {
this.lastRequests_ = [];
}
setLogger(l) {
public setLogger(l: Logger) {
this.logger_ = l;
}
logger() {
public logger() {
return this.logger_;
}
authToken() {
private authToken(): string | null {
if (!this.options_.username() || !this.options_.password()) return null;
try {
// Note: Non-ASCII passwords will throw an error about Latin1 characters - https://github.com/laurent22/joplin/issues/246
@@ -69,24 +106,24 @@ class WebDavApi {
// return base64.encode(utf8.encode(this.options_.username() + ':' + this.options_.password()));
return base64.encode(`${this.options_.username()}:${this.options_.password()}`);
} catch (error) {
error.message = `Cannot encode username/password: ${error.message}`;
(error as Error).message = `Cannot encode username/password: ${(error as Error).message}`;
throw error;
}
}
baseUrl() {
public baseUrl(): string {
return rtrimSlashes(this.options_.baseUrl());
}
relativeBaseUrl() {
public relativeBaseUrl(): string {
const url = new URL(this.baseUrl());
return url.pathname + url.query;
}
async xmlToJson(xml) {
const davNamespaces = []; // Yes, there can be more than one... xmlns:a="DAV:" xmlns:D="DAV:"
public async xmlToJson(xml: string): Promise<JsonValue> {
const davNamespaces: string[] = []; // Yes, there can be more than one... xmlns:a="DAV:" xmlns:D="DAV:"
const nameProcessor = name => {
const nameProcessor = (name: string): string => {
if (name.indexOf('xmlns') !== 0) {
// Check if the current name is within the DAV namespace. If it is, normalise it
// by moving it to the "d:" namespace, which is what all the functions are using.
@@ -106,14 +143,15 @@ class WebDavApi {
return name.toLowerCase();
};
const attrValueProcessor = (value, name) => {
// The namespace is ususally specified like so: xmlns:D="DAV:" ("D" being the alias used in the tag names)
const attrValueProcessor = (value: string, name: string): string => {
// The namespace is usually specified like so: xmlns:D="DAV:" ("D" being the alias used in the tag names)
// In some cases, the namespace can also be empty like so: "xmlns=DAV". In this case, the tags will have
// no namespace so instead of <d:prop> will have just <prop>. This is handled above in nameProcessor()
if (value.toLowerCase() === 'dav:') {
const p = name.split(':');
davNamespaces.push(p.length === 2 ? p[p.length - 1] : '');
}
return value;
};
const options = {
@@ -133,7 +171,7 @@ class WebDavApi {
});
}
valueFromJson(json, keys, type) {
private valueFromJson(json: JsonValue, keys: (string | number)[], type: 'string' | 'object' | 'array'): JsonValue {
let output = json;
for (let i = 0; i < keys.length; i++) {
@@ -171,19 +209,20 @@ class WebDavApi {
return null;
}
stringFromJson(json, keys) {
public stringFromJson(json: JsonValue, keys: (string | number)[]): string | null {
return this.valueFromJson(json, keys, 'string');
}
objectFromJson(json, keys) {
public objectFromJson(json: JsonValue, keys: (string | number)[]): JsonValue {
return this.valueFromJson(json, keys, 'object');
}
arrayFromJson(json, keys) {
public arrayFromJson(json: JsonValue, keys: (string | number)[]): JsonValue[] | null {
return this.valueFromJson(json, keys, 'array');
}
resourcePropByName(resource, outputType, propName) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public resourcePropByName(resource: any, outputType: 'string' | 'array', propName: string): any {
const propStats = resource['d:propstat'];
let output = null;
if (!Array.isArray(propStats)) throw new Error('Missing d:propstat property');
@@ -221,7 +260,7 @@ class WebDavApi {
throw new Error(`Invalid output type: ${outputType}`);
}
async execPropFind(path, depth, fields = null, options = null) {
public async execPropFind(path: string, depth: number, fields: string[] | null = null, options: ExecOptions | null = null): Promise<JsonValue> {
if (fields === null) fields = ['d:getlastmodified'];
let fieldsXml = '';
@@ -247,8 +286,10 @@ class WebDavApi {
return this.exec('PROPFIND', path, body, { Depth: depth }, options);
}
requestToCurl_(url, options) {
const output = [];
// Used for debugging - can be uncommented in exec() to log curl commands
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private _requestToCurl(url: string, options: any): string {
const output: string[] = [];
output.push('curl');
output.push('-v');
if (options.method) output.push(`-X ${options.method}`);
@@ -264,7 +305,7 @@ class WebDavApi {
return output.join(' ');
}
handleNginxHack_(jsonResponse, newErrorHandler) {
private handleNginxHack_(jsonResponse: JsonValue, newErrorHandler: (message: string, code: number)=> JoplinError) {
// Trying to fix 404 error issue with Nginx WebDAV server.
// https://github.com/laurent22/joplin/issues/624
// https://github.com/laurent22/joplin/issues/808
@@ -317,11 +358,11 @@ class WebDavApi {
const responseArray = this.arrayFromJson(jsonResponse, ['d:multistatus', 'd:response']);
if (responseArray && responseArray.length === 1) {
const propStats = this.arrayFromJson(jsonResponse, ['d:multistatus', 'd:response', 0, 'd:propstat']);
if (!propStats.length) return;
if (!propStats || !propStats.length) return;
let count404 = 0;
for (let i = 0; i < propStats.length; i++) {
const status = this.arrayFromJson(jsonResponse, ['d:multistatus', 'd:response', 0, 'd:propstat', i, 'd:status']);
if (status && status.length && status[0].indexOf('404') >= 0) count404++;
if (status && status.length && (status[0] as string).indexOf('404') >= 0) count404++;
}
if (count404 === propStats.length) throw newErrorHandler('Not found', 404);
@@ -335,7 +376,7 @@ class WebDavApi {
// </d:prop>
// </d:propfind>'
async exec(method, path = '', body = null, headers = null, options = null) {
public async exec(method: string, path = '', body: string | null = null, headers: Record<string, string | number> | null = null, options: ExecOptions | null = null): Promise<JsonValue> {
headers = { ...headers };
options = { ...options };
@@ -372,12 +413,13 @@ class WebDavApi {
if (['GET', 'HEAD'].indexOf(method) < 0) headers['If-None-Match'] = `JoplinIgnore-${Math.floor(Math.random() * 100000)}`;
if (!headers['User-Agent']) headers['User-Agent'] = 'Joplin/1.0';
const fetchOptions = {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const fetchOptions: any = {};
fetchOptions.headers = headers;
fetchOptions.method = method;
if (options.path) fetchOptions.path = options.path;
if (body) fetchOptions.body = body;
fetchOptions.ignoreTlsErrors = this.options_.ignoreTlsErrors();
fetchOptions.ignoreTlsErrors = this.options_.ignoreTlsErrors ? this.options_.ignoreTlsErrors() : false;
if (shim.mobilePlatform() === 'android') {
// Using credentials = 'omit' prevents authentication cookies from
// being stored. React Native has issues related to cookie authentication:
@@ -391,7 +433,8 @@ class WebDavApi {
if (shim.httpAgent(url)) fetchOptions.agent = shim.httpAgent(url);
let response = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
let response: any = null;
// console.info('WebDAV Call', `${method} ${url}`, headers, options);
// console.info(this.requestToCurl_(url, fetchOptions));
@@ -417,15 +460,15 @@ class WebDavApi {
// console.info('WebDAV Response', responseText);
// Creates an error object with as much data as possible as it will appear in the log, which will make debugging easier
const newError = (message, code = 0) => {
const newError = (message: string, code = 0): JoplinError => {
// Gives a shorter response for error messages. Useful for cases where a full HTML page is accidentally loaded instead of
// JSON. That way the error message will still show there's a problem but without filling up the log or screen.
const shortResponseText = (`${responseText}`).substr(0, 1024);
return new JoplinError(`${method} ${path}: ${message} (${code}): ${shortResponseText}`, code);
};
let responseJson_ = null;
const loadResponseJson = async () => {
let responseJson_: JsonValue = null;
const loadResponseJson = async (): Promise<JsonValue> => {
if (!responseText) return null;
if (responseJson_) return responseJson_;
// eslint-disable-next-line require-atomic-updates
@@ -482,4 +525,4 @@ class WebDavApi {
}
}
module.exports = WebDavApi;
export default WebDavApi;
+72 -1
View File
@@ -181,7 +181,29 @@ describe('models/Folder.sharing', () => {
}
}));
it('should not fail to update share IDs when an outdated share ID is contained in a read-only folder', async () => {
it('updating share IDs should not fail when a read-only resource is linked to a writeable note', async () => {
const root = await Folder.save({ title: 'read-only' });
let note = await Note.save({ title: 'Test', parent_id: root.id });
note = await shim.attachFileToNote(note, testImagePath);
const resourceId = Note.linkedItemIds(note.body)[0];
// Associate the resource with the share
const shareId = 'abcd1234';
await Resource.save({
id: resourceId,
share_id: shareId,
is_shared: 1,
});
const reset = simulateReadOnlyShareEnv([shareId]);
try {
await Folder.updateAllShareIds(resourceService(), []);
} finally {
reset();
}
});
it('should not fail to update note share IDs when an outdated share ID is contained in a read-only folder', async () => {
const shareId = 'abcd1234';
const root = await Folder.save({ title: 'read-only', share_id: shareId });
// Save a child with a different share ID
@@ -564,6 +586,55 @@ describe('models/Folder.sharing', () => {
expect(await Note.load(note2.id)).not.toMatchObject({ body: note2.body, share_id: '' });
});
it('should not duplicate a resource if both instances are in different read-only shares', async () => {
const resourceService = new ResourceService();
const folder1 = await createFolderTree('', [
{
title: 'folder 1', // Share 1
children: [
{
title: 'note 1',
},
],
},
{
title: 'folder 2', // Share 2
children: [
{
title: 'note 2',
},
],
},
]);
const folder2 = await Folder.loadByTitle('folder 2');
let note1: NoteEntity = await Note.loadByTitle('note 1');
const note2: NoteEntity = await Note.loadByTitle('note 2');
note1 = await shim.attachFileToNote(note1, testImagePath);
await Note.save({ id: note2.id, body: note1.body });
const shareId1 = 'testing1';
const shareId2 = 'testing2';
await Folder.save({ id: folder1.id, share_id: shareId1 });
await Folder.save({ id: folder2.id, share_id: shareId2 });
await msleep(1);
const reset = simulateReadOnlyShareEnv([shareId1, shareId2]);
try {
await resourceService.indexNoteResources(); // Populate note_resources
await Folder.updateAllShareIds(resourceService, []);
// After: Should not have duplicated the resource
expect(await Resource.all()).toHaveLength(1);
} finally {
reset();
}
});
it('should clear share_ids for items that are no longer part of an existing share', async () => {
await createFolderTree('', [
{
+30 -3
View File
@@ -1,5 +1,5 @@
import { BaseItemEntity, defaultFolderIcon, FolderEntity, FolderIcon, NoteEntity, ResourceEntity } from '../services/database/types';
import BaseModel, { DeleteOptions } from '../BaseModel';
import BaseModel, { DeleteOptions, ModelType } from '../BaseModel';
import { FolderLoadOptions } from './utils/types';
import time from '../time';
import { _ } from '../locale';
@@ -19,6 +19,8 @@ import getConflictFolderId from './utils/getConflictFolderId';
import getTrashFolderId from '../services/trash/getTrashFolderId';
import { getCollator } from './utils/getCollator';
import Setting from './Setting';
import { itemIsReadOnlySync, ItemSlice } from './utils/readOnly';
import ItemChange from './ItemChange';
const { substrWithEllipsis } = require('../string-utils.js');
const logger = Logger.create('models/Folder');
@@ -593,6 +595,16 @@ export default class Folder extends BaseItem {
// resume the process from the start (thus the loop) so that we deal
// with the right note/resource associations.
const isReadOnly = (type: ModelType, item: NoteEntity|ResourceEntity) => {
return itemIsReadOnlySync(
type,
ItemChange.SOURCE_UNSPECIFIED,
item as ItemSlice,
Setting.value('sync.userId'),
BaseItem.syncShareCache,
);
};
interface Row {
id: string;
share_id: string;
@@ -672,7 +684,20 @@ export default class Folder extends BaseItem {
const row = rows[i];
const note: NoteEntity = await Note.load(row.note_id);
if (!note) continue; // probably got deleted in the meantime?
const newResource = await Resource.duplicateResource(resourceId);
// Don't update read-only notes:
if (isReadOnly(ModelType.Note, note)) continue;
const newResource = await Resource.duplicateResource(resourceId, {
// Ensure that the resource starts with the correct share_id and is_shared.
// This reduces the number of resources to be processed in the next loop iteration
// and seems to fix an issue related to resources not syncing with read-only shares.
//
// These properties are set directly in the "duplicateResource" call to prevent
// race conditions.
is_shared: note.is_shared,
share_id: note.share_id,
});
logger.info(`updateResourceShareIds: Automatically created resource "${newResource.id}" to replace resource "${resourceId}" because it is shared and duplicate across notes:`, row);
const regex = new RegExp(resourceId, 'gi');
const newBody = note.body.replace(regex, newResource.id);
@@ -721,7 +746,9 @@ export default class Folder extends BaseItem {
resource.blob_updated_time = now;
}
await Resource.save(resource, { autoTimestamp: false });
if (!isReadOnly(ModelType.Resource, resource)) {
await Resource.save(resource, { autoTimestamp: false });
}
}
return;
}
+9 -2
View File
@@ -444,7 +444,11 @@ export default class Resource extends BaseItem {
return await this.fsDriver().readFile(Resource.fullPath(resource), encoding);
}
public static async duplicateResource(resourceId: string): Promise<ResourceEntity> {
public static async duplicateResource(
resourceId: string,
// Overrides property values in the duplicate resource.
propertyOverrides: ResourceEntity = {},
): Promise<ResourceEntity> {
const resource = await Resource.load(resourceId);
const localState = await Resource.localState(resource);
@@ -452,7 +456,10 @@ export default class Resource extends BaseItem {
delete newResource.id;
delete newResource.is_shared;
delete newResource.share_id;
newResource = await Resource.save(newResource);
newResource = await Resource.save({
...newResource,
...propertyOverrides,
});
const newLocalState = { ...localState };
newLocalState.resource_id = newResource.id;
+1 -1
View File
@@ -35,7 +35,7 @@
"pdfjs-dist": "3.11.174",
"react": "18.3.1",
"react-test-renderer": "18.3.1",
"sharp": "0.34.4",
"sharp": "0.34.5",
"tesseract.js": "6.0.1",
"typescript": "5.8.3"
},
+1 -1
View File
@@ -220,7 +220,7 @@ function shimInit(options: ShimInitOptions = null) {
if (shim.isElectron()) {
return shim.electronBridge().showMessageBox(message, options ?? {});
} else {
throw new Error('Not implemented');
throw new Error(`Not implemented: showMessageBox(${JSON.stringify(message)})`);
}
};
+1 -29
View File
@@ -46,7 +46,7 @@ import EncryptionService from '../services/e2ee/EncryptionService';
import DecryptionWorker from '../services/DecryptionWorker';
import RevisionService from '../services/RevisionService';
import ResourceFetcher from '../services/ResourceFetcher';
const WebDavApi = require('../WebDavApi');
import WebDavApi from '../WebDavApi';
const DropboxApi = require('../DropboxApi');
import JoplinServerApi, { Session } from '../JoplinServerApi';
import { FolderEntity, ResourceEntity } from '../services/database/types';
@@ -1137,34 +1137,6 @@ export const mockMobilePlatform = (platform: MobilePlatform) => {
};
};
// Waits for callback to not throw. Similar to react-native-testing-library's waitFor, but works better
// with Joplin's mix of real and fake Jest timers.
const realSetTimeout = setTimeout;
export const waitFor = async (callback: ()=> Promise<void>) => {
const timeout = 10_000;
const startTime = performance.now();
let passed = false;
let lastError: Error|null = null;
while (!passed && performance.now() - startTime < timeout) {
try {
await callback();
passed = true;
lastError = null;
} catch (error) {
lastError = error;
await new Promise<void>(resolve => {
realSetTimeout(() => resolve(), 10);
});
}
}
if (lastError) {
throw lastError;
}
};
export const runWithFakeTimers = async (callback: ()=> Promise<void>) => {
if (typeof jest === 'undefined') {
throw new Error('Fake timers are only supported in jest.');
+31
View File
@@ -0,0 +1,31 @@
// Waits for callback to not throw. Similar to react-native-testing-library's waitFor, but works better
// with Joplin's mix of real and fake Jest timers.
const realSetTimeout = setTimeout;
const waitFor = async (callback: ()=> void|Promise<void>) => {
const timeout = 10_000;
const startTime = performance.now();
let passed = false;
let lastError: Error|null = null;
while (!passed && performance.now() - startTime < timeout) {
try {
await callback();
passed = true;
lastError = null;
} catch (error) {
lastError = error;
await new Promise<void>(resolve => {
realSetTimeout(() => resolve(), 10);
});
}
}
if (lastError) {
throw lastError;
}
};
export default waitFor;
Loaded 100 of 714 files, more files were not shown because too many files have changed in this diff. Show more