feat: Initial clean release of CHM
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
这个提交包含在:
@@ -0,0 +1,142 @@
|
||||
import type { EditOp } from '../buildEditorTools';
|
||||
import findFencedBlock from '../../utils/findFencedBlock';
|
||||
|
||||
export type ApplyEditStatus = 'applied' | 'anchor-not-found' | 'invalid';
|
||||
|
||||
|
||||
// Structured-document fences where the model regenerates the whole block —
|
||||
// plain code fences (```js, ```python) are deliberately excluded.
|
||||
export const supportedStructuredBlockTags = ['jsoncanvas', 'mermaid', 'abc', 'fountain'];
|
||||
|
||||
export const structuredBlockTags = new Set<string>([...supportedStructuredBlockTags]);
|
||||
|
||||
export interface AppliedEdit {
|
||||
op: EditOp;
|
||||
status: ApplyEditStatus;
|
||||
}
|
||||
|
||||
export interface EditApplicationResult {
|
||||
newBody: string;
|
||||
appliedEdits: AppliedEdit[];
|
||||
}
|
||||
|
||||
const findAnchor = (body: string, anchor: string, cursorPos: number) => {
|
||||
if (!anchor) return -1;
|
||||
|
||||
const first = body.indexOf(anchor);
|
||||
if (first === -1) return -1;
|
||||
|
||||
// Prefer the occurrence closest to the cursor — matches user intent.
|
||||
const second = body.indexOf(anchor, first + 1);
|
||||
if (second === -1) return first;
|
||||
|
||||
let bestIndex = first;
|
||||
let bestDistance = Math.abs(first - cursorPos);
|
||||
let next = second;
|
||||
while (next !== -1) {
|
||||
const distance = Math.abs(next - cursorPos);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
bestIndex = next;
|
||||
}
|
||||
next = body.indexOf(anchor, next + 1);
|
||||
}
|
||||
return bestIndex;
|
||||
};
|
||||
|
||||
const isValidEdit = (edit: EditOp, bodyLength: number): boolean => {
|
||||
if (!edit || typeof edit !== 'object') return false;
|
||||
if (typeof edit.op !== 'string') return false;
|
||||
|
||||
switch (edit.op) {
|
||||
case 'appendToNote':
|
||||
case 'replaceSelection':
|
||||
return typeof edit.text === 'string';
|
||||
case 'insertBefore':
|
||||
case 'insertAfter':
|
||||
return typeof edit.anchor === 'string' && edit.anchor.length > 0 && typeof edit.text === 'string';
|
||||
case 'replaceRange':
|
||||
if (typeof edit.anchor !== 'string' || !edit.anchor.length) return false;
|
||||
if (typeof edit.text !== 'string') return false;
|
||||
// Anchor >50% of body is almost certainly a mis-pick — would nuke it.
|
||||
if (edit.anchor.length > bodyLength * 0.5) return false;
|
||||
return true;
|
||||
case 'replaceFencedBlock':
|
||||
if (typeof edit.tag !== 'string' || !structuredBlockTags.has(edit.tag)) return false;
|
||||
if (typeof edit.text !== 'string') return false;
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Edits apply sequentially against the running newBody — a later anchor
|
||||
// targeting text removed by an earlier edit won't be found. replaceSelection
|
||||
// is body-independent and handled by the caller via the editor command.
|
||||
export const applyAnchorEdits = (
|
||||
body: string,
|
||||
edits: EditOp[],
|
||||
cursorPos: number,
|
||||
): EditApplicationResult => {
|
||||
let newBody = body;
|
||||
const appliedEdits: AppliedEdit[] = [];
|
||||
|
||||
for (const edit of edits) {
|
||||
if (!isValidEdit(edit, newBody.length)) {
|
||||
appliedEdits.push({ op: edit, status: 'invalid' });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (edit.op === 'replaceSelection') {
|
||||
// Caller applies this via the editor; mark as done so it isn't retried.
|
||||
appliedEdits.push({ op: edit, status: 'applied' });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (edit.op === 'appendToNote') {
|
||||
// Markdown paragraph breaks need a blank line.
|
||||
let sep = '';
|
||||
if (newBody.length) {
|
||||
if (newBody.endsWith('\n\n')) sep = '';
|
||||
else if (newBody.endsWith('\n')) sep = '\n';
|
||||
else sep = '\n\n';
|
||||
}
|
||||
newBody = `${newBody}${sep}${edit.text}`;
|
||||
appliedEdits.push({ op: edit, status: 'applied' });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (edit.op === 'replaceFencedBlock') {
|
||||
// Replace-only — creating a missing block is appendToNote's job.
|
||||
const match = findFencedBlock(newBody, edit.tag, cursorPos);
|
||||
if (!match) {
|
||||
appliedEdits.push({ op: edit, status: 'anchor-not-found' });
|
||||
continue;
|
||||
}
|
||||
const start = match.index;
|
||||
const end = start + match[0].length;
|
||||
const inner = edit.text.endsWith('\n') ? edit.text : `${edit.text}\n`;
|
||||
newBody = `${newBody.slice(0, start)}\`\`\`${edit.tag}\n${inner}\`\`\`${newBody.slice(end)}`;
|
||||
appliedEdits.push({ op: edit, status: 'applied' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const index = findAnchor(newBody, edit.anchor, cursorPos);
|
||||
if (index === -1) {
|
||||
appliedEdits.push({ op: edit, status: 'anchor-not-found' });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (edit.op === 'insertBefore') {
|
||||
newBody = `${newBody.slice(0, index)}${edit.text}${newBody.slice(index)}`;
|
||||
} else if (edit.op === 'insertAfter') {
|
||||
const insertAt = index + edit.anchor.length;
|
||||
newBody = `${newBody.slice(0, insertAt)}${edit.text}${newBody.slice(insertAt)}`;
|
||||
} else if (edit.op === 'replaceRange') {
|
||||
newBody = `${newBody.slice(0, index)}${edit.text}${newBody.slice(index + edit.anchor.length)}`;
|
||||
}
|
||||
appliedEdits.push({ op: edit, status: 'applied' });
|
||||
}
|
||||
|
||||
return { newBody, appliedEdits };
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户