mirror of
https://github.com/laurent22/joplin.git
synced 2026-11-04 07:42:10 +00:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22dd50bdef | ||
|
|
97a18f722d | ||
|
|
554e6efaab | ||
|
|
cd7af20bc1 | ||
|
|
4346616cae | ||
|
|
ef646adafa | ||
|
|
4ce47807b1 | ||
|
|
9b0bc4d600 | ||
|
|
91b8e4d34d | ||
|
|
9e9bd662dc | ||
|
|
defbbd5d72 | ||
|
|
b2c9dd40dc | ||
|
|
a9049111e4 | ||
|
|
55f642c625 | ||
|
|
dee9ec3495 | ||
|
|
97bf020150 | ||
|
|
2cb2680a5a | ||
|
|
242c6ec3b8 | ||
|
|
ed0b1ae390 | ||
|
|
de29e4ff92 | ||
|
|
9d96e31b83 | ||
|
|
aad460e9a1 | ||
|
|
00248a9177 | ||
|
|
af2926b634 | ||
|
|
916ed9bbfb | ||
|
|
b32015864e | ||
|
|
8939ef1c19 | ||
|
|
31bba39ae9 | ||
|
|
9dc49f0c24 |
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"exceptions": [
|
||||
"@joplin/editor",
|
||||
"@joplin/fork-htmlparser2",
|
||||
"@joplin/whisper-voice-typing",
|
||||
"@joplin/fork-sax",
|
||||
"@joplin/fork-uslug",
|
||||
"@joplin/htmlpack",
|
||||
|
||||
+1
-1
@@ -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
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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="``` " data-joplin-source-close=" ```">
|
||||
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("joplin://00000000000000000000000000000001", { resourceId: "00000000000000000000000000000001" }); 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("joplin://00000000000000000000000000000001#page=1", { resourceId: "00000000000000000000000000000001" }); 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("joplin://00000000000000000000000000000001#t=1,2", { resourceId: "00000000000000000000000000000001" }); 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("joplin://00000000000000000000000000000001", { resourceId: "00000000000000000000000000000001" }); 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)
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
package net.cozic.joplin.audio
|
||||
|
||||
|
||||
class InvalidSessionIdException(id: Int) : IllegalArgumentException("Invalid session ID $id") {
|
||||
}
|
||||
-64
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
-62
@@ -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()
|
||||
}
|
||||
}
|
||||
-87
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
-111
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Vendored
+2
-2
@@ -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
@@ -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
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
+45
-31
@@ -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 };
|
||||
@@ -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,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)",
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
|
||||
+1048
-829
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>
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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/'),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
@@ -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('', [
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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)})`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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
Reference in new issue
Block a user