1
0
mirror of https://github.com/laurent22/joplin.git synced 2026-02-01 07:49:31 +02:00

Compare commits

..

1 Commits

Author SHA1 Message Date
Laurent Cozic
142d5e83c7 update 2026-01-03 09:18:43 +00:00
61 changed files with 911 additions and 1203 deletions

View File

@@ -115,7 +115,6 @@ packages/app-cli/app/command-export.js
packages/app-cli/app/command-geoloc.js
packages/app-cli/app/command-help.js
packages/app-cli/app/command-import.js
packages/app-cli/app/command-keymap.js
packages/app-cli/app/command-ls.js
packages/app-cli/app/command-mkbook.test.js
packages/app-cli/app/command-mkbook.js
@@ -1228,7 +1227,6 @@ packages/lib/InMemoryCache.js
packages/lib/JoplinDatabase.js
packages/lib/JoplinError.js
packages/lib/JoplinServerApi.js
packages/lib/ObjectUtils.test.js
packages/lib/ObjectUtils.js
packages/lib/PerformanceLogger.test.js
packages/lib/PerformanceLogger.js

2
.gitignore vendored
View File

@@ -88,7 +88,6 @@ packages/app-cli/app/command-export.js
packages/app-cli/app/command-geoloc.js
packages/app-cli/app/command-help.js
packages/app-cli/app/command-import.js
packages/app-cli/app/command-keymap.js
packages/app-cli/app/command-ls.js
packages/app-cli/app/command-mkbook.test.js
packages/app-cli/app/command-mkbook.js
@@ -1201,7 +1200,6 @@ packages/lib/InMemoryCache.js
packages/lib/JoplinDatabase.js
packages/lib/JoplinError.js
packages/lib/JoplinServerApi.js
packages/lib/ObjectUtils.test.js
packages/lib/ObjectUtils.js
packages/lib/PerformanceLogger.test.js
packages/lib/PerformanceLogger.js

View File

@@ -11,6 +11,11 @@
},
"nodejs": "24.5.0",
"pkg-config": "latest",
"darwin.apple_sdk.frameworks.Foundation": { // satisfies missing CoreText/CoreText.h
// https://github.com/NixOS/nixpkgs/blob/master/pkgs/os-specific/darwin/apple-sdk/default.nix
"version": "",
"platforms": ["aarch64-darwin", "x86_64-darwin"],
},
"python": "3.13.3",
"bat": "latest",
"electron": {

View File

@@ -1,48 +0,0 @@
import BaseCommand from './base-command';
import app from './app';
import { _ } from '@joplin/lib/locale';
const { cliUtils } = require('./cli-utils.js');
interface Args { }
class Command extends BaseCommand {
public override usage() {
return 'keymap';
}
public override description() {
return _('Displays the configured keyboard shortcuts.');
}
public override compatibleUis() {
return ['cli', 'gui'];
}
public override async action(_args: Args) {
const keymaps = await app().loadKeymaps();
this.stdout(_('Configured keyboard shortcuts:\n'));
const rows = [];
const padding = ' ';
rows.push([`${padding}${_('KEYS')}`, _('TYPE'), _('COMMAND')]);
rows.push([`${padding}----`, '----', '-------']);
for (const item of keymaps) {
const formattedKeys = item.keys
.map((k: string) => (k === ' ' ? `(${'SPACE'})` : k))
.join(', ');
rows.push([padding + formattedKeys, item.type, item.command]);
}
cliUtils.printArray(this.stdout.bind(this), rows);
if (app().gui() && !app().gui().isDummy()) {
app().gui().showConsole();
app().gui().maximizeConsole();
}
}
}
module.exports = Command;

View File

@@ -1,10 +0,0 @@
<div class="joplin-editable joplin-abc-notation">
<pre class="joplin-source" data-abc-options="{&quot;responsive&quot;:&quot;resize&quot;}" data-joplin-language="abc" data-joplin-source-open="```abc&#10;" data-joplin-source-close="&#10;```&#10;">{responsive:'resize'}
---
K:F
!f!(fgag-g2c2)|</pre>
<pre class="joplin-rendered joplin-abc-notation-rendered">K:F
!f!(fgag-g2c2)|</pre>
</div>

View File

@@ -1,6 +0,0 @@
```abc
{ responsive: 'resize' }
---
K:F
!f!(fgag-g2c2)|
```

View File

@@ -2,7 +2,7 @@ import { RefObject, Dispatch, SetStateAction, useEffect } from 'react';
import { WindowCommandDependencies, NoteBodyEditorRef, OnChangeEvent, ScrollOptionTypes } from './types';
import editorCommandDeclarations, { enabledCondition } from '../editorCommandDeclarations';
import CommandService, { CommandDeclaration, CommandRuntime, CommandContext, RegisteredRuntime } from '@joplin/lib/services/CommandService';
import { formatMsToLocal } from '@joplin/utils/time';
import time from '@joplin/lib/time';
import { reg } from '@joplin/lib/registry';
import getWindowCommandPriority from './getWindowCommandPriority';
@@ -50,7 +50,7 @@ function editorCommandRuntime(
if (declaration.name === 'insertDateTime') {
return editorRef.current.execCommand({
name: 'insertText',
value: formatMsToLocal(Date.now()),
value: time.formatMsToLocal(new Date().getTime()),
});
} else if (declaration.name === 'scrollToHash') {
return editorRef.current.scrollTo({

View File

@@ -51,11 +51,9 @@ const getParentOffset = (childIndex: number, listItems: ListItem[]): number|null
};
const findNextTypeAheadMatch = (selectedIndex: number, query: string, listItems: ListItem[]) => {
const normalize = (text: string) => text.trim().toLowerCase();
const matches = (item: ListItem) => {
return normalize(item.label).startsWith(normalize(query));
return item.label.startsWith(query);
};
const indexBefore = listItems.slice(0, selectedIndex).findIndex(matches);
// Search in all results **after** the current. This prevents the current item from
// always being identified as the next match, if the user repeatedly presses the

View File

@@ -123,8 +123,8 @@ const ToolbarBaseComponent: React.FC<Props> = props => {
};
const tabIndex = indexInFocusable === (selectedIndex % focusableItems.length) ? 0 : -1;
const setButtonRefCallback = (button: HTMLButtonElement | null) => {
if (button && tabIndex === 0 && containerHasFocus) {
const setButtonRefCallback = (button: HTMLButtonElement) => {
if (tabIndex === 0 && containerHasFocus) {
focus('ToolbarBase', button);
}
};

View File

@@ -64,10 +64,6 @@ test.describe('sidebar', () => {
await expect(mainWindow.locator(':focus')).toHaveText('Folder b');
await mainWindow.keyboard.type('A');
await expect(mainWindow.locator(':focus')).toHaveText('All notes');
// Should be case-insensitive
await mainWindow.keyboard.type('f');
await expect(mainWindow.locator(':focus')).toHaveText('Folder b');
});
test('left/right arrow keys should expand/collapse notebooks', async ({ electronApp, mainWindow }) => {

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/app-desktop",
"version": "3.5.10",
"version": "3.5.9",
"description": "Joplin for Desktop",
"main": "main.bundle.js",
"private": true,

View File

@@ -130,12 +130,6 @@ const makeAccessDeniedResponse = (message: string) => {
});
};
const makeNotFoundResponse = () => {
return new Response('not found', {
status: 404,
});
};
// Creating a custom protocol allows us to isolate iframes by giving them
// different domain names from the main Joplin app.
//
@@ -216,24 +210,10 @@ const handleCustomProtocols = (): CustomProtocolHandler => {
const rangeHeader = request.headers.get('Range');
let response;
try {
if (!rangeHeader) {
response = await net.fetch(asFileUrl);
} else {
response = await handleRangeRequest(request, pathname);
}
} catch (error) {
if (
// Errors from NodeJS fs methods (e.g. fs.stat()
error.code === 'ENOENT'
// Errors from Electron's net.fetch(). Use error.message since these errors don't
// seem to have a specific .code or .name.
|| error.message === 'net::ERR_FILE_NOT_FOUND'
) {
response = makeNotFoundResponse();
} else {
throw error;
}
if (!rangeHeader) {
response = await net.fetch(asFileUrl);
} else {
response = await handleRangeRequest(request, pathname);
}
if (mediaOnly) {

View File

@@ -89,8 +89,8 @@ android {
applicationId "net.cozic.joplin"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2097787
versionName "3.5.7"
versionCode 2097786
versionName "3.5.6"
ndk {
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
}

View File

@@ -1,7 +1,7 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import { CommandRuntimeProps } from '../types';
import { formatMsToLocal } from '@joplin/utils/time';
import time from '@joplin/lib/time';
export const declaration: CommandDeclaration = {
name: 'insertDateTime',
@@ -12,7 +12,7 @@ export const declaration: CommandDeclaration = {
export const runtime = (props: CommandRuntimeProps): CommandRuntime => {
return {
execute: async (_context: CommandContext) => {
props.insertText(formatMsToLocal(Date.now()));
props.insertText(time.formatDateToLocal(new Date()));
},
enabledCondition: '!noteIsReadOnly',

View File

@@ -1,5 +1,5 @@
module.exports = {
hash:"88e5d809af57eac7b86c4deaf21b9345", files: {
hash:"daebd8498ff273c64cf5905d4356e66a", files: {
'abc/abc_render.js': { data: require('./abc/abc_render.js.base64.js'), mime: 'application/javascript', encoding: 'base64' },
'abc/abcjs-basic-min.js': { data: require('./abc/abcjs-basic-min.js.base64.js'), mime: 'application/javascript', encoding: 'base64' },
'highlight.js/atom-one-dark-reasonable.css': { data: require('./highlight.js/atom-one-dark-reasonable.css.base64.js'), mime: 'text/css', encoding: 'base64' },

View File

@@ -1 +1 @@
module.exports = {"hash":"88e5d809af57eac7b86c4deaf21b9345","files":["abc/abc_render.js","abc/abcjs-basic-min.js","highlight.js/atom-one-dark-reasonable.css","highlight.js/atom-one-light.css","katex/fonts/KaTeX_AMS-Regular.woff2","katex/fonts/KaTeX_Caligraphic-Bold.woff2","katex/fonts/KaTeX_Caligraphic-Regular.woff2","katex/fonts/KaTeX_Fraktur-Bold.woff2","katex/fonts/KaTeX_Fraktur-Regular.woff2","katex/fonts/KaTeX_Main-Bold.woff2","katex/fonts/KaTeX_Main-BoldItalic.woff2","katex/fonts/KaTeX_Main-Italic.woff2","katex/fonts/KaTeX_Main-Regular.woff2","katex/fonts/KaTeX_Math-BoldItalic.woff2","katex/fonts/KaTeX_Math-Italic.woff2","katex/fonts/KaTeX_SansSerif-Bold.woff2","katex/fonts/KaTeX_SansSerif-Italic.woff2","katex/fonts/KaTeX_SansSerif-Regular.woff2","katex/fonts/KaTeX_Script-Regular.woff2","katex/fonts/KaTeX_Size1-Regular.woff2","katex/fonts/KaTeX_Size2-Regular.woff2","katex/fonts/KaTeX_Size3-Regular.woff2","katex/fonts/KaTeX_Size4-Regular.woff2","katex/fonts/KaTeX_Typewriter-Regular.woff2","katex/katex.css","mermaid/mermaid.min.js","mermaid/mermaid_render.js"]}
module.exports = {"hash":"daebd8498ff273c64cf5905d4356e66a","files":["abc/abc_render.js","abc/abcjs-basic-min.js","highlight.js/atom-one-dark-reasonable.css","highlight.js/atom-one-light.css","katex/fonts/KaTeX_AMS-Regular.woff2","katex/fonts/KaTeX_Caligraphic-Bold.woff2","katex/fonts/KaTeX_Caligraphic-Regular.woff2","katex/fonts/KaTeX_Fraktur-Bold.woff2","katex/fonts/KaTeX_Fraktur-Regular.woff2","katex/fonts/KaTeX_Main-Bold.woff2","katex/fonts/KaTeX_Main-BoldItalic.woff2","katex/fonts/KaTeX_Main-Italic.woff2","katex/fonts/KaTeX_Main-Regular.woff2","katex/fonts/KaTeX_Math-BoldItalic.woff2","katex/fonts/KaTeX_Math-Italic.woff2","katex/fonts/KaTeX_SansSerif-Bold.woff2","katex/fonts/KaTeX_SansSerif-Italic.woff2","katex/fonts/KaTeX_SansSerif-Regular.woff2","katex/fonts/KaTeX_Script-Regular.woff2","katex/fonts/KaTeX_Size1-Regular.woff2","katex/fonts/KaTeX_Size2-Regular.woff2","katex/fonts/KaTeX_Size3-Regular.woff2","katex/fonts/KaTeX_Size4-Regular.woff2","katex/fonts/KaTeX_Typewriter-Regular.woff2","katex/katex.css","mermaid/mermaid.min.js","mermaid/mermaid_render.js"]}

File diff suppressed because one or more lines are too long

View File

@@ -7,128 +7,114 @@ import { tmpdir } from 'os';
import { chdir, cwd } from 'process';
import { execCommand } from '@joplin/utils';
import { glob } from 'glob';
import readRepositoryJson, { BuiltInPluginType, RepositoryData } from './utils/readRepositoryJson';
import readRepositoryJson from './utils/readRepositoryJson';
import getPathToPatchFileFor from './utils/getPathToPatchFileFor';
import getCurrentCommitHash from './utils/getCurrentCommitHash';
import { waitForCliInput } from '@joplin/utils/cli';
interface Options {
outputParentDir: string|null;
beforeInstall: (buildDir: string, pluginName: string)=> Promise<void>;
beforePatch: ()=> Promise<void>;
}
const buildDefaultPlugins = async (options: Options) => {
const buildDefaultPlugins = async (outputParentDir: string|null, options: Options) => {
const pluginSourcesDir = resolve(join(__dirname, 'plugin-sources'));
const pluginRepositoryData = await readRepositoryJson(join(__dirname, 'pluginRepositories.json'));
const originalDirectory = cwd();
const logStatus = (...message: string[]) => {
const blue = '\x1b[96m';
const reset = '\x1b[0m';
console.log(blue, ...message, reset);
};
for (const pluginId in pluginRepositoryData) {
const repositoryData = pluginRepositoryData[pluginId];
const outputPath = options.outputParentDir && join(options.outputParentDir, `${pluginId}.jpl`);
if (repositoryData.type === BuiltInPluginType.Built) {
await buildPlugin(pluginId, repositoryData, outputPath, options);
} else {
if (!outputPath) {
console.warn('Skipping NPM plugin,', pluginId, ': missing output path.');
continue;
const buildDir = await mkdtemp(join(tmpdir(), 'default-plugin-build'));
try {
logStatus('Building plugin', pluginId, 'at', buildDir);
const pluginDir = resolve(join(pluginSourcesDir, pluginId));
// Clone the repository if not done yet
if (!(await exists(pluginDir)) || (await readdir(pluginDir)).length === 0) {
logStatus(`Cloning from repository ${repositoryData.cloneUrl}`);
await execCommand(['git', 'clone', '--', repositoryData.cloneUrl, pluginDir]);
chdir(pluginDir);
}
logStatus('Copying plugin', pluginId, 'JPL file to', outputPath);
await copy(join(__dirname, 'node_modules', repositoryData.package, 'publish', `${pluginId}.jpl`), outputPath);
logStatus('Copied.');
}
}
};
const logStatus = (...message: string[]) => {
const blue = '\x1b[96m';
const reset = '\x1b[0m';
console.log(blue, ...message, reset);
};
const buildPlugin = async (pluginId: string, repositoryData: RepositoryData, outputPath: string|null, options: Options) => {
const pluginSourcesDir = resolve(join(__dirname, 'plugin-sources'));
const originalDirectory = cwd();
const buildDir = await mkdtemp(join(tmpdir(), 'default-plugin-build'));
try {
logStatus('Building plugin', pluginId, 'at', buildDir);
const pluginDir = resolve(join(pluginSourcesDir, pluginId));
// Clone the repository if not done yet
if (!(await exists(pluginDir)) || (await readdir(pluginDir)).length === 0) {
logStatus(`Cloning from repository ${repositoryData.cloneUrl}`);
await execCommand(['git', 'clone', '--', repositoryData.cloneUrl, pluginDir]);
chdir(pluginDir);
}
const expectedCommitHash = repositoryData.commit;
chdir(pluginDir);
const expectedCommitHash = repositoryData.commit;
logStatus(`Switching to commit ${expectedCommitHash}`);
await execCommand(['git', 'switch', repositoryData.branch]);
logStatus(`Switching to commit ${expectedCommitHash}`);
await execCommand(['git', 'switch', repositoryData.branch]);
try {
await execCommand(['git', 'checkout', expectedCommitHash]);
} catch (error) {
logStatus(`git checkout failed with error ${error}. Fetching...`);
await execCommand(['git', 'fetch']);
await execCommand(['git', 'checkout', expectedCommitHash]);
}
try {
await execCommand(['git', 'checkout', expectedCommitHash]);
if (await getCurrentCommitHash() !== expectedCommitHash) {
throw new Error(`Unable to checkout commit ${expectedCommitHash}`);
}
logStatus('Copying repository files...');
await copy(pluginDir, buildDir, {
filter: fileName => {
return basename(fileName) !== '.git';
},
});
chdir(buildDir);
logStatus('Initializing repository.');
await execCommand('git init . -b main');
logStatus('Running before-patch hook.');
await options.beforePatch();
const patchFile = getPathToPatchFileFor(pluginId);
if (await exists(patchFile)) {
logStatus('Applying patch.');
await execCommand(['git', 'apply', patchFile]);
}
await options.beforeInstall(buildDir, pluginId);
logStatus('Installing dependencies.');
await execCommand('npm install');
const jplFiles = await glob('publish/*.jpl');
logStatus(`Found built .jpl files: ${JSON.stringify(jplFiles)}`);
if (jplFiles.length === 0) {
throw new Error(`No published files found in ${buildDir}/publish`);
}
if (outputParentDir !== null) {
logStatus(`Checking output directory in ${outputParentDir}`);
const outputPath = join(outputParentDir, `${pluginId}.jpl`);
const sourceFile = jplFiles[0];
logStatus(`Copying built file from ${sourceFile} to ${outputPath}`);
await copy(sourceFile, outputPath);
} else {
console.warn('No output directory specified. Not copying built .jpl files.');
}
} catch (error) {
logStatus(`git checkout failed with error ${error}. Fetching...`);
await execCommand(['git', 'fetch']);
await execCommand(['git', 'checkout', expectedCommitHash]);
console.error(error);
console.log('Build directory', buildDir);
await waitForCliInput();
throw error;
} finally {
chdir(originalDirectory);
await remove(buildDir);
logStatus('Removed build directory');
}
if (await getCurrentCommitHash() !== expectedCommitHash) {
throw new Error(`Unable to checkout commit ${expectedCommitHash}`);
}
logStatus('Copying repository files...');
await copy(pluginDir, buildDir, {
filter: fileName => {
return basename(fileName) !== '.git';
},
});
chdir(buildDir);
logStatus('Initializing repository.');
await execCommand('git init . -b main');
logStatus('Running before-patch hook.');
await options.beforePatch();
const patchFile = getPathToPatchFileFor(pluginId);
if (await exists(patchFile)) {
logStatus('Applying patch.');
await execCommand(['git', 'apply', patchFile]);
}
await options.beforeInstall(buildDir, pluginId);
logStatus('Installing dependencies.');
await execCommand('npm install');
const jplFiles = await glob('publish/*.jpl');
logStatus(`Found built .jpl files: ${JSON.stringify(jplFiles)}`);
if (jplFiles.length === 0) {
throw new Error(`No published files found in ${buildDir}/publish`);
}
if (outputPath !== null) {
const sourceFile = jplFiles[0];
logStatus(`Copying built file from ${sourceFile} to ${outputPath}`);
await copy(sourceFile, outputPath);
} else {
console.warn('No output directory specified. Not copying built .jpl files.');
}
} catch (error) {
console.error(error);
console.log('Build directory', buildDir);
await waitForCliInput();
throw error;
} finally {
chdir(originalDirectory);
await remove(buildDir);
logStatus('Removed build directory');
}
};

View File

@@ -1,8 +1,7 @@
import buildDefaultPlugins from '../buildDefaultPlugins';
const buildAll = (outputDirectory: string) => {
return buildDefaultPlugins({
outputParentDir: outputDirectory,
return buildDefaultPlugins(outputDirectory, {
beforeInstall: async () => { },
beforePatch: async () => { },
});

View File

@@ -8,8 +8,7 @@ import getPathToPatchFileFor from '../utils/getPathToPatchFileFor';
const editPatch = async (targetPluginId: string, outputParentDir: string|null) => {
let patchedPlugin = false;
await buildDefaultPlugins({
outputParentDir: outputParentDir,
await buildDefaultPlugins(outputParentDir, {
beforePatch: async () => {
// To make updating just the patch possible, a commit is created just before applying
// the patch.
@@ -35,7 +34,7 @@ const editPatch = async (targetPluginId: string, outputParentDir: string|null) =
});
if (!patchedPlugin) {
throw new Error(`No patchable default plugin with ID ${targetPluginId} found! Make sure that the plugin has a "cloneUrl" and "branch" in pluginRepositories.json.`);
throw new Error(`No default plugin with ID ${targetPluginId} found!`);
}
};

View File

@@ -14,7 +14,6 @@
},
"devDependencies": {
"@types/yargs": "17.0.33",
"joplin-plugin-freehand-drawing": "4.2.0",
"ts-node": "10.9.2",
"typescript": "5.8.3"
},

View File

@@ -2,10 +2,11 @@
"io.github.jackgruber.backup": {
"cloneUrl": "https://github.com/JackGruber/joplin-plugin-backup.git",
"branch": "master",
"commit": "2c3da7056e7ac39c86c2051a4fdb99d9534dd0a1"
"commit": "abb58175e2d2bf34899f1b32cb74137e5c788bf9"
},
"io.github.personalizedrefrigerator.js-draw": {
"cloneUrl": "https://github.com/personalizedrefrigerator/joplin-plugin-freehand-drawing.git",
"package": "joplin-plugin-freehand-drawing"
"branch": "main",
"commit": "63b6d3f185b5b3664632e498df7c7ad7824038d0"
}
}

View File

@@ -1,28 +1,13 @@
import { readFile } from 'fs-extra';
export enum BuiltInPluginType {
// Plugins that need to be built when building Joplin (e.g. if the plugin
// needs to be patched)
Built,
// Plugins that can be fetched directly from NPM. Must also be marked as a
// dev dependency.
FromNpm,
}
export interface RepositoryData {
type: BuiltInPluginType.Built;
cloneUrl: string;
branch: string;
commit: string;
}
export interface NpmReference {
type: BuiltInPluginType.FromNpm;
package: string;
}
export interface AllRepositoryData {
[pluginId: string]: RepositoryData|NpmReference;
[pluginId: string]: RepositoryData;
}
const readRepositoryJson = async (repositoryDataFilepath: string): Promise<AllRepositoryData> => {
@@ -41,17 +26,9 @@ const readRepositoryJson = async (repositoryDataFilepath: string): Promise<AllRe
}
};
let type;
if ('branch' in parsedJson[pluginId]) {
assertPropertyIsString('cloneUrl');
assertPropertyIsString('branch');
assertPropertyIsString('commit');
type = BuiltInPluginType.Built;
} else {
assertPropertyIsString('package');
type = BuiltInPluginType.FromNpm;
}
parsedJson[pluginId] = { ...parsedJson[pluginId], type };
assertPropertyIsString('cloneUrl');
assertPropertyIsString('branch');
assertPropertyIsString('commit');
}
return parsedJson;

View File

@@ -50,7 +50,7 @@ describe('createEditor', () => {
const headerLine = document.body.querySelector('.cm-headerLine')!;
expect(headerLine.textContent).toBe(headerLineText);
expect(getComputedStyle(headerLine).fontSize).toBe('1.5em');
expect(getComputedStyle(headerLine).fontSize).toBe('1.6em');
// CodeMirror nests the tag that styles the header within .cm-headerLine:
// <div class='cm-headerLine'><span class='someclass'>Testing...</span></div>

View File

@@ -3,31 +3,20 @@ import { SyntaxNodeRef } from '@lezer/common';
import makeReplaceExtension from './utils/makeInlineReplaceExtension';
import toggleCheckboxAt from '../../utils/markdown/toggleCheckboxAt';
const checkboxContainerClassName = 'cm-ext-checkbox-toggle';
const checkboxClassName = 'cm-ext-checkbox';
const checkboxClassName = 'cm-ext-checkbox-toggle';
class CheckboxWidget extends WidgetType {
public constructor(
private checked: boolean,
private depth: number,
private label: string,
private markup: string,
) {
public constructor(private checked: boolean, private depth: number, private label: string) {
super();
}
public eq(other: CheckboxWidget) {
return other.checked === this.checked
&& other.depth === this.depth
&& other.label === this.label
&& other.markup === this.markup;
return other.checked === this.checked && other.depth === this.depth && other.label === this.label;
}
private applyContainerClasses(container: HTMLElement) {
container.classList.add(checkboxContainerClassName);
// For sizing: Should have the same font/styles as non-rendered checkboxes:
container.classList.add('cm-taskMarker');
container.classList.add(checkboxClassName);
for (const className of [...container.classList]) {
if (className.startsWith('-depth-')) {
@@ -41,22 +30,12 @@ class CheckboxWidget extends WidgetType {
public toDOM(view: EditorView) {
const container = document.createElement('span');
const sizingNode = document.createElement('span');
sizingNode.classList.add('sizing');
sizingNode.textContent = this.markup;
container.appendChild(sizingNode);
const checkboxWrapper = document.createElement('span');
checkboxWrapper.classList.add('content');
container.appendChild(checkboxWrapper);
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = this.checked;
checkbox.ariaLabel = this.label;
checkbox.title = this.label;
checkbox.classList.add(checkboxClassName);
checkboxWrapper.appendChild(checkbox);
container.appendChild(checkbox);
checkbox.oninput = () => {
toggleCheckboxAt(view.posAtDOM(container))(view);
@@ -87,32 +66,16 @@ const completedListItemDecoration = Decoration.line({ class: completedTaskClassN
const replaceCheckboxes = [
EditorView.theme({
[`& .${checkboxContainerClassName}`]: {
position: 'relative',
'& > .sizing': {
visibility: 'hidden',
},
'& > .content': {
position: 'absolute',
left: '0',
right: '0',
top: '0',
bottom: '0',
textAlign: 'center',
},
},
[`& .${checkboxClassName}`]: {
verticalAlign: 'middle',
// Ensure that the checkbox grows as the font size increases:
width: '100%',
minHeight: '70%',
// Shift the checkbox slightly so that it's aligned with the list item bullet point
margin: '0',
marginBottom: '3px',
'& > input': {
width: '1.1em',
height: '1.1em',
margin: '4px',
verticalAlign: 'middle',
},
'&:not(.-depth-1) > input': {
marginInlineStart: 0,
},
},
[`& .${completedTaskClassName}`]: {
opacity: 0.69,
@@ -121,7 +84,7 @@ const replaceCheckboxes = [
EditorView.domEventHandlers({
mousedown: (event) => {
const target = event.target as Element;
if (target.nodeName === 'INPUT' && target.classList?.contains(checkboxClassName)) {
if (target.nodeName === 'INPUT' && target.parentElement?.classList?.contains(checkboxClassName)) {
// Let the checkbox handle the event
return true;
}
@@ -138,14 +101,8 @@ const replaceCheckboxes = [
if (node.name === 'TaskMarker') {
const containerLine = state.doc.lineAt(node.from);
const labelText = state.doc.sliceString(node.to, containerLine.to);
const markerText = state.doc.sliceString(node.from, node.to);
return new CheckboxWidget(
markerIsChecked(node),
parentTags.get('ListItem') ?? 0,
labelText,
markerText,
);
return new CheckboxWidget(markerIsChecked(node), parentTags.get('ListItem') ?? 0, labelText);
} else if (node.name === 'Task') {
const marker = node.node.getChild('TaskMarker');
if (marker && markerIsChecked(marker)) {
@@ -162,7 +119,7 @@ const replaceCheckboxes = [
return null;
}
return [node.from, node.to];
return [listMarker.from, node.to];
} else if (node.name === 'Task') {
const taskLine = state.doc.lineAt(node.from);
return [taskLine.from];

View File

@@ -77,9 +77,7 @@ const makeBlockReplaceExtension = (extensionSpec: ReplacementExtension) => {
return extensionSpec.shouldFullReRender(transaction);
};
const treeChanged = syntaxTree(transaction.state) !== syntaxTree(transaction.startState);
if (transaction.docChanged || selectionChanged || wasRerenderRequested() || treeChanged) {
if (transaction.docChanged || selectionChanged || wasRerenderRequested()) {
decorations = updateDecorations(transaction.state, extensionSpec);
}

View File

@@ -86,7 +86,6 @@ const createTheme = (theme: EditorTheme): Extension[] => {
const baseHeadingStyle = {
fontWeight: 'bold',
fontFamily: theme.fontFamily,
paddingBottom: '0.2em',
};
const codeMirrorTheme = EditorView.theme({
@@ -211,12 +210,7 @@ const createTheme = (theme: EditorTheme): Extension[] => {
// small.
'& .cm-h1': {
...baseHeadingStyle,
fontSize: '1.5em',
},
// Only underline level 1 headings not in block quotes. The underline overlaps with the blockquote border.
'& .cm-h1:not(.cm-blockQuote)': {
borderBottom: `1px solid ${theme.dividerColor}`,
marginBottom: '0.1em',
fontSize: '1.6em',
},
'& .cm-h2': {
...baseHeadingStyle,

View File

@@ -135,12 +135,9 @@ const commands: Record<EditorCommandType, ExtendedCommand|null> = {
if (view) {
const selectedText = getTextBetween(state.doc, state.selection.from, state.selection.to);
const content = selectedText || '...';
const blockStart = block ? '$$\n\t' : '$';
return showCreateEditablePrompt({
source: block ? `${blockStart}${content}\n$$` : `${blockStart}${content}$`,
inline: !block,
cursor: blockStart.length,
})(state, dispatch, view);
return showCreateEditablePrompt(
block ? `$$\n\t${content}\n$$` : `$${content}$`, !block,
)(state, dispatch, view);
}
return true;
}
@@ -183,13 +180,10 @@ const commands: Record<EditorCommandType, ExtendedCommand|null> = {
return true;
},
[EditorCommandType.InsertCodeBlock]: (state, dispatch, view) => {
const sourceBlockStart = '```\n';
const selectedText = getTextBetween(state.doc, state.selection.from, state.selection.to);
return showCreateEditablePrompt({
source: `${sourceBlockStart}${selectedText}\n\`\`\``,
inline: false,
cursor: sourceBlockStart.length,
})(state, dispatch, view);
return showCreateEditablePrompt(
`\`\`\`\n${selectedText}\n\`\`\``, false,
)(state, dispatch, view);
},
[EditorCommandType.ToggleSearch]: (state, dispatch, view) => {
const command = setSearchVisible(!getSearchVisible(state));

View File

@@ -4,20 +4,19 @@ import { MarkType, ResolvedPos } from 'prosemirror-model';
import { EditorState, Plugin, Transaction } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import { closeHistory } from 'prosemirror-history';
import showCreateEditablePrompt from './joplinEditablePlugin/showCreateEditablePrompt';
interface InputRuleData {
interface InlineInputRule {
match: RegExp;
commitCharacter: string|null;
handler: (view: EditorView, match: RegExpMatchArray, start: number, end: number, commitCharacter: string)=> Transaction|null;
matchEndCharacter: string;
handler: (state: EditorState, match: RegExpMatchArray, start: number, end: number, commitCharacter: string)=> Transaction|null;
}
// A custom input rule extension for inline input replacements.
//
// Ref: https://github.com/ProseMirror/prosemirror-inputrules/blob/43ef04ce9c1512ef8f2289578309c40b431ed3c5/src/inputrules.ts#L82
// See https://discuss.prosemirror.net/t/trigger-inputrule-on-enter/1118 for why this approach is needed.
const inlineInputRules = (rules: InputRuleData[], commitCharacterExpression: RegExp) => {
const inlineInputRules = (rules: InlineInputRule[], commitCharacterExpression: RegExp) => {
const getContentBeforeCursor = (cursorInformation: ResolvedPos) => {
const parent = cursorInformation.parent;
const offsetInParent = cursorInformation.parentOffset;
@@ -26,11 +25,9 @@ const inlineInputRules = (rules: InputRuleData[], commitCharacterExpression: Reg
};
const getApplicableRule = (state: EditorState, cursor: number, justTypedText: string) => {
const candidateRules = rules.filter(rule => justTypedText.endsWith(rule.commitCharacter ?? ''));
if (!candidateRules.length) {
if (!rules.some(rule => justTypedText.endsWith(rule.matchEndCharacter))) {
return false;
}
const cursorInformation = state.doc.resolve(cursor);
const inCode = cursorInformation.parent.type.spec.code;
if (inCode) {
@@ -38,7 +35,7 @@ const inlineInputRules = (rules: InputRuleData[], commitCharacterExpression: Reg
}
const beforeCursor = getContentBeforeCursor(cursorInformation) + justTypedText;
for (const rule of candidateRules) {
for (const rule of rules) {
const match = beforeCursor.match(rule.match);
if (!match) continue;
@@ -47,7 +44,7 @@ const inlineInputRules = (rules: InputRuleData[], commitCharacterExpression: Reg
return null;
};
type PluginState = { pendingRule: InputRuleData };
type PluginState = { pendingRule: InlineInputRule };
const run = (view: EditorView, cursor: number, commitData: string) => {
const commitCharacter = commitCharacterExpression.exec(commitData) ? commitData : '';
@@ -60,7 +57,7 @@ const inlineInputRules = (rules: InputRuleData[], commitCharacterExpression: Reg
const beforeCursor = getContentBeforeCursor(view.state.doc.resolve(cursor));
const match = beforeCursor.match(availableRule.match);
if (match) {
const transaction = availableRule.handler(view, match, cursor - match[0].length, cursor, commitCharacter);
const transaction = availableRule.handler(view.state, match, cursor - match[0].length, cursor, commitCharacter);
if (transaction) {
// closeHistory: Move the markup completion to a separate history event so that it
// can be undone separately.
@@ -112,25 +109,17 @@ const inlineInputRules = (rules: InputRuleData[], commitCharacterExpression: Reg
return plugin;
};
type OnReplace = (matches: RegExpMatchArray, view: EditorView)=> string;
interface InputRuleOptions {
contentRegex: string|RegExp;
commitCharacter: string|null; // null => only "Enter"
onReplace: OnReplace;
marks: MarkType[];
}
const makeInputRule = ({
contentRegex, commitCharacter, onReplace, marks,
}: InputRuleOptions): InputRuleData => {
const makeMarkInputRule = (
regExpString: string, matchEndCharacter: string, replacement: (matches: RegExpMatchArray)=> string, mark: MarkType,
): InlineInputRule => {
const commitCharacterExp = '[.?!,:;¡¿() \\n]';
const regex = typeof contentRegex === 'string' ? new RegExp(`(^|${commitCharacterExp})${contentRegex}$`) : contentRegex;
const regex = new RegExp(`(^|${commitCharacterExp})${regExpString}$`);
return {
match: regex,
commitCharacter,
handler: (view, match, start, end, endCommitCharacter) => {
const state = view.state;
matchEndCharacter,
handler: (state, match, start, end, endCommitCharacter) => {
let transaction = state.tr.delete(start, end);
const marks = [schema.mark(mark)];
const startCommitCharacter = match[1];
@@ -142,12 +131,13 @@ const makeInputRule = ({
];
matchesWithoutCommitCharacters.groups = match.groups;
const replacement = onReplace(matchesWithoutCommitCharacters, view);
const replacementText = replacement(matchesWithoutCommitCharacters);
transaction = transaction.insert(
transaction.mapping.map(start, -1),
[
!!startCommitCharacter && schema.text(startCommitCharacter),
!!replacement && schema.text(replacement, marks.map(type => schema.mark(type))),
!!replacementText && schema.text(replacementText, marks),
!!endCommitCharacter && schema.text(endCommitCharacter),
].filter(node => !!node),
);
@@ -159,51 +149,33 @@ const makeInputRule = ({
const baseInputRules = buildInputRules(schema);
const inlineContentExp = '\\S[^\\n]*\\S|\\S';
const noMatchRegex = /$^/;
const inputRulesExtension = [
baseInputRules,
inlineInputRules([
makeInputRule({
contentRegex: /(^|[\n])(```+)(\w*)$/,
commitCharacter: '',
onReplace: (match, view) => {
const blockStart = `${match[1]}${match[2]}\n`;
const block = `${blockStart}\n${match[1]}`;
showCreateEditablePrompt({
source: block,
inline: false,
cursor: blockStart.length,
})(view.state, view.dispatch, view);
return '';
},
marks: [],
}),
], noMatchRegex),
inlineInputRules([
makeInputRule({
contentRegex: `\\*\\*(${inlineContentExp})\\*\\*`,
commitCharacter: '*',
onReplace: (match) => match[1],
marks: [schema.marks.strong],
}),
makeInputRule({
contentRegex: `\\*(${inlineContentExp})\\*`,
commitCharacter: '*',
onReplace: (match) => match[1],
marks: [schema.marks.emphasis],
}),
makeInputRule({
contentRegex: `_(${inlineContentExp})_`,
commitCharacter: '_',
onReplace: (match) => match[1],
marks: [schema.marks.emphasis],
}),
makeInputRule({
contentRegex: `\`(${inlineContentExp})\``,
commitCharacter: '`',
onReplace: (match) => match[1],
marks: [schema.marks.code],
}),
makeMarkInputRule(
`\\*\\*(${inlineContentExp})\\*\\*`,
'*',
(match) => match[1],
schema.marks.strong,
),
makeMarkInputRule(
`\\*(${inlineContentExp})\\*`,
'*',
(match) => match[1],
schema.marks.emphasis,
),
makeMarkInputRule(
`_(${inlineContentExp})_`,
'_',
(match) => match[1],
schema.marks.emphasis,
),
makeMarkInputRule(
`[\`](${inlineContentExp})[\`]`,
'`',
(match) => match[1],
schema.marks.code,
),
], /[ .,?)!;]/),
];
export default inputRulesExtension;

View File

@@ -23,15 +23,13 @@ const createEditorDialogForNode = (nodePosition: number, view: EditorView, onHid
view.state.doc.nodeAt(nodePosition)
);
const openCharacters = getNode().attrs.openCharacters ?? '';
const { dismiss } = createEditorDialog({
editorApi: getEditorApi(view.state),
source: [
openCharacters,
getNode().attrs.openCharacters,
getNode().attrs.source,
getNode().attrs.closeCharacters ?? '',
getNode().attrs.closeCharacters,
].join(''),
cursor: openCharacters.length,
onSave: async (source) => {
view.dispatch(
view.state.tr.setNodeAttribute(

View File

@@ -38,11 +38,7 @@ describe('showCreateEditorPrompt', () => {
test('should allow creating a new code block', async () => {
const editor = createEditor('');
showCreateEditablePrompt({
source: '```\ntest\n```',
inline: false,
cursor: 8,
})(editor.state, editor.dispatch, editor);
showCreateEditablePrompt('```\ntest\n```', false)(editor.state, editor.dispatch, editor);
const dialog = findEditorDialog();
dialog.submitButton.click();
@@ -58,16 +54,4 @@ describe('showCreateEditorPrompt', () => {
}],
});
});
test('should position the cursor at the provided location', () => {
const editor = createEditor('');
showCreateEditablePrompt({
source: '```\n\n```',
inline: false,
cursor: 4,
})(editor.state, editor.dispatch, editor);
const dialog = findEditorDialog();
expect(dialog.editor.selectionStart).toBe(4);
});
});

View File

@@ -5,20 +5,13 @@ import postProcessRenderedHtml from './utils/postProcessRenderedHtml';
import schema from '../../schema';
import { JoplinEditableAttributes } from './joplinEditablePlugin';
interface EditablePromptOptions {
source: string;
inline: boolean;
cursor: number;
}
const showCreateEditablePrompt = ({ source, inline, cursor }: EditablePromptOptions): Command => (_state, dispatch, view) => {
const showCreateEditablePrompt = (source: string, inline: boolean): Command => (_state, dispatch, view) => {
if (!dispatch) return true;
if (!view) throw new Error('Missing required argument: view');
createEditorDialog({
editorApi: getEditorApi(view.state),
source,
cursor,
onSave: async (newSource) => {
source = newSource;
},

View File

@@ -1,17 +1,15 @@
import { EditorApi } from '../../joplinEditorApiPlugin';
import { EditorLanguageType } from '../../../../types';
import showModal from '../../../utils/dom/showModal';
import { focus } from '@joplin/lib/utils/focusHandler';
interface Options {
source: string;
cursor: number;
editorApi: EditorApi;
onSave: (newContent: string)=> void;
onDismiss: ()=> void;
}
const createEditorDialog = ({ editorApi, source, cursor, onSave, onDismiss }: Options) => {
const createEditorDialog = ({ editorApi, source, onSave, onDismiss }: Options) => {
const content = document.createElement('div');
content.classList.add('editor-dialog-content');
document.body.appendChild(content);
@@ -24,10 +22,9 @@ const createEditorDialog = ({ editorApi, source, cursor, onSave, onDismiss }: Op
},
);
editor.updateBody(source);
editor.select(cursor, cursor);
const _ = editorApi.localize;
const modal = showModal({
return showModal({
content,
doneLabel: _('Done'),
onDismiss: () => {
@@ -35,10 +32,6 @@ const createEditorDialog = ({ editorApi, source, cursor, onSave, onDismiss }: Op
editor.remove();
},
});
focus('createEditorDialog', editor);
return modal;
};
export default createEditorDialog;

View File

@@ -48,9 +48,6 @@ const joplinEditorApiPlugin = new Plugin<EditorApi>({
updateBody: (newValue) => {
editor.textArea.value = newValue;
},
select: (anchor, head) => {
editor.textArea.setSelectionRange(Math.min(anchor, head), Math.max(anchor, head));
},
};
},
}),

View File

@@ -18,7 +18,6 @@ export interface CodeEditorControl {
focus: ()=> void;
remove: ()=> void;
updateBody: (newValue: string)=> void;
select: (from: number, to: number)=> void;
}
export type OnCodeEditorChange = (newValue: string)=> void;

View File

@@ -1,24 +0,0 @@
import { convertValuesToFunctions, sortByValue } from './ObjectUtils';
describe('ObjectUtils', () => {
test('should convert object values to functions', () => {
const v = convertValuesToFunctions({ a: 6, b: ()=>7, c: 'test' });
expect(v.a()).toBe(6);
expect(v.b()).toBe(7);
expect(v.c()).toBe('test');
});
test('should sort an object\'s entries by value', () => {
expect(sortByValue({
a: 1,
b: 'test1',
c: 'test3',
d: 'test2',
})).toEqual({
b: 'test1',
d: 'test2',
c: 'test3',
a: 1,
});
});
});

View File

@@ -1,9 +1,5 @@
type AnyObject = Record<string|symbol, unknown>;
type SortableValue = string|number;
type SortableObject = Record<string, SortableValue>;
export function sortByValue<T extends SortableObject>(object: T): T {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export function sortByValue(object: any) {
const temp = [];
for (const k in object) {
if (!object.hasOwnProperty(k)) continue;
@@ -14,8 +10,8 @@ export function sortByValue<T extends SortableObject>(object: T): T {
}
temp.sort((a, b) => {
let v1: SortableValue = a.value;
let v2: SortableValue = b.value;
let v1 = a.value;
let v2 = b.value;
if (typeof v1 === 'string') v1 = v1.toLowerCase();
if (typeof v2 === 'string') v2 = v2.toLowerCase();
if (v1 === v2) return 0;
@@ -32,7 +28,8 @@ export function sortByValue<T extends SortableObject>(object: T): T {
return output;
}
export function fieldsEqual(o1: AnyObject, o2: AnyObject) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export function fieldsEqual(o1: any, o2: any) {
if ((!o1 || !o2) && o1 !== o2) return false;
for (const k in o1) {
@@ -48,11 +45,8 @@ export function fieldsEqual(o1: AnyObject, o2: AnyObject) {
return true;
}
type ValuesToFunctions<T extends AnyObject> = {
[k in keyof T]: T[k] extends ()=> unknown ? T[k] : ()=> T[k]
};
export function convertValuesToFunctions<T extends AnyObject>(o: T): ValuesToFunctions<T> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export function convertValuesToFunctions(o: any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const output: any = {};
for (const n in o) {
@@ -64,7 +58,8 @@ export function convertValuesToFunctions<T extends AnyObject>(o: T): ValuesToFun
return output;
}
export function isEmpty(o: AnyObject|null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export function isEmpty(o: any) {
if (!o) return true;
return Object.keys(o).length === 0 && o.constructor === Object;
}

View File

@@ -1,5 +1,6 @@
import Setting, { AppType, SettingMetadataSection, SettingSectionSource } from '../../../models/Setting';
import SyncTargetRegistry from '../../../SyncTargetRegistry';
const ObjectUtils = require('../../../ObjectUtils');
const { _ } = require('../../../locale');
import { createSelector } from 'reselect';
import Logger from '@joplin/utils/Logger';
@@ -7,7 +8,6 @@ import Logger from '@joplin/utils/Logger';
import { type ReactNode } from 'react';
import { type Registry } from '../../../registry';
import settingValidations from '../../../models/settings/settingValidations';
import { convertValuesToFunctions } from '../../../ObjectUtils';
const logger = Logger.create('config-shared');
@@ -75,11 +75,11 @@ export const checkSyncConfig = async (comp: ConfigScreenComponent, settings: any
const SyncTargetClass = SyncTargetRegistry.classById(syncTargetId);
const options = {
...Setting.subValues(`sync.${syncTargetId}`, settings, { includeConstants: true }),
...Setting.subValues('net', settings, { includeConstants: true }) };
...Setting.subValues(`sync.${syncTargetId}`, settings),
...Setting.subValues('net', settings) };
comp.setState({ checkSyncConfigResult: 'checking' });
const result = await SyncTargetClass.checkConfig(convertValuesToFunctions(options));
const result = await SyncTargetClass.checkConfig(ObjectUtils.convertValuesToFunctions(options));
comp.setState({ checkSyncConfigResult: result });
if (result.ok) {

View File

@@ -171,11 +171,6 @@ interface UserSettingMigration {
isPluginSetting: boolean;
}
interface SubValuesOptions {
includeBaseKeyInName?: boolean;
includeConstants?: boolean;
}
const userSettingMigration: UserSettingMigration[] = [
{
oldName: 'spellChecker.language',
@@ -1105,29 +1100,18 @@ class Setting extends BaseModel {
// and baseKey is 'sync.5', the function will return
// { path: 'http://example', username: 'testing' }
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public static subValues(baseKey: string, settings: Partial<SettingsRecord>, options: SubValuesOptions|null = null) {
public static subValues(baseKey: string, settings: Partial<SettingsRecord>, options: any = null) {
const includeBaseKeyInName = !!options && !!options.includeBaseKeyInName;
const subKey = (key: string) => {
return includeBaseKeyInName ? key : key.substring(baseKey.length + 1);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const output: any = {};
for (const [key, value] of Object.entries(settings)) {
if (key.startsWith(baseKey)) {
output[subKey(key)] = value;
for (const key in settings) {
if (!settings.hasOwnProperty(key)) continue;
if (key.indexOf(baseKey) === 0) {
const subKey = includeBaseKeyInName ? key : key.substr(baseKey.length + 1);
output[subKey] = settings[key];
}
}
if (options?.includeConstants) {
for (const [key, value] of Object.entries(this.constants_)) {
if (key.startsWith(baseKey)) {
output[subKey(key)] = value;
}
}
}
return output;
}

View File

@@ -1118,7 +1118,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
label: () => `${_('Enable file:// URLs for images and videos')}${wysiwygYes}`,
},
'markdown.plugin.abc.options': { storage: SettingStorage.File, isGlobal: true, value: '', type: SettingItemType.String, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('ABC musical notation: Options')}${wysiwygNo}`, description: () => _('Options that should be used whenever rendering ABC code. It must be a JSON5 object. The full list of options is available at: %s', 'https://paulrosen.github.io/abcjs/visual/render-abc-options.html') },
'markdown.plugin.abc.options': { storage: SettingStorage.File, isGlobal: true, value: '', type: SettingItemType.String, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('ABC musical notation: Options')}${wysiwygNo}`, description: () => _('Options that should be used whenever rendering ABC code. It must be a JSON5 object. The full list of options is available at: %1', 'https://paulrosen.github.io/abcjs/visual/render-abc-options.html') },
// Tray icon (called AppIndicator) doesn't work in Ubuntu
// http://www.webupd8.org/2017/04/fix-appindicator-not-working-for.html

View File

@@ -231,7 +231,7 @@ describe('InteropService_Importer_OneNote', () => {
const noteToTest = notes.find(n => n.title === 'Tips from a Pro Using Trees for Dramatic Landscape Photography');
expectWithInstructions(noteToTest).toBeTruthy();
expectWithInstructions(noteToTest.body).toContain('<a href="onenote:https://d.docs.live.net/c8d3bbab7f1acf3a/Documents/Photography/%E9%A3%8E%E6%99%AF.one#Tips%20from%20a%20Pro%20Using%20Trees%20for%20Dramatic%20Landscape%20Photography&amp;section-id={262ADDFB-A4DC-4453-A239-0024D6769962}&amp;page-id={88D803A5-4F43-48D4-9B16-4C024F5787DC}&amp;end" style="">Tips from a Pro: Using Trees for Dramatic Landscape Photography</a>');
expectWithInstructions(noteToTest.body).toContain('<a href="onenote:https://d.docs.live.net/c8d3bbab7f1acf3a/Documents/Photography/%E9%A3%8E%E6%99%AF.one#Tips%20from%20a%20Pro%20Using%20Trees%20for%20Dramatic%20Landscape%20Photography&section-id={262ADDFB-A4DC-4453-A239-0024D6769962}&page-id={88D803A5-4F43-48D4-9B16-4C024F5787DC}&end" style="">Tips from a Pro: Using Trees for Dramatic Landscape Photography</a>');
});
it('should render links properly by ignoring wrongly set indices when the first character is a hyperlink marker', async () => {

View File

@@ -69,8 +69,6 @@ export default class InteropService_Importer_OneNote extends InteropService_Impo
// other files.
fileNamePattern: '*.one',
});
await this.fixIncorrectLatin1Decoding_(extractPath);
} else {
throw new Error(`Unknown file extension: ${fileExtension}`);
}
@@ -179,7 +177,6 @@ export default class InteropService_Importer_OneNote extends InteropService_Impo
const pipeline = [
(dom: Document, currentFolder: string) => this.extractSvgsToFiles_(dom, currentFolder),
(dom: Document, currentFolder: string) => this.convertExternalLinksToInternalLinks_(dom, currentFolder),
(dom: Document, _currentFolder: string) => Promise.resolve(this.simplifyHtml_(dom)),
];
for (const file of htmlFiles) {
@@ -237,28 +234,6 @@ export default class InteropService_Importer_OneNote extends InteropService_Impo
return changed;
}
private simplifyHtml_(dom: Document) {
const selectors = [
// <script> blocks that aren't marked with a specific type (e.g. application/tex).
'script:not([type])',
// ID mappings (unused at this stage of the import process)
'meta[name="X-Original-Page-Id"]',
// Empty iframes
'iframe[src=""]',
];
let changed = false;
for (const selector of selectors) {
for (const element of dom.querySelectorAll(selector)) {
element.remove();
changed = true;
}
}
return changed;
}
private async extractSvgsToFiles_(dom: Document, svgBaseFolder: string) {
const { svgs, changed } = this.extractSvgs(dom);
@@ -320,47 +295,4 @@ export default class InteropService_Importer_OneNote extends InteropService_Impo
changed: true,
};
}
// Works around a decoding issue in which file names are extracted as latin1 strings,
// rather than UTF-8 strings. For example, OneNote seems to encode filenames as UTF-8 in .onepkg files.
// However, EXPAND.EXE reads the filenames as latin1. As a result, "é.one" becomes
// "é.one" when extracted from the archive.
// This workaround re-encodes filenames as UTF-8.
private async fixIncorrectLatin1Decoding_(parentDir: string) {
// Only seems to be necessary on Windows.
if (!shim.isWindows()) return;
const fixEncoding = async (basePath: string, fileName: string) => {
const originalPath = join(basePath, fileName);
let newPath;
let fixedFileName = Buffer.from(fileName, 'latin1').toString('utf8');
if (fixedFileName !== fileName) {
// In general, the path shouldn't start with "."s or contain path separators.
// However, if it does, these characters might cause import errors, so remove them:
fixedFileName = fixedFileName.replace(/^\.+/, '');
fixedFileName = fixedFileName.replace(/[/\\]/g, ' ');
// Avoid path traversal: Ensure that the file path is contained within the base directory
const newFullPathSafe = shim.fsDriver().resolveRelativePathWithinDir(basePath, fixedFileName);
await shim.fsDriver().move(originalPath, newFullPathSafe);
newPath = newFullPathSafe;
} else {
newPath = originalPath;
}
if (await shim.fsDriver().isDirectory(originalPath)) {
const children = await shim.fsDriver().readDirStats(newPath, { recursive: false });
for (const child of children) {
await fixEncoding(originalPath, child.path);
}
}
};
const stats = await shim.fsDriver().readDirStats(parentDir, { recursive: false });
for (const stat of stats) {
await fixEncoding(parentDir, stat.path);
}
}
}

File diff suppressed because one or more lines are too long

View File

@@ -77,19 +77,13 @@ impl DataElementPackage {
cell: ExGuid,
storage_index: &StorageIndex,
) -> Result<Vec<&ObjectGroup>> {
let revision_mapping_id = self
.resolve_cell_revision_manifest_id(storage_index, cell)
.or_else(|| {
storage_index
.cell_mappings
.values()
.find(|mapping| mapping.id == cell)
.and_then(|mapping| {
storage_index.find_revision_mapping_by_serial(&mapping.serial)
})
})
let revision_id = self
.find_cell_revision_id(cell)
.ok_or_else(|| ErrorKind::MalformedFssHttpBData("cell revision id not found".into()))?;
let revision_mapping_id = storage_index
.find_revision_mapping_id(revision_id)
.ok_or_else(|| {
ErrorKind::MalformedFssHttpBData("revision manifest id not found".into())
ErrorKind::MalformedFssHttpBData("revision mapping id not found".into())
})?;
let revision_manifest = self
.find_revision_manifest(revision_mapping_id)
@@ -118,46 +112,16 @@ impl DataElementPackage {
self.storage_indexes.values().next()
}
/// Look up a storage index by its ID.
pub(crate) fn find_storage_index_by_id(&self, id: ExGuid) -> Option<&StorageIndex> {
self.storage_indexes.get(&id)
}
/// Find the first storage manifest.
pub(crate) fn find_storage_manifest(&self) -> Option<&StorageManifest> {
self.storage_manifests.values().next()
}
/// Resolve a revision manifest ID, falling back when revision mappings are missing.
pub(crate) fn resolve_revision_manifest_id(
&self,
storage_index: &StorageIndex,
id: ExGuid,
) -> Option<ExGuid> {
storage_index
.find_revision_mapping_id(id)
.or_else(|| self.revision_manifests.get(&id).map(|_| id))
}
/// Look up a cell revision ID by the cell's manifest ID.
pub(crate) fn find_cell_revision_id(&self, id: ExGuid) -> Option<ExGuid> {
self.cell_manifests.get(&id).copied()
}
/// Resolve a cell's revision manifest ID, with a fallback for newer files.
pub(crate) fn resolve_cell_revision_manifest_id(
&self,
storage_index: &StorageIndex,
cell_manifest_id: ExGuid,
) -> Option<ExGuid> {
let resolved = self
.find_cell_revision_id(cell_manifest_id)
.and_then(|revision_id| self.resolve_revision_manifest_id(storage_index, revision_id))
.or_else(|| self.resolve_revision_manifest_id(storage_index, cell_manifest_id));
resolved
}
/// Look up a revision manifest by its ID.
pub(crate) fn find_revision_manifest(&self, id: ExGuid) -> Option<&RevisionManifest> {
self.revision_manifests.get(&id)
@@ -225,7 +189,7 @@ impl DataElement {
return Err(ErrorKind::MalformedFssHttpBData(
format!("invalid element type: 0x{:X}", x).into(),
)
.into());
.into())
}
}

View File

@@ -32,15 +32,6 @@ impl StorageIndex {
.get(&id)
.map(|mapping| mapping.revision_mapping)
}
pub(crate) fn find_revision_mapping_by_serial(&self, serial: &SerialNumber) -> Option<ExGuid> {
self.revision_mappings
.values()
.find(|mapping| {
mapping.serial.guid == serial.guid && mapping.serial.serial == serial.serial
})
.map(|mapping| mapping.revision_mapping)
}
}
/// A storage indexes manifest mapping.

View File

@@ -71,8 +71,7 @@ pub(crate) fn parse_store(package: &OneStorePackaging) -> Result<FssHttpbOneStor
// [ONESTORE] 2.7.1: Parse storage manifest
let storage_index = package
.data_element_package
.find_storage_index_by_id(package.storage_index)
.or_else(|| package.data_element_package.find_storage_index())
.find_storage_index()
.ok_or_else(|| ErrorKind::MalformedOneStoreData("storage index is missing".into()))?;
let storage_manifest = package
.data_element_package

View File

@@ -7,7 +7,7 @@ use crate::fsshttpb_onestore::revision_role::RevisionRole;
use crate::onestore;
use crate::shared::cell_id::CellId;
use crate::shared::exguid::ExGuid;
use parser_utils::errors::{ErrorKind, Result};
use parser_utils::errors::Result;
use std::collections::HashMap;
pub(crate) type GroupData<'a> = HashMap<(ExGuid, u64), &'a ObjectGroupData>;
@@ -58,31 +58,16 @@ impl<'b> ObjectSpace {
let context_id = cell_id.0;
let object_space_id = cell_id.1;
let cell_revision_id = packaging
.data_element_package
.find_cell_revision_id(mapping.id);
let revision_manifest_id = packaging
.data_element_package
.resolve_cell_revision_manifest_id(storage_index, mapping.id)
.or_else(|| storage_index.find_revision_mapping_by_serial(&mapping.serial));
if revision_manifest_id.is_none() && cell_revision_id.map(|id| id.is_nil()).unwrap_or(false)
{
return Ok((
cell_id,
ObjectSpace {
id: object_space_id,
context: context_id,
roots: HashMap::new(),
objects: HashMap::new(),
},
));
}
let revision_manifest_id = revision_manifest_id.ok_or_else(|| {
ErrorKind::MalformedOneStoreData("no revision manifest id found".into())
})?;
let cell_manifest_id = ObjectSpace::find_cell_manifest_id(mapping.id, packaging)
.ok_or_else(|| parser_error!(MalformedOneStoreData, "cell manifest id not found"))?;
let revision_manifest_id = storage_index
.find_revision_mapping_id(cell_manifest_id)
.ok_or_else(|| {
parser_error!(
MalformedOneStoreData,
"No revision manifest id found. (Unable to find revision?)."
)
})?;
let mut objects = HashMap::new();
let mut roots = HashMap::new();
@@ -113,4 +98,15 @@ impl<'b> ObjectSpace {
Ok((cell_id, space))
}
fn find_cell_manifest_id(
cell_manifest_id: ExGuid,
packaging: &OneStorePackaging,
) -> Option<ExGuid> {
packaging
.data_element_package
.cell_manifests
.get(&cell_manifest_id)
.copied()
}
}

View File

@@ -42,11 +42,10 @@ impl Revision {
.base_rev_id
.as_option()
.map(|mapping_id| {
packaging
.data_element_package
.resolve_revision_manifest_id(storage_index, mapping_id)
storage_index
.find_revision_mapping_id(mapping_id)
.ok_or_else(|| {
ErrorKind::MalformedOneStoreData("revision manifest id not found".into())
ErrorKind::MalformedOneStoreData("revision mapping not found".into())
})
})
.transpose()?;

View File

@@ -25,14 +25,10 @@ impl<'a> Renderer<'a> {
let file_type = Self::guess_type(file);
match file_type {
// TODO: As of 01-06-2026, Joplin has limited or no support for <video> and <audio> elements in HTML notes.
// For example, <video> elements can only reference web URLs and <audio> elements aren't
// supported at all.
//
// See also: https://github.com/laurent22/joplin/issues/11939.
// TODO: we still don't have support for the audio tag on html notes https://github.com/laurent22/joplin/issues/11939
// FileType::Audio => content = format!("<audio class=\"media-player media-audio\"controls><source src=\"{}\" type=\"audio/x-wav\"></source></audio>", filename),
// FileType::Video => content = format!("<video controls src=\"{}\" {}></video>", filename, styles.to_html_attr()),
FileType::Unknown | FileType::Audio | FileType::Video => {
FileType::Video => content = format!("<video controls src=\"{}\" {}></video>", filename, styles.to_html_attr()),
FileType::Unknown | FileType::Audio => {
styles.set("font-size", "11pt".into());
styles.set("line-height", "17px".into());
let style_attr = styles.to_html_attr();

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ name }}</title>

View File

@@ -2,22 +2,14 @@ const { execCommand } = require('@joplin/utils');
const yargs = require('yargs');
async function main() {
const argv = yargs.argv;
if (!argv.profile) throw new Error('OneNote build: profile value is missing');
if (!['release', 'dev'].includes(argv.profile)) throw new Error('OneNote build: profile value is invalid');
const isDevBuild = argv.profile === 'dev';
if (!isDevBuild && !process.env.IS_CONTINUOUS_INTEGRATION) {
if (!process.env.IS_CONTINUOUS_INTEGRATION) {
// eslint-disable-next-line no-console
console.info([
console.info(
'----------------------------------------------------------------\n' +
'Not building onenote-converter because it is not a continuous integration environment.',
'',
'Either:',
' - Re-run with the IS_CONTINUOUS_INTEGRATION environment variable set to 1.',
' - Run "yarn buildDev" to create a development build.',
'Not building onenote-converter because it is not a continuous integration environment.\n' +
'Use IS_CONTINUOUS_INTEGRATION=1 env var if build is necessary.\n' +
'----------------------------------------------------------------',
].join('\n'));
);
return;
}
@@ -28,12 +20,15 @@ async function main() {
return;
}
const argv = yargs.argv;
if (!argv.profile) throw new Error('OneNote build: profile value is missing');
if (!['release', 'dev'].includes(argv.profile)) throw new Error('OneNote build: profile value is invalid');
const buildCommand = `wasm-pack build --target nodejs --${argv.profile} ./renderer`;
await execCommand(buildCommand);
if (isDevBuild) return;
if (argv.profile !== 'release') return;
// If release build, remove intermediary folder to decrease size of release
const removeIntermediaryFolder = 'cargo clean';

View File

@@ -52,33 +52,25 @@ const plugin = (markdownIt: MarkdownIt, ruleOptions: any) => {
const token = tokens[idx];
if (token.info !== 'abc') return defaultRender(tokens, idx, options, env, self);
const escapeHtml = markdownIt.utils.escapeHtml;
ruleOptions.context.pluginWasUsed.abc = true;
try {
const parsed = parseAbcContent(token.content);
const globalOptions = ruleOptions.globalSettings ? parseGlobalOptions(ruleOptions.globalSettings['markdown.plugin.abc.options']) : {};
const content = parsed.markup.trim();
const contentHtml = escapeHtml(content);
const optionsHtml = escapeHtml(JSON.stringify({
const contentHtml = markdownIt.utils.escapeHtml(parsed.markup.trim());
const optionsHtml = markdownIt.utils.escapeHtml(JSON.stringify({
...globalOptions,
...parsed.options,
}));
const sourceContentLines: string[] = [];
if (parsed.options && Object.keys(parsed.options).length) sourceContentLines.push(JSON5.stringify(parsed.options));
sourceContentLines.push(content);
const sourceContentHtml = escapeHtml(sourceContentLines.join('\n---\n'));
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&#10;" data-joplin-source-close="&#10;\`\`\`&#10;">${sourceContentHtml}</pre>
<pre class="joplin-source" data-abc-options="${optionsHtml}" data-joplin-language="abc" data-joplin-source-open="\`\`\`abc&#10;" data-joplin-source-close="&#10;\`\`\`&#10;">${contentHtml}</pre>
<pre class="joplin-rendered joplin-abc-notation-rendered">${contentHtml}</pre>
</div>
`;
} catch (error) {
return `<div class="inline-code">${escapeHtml(error.message)}</div}>`;
return `<div class="inline-code">${markdownIt.utils.escapeHtml(error.message)}</div}>`;
}
};
};

File diff suppressed because one or more lines are too long

View File

@@ -41,7 +41,7 @@
"html-entities": "1.4.0",
"json-stringify-safe": "5.0.1",
"json5": "2.2.3",
"katex": "0.16.23",
"katex": "0.16.22",
"markdown-it": "13.0.2",
"markdown-it-abbr": "1.0.4",
"markdown-it-anchor": "5.3.0",

View File

@@ -226,4 +226,3 @@ xhdpi
xxhdpi
xxxhdpi
scrollend
fgag

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,5 @@
# Joplin Android Changelog
## [android-v3.5.7](https://github.com/laurent22/joplin/releases/tag/android-v3.5.7) (Pre-release) - 2026-01-08T19:30:28Z
- New: Rich Text Editor: Add shortcuts for inserting code blocks (#14055 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Improved: Accessibility: In-editor rendering: Fix rendered checkboxes are very small on mobile (#14056 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Improved: Markdown editor: Make header styles more closely match the note viewer (#14053) (#13753 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Improved: Markdown editor: Prevent layout shift when hiding/showing rendered checkboxes (#14044) (#13159 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Improved: Updated packages @rollup/plugin-node-resolve (v16.0.2), katex (v0.16.23)
- Fixed: Fix "Check synchronization configuration" button (#14031) (#14030 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Fixed: Fix ABC Sheet Music setting includes "Translation error" in description (#14058) (#14049 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Fixed: Images sometimes don't render until you click somewhere in the note (#14019) (#13963 by [@bwat47](https://github.com/bwat47))
- Fixed: Insert time command not respecting locale settings (#13994) (#13229 by [@HIJOdelIDANII](https://github.com/HIJOdelIDANII))
## [android-v3.5.6](https://github.com/laurent22/joplin/releases/tag/android-v3.5.6) (Pre-release) - 2025-12-27T20:34:44Z
- Revert "All: Apache Tomcat WebDAV compatibility for sync (#13614)"

View File

@@ -9,7 +9,6 @@ WebDAV-compatible services that are known to work with Joplin:
- [Fastmail](https://www.fastmail.com/)
- [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)
- [Nextcloud](https://nextcloud.com/)
- [OwnCloud](https://owncloud.org/)

View File

@@ -76,12 +76,18 @@ Your Contributions.
waived such rights for your Contributions to the Company, or that your
employer has executed a separate Corporate CLA with the Company.
5. You represent that each of Your Contributions is Your original creation (see
section 7 for submissions on behalf of others). You represent that Your
Contribution submissions include complete details of any third-party license
or other restriction (including, but not limited to, related patents and
trademarks) of which you are personally aware and which are associated with
any part of Your Contributions.
5. You represent that each of Your Contributions is Your original creation or
that You otherwise have the lawful right to submit it, and that each
Contribution is a work of human authorship. You represent that, although
automated or artificial intelligence tools may have been used to assist in
the creation of a Contribution, You have exercised creative judgment in
reviewing, selecting, modifying, or arranging the Contribution, and that no
Contribution is submitted that was generated entirely by automated means
without meaningful human involvement. You represent that Your Contribution
submissions include complete details of any third-party license or other
restriction (including, but not limited to, related patents and trademarks)
of which you are personally aware and which are associated with any part of
Your Contributions.
6. You are not expected to provide support for Your Contributions, except to the
extent You desire to provide support. You may provide support for free, for a

View File

@@ -30,18 +30,6 @@ For example,
}
```
Or, alternatively, if the build process for the plugin is trusted (e.g. it uses [NPM trusted publishing](https://docs.npmjs.com/trusted-publishers)) and the plugin doesn't need to be patched,
1. Add the plugin to `devDependencies` in `package.json`.
2. Update `pluginRepositories.json`, replacing `""` with the plugin's NPM package name:
```json
{
"plugin.id.here": {
"cloneUrl": "https://example.com/plugin-repo/plugin-repo-here.git",
"package": ""
}
}
```
## Patching the plugin
Some plugins need patching. To create or update a plugin's patch, run the `patch-plugin` command in the `packages/default-plugins/` directory.

View File

@@ -10643,7 +10643,6 @@ __metadata:
"@joplin/utils": "npm:~3.5"
"@types/yargs": "npm:17.0.33"
fs-extra: "npm:11.3.2"
joplin-plugin-freehand-drawing: "npm:4.2.0"
ts-node: "npm:10.9.2"
typescript: "npm:5.8.3"
yargs: "npm:17.7.2"
@@ -10987,7 +10986,7 @@ __metadata:
jest-environment-jsdom: "npm:29.7.0"
json-stringify-safe: "npm:5.0.1"
json5: "npm:2.2.3"
katex: "npm:0.16.23"
katex: "npm:0.16.22"
markdown-it: "npm:13.0.2"
markdown-it-abbr: "npm:1.0.4"
markdown-it-anchor: "npm:5.3.0"
@@ -34929,16 +34928,6 @@ __metadata:
languageName: node
linkType: hard
"joplin-plugin-freehand-drawing@npm:4.2.0":
version: 4.2.0
resolution: "joplin-plugin-freehand-drawing@npm:4.2.0"
dependencies:
"@js-draw/material-icons": "npm:1.33.0"
js-draw: "npm:1.33.0"
checksum: 10/457c23b7fbd6f1e3a48568395c1e456d570a3c68e25b40fba97973fd848de1b513be2f1ece6eb9babae862e19ba140aca871eee0e3a8a9cb5a033e0c0fd78934
languageName: node
linkType: hard
"joplin@workspace:packages/app-cli":
version: 0.0.0-use.local
resolution: "joplin@workspace:packages/app-cli"
@@ -35512,14 +35501,14 @@ __metadata:
languageName: node
linkType: hard
"katex@npm:0.16.23":
version: 0.16.23
resolution: "katex@npm:0.16.23"
"katex@npm:0.16.22":
version: 0.16.22
resolution: "katex@npm:0.16.22"
dependencies:
commander: "npm:^8.3.0"
bin:
katex: cli.js
checksum: 10/aee350216b80117f65609e933ae1347b0ffd111cda85756de463983a0d874895d2a7c25864e7f7327976e38d96a2c6c3ca07432cabc8b0cdbfecafbe7e155d70
checksum: 10/fdb8667d9aa971154502b120ba340766754d202e3d3e322aca0a96de27032ad2dbb8a7295d798d310cd7ce4ddd21ed1f3318895541b61c9b4fdf611166589e02
languageName: node
linkType: hard