You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2026-04-14 11:22:36 +02:00
Compare commits
34 Commits
v3.6.7
...
plugin_ext
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe8b81bce7 | ||
|
|
33fd47d9e7 | ||
|
|
bf91a28691 | ||
|
|
1bbd60318a | ||
|
|
489b77af56 | ||
|
|
ff24ad7c9f | ||
|
|
0021339f36 | ||
|
|
f545726339 | ||
|
|
825a5d4bc0 | ||
|
|
1a1f3cdd03 | ||
|
|
0d1b2aaa8b | ||
|
|
171b979bc4 | ||
|
|
e19856a6d0 | ||
|
|
9a180fcd50 | ||
|
|
0473dd3116 | ||
|
|
cbf443de34 | ||
|
|
9a28c65baf | ||
|
|
623da377db | ||
|
|
de6378473f | ||
|
|
886a8cc1a1 | ||
|
|
000d321f56 | ||
|
|
f1e4545813 | ||
|
|
e794176171 | ||
|
|
04d0626ede | ||
|
|
4421bf8bde | ||
|
|
f0eb41fe6c | ||
|
|
9e997503a1 | ||
|
|
3a6b4b12e7 | ||
|
|
f0a7f55366 | ||
|
|
cb2756160b | ||
|
|
379a53eca5 | ||
|
|
548d1a49ba | ||
|
|
7e08d0c77d | ||
|
|
cb3878afa6 |
@@ -966,6 +966,7 @@ packages/app-mobile/utils/ShareUtils.test.js
|
||||
packages/app-mobile/utils/ShareUtils.js
|
||||
packages/app-mobile/utils/TlsUtils.js
|
||||
packages/app-mobile/utils/appDefaultState.js
|
||||
packages/app-mobile/utils/appReducer.test.js
|
||||
packages/app-mobile/utils/appReducer.js
|
||||
packages/app-mobile/utils/autodetectTheme.js
|
||||
packages/app-mobile/utils/buildStartupTasks.js
|
||||
|
||||
2
.github/workflows/cla.yml
vendored
2
.github/workflows/cla.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
- name: "CLA Assistant"
|
||||
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
|
||||
# Beta Release
|
||||
uses: contributor-assistant/github-action@v2.3.2
|
||||
uses: contributor-assistant/github-action@v2.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# the below token should have repo scope and must be manually added by you in the repository's secret
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -939,6 +939,7 @@ packages/app-mobile/utils/ShareUtils.test.js
|
||||
packages/app-mobile/utils/ShareUtils.js
|
||||
packages/app-mobile/utils/TlsUtils.js
|
||||
packages/app-mobile/utils/appDefaultState.js
|
||||
packages/app-mobile/utils/appReducer.test.js
|
||||
packages/app-mobile/utils/appReducer.js
|
||||
packages/app-mobile/utils/autodetectTheme.js
|
||||
packages/app-mobile/utils/buildStartupTasks.js
|
||||
|
||||
@@ -102,6 +102,9 @@
|
||||
},
|
||||
"packageManager": "yarn@4.9.2",
|
||||
"resolutions": {
|
||||
"@codemirror/state": "6.6.0",
|
||||
"@codemirror/view": "6.41.0",
|
||||
"@codemirror/language": "6.12.3",
|
||||
"react-native-camera@4.2.1": "patch:react-native-camera@npm%3A4.2.1#./.yarn/patches/react-native-camera-npm-4.2.1-24b2600a7e.patch",
|
||||
"eslint": "patch:eslint@8.57.1#./.yarn/patches/eslint-npm-8.39.0-d92bace04d.patch",
|
||||
"app-builder-lib@24.4.0": "patch:app-builder-lib@npm%3A24.4.0#./.yarn/patches/app-builder-lib-npm-24.4.0-05322ff057.patch",
|
||||
|
||||
@@ -338,7 +338,7 @@ describe('MdToHtml', () => {
|
||||
for (const [tex, input] of tests) {
|
||||
const html = await mdToHtml.render(input, null, { bodyOnly: true });
|
||||
|
||||
const opening = '<pre class="joplin-source" data-joplin-language="katex" data-joplin-source-open="$$ " data-joplin-source-close=" $$ ">';
|
||||
const opening = '<pre class="joplin-source" hidden data-joplin-language="katex" data-joplin-source-open="$$ " data-joplin-source-close=" $$ ">';
|
||||
const closing = '</pre>';
|
||||
|
||||
// Remove any single leading and trailing newlines, those are included in data-joplin-source-open
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
<div class="joplin-editable joplin-abc-notation">
|
||||
<pre class="joplin-source" data-abc-options="{"responsive":"resize"}" data-joplin-language="abc" data-joplin-source-open="```abc " data-joplin-source-close=" ``` ">{responsive:'resize'}
|
||||
<pre class="joplin-source" hidden data-abc-options="{"responsive":"resize"}" data-joplin-language="abc" data-joplin-source-open="```abc " data-joplin-source-close=" ``` ">{responsive:'resize'}
|
||||
---
|
||||
K:F
|
||||
!f!(fgag-g2c2)|</pre>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="joplin-editable"><pre class="joplin-source" data-joplin-language="javascript" data-joplin-source-open="```javascript " data-joplin-source-close=" ```">function() {
|
||||
<div class="joplin-editable"><pre class="joplin-source" hidden data-joplin-language="javascript" data-joplin-source-open="```javascript " data-joplin-source-close=" ```">function() {
|
||||
console.info('bonjour');
|
||||
}</pre><pre class="hljs"><code><span class="hljs-keyword">function</span>(<span class="hljs-params"></span>) {
|
||||
<span class="hljs-variable language_">console</span>.<span class="hljs-title function_">info</span>(<span class="hljs-string">'bonjour'</span>);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
<div class="joplin-editable">
|
||||
<span class="joplin-source" data-joplin-source-open="" data-joplin-source-close="">https://www.youtube.com/watch?v=iJqe9pC-z-Y</span>
|
||||
<span class="joplin-source" hidden data-joplin-source-open="" data-joplin-source-close="">https://www.youtube.com/watch?v=iJqe9pC-z-Y</span>
|
||||
<div class="joplin-youtube-player-rendered">
|
||||
<iframe src="https://www.youtube-nocookie.com/embed/iJqe9pC-z-Y" title="YouTube video player" frameborder="0" allowfullscreen></iframe>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<p>Link: <a data-from-md title='https://www.youtube.com/watch?v=iJqe9pC-z-Y' href='https://www.youtube.com/watch?v=iJqe9pC-z-Y' onclick='postMessage("https://www.youtube.com/watch?v=iJqe9pC-z-Y", { resourceId: "" }); return false;'>https://www.youtube.com/watch?v=iJqe9pC-z-Y</a></p>
|
||||
<p>
|
||||
<div class="joplin-editable">
|
||||
<span class="joplin-source" data-joplin-source-open="" data-joplin-source-close="">https://www.youtube.com/watch?v=iJqe9pC-z-Y</span>
|
||||
<span class="joplin-source" hidden data-joplin-source-open="" data-joplin-source-close="">https://www.youtube.com/watch?v=iJqe9pC-z-Y</span>
|
||||
<div class="joplin-youtube-player-rendered">
|
||||
<iframe src="https://www.youtube-nocookie.com/embed/iJqe9pC-z-Y" title="YouTube video player" frameborder="0" allowfullscreen></iframe>
|
||||
</div>
|
||||
|
||||
@@ -1 +1 @@
|
||||
<div class="joplin-editable"><pre class="joplin-source" data-joplin-language=""><svg/onload=top.eval(atob("cmVxdWlyZSgnY2hpbGRfcHJvY2VzcycpLmV4ZWMoJ29wZW4gLW4gL1N5c3RlbS9BcHBsaWNhdGlvbnMvQ2FsY3VsYXRvci5hcHAvQ29udGVudHMvTWFjT1MvQ2FsY3VsYXRvcicp"))>" data-joplin-source-open="```"><svg/onload=top.eval(atob("cmVxdWlyZSgnY2hpbGRfcHJvY2VzcycpLmV4ZWMoJ29wZW4gLW4gL1N5c3RlbS9BcHBsaWNhdGlvbnMvQ2FsY3VsYXRvci5hcHAvQ29udGVudHMvTWFjT1MvQ2FsY3VsYXRvcicp"))> " data-joplin-source-close=" ```">ts</pre><pre class="hljs"><code><span class="hljs-attribute">ts</span></code></pre></div>
|
||||
<div class="joplin-editable"><pre class="joplin-source" hidden data-joplin-language=""><svg/onload=top.eval(atob("cmVxdWlyZSgnY2hpbGRfcHJvY2VzcycpLmV4ZWMoJ29wZW4gLW4gL1N5c3RlbS9BcHBsaWNhdGlvbnMvQ2FsY3VsYXRvci5hcHAvQ29udGVudHMvTWFjT1MvQ2FsY3VsYXRvcicp"))>" data-joplin-source-open="```"><svg/onload=top.eval(atob("cmVxdWlyZSgnY2hpbGRfcHJvY2VzcycpLmV4ZWMoJ29wZW4gLW4gL1N5c3RlbS9BcHBsaWNhdGlvbnMvQ2FsY3VsYXRvci5hcHAvQ29udGVudHMvTWFjT1MvQ2FsY3VsYXRvcicp"))> " data-joplin-source-close=" ```">ts</pre><pre class="hljs"><code><span class="hljs-attribute">ts</span></code></pre></div>
|
||||
|
||||
@@ -1 +1 @@
|
||||
<div class="joplin-editable"><pre class="joplin-source" data-joplin-language="html" data-joplin-source-open="```html " data-joplin-source-close=" ```"><a href="#" onclick="leavethisalone">testing fence</a></pre><pre class="hljs"><code><span class="hljs-tag"><<span class="hljs-name">a</span> <span class="hljs-attr">href</span>=<span class="hljs-string">"#"</span> <span class="hljs-attr">onclick</span>=<span class="hljs-string">"leavethisalone"</span>></span>testing fence<span class="hljs-tag"></<span class="hljs-name">a</span>></span></code></pre></div>
|
||||
<div class="joplin-editable"><pre class="joplin-source" hidden data-joplin-language="html" data-joplin-source-open="```html " data-joplin-source-close=" ```"><a href="#" onclick="leavethisalone">testing fence</a></pre><pre class="hljs"><code><span class="hljs-tag"><<span class="hljs-name">a</span> <span class="hljs-attr">href</span>=<span class="hljs-string">"#"</span> <span class="hljs-attr">onclick</span>=<span class="hljs-string">"leavethisalone"</span>></span>testing fence<span class="hljs-tag"></<span class="hljs-name">a</span>></span></code></pre></div>
|
||||
|
||||
@@ -94,10 +94,6 @@ const useDocumentSetup = (doc: Document|null, setLoaded: OnSetLoaded) => {
|
||||
useEffect(() => {
|
||||
if (!doc) return;
|
||||
|
||||
doc.open();
|
||||
doc.write('<!DOCTYPE html><html><head></head><body></body></html>');
|
||||
doc.close();
|
||||
|
||||
const cssUrls = [
|
||||
'style.min.css',
|
||||
];
|
||||
|
||||
@@ -11,6 +11,7 @@ const baseContext: Record<string, any> = {
|
||||
noteIsMarkdown: true,
|
||||
noteIsReadOnly: false,
|
||||
richTextEditorVisible: false,
|
||||
hasActivePluginEditor: false,
|
||||
};
|
||||
|
||||
describe('editorCommandDeclarations', () => {
|
||||
|
||||
@@ -22,10 +22,10 @@ export const enabledCondition = (commandName: string) => {
|
||||
const allowInViewerAndReadOnlyMode = worksInViewerAndReadOnlyMode.includes(commandName);
|
||||
|
||||
const editorPaneCondition = markdownEditorOnly
|
||||
? 'markdownEditorPaneVisible'
|
||||
? '(markdownEditorPaneVisible || hasActivePluginEditor)'
|
||||
: allowInViewerAndReadOnlyMode
|
||||
? '(markdownEditorPaneVisible || richTextEditorVisible || markdownViewerPaneVisible)'
|
||||
: '(markdownEditorPaneVisible || richTextEditorVisible)';
|
||||
? '(markdownEditorPaneVisible || richTextEditorVisible || markdownViewerPaneVisible || hasActivePluginEditor)'
|
||||
: '(markdownEditorPaneVisible || richTextEditorVisible || hasActivePluginEditor)';
|
||||
|
||||
const output = [
|
||||
// gotoAnythingVisible: Enable if the command palette (which is a modal dialog) is visible
|
||||
|
||||
@@ -1,24 +1,10 @@
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { processImagesInPastedHtml, processPastedHtml, getResourcesFromPasteEvent } from './resourceHandling';
|
||||
import { processImagesInPastedHtml, processPastedHtml } from './resourceHandling';
|
||||
import markupLanguageUtils from '@joplin/lib/markupLanguageUtils';
|
||||
import HtmlToMd from '@joplin/lib/HtmlToMd';
|
||||
import { HtmlToMarkdownHandler, MarkupToHtmlHandler } from './types';
|
||||
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
|
||||
jest.mock('electron', () => ({
|
||||
clipboard: {
|
||||
has: jest.fn(),
|
||||
readBuffer: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
interface ClipboardMock {
|
||||
has: jest.Mock;
|
||||
readBuffer: jest.Mock;
|
||||
}
|
||||
|
||||
const mockClipboard = (require('electron') as { clipboard: ClipboardMock }).clipboard;
|
||||
|
||||
const createTestMarkupConverters = () => {
|
||||
const markupToHtml: MarkupToHtmlHandler = async (markupLanguage, markup, options) => {
|
||||
const conv = markupLanguageUtils.newMarkupToHtml({}, {
|
||||
@@ -37,11 +23,6 @@ const createTestMarkupConverters = () => {
|
||||
};
|
||||
|
||||
describe('resourceHandling', () => {
|
||||
afterEach(() => {
|
||||
mockClipboard.has.mockReset();
|
||||
mockClipboard.readBuffer.mockReset();
|
||||
});
|
||||
|
||||
it('should sanitize pasted HTML', async () => {
|
||||
Setting.setConstant('resourceDir', '/home/.config/joplin/resources');
|
||||
|
||||
@@ -148,39 +129,4 @@ describe('resourceHandling', () => {
|
||||
expect(result).not.toContain(expectAbsent);
|
||||
expect(result).not.toContain('data:');
|
||||
});
|
||||
|
||||
// Tests for getResourcesFromPasteEvent - clipboard image paste (issue #14613)
|
||||
// The test environment (non-Electron, no sharp) skips image validation and
|
||||
// just copies the file, so any non-empty buffer works as test data.
|
||||
const testImageBuffer = Buffer.from(minimalPng, 'base64');
|
||||
|
||||
test.each([
|
||||
{ format: 'image/jpeg', description: 'JPEG (bug #14613)' },
|
||||
{ format: 'image/jpg', description: 'JPG alias' },
|
||||
{ format: 'image/png', description: 'PNG (regression check)' },
|
||||
])('should paste $description image from clipboard via getResourcesFromPasteEvent', async ({ format }) => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
mockClipboard.has.mockImplementation((f: string) => f === format);
|
||||
mockClipboard.readBuffer.mockImplementation((f: string) => {
|
||||
return f === format ? testImageBuffer : Buffer.alloc(0);
|
||||
});
|
||||
const mockEvent = { preventDefault: jest.fn() };
|
||||
const result = await getResourcesFromPasteEvent(mockEvent);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0]).toContain('](:/');
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ description: 'clipboard has no image', hasResult: false },
|
||||
{ description: 'buffer is empty despite has() returning true', hasResult: true },
|
||||
])('should return empty when $description', async ({ hasResult }) => {
|
||||
mockClipboard.has.mockReturnValue(hasResult);
|
||||
mockClipboard.readBuffer.mockReturnValue(Buffer.alloc(0));
|
||||
const mockEvent = { preventDefault: jest.fn() };
|
||||
const result = await getResourcesFromPasteEvent(mockEvent);
|
||||
expect(result).toEqual([]);
|
||||
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -93,38 +93,28 @@ export function resourcesStatus(resourceInfos: any) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
export async function getResourcesFromPasteEvent(event: any) {
|
||||
const output = [];
|
||||
const formats = clipboard.availableFormats();
|
||||
for (let i = 0; i < formats.length; i++) {
|
||||
const format = formats[i].toLowerCase();
|
||||
const formatType = format.split('/')[0];
|
||||
|
||||
// clipboard.has() and readBuffer() are used instead of availableFormats() and
|
||||
// readImage(), which don't work for JPEG on Linux.
|
||||
// https://github.com/laurent22/joplin/issues/14613
|
||||
const supportedFormats = ['image/png', 'image/jpeg', 'image/jpg'];
|
||||
|
||||
for (const format of supportedFormats) {
|
||||
if (!clipboard.has(format)) continue;
|
||||
|
||||
const data = clipboard.readBuffer(format);
|
||||
if (!data || data.length === 0) continue;
|
||||
|
||||
if (event) event.preventDefault();
|
||||
|
||||
const fileExt = mimeUtils.toFileExtension(format);
|
||||
const filePath = `${Setting.value('tempDir')}/${md5(Date.now() + Math.random())}.${fileExt}`;
|
||||
|
||||
let md = null;
|
||||
try {
|
||||
await shim.fsDriver().writeFile(filePath, data, 'buffer');
|
||||
md = await commandAttachFileToBody('', [filePath]);
|
||||
} finally {
|
||||
try {
|
||||
await shim.fsDriver().remove(filePath);
|
||||
} catch (cleanupError) {
|
||||
logger.warn('getResourcesFromPasteEvent: Failed to remove temporary file.', cleanupError);
|
||||
if (formatType === 'image') {
|
||||
// writeImageToFile can process only image/jpeg, image/jpg or image/png mime types
|
||||
if (['image/png', 'image/jpg', 'image/jpeg'].indexOf(format) < 0) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (event) event.preventDefault();
|
||||
|
||||
if (md) {
|
||||
output.push(md);
|
||||
break;
|
||||
const image = clipboard.readImage();
|
||||
|
||||
const fileExt = mimeUtils.toFileExtension(format);
|
||||
const filePath = `${Setting.value('tempDir')}/${md5(Date.now())}.${fileExt}`;
|
||||
|
||||
await shim.writeImageToFile(image, format, filePath);
|
||||
const md = await commandAttachFileToBody('', [filePath]);
|
||||
await shim.fsDriver().remove(filePath);
|
||||
|
||||
if (md) output.push(md);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
|
||||
@@ -72,11 +72,8 @@ const SidebarComponent = (props: Props) => {
|
||||
|
||||
const syncButton = renderSynchronizeButton(props.syncStarted ? 'cancel' : 'sync');
|
||||
|
||||
// Show toggle when there are log lines or a completed timestamp
|
||||
const hasContent = lines.length > 0 || completedTime;
|
||||
|
||||
// Toggle to show/hide sync log output
|
||||
const toggleButton = hasContent ? (
|
||||
const toggleButton = (
|
||||
<button
|
||||
className="sidebar-sync-toggle"
|
||||
onClick={toggleSyncReport}
|
||||
@@ -84,10 +81,14 @@ const SidebarComponent = (props: Props) => {
|
||||
aria-label={syncReportExpanded ? _('Hide sync log') : _('Show sync log')}
|
||||
title={syncReportExpanded ? _('Hide sync log') : _('Show sync log')}
|
||||
>
|
||||
<i className={`fas fa-caret-${syncReportExpanded ? 'down' : 'up'}`} />
|
||||
{!syncReportExpanded && completedTime ? <span className="timestamp">{_('Last sync: %s', completedTime)}</span> : ''}
|
||||
<i className={`fas fa-caret-${syncReportExpanded ? 'down' : 'right'}`} />
|
||||
{(completedTime || props.syncStarted) ? (
|
||||
<span className="timestamp">
|
||||
{props.syncStarted ? _('Last sync: In progress...') : _('Last sync: %s', completedTime)}
|
||||
</span>
|
||||
) : ''}
|
||||
</button>
|
||||
) : null;
|
||||
);
|
||||
|
||||
// Sync log output, only visible when expanded
|
||||
const syncReportComp = (syncReportExpanded && lines.length > 0) ? (
|
||||
@@ -104,7 +105,7 @@ const SidebarComponent = (props: Props) => {
|
||||
<StyledRoot className='sidebar _scrollbar2' role='navigation' aria-label={_('Sidebar')}>
|
||||
<div style={{ flex: 1 }}><FolderAndTagList /></div>
|
||||
<div style={{ flex: 0, padding: theme.mainPadding }}>
|
||||
{toggleButton}
|
||||
{(completedTime || props.syncStarted) ? toggleButton : null}
|
||||
{syncReportComp}
|
||||
{syncButton}
|
||||
</div>
|
||||
|
||||
@@ -105,7 +105,7 @@ export const StyledSyncReport = styled.div`
|
||||
opacity: 0.5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: 5px;
|
||||
margin-left: 25px;
|
||||
margin-right: 5px;
|
||||
margin-bottom: 10px;
|
||||
word-wrap: break-word;
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
.sidebar-sync-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--joplin-color2);
|
||||
opacity: 0.5;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
width: 100%;
|
||||
font-size: calc(var(--joplin-font-size) * 1.6);
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
>.timestamp {
|
||||
font-size: 0.6em;
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
.sidebar-sync-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 4px 5px 4px 5px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--joplin-color2);
|
||||
opacity: 0.5;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
font-size: calc(var(--joplin-font-size) * 1.6);
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
i {
|
||||
width: 16px;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
font-size: calc(var(--joplin-toolbar-icon-size) * 0.8);
|
||||
}
|
||||
|
||||
>.timestamp {
|
||||
font-size: 0.6em;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,7 +373,7 @@
|
||||
ipc.focus = (event) => {
|
||||
const dummyID = 'joplin-content-focus-dummy';
|
||||
if (! document.getElementById(dummyID)) {
|
||||
const focusDummy = '<div style="width: 0; height: 0; overflow: hidden"><a id="' + dummyID + '" href="#">Note viewer top</a></div>';
|
||||
const focusDummy = '<div style="width: 0; height: 0; overflow: hidden"><a id="' + dummyID + '" href="#" aria-label="Note viewer top"></a></div>';
|
||||
contentElement.insertAdjacentHTML("afterbegin", focusDummy);
|
||||
}
|
||||
const scrollTop = contentElement.scrollTop;
|
||||
@@ -867,8 +867,19 @@
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.appendChild(range.cloneContents());
|
||||
|
||||
|
||||
wrapper.querySelectorAll('style').forEach(s => s.remove());
|
||||
|
||||
// The hidden attribute on .joplin-source helps some apps, but many
|
||||
// external editors (Word, Google Docs) ignore hidden/display:none
|
||||
// when pasting clipboard HTML. Remove them explicitly as a fallback.
|
||||
wrapper.querySelectorAll('.joplin-source').forEach(s => s.remove());
|
||||
|
||||
// Remove the accessibility focus dummy link container.
|
||||
const focusDummy = wrapper.querySelector('#joplin-content-focus-dummy');
|
||||
if (focusDummy && focusDummy.parentElement) focusDummy.parentElement.remove();
|
||||
|
||||
|
||||
const inlineTags = new Set(['STRONG', 'EM', 'CODE', 'S', 'DEL', 'INS', 'MARK', 'SUP', 'SUB', 'U', 'SPAN', 'A']);
|
||||
let node = range.commonAncestorContainer;
|
||||
if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "3.6.7",
|
||||
"version": "3.6.8",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.bundle.js",
|
||||
"private": true,
|
||||
@@ -160,7 +160,7 @@
|
||||
"@types/node": "18.19.130",
|
||||
"@types/react": "19.1.10",
|
||||
"@types/react-dom": "19.1.7",
|
||||
"@types/react-redux": "7.1.33",
|
||||
"@types/react-redux": "7.1.34",
|
||||
"@types/styled-components": "5.1.36",
|
||||
"async-mutex": "0.5.0",
|
||||
"axios": "^1.7.7",
|
||||
|
||||
@@ -6,7 +6,7 @@ ruby ">= 2.6.10"
|
||||
# Exclude problematic versions of cocoapods and activesupport that causes build failures.
|
||||
gem 'cocoapods', '>= 1.13', '!= 1.15.0', '!= 1.15.1'
|
||||
gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0'
|
||||
gem 'xcodeproj', '< 1.26.0'
|
||||
gem 'xcodeproj', '< 1.27.1'
|
||||
gem 'concurrent-ruby', '< 1.3.4'
|
||||
|
||||
# Ruby 3.4.0 has removed some libraries from the standard library.
|
||||
|
||||
@@ -83,8 +83,8 @@ android {
|
||||
applicationId "net.cozic.joplin"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2097802
|
||||
versionName "3.6.14"
|
||||
versionCode 2097803
|
||||
versionName "3.6.15"
|
||||
|
||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@ const builtInCommandNames = [
|
||||
'setTags',
|
||||
EditorCommandType.ToggleSearch,
|
||||
'hideKeyboard',
|
||||
'-',
|
||||
`editor.${EditorCommandType.GoDocStart}`,
|
||||
`editor.${EditorCommandType.GoDocEnd}`,
|
||||
];
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ const omitFromDefault: string[] = [
|
||||
`editor.${EditorCommandType.DeleteLine}`,
|
||||
`editor.${EditorCommandType.DuplicateLine}`,
|
||||
`editor.${EditorCommandType.SortSelectedLines}`,
|
||||
`editor.${EditorCommandType.GoDocStart}`,
|
||||
`editor.${EditorCommandType.GoDocEnd}`,
|
||||
];
|
||||
|
||||
// The "hide keyboard" button is only needed on iOS, so only show it there by default.
|
||||
|
||||
@@ -168,6 +168,16 @@ const declarations: CommandDeclaration[] = [
|
||||
label: () => _('Link'),
|
||||
iconName: 'material link',
|
||||
},
|
||||
{
|
||||
name: `editor.${EditorCommandType.GoDocStart}`,
|
||||
label: () => _('Go to start of note'),
|
||||
iconName: 'material page-first',
|
||||
},
|
||||
{
|
||||
name: `editor.${EditorCommandType.GoDocEnd}`,
|
||||
label: () => _('Go to end of note'),
|
||||
iconName: 'material page-last',
|
||||
},
|
||||
];
|
||||
|
||||
export default declarations;
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from 'react';
|
||||
import { PrimaryButton, SecondaryButton } from '../buttons';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Audio, InterruptionModeIOS } from 'expo-av';
|
||||
import { AudioQuality, getRecordingPermissionsAsync, IOSOutputFormat, requestRecordingPermissionsAsync, setAudioModeAsync, type RecordingOptions, useAudioRecorder as useExpoAudioRecorder, useAudioRecorderState } from 'expo-audio';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { OnFileSavedCallback, RecorderState } from './types';
|
||||
import { Platform } from 'react-native';
|
||||
@@ -11,7 +11,6 @@ import FsDriverWeb from '../../utils/fs-driver/fs-driver-rn.web';
|
||||
import uuid from '@joplin/lib/uuid';
|
||||
import RecordingControls from './RecordingControls';
|
||||
import { Text } from 'react-native-paper';
|
||||
import { AndroidAudioEncoder, AndroidOutputFormat, IOSAudioQuality, IOSOutputFormat, RecordingOptions } from 'expo-av/build/Audio';
|
||||
import time from '@joplin/lib/time';
|
||||
import { toFileExtension } from '@joplin/lib/mime-utils';
|
||||
import { formatMsToDurationCompat, msleep } from '@joplin/utils/time';
|
||||
@@ -25,23 +24,21 @@ interface Props {
|
||||
|
||||
// Modified from the Expo default recording options to create
|
||||
// .m4a recordings on both Android and iOS (rather than .3gp on Android).
|
||||
const recordingOptions = (): RecordingOptions => ({
|
||||
const recordingOptions: RecordingOptions = {
|
||||
extension: '.m4a',
|
||||
isMeteringEnabled: true,
|
||||
sampleRate: 44100,
|
||||
numberOfChannels: 2,
|
||||
bitRate: 64000,
|
||||
android: {
|
||||
extension: '.m4a',
|
||||
outputFormat: AndroidOutputFormat.MPEG_4,
|
||||
audioEncoder: AndroidAudioEncoder.AAC,
|
||||
sampleRate: 44100,
|
||||
numberOfChannels: 2,
|
||||
bitRate: 64000,
|
||||
outputFormat: 'mpeg4',
|
||||
audioEncoder: 'aac',
|
||||
},
|
||||
ios: {
|
||||
extension: '.m4a',
|
||||
audioQuality: IOSAudioQuality.MIN,
|
||||
audioQuality: AudioQuality.MIN,
|
||||
outputFormat: IOSOutputFormat.MPEG4AAC,
|
||||
sampleRate: 44100,
|
||||
numberOfChannels: 2,
|
||||
bitRate: 64000,
|
||||
linearPCMBitDepth: 16,
|
||||
linearPCMIsBigEndian: false,
|
||||
linearPCMIsFloat: false,
|
||||
@@ -56,14 +53,16 @@ const recordingOptions = (): RecordingOptions => ({
|
||||
].find(type => MediaRecorder.isTypeSupported(type)) ?? 'audio/webm',
|
||||
bitsPerSecond: 128000,
|
||||
} : {},
|
||||
});
|
||||
};
|
||||
|
||||
const getRecordingFileName = (extension: string) => {
|
||||
return `recording-${time.formatDateToLocal(new Date())}${extension}`;
|
||||
};
|
||||
|
||||
const recordingToSaveData = async (recording: Audio.Recording) => {
|
||||
let uri = recording.getURI();
|
||||
const recordingToSaveData = async (recordingUri: string|null) => {
|
||||
if (!recordingUri) throw new Error(_('Unable to access the recording file.'));
|
||||
|
||||
let uri = recordingUri;
|
||||
let type: string|undefined;
|
||||
let fileName;
|
||||
|
||||
@@ -73,7 +72,7 @@ const recordingToSaveData = async (recording: Audio.Recording) => {
|
||||
const fetchResult = await fetch(uri);
|
||||
const blob = await fetchResult.blob();
|
||||
|
||||
type = recordingOptions().web.mimeType;
|
||||
type = recordingOptions.web.mimeType;
|
||||
const extension = `.${toFileExtension(type)}`;
|
||||
fileName = getRecordingFileName(extension);
|
||||
const file = new File([blob], fileName);
|
||||
@@ -82,10 +81,9 @@ const recordingToSaveData = async (recording: Audio.Recording) => {
|
||||
await (shim.fsDriver() as FsDriverWeb).createReadOnlyVirtualFile(path, file);
|
||||
uri = path;
|
||||
} else {
|
||||
const options = recordingOptions();
|
||||
const extension = Platform.select({
|
||||
android: options.android.extension,
|
||||
ios: options.ios.extension,
|
||||
android: recordingOptions.android.extension,
|
||||
ios: recordingOptions.ios.extension,
|
||||
default: '',
|
||||
});
|
||||
fileName = getRecordingFileName(extension);
|
||||
@@ -95,75 +93,72 @@ const recordingToSaveData = async (recording: Audio.Recording) => {
|
||||
};
|
||||
|
||||
const resetAudioMode = async () => {
|
||||
await Audio.setAudioModeAsync({
|
||||
// When enabled, iOS may use the small (phone call) speaker
|
||||
// instead of the default one, so it's disabled when not recording:
|
||||
allowsRecordingIOS: false,
|
||||
playsInSilentModeIOS: false,
|
||||
staysActiveInBackground: false,
|
||||
await setAudioModeAsync({
|
||||
allowsRecording: false,
|
||||
allowsBackgroundRecording: false,
|
||||
playsInSilentMode: false,
|
||||
shouldPlayInBackground: false,
|
||||
});
|
||||
};
|
||||
|
||||
const useAudioRecorder = (onFileSaved: OnFileSavedCallback, onDismiss: ()=> void) => {
|
||||
const [permissionResponse, requestPermissions] = Audio.usePermissions();
|
||||
const [recordingState, setRecordingState] = useState<RecorderState>(RecorderState.Idle);
|
||||
const [error, setError] = useState('');
|
||||
const [duration, setDuration] = useState(0);
|
||||
const recorder = useExpoAudioRecorder(recordingOptions);
|
||||
const recorderStatus = useAudioRecorderState(recorder, 100);
|
||||
const isRecordingRef = useRef(false);
|
||||
|
||||
const recordingRef = useRef<Audio.Recording|null>(null);
|
||||
const onStartRecording = useCallback(async () => {
|
||||
try {
|
||||
setRecordingState(RecorderState.Loading);
|
||||
setError('');
|
||||
|
||||
if (permissionResponse?.status !== 'granted') {
|
||||
const response = await requestPermissions();
|
||||
const permissionResponse = await getRecordingPermissionsAsync();
|
||||
if (permissionResponse.status !== 'granted') {
|
||||
const response = await requestRecordingPermissionsAsync();
|
||||
if (!response.granted) {
|
||||
throw new Error(_('Missing permission to record audio.'));
|
||||
}
|
||||
|
||||
// Work around "This experience is currently in the background, so the audio session could not be activated"
|
||||
// See https://github.com/expo/expo/issues/21782
|
||||
// May be resolved by migrating to expo-audio.
|
||||
await msleep(500);
|
||||
}
|
||||
|
||||
await Audio.setAudioModeAsync({
|
||||
allowsRecordingIOS: true,
|
||||
playsInSilentModeIOS: true,
|
||||
staysActiveInBackground: true,
|
||||
await setAudioModeAsync({
|
||||
allowsRecording: true,
|
||||
allowsBackgroundRecording: true,
|
||||
playsInSilentMode: true,
|
||||
shouldPlayInBackground: true,
|
||||
// Fixes an issue where opening a recording in the iOS audio player
|
||||
// breaks creating new recordings.
|
||||
// See https://github.com/expo/expo/issues/31152#issuecomment-2341811087
|
||||
interruptionModeIOS: InterruptionModeIOS.DoNotMix,
|
||||
interruptionMode: 'doNotMix',
|
||||
});
|
||||
await recorder.prepareToRecordAsync();
|
||||
isRecordingRef.current = true;
|
||||
recorder.record();
|
||||
setRecordingState(RecorderState.Recording);
|
||||
const recording = new Audio.Recording();
|
||||
await recording.prepareToRecordAsync(recordingOptions());
|
||||
recording.setOnRecordingStatusUpdate(status => {
|
||||
setDuration(status.durationMillis);
|
||||
});
|
||||
recordingRef.current = recording;
|
||||
await recording.startAsync();
|
||||
} catch (error) {
|
||||
logger.error('Error starting recording:', error);
|
||||
setError(`Recording error: ${error}`);
|
||||
setRecordingState(RecorderState.Error);
|
||||
|
||||
void recordingRef.current?.stopAndUnloadAsync();
|
||||
recordingRef.current = null;
|
||||
if (isRecordingRef.current) {
|
||||
isRecordingRef.current = false;
|
||||
void recorder.stop();
|
||||
}
|
||||
}
|
||||
}, [permissionResponse, requestPermissions]);
|
||||
}, [recorder]);
|
||||
|
||||
const onStopRecording = useCallback(async () => {
|
||||
const recording = recordingRef.current;
|
||||
recordingRef.current = null;
|
||||
|
||||
try {
|
||||
setRecordingState(RecorderState.Processing);
|
||||
await recording.stopAndUnloadAsync();
|
||||
await recorder.stop();
|
||||
isRecordingRef.current = false;
|
||||
await resetAudioMode();
|
||||
|
||||
const saveEvent = await recordingToSaveData(recording);
|
||||
const saveEvent = await recordingToSaveData(recorder.uri);
|
||||
onFileSaved(saveEvent);
|
||||
onDismiss();
|
||||
} catch (error) {
|
||||
@@ -171,25 +166,35 @@ const useAudioRecorder = (onFileSaved: OnFileSavedCallback, onDismiss: ()=> void
|
||||
setError(`Save error: ${error}`);
|
||||
setRecordingState(RecorderState.Error);
|
||||
}
|
||||
}, [onFileSaved, onDismiss]);
|
||||
}, [onFileSaved, onDismiss, recorder]);
|
||||
|
||||
const onStartStopRecording = useCallback(async () => {
|
||||
if (recordingState === RecorderState.Idle) {
|
||||
await onStartRecording();
|
||||
} else if (recordingState === RecorderState.Recording && recordingRef.current) {
|
||||
} else if (recordingState === RecorderState.Recording) {
|
||||
await onStopRecording();
|
||||
}
|
||||
}, [recordingState, onStartRecording, onStopRecording]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (recordingRef.current) {
|
||||
void recordingRef.current?.stopAndUnloadAsync();
|
||||
recordingRef.current = null;
|
||||
void resetAudioMode();
|
||||
}
|
||||
}, []);
|
||||
if (isRecordingRef.current) {
|
||||
isRecordingRef.current = false;
|
||||
|
||||
return { onStartStopRecording, error, duration, recordingState };
|
||||
const stopRecorderOnCleanup = async () => {
|
||||
try {
|
||||
await recorder.stop();
|
||||
} catch (error) {
|
||||
logger.warn('Error stopping recorder during cleanup:', error);
|
||||
}
|
||||
|
||||
await resetAudioMode();
|
||||
};
|
||||
|
||||
void stopRecorderOnCleanup();
|
||||
}
|
||||
}, [recorder]);
|
||||
|
||||
return { onStartStopRecording, error, duration: recorderStatus.durationMillis, recordingState };
|
||||
};
|
||||
|
||||
const AudioRecordingBanner: React.FC<Props> = props => {
|
||||
|
||||
@@ -520,7 +520,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 152;
|
||||
CURRENT_PROJECT_VERSION = 153;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
@@ -529,7 +529,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.6.3;
|
||||
MARKETING_VERSION = 13.6.4;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -555,7 +555,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 152;
|
||||
CURRENT_PROJECT_VERSION = 153;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
@@ -563,7 +563,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.6.3;
|
||||
MARKETING_VERSION = 13.6.4;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -758,7 +758,7 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 152;
|
||||
CURRENT_PROJECT_VERSION = 153;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
@@ -769,7 +769,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.6.3;
|
||||
MARKETING_VERSION = 13.6.4;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
@@ -801,7 +801,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 152;
|
||||
CURRENT_PROJECT_VERSION = 153;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
@@ -812,7 +812,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.6.3;
|
||||
MARKETING_VERSION = 13.6.4;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
PODS:
|
||||
- EXAV (16.0.8):
|
||||
- ExpoModulesCore
|
||||
- ReactCommon/turbomodule/core
|
||||
- EXConstants (18.0.13):
|
||||
- ExpoModulesCore
|
||||
- EXImageLoader (6.0.0):
|
||||
@@ -34,6 +31,8 @@ PODS:
|
||||
- Yoga
|
||||
- ExpoAsset (12.0.12):
|
||||
- ExpoModulesCore
|
||||
- ExpoAudio (1.1.1):
|
||||
- ExpoModulesCore
|
||||
- ExpoCamera (17.0.10):
|
||||
- ExpoModulesCore
|
||||
- ZXingObjC/OneD
|
||||
@@ -72,9 +71,9 @@ PODS:
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- FBLazyVector (0.81.6)
|
||||
- hermes-engine (0.81.5):
|
||||
- hermes-engine/Pre-built (= 0.81.5)
|
||||
- hermes-engine/Pre-built (0.81.5)
|
||||
- hermes-engine (0.81.6):
|
||||
- hermes-engine/Pre-built (= 0.81.6)
|
||||
- hermes-engine/Pre-built (0.81.6)
|
||||
- JoplinCommonShareExtension (1.0.0)
|
||||
- JoplinRNShareExtension (1.0.0):
|
||||
- JoplinCommonShareExtension
|
||||
@@ -154,7 +153,7 @@ PODS:
|
||||
- React-utils
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- React-Core-prebuilt (0.81.5):
|
||||
- React-Core-prebuilt (0.81.6):
|
||||
- ReactNativeDependencies
|
||||
- React-Core/CoreModulesHeaders (0.81.6):
|
||||
- hermes-engine
|
||||
@@ -2134,7 +2133,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- SDWebImage/Core (5.21.5)
|
||||
- SDWebImage/Core (5.21.7)
|
||||
- SDWebImageWebPCoder (0.15.0):
|
||||
- libwebp (~> 1.0)
|
||||
- SDWebImage/Core (~> 5.17)
|
||||
@@ -2169,11 +2168,11 @@ PODS:
|
||||
- ZXingObjC/Core
|
||||
|
||||
DEPENDENCIES:
|
||||
- EXAV (from `../node_modules/expo-av/ios`)
|
||||
- EXConstants (from `../node_modules/expo-constants/ios`)
|
||||
- EXImageLoader (from `../node_modules/expo-image-loader/ios`)
|
||||
- Expo (from `../node_modules/expo`)
|
||||
- ExpoAsset (from `../node_modules/expo-asset/ios`)
|
||||
- ExpoAudio (from `../node_modules/expo-audio/ios`)
|
||||
- ExpoCamera (from `../node_modules/expo-camera/ios`)
|
||||
- ExpoFileSystem (from `../node_modules/expo-file-system/ios`)
|
||||
- ExpoFont (from `../node_modules/expo-font/ios`)
|
||||
@@ -2293,8 +2292,6 @@ SPEC REPOS:
|
||||
- ZXingObjC
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
EXAV:
|
||||
:path: "../node_modules/expo-av/ios"
|
||||
EXConstants:
|
||||
:path: "../node_modules/expo-constants/ios"
|
||||
EXImageLoader:
|
||||
@@ -2303,6 +2300,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/expo"
|
||||
ExpoAsset:
|
||||
:path: "../node_modules/expo-asset/ios"
|
||||
ExpoAudio:
|
||||
:path: "../node_modules/expo-audio/ios"
|
||||
ExpoCamera:
|
||||
:path: "../node_modules/expo-camera/ios"
|
||||
ExpoFileSystem:
|
||||
@@ -2522,11 +2521,11 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/react-native/ReactCommon/yoga"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
EXAV: b60fcf142fae6684d295bc28cd7cfcb3335570ea
|
||||
EXConstants: fce59a631a06c4151602843667f7cfe35f81e271
|
||||
EXImageLoader: 189e3476581efe3ad4d1d3fb4735b7179eb26f05
|
||||
Expo: 04993fbd7b06dc98ffac58da8847298470dc3db1
|
||||
ExpoAsset: f867e55ceb428aab99e1e8c082b5aee7c159ea18
|
||||
ExpoAudio: e4cfe3a2f3317b8487460685385a9867a07fb4fb
|
||||
ExpoCamera: 6a326deb45ba840749652e4c15198317aa78497e
|
||||
ExpoFileSystem: 858a44267a3e6e9057e0888ad7c7cfbf55d52063
|
||||
ExpoFont: 35ac6191ed86bbf56b3ebd2d9154eda9fad5b509
|
||||
@@ -2534,7 +2533,7 @@ SPEC CHECKSUMS:
|
||||
ExpoLocalAuthentication: 8a31808565da7af926dd9b595e98594d8b1553b6
|
||||
ExpoModulesCore: f3da4f1ab5a8375d0beafab763739dbee8446583
|
||||
FBLazyVector: 14ce6e3675cacb2683ad30272f04274a4ee5b67d
|
||||
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
|
||||
hermes-engine: 7219f6e751ad6ec7f3d7ec121830ee34dae40749
|
||||
JoplinCommonShareExtension: a8b60b02704d85a7305627912c0240e94af78db7
|
||||
JoplinRNShareExtension: e158a4b53ee0aa9cd3037a16221dc8adbd6f7860
|
||||
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
||||
@@ -2546,7 +2545,7 @@ SPEC CHECKSUMS:
|
||||
React: 348d1689d8686d034c5b7667dc45de86c6319dd1
|
||||
React-callinvoker: 2c3b664f3482f5bc5560ea1edcbbe69748752f08
|
||||
React-Core: 346787852200a732b187805344b8a350d464e004
|
||||
React-Core-prebuilt: 02f0ad625ddd47463c009c2d0c5dd35c0d982599
|
||||
React-Core-prebuilt: 721ab014acfaff1e4b8fc0d2f7d6f41ea9a706ed
|
||||
React-CoreModules: 7e07391a1082d02c37f846a362f7574ab035933c
|
||||
React-cxxreact: c50d278c785792a077a6b357aaabd9e5d09e9c6f
|
||||
React-debug: 1b91785fec02ea76c793ead23bed1528d96b4262
|
||||
@@ -2635,7 +2634,7 @@ SPEC CHECKSUMS:
|
||||
RNSecureRandom: b64d263529492a6897e236a22a2c4249aa1b53dc
|
||||
RNShare: 0e600372fb35783fe30d413efd28d11de2bf6cf0
|
||||
RNSVG: cf9ae78f2edf2988242c71a6392d15ff7dd62522
|
||||
SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838
|
||||
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
|
||||
SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377
|
||||
WhisperVoiceTyping: 343ea840cbde2a5f3508f8b016ebcf1c089179ea
|
||||
Yoga: 786fa7d9d2ff6060b4e688062243fa69c323d140
|
||||
|
||||
@@ -76,14 +76,41 @@ jest.mock('@react-native-clipboard/clipboard', () => {
|
||||
return { default: { getString: jest.fn(), setString: jest.fn() } };
|
||||
});
|
||||
|
||||
jest.doMock('expo-audio', () => {
|
||||
return {
|
||||
AudioQuality: {
|
||||
MIN: 'min',
|
||||
},
|
||||
IOSOutputFormat: {
|
||||
MPEG4AAC: 'mpeg4aac',
|
||||
},
|
||||
getRecordingPermissionsAsync: jest.fn(async () => ({
|
||||
status: 'granted',
|
||||
granted: true,
|
||||
})),
|
||||
requestRecordingPermissionsAsync: jest.fn(async () => ({
|
||||
status: 'granted',
|
||||
granted: true,
|
||||
})),
|
||||
setAudioModeAsync: jest.fn(async () => null),
|
||||
useAudioRecorder: jest.fn(() => ({
|
||||
prepareToRecordAsync: jest.fn(async () => null),
|
||||
record: jest.fn(),
|
||||
stop: jest.fn(async () => null),
|
||||
uri: null,
|
||||
})),
|
||||
useAudioRecorderState: jest.fn(() => ({
|
||||
durationMillis: 0,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const emptyMockPackages = [
|
||||
'react-native-share',
|
||||
'react-native-file-viewer',
|
||||
'react-native-image-picker',
|
||||
'@react-native-documents/picker',
|
||||
'@joplin/react-native-saf-x',
|
||||
'expo-av',
|
||||
'expo-av/build/Audio',
|
||||
'expo-image-manipulator',
|
||||
];
|
||||
for (const packageName of emptyMockPackages) {
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"deprecated-react-native-prop-types": "5.0.0",
|
||||
"events": "3.3.0",
|
||||
"expo": "54.0.31",
|
||||
"expo-av": "16.0.8",
|
||||
"expo-audio": "1.1.1",
|
||||
"expo-camera": "17.0.10",
|
||||
"expo-image-manipulator": "14.0.8",
|
||||
"expo-local-authentication": "17.0.8",
|
||||
@@ -77,7 +77,7 @@
|
||||
"react-native-rsa-native": "2.0.5",
|
||||
"react-native-safe-area-context": "5.6.2",
|
||||
"react-native-securerandom": "1.0.1",
|
||||
"react-native-share": "12.2.1",
|
||||
"react-native-share": "12.2.2",
|
||||
"react-native-sqlite-storage": "6.0.1",
|
||||
"react-native-svg": "15.15.1",
|
||||
"react-native-url-polyfill": "2.0.0",
|
||||
@@ -115,8 +115,8 @@
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/node": "18.19.130",
|
||||
"@types/react": "19.1.10",
|
||||
"@types/react-redux": "7.1.33",
|
||||
"@types/serviceworker": "0.0.172",
|
||||
"@types/react-redux": "7.1.34",
|
||||
"@types/serviceworker": "0.0.176",
|
||||
"@types/tar-stream": "3.1.4",
|
||||
"babel-jest": "29.7.0",
|
||||
"babel-loader": "9.1.3",
|
||||
@@ -146,7 +146,7 @@
|
||||
"url-loader": "4.1.1",
|
||||
"webpack": "5.97.1",
|
||||
"webpack-cli": "5.1.4",
|
||||
"webpack-dev-server": "5.2.2"
|
||||
"webpack-dev-server": "5.2.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
48
packages/app-mobile/utils/appReducer.test.ts
Normal file
48
packages/app-mobile/utils/appReducer.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import appReducer from './appReducer';
|
||||
import appDefaultState, { DEFAULT_ROUTE } from './appDefaultState';
|
||||
|
||||
const notesRoute = { type: 'NAV_GO', routeName: 'Notes', folderId: 'folder1' };
|
||||
const settingsRoute = { type: 'NAV_GO', routeName: 'Config' };
|
||||
const deletedFolderId = 'folder1';
|
||||
|
||||
const clearHistory = () => appReducer(appDefaultState, { type: 'NAV_GO', routeName: 'Notes', clearHistory: true });
|
||||
|
||||
// Simulates the exact scenario: navigate to a folder with no prior history, then delete it
|
||||
const makeDeletedRouteWithEmptyHistory = () => {
|
||||
let state = appReducer(appDefaultState, {
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Notes',
|
||||
folderId: deletedFolderId,
|
||||
clearHistory: true,
|
||||
});
|
||||
state = appReducer(state, { type: 'FOLDER_DELETE', id: deletedFolderId });
|
||||
return state;
|
||||
};
|
||||
|
||||
describe('appReducer', () => {
|
||||
test('historyCanGoBack is true after navigating from Notes to Settings', () => {
|
||||
let state = clearHistory();
|
||||
state = appReducer(state, notesRoute);
|
||||
state = appReducer(state, settingsRoute);
|
||||
expect(state.historyCanGoBack).toBe(true);
|
||||
});
|
||||
|
||||
test('historyCanGoBack remains true after navigating away from a deleted route', () => {
|
||||
let state = makeDeletedRouteWithEmptyHistory();
|
||||
|
||||
// Navigate forward (e.g. open Settings)
|
||||
state = appReducer(state, settingsRoute);
|
||||
|
||||
expect(state.historyCanGoBack).toBe(true);
|
||||
});
|
||||
|
||||
test('going back from Settings after folder deletion lands on DEFAULT_ROUTE', () => {
|
||||
let state = makeDeletedRouteWithEmptyHistory();
|
||||
state = appReducer(state, settingsRoute);
|
||||
|
||||
// Go back
|
||||
state = appReducer(state, { type: 'NAV_BACK' });
|
||||
|
||||
expect(state.route).toEqual(DEFAULT_ROUTE);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import reducer from '@joplin/lib/reducer';
|
||||
import { AppState } from './types';
|
||||
import appDefaultState from './appDefaultState';
|
||||
import appDefaultState, { DEFAULT_ROUTE } from './appDefaultState';
|
||||
import fastDeepEqual = require('fast-deep-equal');
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
|
||||
@@ -76,6 +76,10 @@ const appReducer = (state = appDefaultState, action: any) => {
|
||||
if (currentRoute.isDeleted) {
|
||||
// Do not add the item to the history, and remove the last item in the history if that is now the selected item
|
||||
removeLatestFolderIfSelected(navHistory, action);
|
||||
// Push DEFAULT_ROUTE so there's always a valid back target after deletion
|
||||
if (!navHistory.length) {
|
||||
navHistory.push(DEFAULT_ROUTE);
|
||||
}
|
||||
} else if (isDifferentRoute) {
|
||||
navHistory.push(currentRoute);
|
||||
}
|
||||
|
||||
@@ -144,4 +144,21 @@ describe('ProseMirror/commands', () => {
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
test('goDocEnd should move the cursor to the end of the document', () => {
|
||||
const editor = createTestEditor({ html: '<p>First</p><p>Last</p>' });
|
||||
|
||||
commands[EditorCommandType.GoDocEnd](editor.state, editor.dispatch, editor);
|
||||
|
||||
expect(editor.state.selection.from).toBe(editor.state.doc.content.size - 1);
|
||||
});
|
||||
|
||||
test('goDocStart should move the cursor to the start of the document', () => {
|
||||
const editor = createTestEditor({ html: '<p>First</p><p>Last</p>' });
|
||||
|
||||
moveCursorToEnd(editor);
|
||||
commands[EditorCommandType.GoDocStart](editor.state, editor.dispatch, editor);
|
||||
|
||||
expect(editor.state.selection.from).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Command, EditorState, Transaction } from 'prosemirror-state';
|
||||
import { Command, EditorState, TextSelection, Transaction } from 'prosemirror-state';
|
||||
import { EditorCommandType } from '../../types';
|
||||
import { redo, undo } from 'prosemirror-history';
|
||||
import { autoJoin, selectAll, setBlockType, toggleMark } from 'prosemirror-commands';
|
||||
@@ -247,8 +247,14 @@ const commands: Record<EditorCommandType, ExtendedCommand|null> = {
|
||||
[EditorCommandType.InsertNewlineAndIndent]: null,
|
||||
[EditorCommandType.SwapLineUp]: null,
|
||||
[EditorCommandType.SwapLineDown]: null,
|
||||
[EditorCommandType.GoDocEnd]: null,
|
||||
[EditorCommandType.GoDocStart]: null,
|
||||
[EditorCommandType.GoDocEnd]: (state, dispatch) => {
|
||||
dispatch(state.tr.setSelection(TextSelection.atEnd(state.doc)).scrollIntoView());
|
||||
return true;
|
||||
},
|
||||
[EditorCommandType.GoDocStart]: (state, dispatch) => {
|
||||
dispatch(state.tr.setSelection(TextSelection.atStart(state.doc)).scrollIntoView());
|
||||
return true;
|
||||
},
|
||||
[EditorCommandType.GoLineStart]: null,
|
||||
[EditorCommandType.GoLineEnd]: null,
|
||||
[EditorCommandType.GoLineUp]: null,
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/node": "18.19.130",
|
||||
"@types/react": "19.1.10",
|
||||
"@types/react-redux": "7.1.33",
|
||||
"@types/react-redux": "7.1.34",
|
||||
"@types/styled-components": "5.1.32",
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
@@ -28,17 +28,17 @@
|
||||
"typescript": "5.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "6.20.0",
|
||||
"@codemirror/commands": "6.10.1",
|
||||
"@codemirror/autocomplete": "6.20.1",
|
||||
"@codemirror/commands": "6.10.3",
|
||||
"@codemirror/lang-html": "6.4.11",
|
||||
"@codemirror/lang-markdown": "6.5.0",
|
||||
"@codemirror/language": "6.10.4",
|
||||
"@codemirror/language": "6.12.3",
|
||||
"@codemirror/language-data": "6.3.1",
|
||||
"@codemirror/legacy-modes": "6.5.2",
|
||||
"@codemirror/lint": "6.9.2",
|
||||
"@codemirror/search": "6.5.8",
|
||||
"@codemirror/state": "6.5.4",
|
||||
"@codemirror/view": "6.35.0",
|
||||
"@codemirror/lint": "6.9.5",
|
||||
"@codemirror/search": "6.6.0",
|
||||
"@codemirror/state": "6.6.0",
|
||||
"@codemirror/view": "6.41.0",
|
||||
"@joplin/fork-uslug": "^2.0.0",
|
||||
"@lezer/common": "1.5.0",
|
||||
"@lezer/highlight": "1.2.3",
|
||||
@@ -49,7 +49,7 @@
|
||||
"prosemirror-commands": "1.7.1",
|
||||
"prosemirror-dropcursor": "1.8.2",
|
||||
"prosemirror-example-setup": "1.2.3",
|
||||
"prosemirror-gapcursor": "1.3.2",
|
||||
"prosemirror-gapcursor": "1.4.0",
|
||||
"prosemirror-history": "1.5.0",
|
||||
"prosemirror-inputrules": "1.5.0",
|
||||
"prosemirror-keymap": "1.2.3",
|
||||
|
||||
@@ -224,17 +224,44 @@ const markdownUtils = {
|
||||
if (!body) return '';
|
||||
const spaceEntities = / /g;
|
||||
body = body.replace(spaceEntities, ' ');
|
||||
const lines = body.trim().split('\n');
|
||||
const title = lines[0].trim();
|
||||
const lines = body.split('\n');
|
||||
let title = '';
|
||||
|
||||
const mdLinkRegex = /!?\[([^\]]+?)\]\(.+?\)/g;
|
||||
const emptyMdLinkRegex = /!?\[\]\((.+?)\)/g;
|
||||
const filterRegex = /^[# \n\t*`-]*/;
|
||||
return title
|
||||
.replace(filterRegex, '')
|
||||
.replace(mdLinkRegex, '$1')
|
||||
.replace(emptyMdLinkRegex, '$1')
|
||||
.substring(0, 80);
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed) {
|
||||
title = trimmed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
title = title.replace(/<\/?(ins|del|mark|sub|sup)>/g, '');
|
||||
title = title.replace(/!?\[([^\]]*)\]\(([^()\n]+(?:\([^()\n]*\)[^()\n]*)*)\)/g, (_match, text, url) => {
|
||||
return text || url;
|
||||
});
|
||||
const formattingPatterns = [
|
||||
/(\*\*\*|___)(.*?)\1/g,
|
||||
/(\*\*|__)(.*?)\1/g,
|
||||
/(\*|_)(.*?)\1/g,
|
||||
/(~~)(.*?)\1/g,
|
||||
/(==)(.*?)\1/g,
|
||||
/(\^)(.*?)\1/g,
|
||||
];
|
||||
|
||||
let prev;
|
||||
do {
|
||||
prev = title;
|
||||
for (const pattern of formattingPatterns) {
|
||||
title = title.replace(pattern, '$2');
|
||||
}
|
||||
} while (title !== prev);
|
||||
|
||||
title = title.replace(/^\s*([-*+]|\d+[.)])\s+(\[[ xX]\]\s+)?/, '');
|
||||
title = title.replace(/^(~{2,}|={2,})$/, '');
|
||||
title = title.replace(/^[#>\-*`\s=]+/, '');
|
||||
title = title.replace(/[#>\-*`\s=]+$/, '');
|
||||
title = title.trim();
|
||||
return title.substring(0, 80);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -115,6 +115,15 @@ describe('markdownUtils', () => {
|
||||
['These are [link1](one), [link2](two) and ', 'These are link1, link2 and link3'],
|
||||
['No description link to [](https://joplinapp.org)', 'No description link to https://joplinapp.org'],
|
||||
[' \n\nThis is a test test.', 'This is a test test.'],
|
||||
['***Example title***', 'Example title'],
|
||||
['==~~Formatted title~~==', 'Formatted title'],
|
||||
['<ins>Important note title</ins>', 'Important note title'],
|
||||
['`Inline code title`', 'Inline code title'],
|
||||
['<ins>~~==***Deeply formatted title***==~~</ins>', 'Deeply formatted title'],
|
||||
['C++ <vector> usage guide', 'C++ <vector> usage guide'],
|
||||
['C# programming basics', 'C# programming basics'],
|
||||
['Understanding a ~ b relationship', 'Understanding a ~ b relationship'],
|
||||
['***~~***', ''],
|
||||
])('should create a default note title from the note body (%j -> %j)', (body, expectedTitle) => {
|
||||
expect(markdownUtils.titleFromBody(body)).toBe(expectedTitle);
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"@types/node-rsa": "1.1.4",
|
||||
"@types/react": "19.1.10",
|
||||
"@types/react-dom": "19.1.7",
|
||||
"@types/uuid": "10.0.0",
|
||||
"@types/uuid": "11.0.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-expect-message": "1.1.3",
|
||||
"jsdom": "26.1.0",
|
||||
|
||||
@@ -53,8 +53,8 @@ const defaultKeymapItems = {
|
||||
{ accelerator: 'F1', command: 'help' },
|
||||
{ accelerator: 'Cmd+D', command: 'editor.deleteLine' },
|
||||
{ accelerator: 'Shift+Cmd+D', command: 'editor.duplicateLine' },
|
||||
{ accelerator: 'Cmd+Z', command: 'editor.undo' },
|
||||
{ accelerator: 'Cmd+Y', command: 'editor.redo' },
|
||||
{ accelerator: 'Cmd+Z', command: 'globalUndo' },
|
||||
{ accelerator: 'Cmd+Shift+Z', command: 'globalRedo' },
|
||||
{ accelerator: 'Cmd+[', command: 'editor.indentLess' },
|
||||
{ accelerator: 'Cmd+]', command: 'editor.indentMore' },
|
||||
{ accelerator: 'Cmd+/', command: 'editor.toggleComment' },
|
||||
@@ -109,8 +109,8 @@ const defaultKeymapItems = {
|
||||
{ accelerator: 'F1', command: 'help' },
|
||||
{ accelerator: 'Ctrl+D', command: 'editor.deleteLine' },
|
||||
{ accelerator: 'Shift+Ctrl+D', command: 'editor.duplicateLine' },
|
||||
{ accelerator: 'Ctrl+Z', command: 'editor.undo' },
|
||||
{ accelerator: 'Ctrl+Y', command: 'editor.redo' },
|
||||
{ accelerator: 'Ctrl+Z', command: 'globalUndo' },
|
||||
{ accelerator: 'Ctrl+Y', command: 'globalRedo' },
|
||||
{ accelerator: 'Ctrl+[', command: 'editor.indentLess' },
|
||||
{ accelerator: 'Ctrl+]', command: 'editor.indentMore' },
|
||||
{ accelerator: 'Ctrl+/', command: 'editor.toggleComment' },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { toIso639Alpha3 } from '../../locale';
|
||||
import { countryCodeOnly, languageCodeOnly, toIso639Alpha3 } from '../../locale';
|
||||
import Resource from '../../models/Resource';
|
||||
import Setting from '../../models/Setting';
|
||||
import shim from '../../shim';
|
||||
@@ -23,6 +23,24 @@ export const supportedMimeTypes = [
|
||||
'image/x-portable-bitmap',
|
||||
];
|
||||
|
||||
// Tesseract uses its own language codes that don't always match ISO 639-3.
|
||||
// For example, the ISO 639-3 code for Chinese is "zho" but Tesseract uses
|
||||
// "chi_sim" (Simplified) and "chi_tra" (Traditional), and Norwegian Bokmål
|
||||
// is "nob" in ISO 639-3 but "nor" in Tesseract.
|
||||
const iso639ToTesseractOverrides: Record<string, string> = {
|
||||
'nob': 'nor',
|
||||
};
|
||||
|
||||
const localeToTesseractLanguage = (locale: string): string => {
|
||||
const lang = languageCodeOnly(locale);
|
||||
if (lang === 'zh') {
|
||||
const country = countryCodeOnly(locale).toUpperCase();
|
||||
return country === 'TW' ? 'chi_tra' : 'chi_sim';
|
||||
}
|
||||
const alpha3 = toIso639Alpha3(locale);
|
||||
return iso639ToTesseractOverrides[alpha3] || alpha3;
|
||||
};
|
||||
|
||||
const resourceInfo = (resource: ResourceEntity) => {
|
||||
return `${resource.id} (type ${resource.mime})`;
|
||||
};
|
||||
@@ -213,7 +231,7 @@ export default class OcrService {
|
||||
};
|
||||
|
||||
try {
|
||||
const language = toIso639Alpha3(Setting.value('locale'));
|
||||
const language = localeToTesseractLanguage(Setting.value('locale'));
|
||||
const processedResourceIds: string[] = [];
|
||||
|
||||
// Queue all resources for processing
|
||||
|
||||
@@ -19,6 +19,11 @@ const uslug = require('@joplin/fork-uslug');
|
||||
|
||||
const logger = Logger.create('PluginService');
|
||||
|
||||
interface PluginExtractionState {
|
||||
size: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// Plugin data is split into two:
|
||||
//
|
||||
// - First there's the service `plugins` property, which contains the
|
||||
@@ -105,6 +110,7 @@ export default class PluginService extends BaseService {
|
||||
private startedPlugins_: Record<string, boolean> = {};
|
||||
private isSafeMode_ = false;
|
||||
private pluginsChangeListeners_: LoadedPluginsChangeListener[] = [];
|
||||
private extractionStates_: Record<string, PluginExtractionState> = null;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public initialize(appVersion: string, platformImplementation: any, runner: BasePluginRunner, store: any) {
|
||||
@@ -197,6 +203,27 @@ export default class PluginService extends BaseService {
|
||||
await shim.fsDriver().remove(plugin.baseDir);
|
||||
}
|
||||
|
||||
private extractionStatePath(): string {
|
||||
return `${Setting.value('cacheDir')}/plugin-extraction-state.json`;
|
||||
}
|
||||
|
||||
private async loadExtractionStates(): Promise<Record<string, PluginExtractionState>> {
|
||||
if (!this.extractionStates_) {
|
||||
try {
|
||||
const text = await shim.fsDriver().readFile(this.extractionStatePath(), 'utf8');
|
||||
this.extractionStates_ = JSON.parse(text);
|
||||
} catch {
|
||||
this.extractionStates_ = {};
|
||||
}
|
||||
}
|
||||
return this.extractionStates_;
|
||||
}
|
||||
|
||||
private async saveExtractionStates(states: Record<string, PluginExtractionState>): Promise<void> {
|
||||
this.extractionStates_ = states;
|
||||
await shim.fsDriver().writeFile(this.extractionStatePath(), JSON.stringify(states), 'utf8');
|
||||
}
|
||||
|
||||
public pluginById(id: string): Plugin {
|
||||
if (!this.plugins_[id]) throw new Error(`Plugin not found: ${id}`);
|
||||
|
||||
@@ -291,15 +318,22 @@ export default class PluginService extends BaseService {
|
||||
baseDir = rtrimSlashes(baseDir);
|
||||
|
||||
const fname = filename(path);
|
||||
const hash = await shim.fsDriver().md5File(path);
|
||||
|
||||
const unpackDir = `${Setting.value('cacheDir')}/${fname}`;
|
||||
const manifestFilePath = `${unpackDir}/manifest.json`;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
let manifest: any = await this.loadManifestToObject(manifestFilePath);
|
||||
// Use file size + mtime to check if the .jpl has changed, to
|
||||
// avoid computing an MD5 hash of the full file on every startup.
|
||||
const stat = await shim.fsDriver().stat(path);
|
||||
const extractionStates = await this.loadExtractionStates();
|
||||
const extractionState = extractionStates[fname];
|
||||
const extractionValid = extractionState
|
||||
&& extractionState.size === stat.size
|
||||
&& extractionState.timestamp === stat.mtime.getTime()
|
||||
&& await shim.fsDriver().exists(manifestFilePath);
|
||||
|
||||
if (!extractionValid) {
|
||||
logger.info(`Extracting plugin: ${fname}`);
|
||||
|
||||
if (!manifest || manifest._package_hash !== hash) {
|
||||
await shim.fsDriver().remove(unpackDir);
|
||||
await shim.fsDriver().mkdir(unpackDir);
|
||||
|
||||
@@ -310,12 +344,16 @@ export default class PluginService extends BaseService {
|
||||
cwd: unpackDir,
|
||||
});
|
||||
|
||||
manifest = await this.loadManifestToObject(manifestFilePath);
|
||||
const manifest = await this.loadManifestToObject(manifestFilePath);
|
||||
if (!manifest) throw new Error(`Missing manifest file at: ${manifestFilePath}`);
|
||||
|
||||
manifest._package_hash = hash;
|
||||
|
||||
await shim.fsDriver().writeFile(manifestFilePath, JSON.stringify(manifest, null, '\t'), 'utf8');
|
||||
extractionStates[fname] = {
|
||||
size: stat.size,
|
||||
timestamp: stat.mtime.getTime(),
|
||||
};
|
||||
await this.saveExtractionStates(extractionStates);
|
||||
} else {
|
||||
logger.info(`Using already extracted plugin: ${fname}`);
|
||||
}
|
||||
|
||||
return this.loadPluginFromPath(unpackDir);
|
||||
@@ -495,6 +533,7 @@ export default class PluginService extends BaseService {
|
||||
logger.error(`Could not load plugin: ${pluginPath}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public async loadAndRunDevPlugins(settings: PluginSettings) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Action, createStore } from 'redux';
|
||||
import MockPlatformImplementation from './testing/MockPlatformImplementation';
|
||||
import createTestPlugin from '../../testing/plugins/createTestPlugin';
|
||||
import Plugin from './Plugin';
|
||||
import shim from '../../shim';
|
||||
|
||||
const createMockReduxStore = () => {
|
||||
return createStore((state: State = defaultState, action: Action<string>) => {
|
||||
@@ -136,6 +137,44 @@ describe('loadPlugins', () => {
|
||||
expect([...pluginRunner.runningPluginIds].sort()).toMatchObject(expectedRunningIds);
|
||||
});
|
||||
|
||||
test('should skip extraction when jpl has not changed', async () => {
|
||||
const pluginId = 'joplin.test.plugin.packed';
|
||||
await createTestPlugin({
|
||||
...defaultManifestProperties,
|
||||
id: pluginId,
|
||||
name: 'Test JPL Plugin',
|
||||
}, { format: 'jpl' });
|
||||
|
||||
const pluginRunner = new MockPluginRunner();
|
||||
const store = createMockReduxStore();
|
||||
const service = PluginService.instance();
|
||||
service.initialize('2.3.4', platformImplementation, pluginRunner, store);
|
||||
|
||||
const tarExtractSpy = jest.spyOn(shim.fsDriver(), 'tarExtract');
|
||||
|
||||
// First load should extract
|
||||
await service.loadAndRunPlugins(Setting.value('pluginDir'), Setting.value('plugins.states'));
|
||||
expect(tarExtractSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second load with same file should skip extraction
|
||||
await service.unloadPlugin(pluginId);
|
||||
await service.loadAndRunPlugins(Setting.value('pluginDir'), Setting.value('plugins.states'));
|
||||
expect(tarExtractSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Recreating the jpl (different mtime/size) should trigger re-extraction
|
||||
await service.unloadPlugin(pluginId);
|
||||
await createTestPlugin({
|
||||
...defaultManifestProperties,
|
||||
id: pluginId,
|
||||
name: 'Test JPL Plugin',
|
||||
}, { format: 'jpl', onStart: '/* changed */' });
|
||||
|
||||
await service.loadAndRunPlugins(Setting.value('pluginDir'), Setting.value('plugins.states'));
|
||||
expect(tarExtractSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
tarExtractSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('should not block allPluginsStarted when a plugin fails to start', async () => {
|
||||
// This tests the fix for https://github.com/laurent22/joplin/issues/12793
|
||||
// When a plugin crashes before calling register(), it should not block
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { join } from 'path';
|
||||
import { PluginManifest } from '../../services/plugins/utils/types';
|
||||
import Setting from '../../models/Setting';
|
||||
import { writeFile } from 'fs-extra';
|
||||
import { mkdirp, writeFile } from 'fs-extra';
|
||||
import { defaultPluginSetting } from '../../services/plugins/PluginService';
|
||||
import shim from '../../shim';
|
||||
|
||||
|
||||
const setPluginEnabled = (id: string, enabled: boolean) => {
|
||||
@@ -19,22 +20,35 @@ const setPluginEnabled = (id: string, enabled: boolean) => {
|
||||
interface Options {
|
||||
onStart?: string;
|
||||
enabled?: boolean;
|
||||
format?: 'js' | 'jpl';
|
||||
}
|
||||
|
||||
const createTestPlugin = async (manifest: PluginManifest, { onStart = '', enabled = true }: Options = {}) => {
|
||||
const pluginSource = `
|
||||
const createTestPlugin = async (manifest: PluginManifest, { onStart = '', enabled = true, format = 'js' }: Options = {}) => {
|
||||
const scriptSource = `joplin.plugins.register({
|
||||
onStart: async function() {
|
||||
${onStart}
|
||||
},
|
||||
});`;
|
||||
|
||||
if (format === 'jpl') {
|
||||
const tempDir = join(Setting.value('tempDir'), `plugin-build-${manifest.id}`);
|
||||
await mkdirp(tempDir);
|
||||
await writeFile(join(tempDir, 'manifest.json'), JSON.stringify(manifest), 'utf-8');
|
||||
await writeFile(join(tempDir, 'index.js'), scriptSource, 'utf-8');
|
||||
|
||||
const jplPath = join(Setting.value('pluginDir'), `${manifest.id}.jpl`);
|
||||
await shim.fsDriver().tarCreate({ cwd: tempDir, file: jplPath }, ['manifest.json', 'index.js']);
|
||||
} else {
|
||||
const pluginSource = `
|
||||
/* joplin-manifest:
|
||||
${JSON.stringify(manifest)}
|
||||
*/
|
||||
|
||||
joplin.plugins.register({
|
||||
onStart: async function() {
|
||||
${onStart}
|
||||
},
|
||||
});
|
||||
${scriptSource}
|
||||
`;
|
||||
const pluginPath = join(Setting.value('pluginDir'), `${manifest.id}.js`);
|
||||
await writeFile(pluginPath, pluginSource, 'utf-8');
|
||||
const pluginPath = join(Setting.value('pluginDir'), `${manifest.id}.js`);
|
||||
await writeFile(pluginPath, pluginSource, 'utf-8');
|
||||
}
|
||||
|
||||
setPluginEnabled(manifest.id, enabled);
|
||||
|
||||
|
||||
@@ -535,7 +535,7 @@ export default class MdToHtml implements MarkupRenderer {
|
||||
// The strings includes the last \n that is part of the fence,
|
||||
// so we remove it because we need the exact code in the source block
|
||||
const trimmedStr = this.removeLastNewLine(str);
|
||||
const sourceBlockHtml = `<pre class="joplin-source" data-joplin-language="${htmlentities(lang)}" data-joplin-source-open="\`\`\`${htmlentities(lang)} " data-joplin-source-close=" \`\`\`">${markdownIt.utils.escapeHtml(trimmedStr)}</pre>`;
|
||||
const sourceBlockHtml = `<pre class="joplin-source" hidden data-joplin-language="${htmlentities(lang)}" data-joplin-source-open="\`\`\`${htmlentities(lang)} " data-joplin-source-close=" \`\`\`">${markdownIt.utils.escapeHtml(trimmedStr)}</pre>`;
|
||||
|
||||
if (this.shouldSkipHighlighting(trimmedStr, lang)) {
|
||||
outputCodeHtml = markdownIt.utils.escapeHtml(trimmedStr);
|
||||
|
||||
@@ -73,7 +73,7 @@ const plugin = (markdownIt: MarkdownIt, ruleOptions: any) => {
|
||||
|
||||
return `
|
||||
<div class="joplin-editable joplin-abc-notation">
|
||||
<pre class="joplin-source" data-abc-options="${optionsHtml}" data-joplin-language="abc" data-joplin-source-open="\`\`\`abc " data-joplin-source-close=" \`\`\` ">${sourceContentHtml}</pre>
|
||||
<pre class="joplin-source" hidden data-abc-options="${optionsHtml}" data-joplin-language="abc" data-joplin-source-open="\`\`\`abc " data-joplin-source-close=" \`\`\` ">${sourceContentHtml}</pre>
|
||||
<pre class="joplin-rendered joplin-abc-notation-rendered">${contentHtml}</pre>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -68,7 +68,7 @@ const plugin = (markdownIt: MarkdownIt) => {
|
||||
|
||||
return `
|
||||
<div class="joplin-editable">
|
||||
<span class="joplin-source" data-joplin-source-open="" data-joplin-source-close="">${escapedUrl}</span>
|
||||
<span class="joplin-source" hidden data-joplin-source-open="" data-joplin-source-close="">${escapedUrl}</span>
|
||||
<div class="joplin-youtube-player-rendered">
|
||||
<iframe src="${embedUrl}" title="YouTube video player" frameborder="0" allowfullscreen></iframe>
|
||||
</div>
|
||||
|
||||
@@ -143,7 +143,7 @@ function renderFountainScript(markdownIt: any, content: string) {
|
||||
return `
|
||||
<!-- joplin-metadata-print-title = false -->
|
||||
<div class="fountain joplin-editable">
|
||||
<pre class="joplin-source" data-joplin-language="fountain" data-joplin-source-open="\`\`\`fountain " data-joplin-source-close=" \`\`\` ">${markdownIt.utils.escapeHtml(content)}</pre>
|
||||
<pre class="joplin-source" hidden data-joplin-language="fountain" data-joplin-source-open="\`\`\`fountain " data-joplin-source-close=" \`\`\` ">${markdownIt.utils.escapeHtml(content)}</pre>
|
||||
${titlePageHtml}
|
||||
<div class="page">
|
||||
${result.html.script}
|
||||
|
||||
@@ -104,7 +104,7 @@ const plugin = (markdownIt: MarkdownIt, _ruleOptions: unknown) => {
|
||||
// IMPORTANT: No whitespace between joplin-editable and joplin-source elements!
|
||||
// The turndown joplinEditableBlockInfo function iterates childNodes and crashes
|
||||
// on text nodes (whitespace) because they don't have classList.
|
||||
return `<div class="joplin-editable joplin-frontmatter"><pre class="joplin-source" data-joplin-language="frontmatter" data-joplin-source-open="--- " data-joplin-source-close=" --- ">${contentHtml}</pre><div class="joplin-rendered joplin-frontmatter-rendered"><hr class="joplin-frontmatter-marker"/><pre class="hljs">${highlightedContent}</pre><hr class="joplin-frontmatter-marker"/></div></div>`;
|
||||
return `<div class="joplin-editable joplin-frontmatter"><pre class="joplin-source" hidden data-joplin-language="frontmatter" data-joplin-source-open="--- " data-joplin-source-close=" --- ">${contentHtml}</pre><div class="joplin-rendered joplin-frontmatter-rendered"><hr class="joplin-frontmatter-marker"/><pre class="hljs">${highlightedContent}</pre><hr class="joplin-frontmatter-marker"/></div></div>`;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -337,7 +337,7 @@ export default {
|
||||
} catch (error) {
|
||||
outputHtml = renderKatexError(error, 'span');
|
||||
}
|
||||
return `<span class="joplin-editable"><span class="joplin-source" data-joplin-language="katex" data-joplin-source-open="$" data-joplin-source-close="$">${markdownIt.utils.escapeHtml(latex)}</span>${outputHtml}</span>`;
|
||||
return `<span class="joplin-editable"><span class="joplin-source" hidden data-joplin-language="katex" data-joplin-source-open="$" data-joplin-source-close="$">${markdownIt.utils.escapeHtml(latex)}</span>${outputHtml}</span>`;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
@@ -355,7 +355,7 @@ export default {
|
||||
outputHtml = renderKatexError(error, 'div');
|
||||
}
|
||||
|
||||
return `<div class="joplin-editable"><pre class="joplin-source" data-joplin-language="katex" data-joplin-source-open="$$ " data-joplin-source-close=" $$ ">${markdownIt.utils.escapeHtml(latex)}</pre>${outputHtml}</div>`;
|
||||
return `<div class="joplin-editable"><pre class="joplin-source" hidden data-joplin-language="katex" data-joplin-source-open="$$ " data-joplin-source-close=" $$ ">${markdownIt.utils.escapeHtml(latex)}</pre>${outputHtml}</div>`;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
|
||||
@@ -86,7 +86,7 @@ export default {
|
||||
// See PR #4670 https://github.com/laurent22/joplin/pull/4670
|
||||
return `
|
||||
<div class="joplin-editable">
|
||||
<pre class="joplin-source" data-joplin-language="mermaid" data-joplin-source-open="\`\`\`mermaid " data-joplin-source-close=" \`\`\` ">${contentHtml}</pre>
|
||||
<pre class="joplin-source" hidden data-joplin-language="mermaid" data-joplin-source-open="\`\`\`mermaid " data-joplin-source-close=" \`\`\` ">${contentHtml}</pre>
|
||||
${exportButtonMarkup}
|
||||
<pre class="${cssClasses.join(' ')}">${contentHtml}</pre>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"@joplin/utils": "~3.6",
|
||||
"@koa/cors": "3.4.3",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/uuid": "10.0.0",
|
||||
"@types/uuid": "11.0.0",
|
||||
"bcryptjs": "2.4.3",
|
||||
"bulma": "1.0.4",
|
||||
"compare-versions": "6.1.1",
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
"jest": "29.7.0",
|
||||
"js-yaml": "4.1.1",
|
||||
"rss": "1.2.2",
|
||||
"sass": "1.94.3",
|
||||
"sass": "1.96.0",
|
||||
"sqlite3": "5.1.6",
|
||||
"style-to-js": "1.1.21",
|
||||
"ts-node": "10.9.2",
|
||||
|
||||
@@ -211,6 +211,10 @@
|
||||
"v3.6.4": true,
|
||||
"android-v3.6.14": true,
|
||||
"ios-v13.6.3": true,
|
||||
"v3.6.6": true
|
||||
"v3.6.6": true,
|
||||
"v3.6.7": true,
|
||||
"android-v3.6.15": true,
|
||||
"ios-v13.6.4": true,
|
||||
"v3.6.8": true
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@
|
||||
"@types/jest-expect-message": "1.1.0",
|
||||
"@types/koa": "2.15.0",
|
||||
"@types/sharp": "0.32.0",
|
||||
"@types/uuid": "10.0.0",
|
||||
"@types/uuid": "11.0.0",
|
||||
"gulp": "4.0.2",
|
||||
"jest": "29.7.0",
|
||||
"jest-expect-message": "1.1.3",
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# Joplin Android Changelog
|
||||
|
||||
## [android-v3.6.15](https://github.com/laurent22/joplin/releases/tag/android-v3.6.15) - 2026-04-05T13:00:51Z
|
||||
|
||||
- New: Add toolbar button reordering with up/down arrows (#14485 by [@Vpatel1093](https://github.com/Vpatel1093))
|
||||
- Improved: Disable auto correct, auto complete and auto capitalize for setting search field (#14810 by [@mrjo118](https://github.com/mrjo118))
|
||||
- Improved: Implement note attachments management screen (#14818) (#14789 by [@yousef-genedy](https://github.com/yousef-genedy))
|
||||
- Improved: Revert: Start sync when app opens or resumes (#14889)
|
||||
- Improved: Updated packages esbuild (v0.27.2), fs-extra (v11.3.3), glob (v11.1.0), react-native-localize (v3.6.1)
|
||||
- Fixed: Fix editor font setting being ignored in the Rich Text Editor (#14995) (#14974 by Sriram Varun Kumar)
|
||||
- Fixed: Fix encrypted notes not decrypting after updating master password (#14996) (#14984 by Sriram Varun Kumar)
|
||||
- Fixed: Prevent Note Tags dialog from closing before discard confirmation on web (#14998) (#14771 by [@zainAwan9175](https://github.com/zainAwan9175))
|
||||
- Fixed: Prevent duplicate tags caused by Unicode normalization (#14599) (#14540 by [@itisrohit](https://github.com/itisrohit))
|
||||
- Fixed: Show confirmation dialog before closing tags dialog with unsaved changes (#14777) (#14771 by [@zainAwan9175](https://github.com/zainAwan9175))
|
||||
- Fixed: Tag's note list fails to update after removing the tag from a note (#14944) (#11122 by [@Fardin96](https://github.com/Fardin96))
|
||||
|
||||
## [android-v3.6.14](https://github.com/laurent22/joplin/releases/tag/android-v3.6.14) - 2026-03-16T22:14:47Z
|
||||
|
||||
- New: Add ability to set per notebook sorting on mobile (#14562 by [@mrjo118](https://github.com/mrjo118))
|
||||
|
||||
@@ -1,5 +1,42 @@
|
||||
# Joplin Desktop Changelog
|
||||
|
||||
## [v3.6.8](https://github.com/laurent22/joplin/releases/tag/v3.6.8) (Pre-release) - 2026-04-07T07:28:36Z
|
||||
|
||||
- Desktop: Fixed regression that prevented images from being pasted in editor ([#14750](https://github.com/laurent22/joplin/issues/14750))
|
||||
|
||||
## [v3.6.7](https://github.com/laurent22/joplin/releases/tag/v3.6.7) (Pre-release) - 2026-04-05T15:21:11Z
|
||||
|
||||
- Improved: Added fullscreen shortcut (Ctrl + Cmd + F) ([#14926](https://github.com/laurent22/joplin/issues/14926)) ([#9637](https://github.com/laurent22/joplin/issues/9637) by [@DevrG03](https://github.com/DevrG03))
|
||||
- Improved: Completed date/time is shown as a number ([#14808](https://github.com/laurent22/joplin/issues/14808)) ([#14797](https://github.com/laurent22/joplin/issues/14797) by [@Pixels57](https://github.com/Pixels57))
|
||||
- Improved: Enable Copy and Select All in viewer and read-only modes ([#14956](https://github.com/laurent22/joplin/issues/14956) by [@FischLu](https://github.com/FischLu))
|
||||
- Improved: Improve checkbox completion icon in detailed note list ([#14780](https://github.com/laurent22/joplin/issues/14780)) ([#14778](https://github.com/laurent22/joplin/issues/14778) by [@Ehtesham-Zahid](https://github.com/Ehtesham-Zahid))
|
||||
- Improved: Improve clarity of master password warning message ([#14724](https://github.com/laurent22/joplin/issues/14724)) ([#14717](https://github.com/laurent22/joplin/issues/14717) by [@Vinayreddy765](https://github.com/Vinayreddy765))
|
||||
- Improved: Replace smalltalk with React Dialog to add password visibility in encryption setup ([#14739](https://github.com/laurent22/joplin/issues/14739) by [@himanshumishra1309](https://github.com/himanshumishra1309))
|
||||
- Improved: Revert: Start sync when app opens or resumes ([#14889](https://github.com/laurent22/joplin/issues/14889))
|
||||
- Improved: Updated packages @playwright/test (v1.57.0), esbuild (v0.27.1), fs-extra (v11.3.3), glob (v11.1.0), nan (v2.24.0)
|
||||
- Improved: Upgrade Electron to v40.8.3 ([#14882](https://github.com/laurent22/joplin/issues/14882) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||
- Fixed: Accessibility: Fix accessibility issues flagged by automated tools in the note properties dialog ([#14798](https://github.com/laurent22/joplin/issues/14798) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||
- Fixed: Disable "Expand all notebooks" button when no sub-notebooks exist ([#14891](https://github.com/laurent22/joplin/issues/14891)) ([#14890](https://github.com/laurent22/joplin/issues/14890) by [@dipanshurdev](https://github.com/dipanshurdev))
|
||||
- Fixed: Fix JPEG image paste from clipboard on Linux ([#14750](https://github.com/laurent22/joplin/issues/14750)) ([#14613](https://github.com/laurent22/joplin/issues/14613) by [@moaaz-ae](https://github.com/moaaz-ae))
|
||||
- Fixed: Fix Markdown export losing folders that differ only by special characters ([#14869](https://github.com/laurent22/joplin/issues/14869)) ([#9436](https://github.com/laurent22/joplin/issues/9436) by [@lnxd](https://github.com/lnxd))
|
||||
- Fixed: Fix OneNote zip import path when .one files are at root level ([#14605](https://github.com/laurent22/joplin/issues/14605)) ([#14223](https://github.com/laurent22/joplin/issues/14223) by [@Kaushalendra-Marcus](https://github.com/Kaushalendra-Marcus))
|
||||
- Fixed: Fix changes made in an external editor are sometimes ignored ([#14957](https://github.com/laurent22/joplin/issues/14957)) ([#14954](https://github.com/laurent22/joplin/issues/14954) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||
- Fixed: Fix crash when closing secondary windows ([#14892](https://github.com/laurent22/joplin/issues/14892)) ([#14628](https://github.com/laurent22/joplin/issues/14628) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||
- Fixed: Fix incorrectly re-instated code ([#14962](https://github.com/laurent22/joplin/issues/14962)) ([#14628](https://github.com/laurent22/joplin/issues/14628) by [@mrjo118](https://github.com/mrjo118))
|
||||
- Fixed: Fix inline formatting with trailing/leading whitespace ([#14991](https://github.com/laurent22/joplin/issues/14991)) ([#14990](https://github.com/laurent22/joplin/issues/14990) by [@Harsh16gupta](https://github.com/Harsh16gupta))
|
||||
- Fixed: Fix most Windows-specific test failures ([#14904](https://github.com/laurent22/joplin/issues/14904)) ([#14903](https://github.com/laurent22/joplin/issues/14903) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||
- Fixed: Fix renderer crashes still occuring due to incorrect merge ([#14953](https://github.com/laurent22/joplin/issues/14953)) ([#14628](https://github.com/laurent22/joplin/issues/14628) by [@mrjo118](https://github.com/mrjo118))
|
||||
- Fixed: Fixed Custom Dictionary.txt being saved to wrong directory ([#14749](https://github.com/laurent22/joplin/issues/14749)) ([#12910](https://github.com/laurent22/joplin/issues/12910) by [@Harsh16gupta](https://github.com/Harsh16gupta))
|
||||
- Fixed: Frontmatter export: Include notebook icon in frontmatter export ([#14582](https://github.com/laurent22/joplin/issues/14582)) ([#9673](https://github.com/laurent22/joplin/issues/9673) by Ashutosh Singh)
|
||||
- Fixed: Importing from OneNote: Fix import of ink with negative bounding box coordinates ([#14981](https://github.com/laurent22/joplin/issues/14981) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||
- Fixed: Incomplete (out of screen) ABC Sheet Music rendering ([#14767](https://github.com/laurent22/joplin/issues/14767)) ([#14245](https://github.com/laurent22/joplin/issues/14245) by [@Harsh16gupta](https://github.com/Harsh16gupta))
|
||||
- Fixed: Inline computed styles when copying from the Markdown preview pane ([#14973](https://github.com/laurent22/joplin/issues/14973)) ([#14950](https://github.com/laurent22/joplin/issues/14950) by [@Harsh16gupta](https://github.com/Harsh16gupta))
|
||||
- Fixed: Prevent Plugin API callback registry memory leak ([#14920](https://github.com/laurent22/joplin/issues/14920)) ([#14919](https://github.com/laurent22/joplin/issues/14919) by [@Sandesh13fr](https://github.com/Sandesh13fr))
|
||||
- Fixed: Prevent duplicate tags caused by Unicode normalization ([#14599](https://github.com/laurent22/joplin/issues/14599)) ([#14540](https://github.com/laurent22/joplin/issues/14540) by [@itisrohit](https://github.com/itisrohit))
|
||||
- Fixed: Prevent renderer crash when closing secondary window ([#14849](https://github.com/laurent22/joplin/issues/14849)) ([#14628](https://github.com/laurent22/joplin/issues/14628) by [@Kaushalendra-Marcus](https://github.com/Kaushalendra-Marcus))
|
||||
- Fixed: RTE checklists should create unchecked items on Enter ([#14918](https://github.com/laurent22/joplin/issues/14918)) ([#14914](https://github.com/laurent22/joplin/issues/14914) by [@Sandesh13fr](https://github.com/Sandesh13fr))
|
||||
- Fixed: Share owner sees "Leave notebook" instead of "Share notebook" when server is offline ([#14923](https://github.com/laurent22/joplin/issues/14923)) ([#12994](https://github.com/laurent22/joplin/issues/12994) by [@Rygaa](https://github.com/Rygaa))
|
||||
|
||||
## [v3.6.6](https://github.com/laurent22/joplin/releases/tag/v3.6.6) (Pre-release) - 2026-03-17T10:44:55Z
|
||||
|
||||
- Improved: Add support for Ctrl/Cmd+Wheel to zoom in and out ([#14684](https://github.com/laurent22/joplin/issues/14684)) ([#7914](https://github.com/laurent22/joplin/issues/7914) by Ashutosh Singh)
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# Joplin iOS Changelog
|
||||
|
||||
## [ios-v13.6.4](https://github.com/laurent22/joplin/releases/tag/ios-v13.6.4) - 2026-04-05T15:21:46Z
|
||||
|
||||
- New: Add toolbar button reordering with up/down arrows (#14485 by [@Vpatel1093](https://github.com/Vpatel1093))
|
||||
- Improved: Disable auto correct, auto complete and auto capitalize for setting search field (#14810 by [@mrjo118](https://github.com/mrjo118))
|
||||
- Improved: Implement note attachments management screen (#14818) (#14789 by [@yousef-genedy](https://github.com/yousef-genedy))
|
||||
- Improved: Revert: Start sync when app opens or resumes (#14889)
|
||||
- Improved: Updated packages esbuild (v0.27.2), fs-extra (v11.3.3), glob (v11.1.0), react-native-localize (v3.6.1)
|
||||
- Fixed: Fix editor font setting being ignored in the Rich Text Editor (#14995) (#14974 by Sriram Varun Kumar)
|
||||
- Fixed: Fix encrypted notes not decrypting after updating master password (#14996) (#14984 by Sriram Varun Kumar)
|
||||
- Fixed: Fix mobile app unable to attach file with special characters in the name (#14736) (#12968 by [@gherardi](https://github.com/gherardi))
|
||||
- Fixed: Prevent Note Tags dialog from closing before discard confirmation on web (#14998) (#14771 by [@zainAwan9175](https://github.com/zainAwan9175))
|
||||
- Fixed: Prevent duplicate tags caused by Unicode normalization (#14599) (#14540 by [@itisrohit](https://github.com/itisrohit))
|
||||
- Fixed: Show confirmation dialog before closing tags dialog with unsaved changes (#14777) (#14771 by [@zainAwan9175](https://github.com/zainAwan9175))
|
||||
- Fixed: Tag's note list fails to update after removing the tag from a note (#14944) (#11122 by [@Fardin96](https://github.com/Fardin96))
|
||||
|
||||
## [ios-v13.6.3](https://github.com/laurent22/joplin/releases/tag/ios-v13.6.3) - 2026-03-16T22:17:56Z
|
||||
|
||||
- New: Add ability to set per notebook sorting on mobile (#14562 by [@mrjo118](https://github.com/mrjo118))
|
||||
|
||||
@@ -10,7 +10,7 @@ WebDAV-compatible services that are known to work with Joplin:
|
||||
- [HiDrive](https://www.strato.fr/stockage-en-ligne/) from Strato. [Setup help](https://github.com/laurent22/joplin/issues/309)
|
||||
- [InfiniCLOUD](https://infini-cloud.net/)
|
||||
- [Mailbox.org WebDAV](https://kb.mailbox.org/en/private/drive/) [Setup help](https://userforum-en.mailbox.org/topic/2766-unable-to-sync-joplin-notes-with-mailbox-org-via-webdav#comment-10946)
|
||||
- [Nginx WebDAV Module](https://nginx.org/en/docs/http/ngx_http_dav_module.html)
|
||||
- [Nginx WebDAV Module](https://nginx.org/en/docs/http/ngx_http_dav_module.html), requires [http_dav_ext_module](https://github.com/arut/nginx-dav-ext-module)
|
||||
- [Nextcloud](https://nextcloud.com/)
|
||||
- [OwnCloud](https://owncloud.org/)
|
||||
- [Seafile](https://www.seafile.com/)
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
"browserify",
|
||||
"codemirror",
|
||||
"cspell",
|
||||
"expo-av", // Must be updated with expo
|
||||
"expo-audio", // Must be updated with expo
|
||||
"file-loader",
|
||||
"gradle",
|
||||
"html-webpack-plugin",
|
||||
|
||||
Reference in New Issue
Block a user