diff --git a/README.en.md b/README.en.md index f08c774..cf6a1f9 100644 --- a/README.en.md +++ b/README.en.md @@ -1,6 +1,6 @@ # Labnote Assistant for Obsidian (labnoteo) -**Version 0.88.2** +**Version 0.89.0** [한국어](README.md) @@ -112,7 +112,7 @@ See the generated `.labnoteo/SETUP.md` (admins) for the full asset table, prereq **Samples** — toggle the Samples sidebar view, add custom sample types, and set the global sample folder (default `resources/labsamples`). -**Workflows** — **Auto-sync unit operation TOC** reorders a workflow note's `Related Unit Operations` list to match heading order after edits, so moving a unit-operation block re-sorts the top-of-note TOC. On by default. +**Workflows** — **Auto-sync unit operation TOC** reorders a workflow note's `Related Unit Operations` list to match heading order after edits, so moving a unit-operation block re-sorts the top-of-note TOC. Dragging a block in Obsidian's Outline view tidies the TOC and `---` separators immediately, and a single Ctrl+Z undoes the move. On by default. **AI provider** — pick `none`, `ollama`, or `openai`; set the Ollama/OpenAI endpoint (separate fields so an OpenAI key never leaks to a local Ollama address), the model id (e.g. `qwen3`, `gpt-4o-mini`), and the API key (OpenAI-compatible only, never sent to Ollama). diff --git a/README.md b/README.md index 7d3e1c1..cb6e561 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Labnote Assistant for Obsidian (labnoteo) -**버전 0.88.2** +**버전 0.89.0** [English](README.en.md) @@ -99,7 +99,7 @@ Obsidian 설치와 git·GitHub 계정 준비부터 커뮤니티 플러그인· **워크플로** -- **유닛오퍼레이션 TOC 자동 정렬** (`Auto-sync unit operation TOC`): 편집 후 워크플로 노트의 `Related Unit Operations` 목록을 헤딩 순서에 맞게 자동 정렬합니다 — 유닛 오퍼레이션 블록을 옮기면 문서 맨 위 목차도 따라 재정렬됩니다. 기본 켜짐. +- **유닛오퍼레이션 TOC 자동 정렬** (`Auto-sync unit operation TOC`): 편집 후 워크플로 노트의 `Related Unit Operations` 목록을 헤딩 순서에 맞게 자동 정렬합니다 — 유닛 오퍼레이션 블록을 옮기면 문서 맨 위 목차도 따라 재정렬됩니다. Obsidian Outline 보기에서 드래그로 블록을 옮기면 목차와 `---` 구분선이 즉시 정리되고, Ctrl+Z 한 번으로 이동까지 되돌릴 수 있습니다. 기본 켜짐. **AI 제공자** diff --git a/automation/templates/QUICKSTART.md b/automation/templates/QUICKSTART.md index e42a124..3a4a07e 100644 --- a/automation/templates/QUICKSTART.md +++ b/automation/templates/QUICKSTART.md @@ -243,6 +243,7 @@ README frontmatter: `## Related Unit Operations` 목차가 헤딩 순서대로 다시 만들어집니다. **유닛 오퍼레이션 블록을 옮기거나 지워도** 목차가 자동으로 다시 정렬됩니다(설정 → Labnote Assistant → 워크플로 → `유닛오퍼레이션 TOC 자동 정렬`, 기본 켜짐). +순서는 Obsidian **Outline** 보기에서 헤딩을 드래그해 바꿀 수 있으며, 목차와 `---` 구분선이 자동으로 정리되고 Ctrl+Z 한 번이면 되돌아갑니다. `Experimenter`는 같은 폴더 README의 `author`에서 가져옵니다 — 비어 있으면 빈 값입니다. `Start_date`는 삽입 시각이 자동으로, `End_date`는 비어 있으니 **작업이 끝나면 값을 diff --git a/manifest.json b/manifest.json index bb746c8..3fa80e8 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "labnoteo", "name": "Labnote Assistant", - "version": "0.88.2", + "version": "0.89.0", "minAppVersion": "1.5.0", "description": "Markdown-based lab notebook with sample tracking, workflow checklists, and LLM assistance for biology and bioinformatics experiments.", "author": "Korea Biofoundry (KRIBB)", diff --git a/package.json b/package.json index bdbd87b..9e0e8aa 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "labnoteo", "private": true, - "version": "0.88.2", + "version": "0.89.0", "type": "module", "description": "Markdown-based lab notebook with sample tracking, workflow checklists, and LLM assistance for biology and bioinformatics experiments.", "license": "Apache-2.0", diff --git a/packages/labnoteo-core/package.json b/packages/labnoteo-core/package.json index 52fdb65..bcfb9b3 100644 --- a/packages/labnoteo-core/package.json +++ b/packages/labnoteo-core/package.json @@ -1,6 +1,6 @@ { "name": "@labnoteo/core", - "version": "0.88.2", + "version": "0.89.0", "private": true, "type": "module", "description": "Platform-neutral core logic for labnoteo (parsers, workflow/sample domain logic).", diff --git a/packages/labnoteo-core/src/__tests__/workflowToc.test.ts b/packages/labnoteo-core/src/__tests__/workflowToc.test.ts index e138a73..ea5f622 100644 --- a/packages/labnoteo-core/src/__tests__/workflowToc.test.ts +++ b/packages/labnoteo-core/src/__tests__/workflowToc.test.ts @@ -5,8 +5,61 @@ import { locateInsertedUnitOpHeading, findUnitOpInsertOffset, minimalReplacement, + computeUnitOpSyncEdits, + applyTextEdits, + type TextEdit, } from '../sections/workflowSectionParser'; import { createWorkflowContent } from '../lib/workflowStructure'; +import { buildHwUnitOpMarkdown } from '../lib/unitOpTemplate'; + +/** A workflow note built the way the insert command builds it (block + TOC rebuild). */ +function buildNote(ops: [string, string][]): string { + let md = createWorkflowContent({ id: 'WD010', name: 'Design', description: 'desc' }, 'Dr. Kim'); + ops.forEach(([opId, name]) => { + const block = buildHwUnitOpMarkdown( + { opId, opName: name, opDescription: `${name} step` }, + { experimenter: 'Dr. Kim', dateTime: '2026-09-29 10:00' } + ); + const off = findUnitOpInsertOffset(md); + md = rebuildUnitOpToc(md.slice(0, off) + block + md.slice(off)); + }); + return md; +} + +/** + * Obsidian Outline drag: cut the heading's section (up to the next heading of + * the same or higher level) and paste it before `beforeHeading` — or at the end + * of the Related Unit Operations section when null. + */ +function outlineMove(md: string, heading: string, beforeHeading: string | null): string { + const start = md.indexOf(heading); + const re = /^#{1,3}\s/gm; + re.lastIndex = start + 1; + const next = re.exec(md); + const end = next ? next.index : md.length; + const chunk = md.slice(start, end); + const rest = md.slice(0, start) + md.slice(end); + const at = rest.indexOf(beforeHeading ?? '## Conclusions and Discussion'); + return rest.slice(0, at) + chunk + rest.slice(at); +} + +const tidy = (md: string): string => applyTextEdits(md, computeUnitOpSyncEdits(md)); +const collapseBlanks = (md: string): string => md.replace(/\n{3,}/g, '\n\n'); +const tocOrder = (md: string): string[] => + [...md.matchAll(/^- \[(UHW\d+) /gm)].map(m => m[1]); + +/** Non-blank line right before `offset`'s line. */ +function prevNonBlank(md: string, offset: number): string { + const lines = md.slice(0, offset).split('\n'); + lines.pop(); + for (let i = lines.length - 1; i >= 0; i--) if (lines[i].trim() !== '') return lines[i]; + return ''; +} + +function expectTemplateShape(md: string): void { + for (const m of md.matchAll(/^### \[/gm)) expect(prevNonBlank(md, m.index!)).toBe('---'); + expect(prevNonBlank(md, md.indexOf('## Conclusions and Discussion'))).not.toBe('---'); +} describe('buildUnitOpTocLine', () => { it('matches the serializeWorkflowMd slug/label format', () => { @@ -251,6 +304,287 @@ describe('rebuildUnitOpToc', () => { }); }); +describe('computeUnitOpSyncEdits', () => { + const alpha: [string, string] = ['UHW010', 'Alpha']; + const beta: [string, string] = ['UHW020', 'Beta']; + const gamma: [string, string] = ['UHW030', 'Gamma']; + const abc = buildNote([alpha, beta, gamma]); + const A = '### [UHW010 Alpha]'; + const B = '### [UHW020 Beta]'; + const C = '### [UHW030 Gamma]'; + + it('tidies an Outline drag (C before A) into the template shape', () => { + const dragged = outlineMove(abc, C, A); + // The drag itself leaves A without a break and one dangling before Conclusions. + expect(prevNonBlank(dragged, dragged.indexOf(A))).not.toBe('---'); + expect(prevNonBlank(dragged, dragged.indexOf('## Conclusions and Discussion'))).toBe('---'); + + const out = tidy(dragged); + expect(tocOrder(out)).toEqual(['UHW030', 'UHW010', 'UHW020']); + expectTemplateShape(out); + // Same note as inserting in C, A, B order, up to blank-line runs. + expect(collapseBlanks(out)).toBe(collapseBlanks(buildNote([gamma, alpha, beta]))); + for (const name of ['Alpha', 'Beta', 'Gamma']) expect(out).toContain(`> ${name} step`); + }); + + it('matches a note built in the moved order, for every drag direction', () => { + const cases: [string, string | null, string[]][] = [ + [C, A, ['UHW030', 'UHW010', 'UHW020']], + [A, null, ['UHW020', 'UHW030', 'UHW010']], + [B, A, ['UHW020', 'UHW010', 'UHW030']], + [A, C, ['UHW020', 'UHW010', 'UHW030']], + ]; + for (const [moved, before, order] of cases) { + const out = tidy(outlineMove(abc, moved, before)); + expect(tocOrder(out)).toEqual(order); + expectTemplateShape(out); + // Every block body survives intact. + for (const h of [A, B, C]) { + const body = abc.slice(abc.indexOf(h)).split('\n\n---')[0].replace(/\n+$/, ''); + expect(out).toContain(body.split('## Conclusions')[0].replace(/\n+$/, '')); + } + } + }); + + it('returns sorted, non-overlapping edits that keep the TOC edit inside the list', () => { + const dragged = outlineMove(abc, C, A); + const edits = computeUnitOpSyncEdits(dragged); + expect(edits.length).toBeGreaterThan(1); + for (let i = 1; i < edits.length; i++) expect(edits[i].from).toBeGreaterThanOrEqual(edits[i - 1].to); + const firstBlock = dragged.indexOf('### ['); + expect(edits[0].to).toBeLessThan(firstBlock); + expect(dragged.slice(edits[0].from, edits[0].to)).not.toMatch(/^#|^---$/m); + }); + + it('is idempotent: a tidied note yields no further edits', () => { + const out = tidy(outlineMove(abc, C, A)); + expect(computeUnitOpSyncEdits(out)).toEqual([]); + expect(computeUnitOpSyncEdits(out, { onlyOnReorder: true })).toEqual([]); + }); + + it('leaves separators alone when the order is unchanged', () => { + expect(computeUnitOpSyncEdits(abc)).toEqual([]); + // A stray trailing break is not touched without a reorder. + const dangling = abc.replace('## Conclusions and Discussion', '---\n\n## Conclusions and Discussion'); + expect(computeUnitOpSyncEdits(dangling)).toEqual([]); + }); + + it('only reorders the TOC in notes that do not use separators', () => { + const md = [ + '## Related Unit Operations', + '', + '- [UHW010 A](#uhw010-a)', + '- [UHW020 B](#uhw020-b)', + '', + '### [UHW020 B]', + '', + 'b body', + '', + '### [UHW010 A]', + '', + 'a body', + '', + '## Conclusions and Discussion', + '', + ].join('\n'); + const edits = computeUnitOpSyncEdits(md); + expect(edits).toHaveLength(1); + const out = applyTextEdits(md, edits); + expect(out).not.toContain('---'); + expect(out.indexOf('- [UHW020 B]')).toBeLessThan(out.indexOf('- [UHW010 A]')); + }); + + it('keeps a user --- that is followed by other content', () => { + const md = [ + '## Related Unit Operations', + '', + '- [UHW010 A](#uhw010-a)', + '- [UHW020 B](#uhw020-b)', + '', + '---', + '', + '### [UHW020 B]', + '', + 'b body', + '', + '---', + '', + 'user note after a break', + '', + '---', + '', + '### [UHW010 A]', + '', + '## Conclusions and Discussion', + ].join('\n'); + const out = tidy(md); + expect(out).toContain('b body\n\n---\n\nuser note after a break'); + expect(out.match(/^---$/gm)).toHaveLength(3); + }); + + it('merges back-to-back breaks and drops a dangling one', () => { + const md = [ + '## Related Unit Operations', + '', + '- [UHW010 A](#uhw010-a)', + '- [UHW020 B](#uhw020-b)', + '', + '---', + '', + '---', + '', + '### [UHW020 B]', + '', + '---', + '', + '### [UHW010 A]', + '', + 'a body', + '', + '---', + '', + '## Conclusions and Discussion', + ].join('\n'); + const out = tidy(md); + expect(out.match(/^---$/gm)).toHaveLength(2); + expect(out).toContain('a body\n\n## Conclusions and Discussion'); + }); + + it('does not turn text into a setext heading when inserting a break', () => { + const md = [ + '## Related Unit Operations', + '', + '- [UHW010 A](#uhw010-a)', + '- [UHW020 B](#uhw020-b)', + '', + '---', + '', + '### [UHW020 B]', + 'b body', + '### [UHW010 A]', + '', + '## Conclusions and Discussion', + ].join('\n'); + expect(tidy(md)).toContain('b body\n\n---\n\n### [UHW010 A]'); + }); + + it('with onlyOnReorder, ignores added, removed and renamed headings', () => { + const added = abc.replace('## Conclusions and Discussion', '---\n\n### [UHW040 Delta]\n\n## Conclusions and Discussion'); + expect(computeUnitOpSyncEdits(added, { onlyOnReorder: true })).toEqual([]); + expect(computeUnitOpSyncEdits(added)).toHaveLength(1); + const renamed = abc.replace(B, '### [UHW020 Betamax]'); + expect(computeUnitOpSyncEdits(renamed, { onlyOnReorder: true })).toEqual([]); + expect(computeUnitOpSyncEdits(abc, { onlyOnReorder: true })).toEqual([]); + }); + + it('detects a reorder when the same unit op appears twice', () => { + const md = [ + '## Related Unit Operations', + '', + '- [UHW010 A](#uhw010-a)', + '- [UHW020 B](#uhw020-b)', + '- [UHW010 A](#uhw010-a)', + '', + '---', + '', + '### [UHW010 A]', + '', + '---', + '', + '### [UHW010 A]', + '', + '---', + '', + '### [UHW020 B]', + '', + '## Conclusions and Discussion', + ].join('\n'); + const out = applyTextEdits(md, computeUnitOpSyncEdits(md, { onlyOnReorder: true })); + expect([...out.matchAll(/^- \[(\w+) /gm)].map(m => m[1])).toEqual(['UHW010', 'UHW010', 'UHW020']); + // One A dropped from the TOC is an edit, not a reorder. + const fewer = md.replace('- [UHW010 A](#uhw010-a)\n- [UHW020 B]', '- [UHW020 B]'); + expect(computeUnitOpSyncEdits(fewer, { onlyOnReorder: true })).toEqual([]); + }); + + it('bounds the TOC at the first heading when the leading --- is gone', () => { + const md = [ + '## Related Unit Operations', + '', + '- [UHW010 A](#uhw010-a)', + '- [UHW020 B](#uhw020-b)', + '', + '### [UHW020 B]', + '', + '- [ ] todo item', + '- [protocol](protocol.md)', + '', + '### [UHW010 A]', + '', + ].join('\n'); + const out = rebuildUnitOpToc(md); + expect(out).toContain('- [ ] todo item\n- [protocol](protocol.md)'); + expect(out.indexOf('- [UHW020 B]')).toBeLessThan(out.indexOf('- [UHW010 A]')); + }); + + it('ignores headings and breaks inside code fences', () => { + const md = [ + '## Related Unit Operations', + '', + '- [UHW010 A](#uhw010-a)', + '- [UHW020 B](#uhw020-b)', + '', + '---', + '', + '### [UHW020 B]', + '', + '```md', + '### [UHW099 Example]', + '', + '---', + '```', + '', + '---', + '', + '### [UHW010 A]', + '', + '~~~', + '---', + '~~~', + '', + '## Conclusions and Discussion', + ].join('\n'); + const out = tidy(md); + expect(out).not.toContain('- [UHW099'); + expect(out).toContain('```md\n### [UHW099 Example]\n\n---\n```'); + expect(out).toContain('~~~\n---\n~~~'); + expect(out.indexOf('- [UHW020 B]')).toBeLessThan(out.indexOf('- [UHW010 A]')); + }); + + it('preserves CRLF line endings', () => { + const crlf = outlineMove(abc, C, A).replace(/\n/g, '\r\n'); + const out = tidy(crlf); + expect(out.replace(/\r\n/g, '')).not.toContain('\n'); + expect(out).toBe(tidy(outlineMove(abc, C, A)).replace(/\n/g, '\r\n')); + }); + + it('applies the same TOC result as rebuildUnitOpToc', () => { + const inputs = [ + abc, + outlineMove(abc, C, A), + abc.replace(B, '### [UHW020 Betamax]'), + buildNote([]), + buildNote([beta, alpha]).replace('- [UHW020 Beta](#uhw020-beta)\n', ''), + '## Related Unit Operations\n\n- [UHW010 A](#uhw010-a)\n> keep\n- [UHW020 B](#uhw020-b)\n\n---\n### [UHW020 B]\n---\n### [UHW010 A]\n', + '## Related Unit Operations\n\n> hint\n\n### [UHW010 A]\n', + ]; + for (const md of inputs) { + const firstBlock = md.includes('### [') ? md.indexOf('### [') : md.length + 1; + const tocOnly = computeUnitOpSyncEdits(md).filter((e: TextEdit) => e.to < firstBlock); + expect(applyTextEdits(md, tocOnly)).toBe(rebuildUnitOpToc(md)); + } + }); +}); + describe('locateInsertedUnitOpHeading', () => { it('maps the inserted heading offset into the rebuilt text', () => { const head = [ diff --git a/packages/labnoteo-core/src/index.ts b/packages/labnoteo-core/src/index.ts index 2644979..0853384 100644 --- a/packages/labnoteo-core/src/index.ts +++ b/packages/labnoteo-core/src/index.ts @@ -16,6 +16,9 @@ export { locateInsertedUnitOpHeading, findUnitOpInsertOffset, minimalReplacement, + computeUnitOpSyncEdits, + applyTextEdits, + type TextEdit, } from './sections/workflowSectionParser'; // Re-exported from the dependency-free heading module so browser consumers diff --git a/packages/labnoteo-core/src/sections/workflowSectionParser.ts b/packages/labnoteo-core/src/sections/workflowSectionParser.ts index 6097774..068cf53 100644 --- a/packages/labnoteo-core/src/sections/workflowSectionParser.ts +++ b/packages/labnoteo-core/src/sections/workflowSectionParser.ts @@ -131,30 +131,72 @@ export function findUnitOpInsertOffset(md: string): number { } /** - * Regenerate the entire `## Related Unit Operations` TOC so its entries match - * the **document order** of the `### [opId opName]` unit-op headings. + * One replacement in a markdown string: `[from, to)` becomes `insert`. * - * Unlike {@link appendUnitOpToWorkflowToc} (which always appends), this scans - * the body for the actual headings and rewrites the entry list in that order. - * This keeps the TOC correct even when a unit op is inserted at an arbitrary - * cursor position, and self-heals any previously mis-ordered list. - * - * The edit is confined to the entry lines inside the section (heading → first - * `## ` heading or `---`); surrounding blanks and the template hint blockquotes - * are preserved. Returns the input unchanged when the section is absent. - * CRLF vs LF line endings are detected and preserved. + * Offsets refer to the string the edits were computed from. A list returned by + * {@link computeUnitOpSyncEdits} is sorted by `from` and never overlaps, so it + * can be handed to CodeMirror as one change set or applied with + * {@link applyTextEdits}. */ -export function rebuildUnitOpToc(md: string): string { - const newline = md.includes('\r\n') ? '\r\n' : '\n'; - const lines = md.split(/\r?\n/); +export type TextEdit = { from: number; to: number; insert: string }; - const headingIdx = lines.findIndex(l => l.trim() === '## Related Unit Operations'); - if (headingIdx === -1) return md; +/** A source line: `[start, end)` excludes the line break; `fenced` = inside/on a code fence. */ +interface MdLine { + start: number; + end: number; + text: string; + fenced: boolean; +} + +/** Split into lines with offsets, marking ``` / ~~~ fenced code (fence lines included). */ +function scanLines(md: string): MdLine[] { + const lines: MdLine[] = []; + let fence: { ch: string; len: number } | null = null; + let start = 0; + for (;;) { + const nl = md.indexOf('\n', start); + const end = nl === -1 ? md.length : nl > start && md[nl - 1] === '\r' ? nl - 1 : nl; + const text = md.slice(start, end); + const m = /^ {0,3}(`{3,}|~{3,})/.exec(text); + let fenced = fence !== null; + if (fence) { + if (m && m[1][0] === fence.ch && m[1].length >= fence.len && text.trim() === m[1]) fence = null; + } else if (m) { + fence = { ch: m[1][0], len: m[1].length }; + fenced = true; + } + lines.push({ start, end, text, fenced }); + if (nl === -1) break; + start = nl + 1; + } + return lines; +} - // The entry region ends at the next `## ` heading or the first `---` break. +const isTocEntry = (line: MdLine): boolean => !line.fenced && /^\s*- \[/.test(line.text); + +/** Everything the TOC and separator edits need, from one pass over the document. */ +interface UnitOpTocScan { + lines: MdLine[]; + newline: string; + headingIdx: number; + /** `- [..]` line indices inside the entry region. */ + entryIdxs: number[]; + /** TOC lines rebuilt from the `### [..]` headings, in document order. */ + entries: string[]; +} + +function scanUnitOpToc(md: string): UnitOpTocScan | null { + const lines = scanLines(md); + const headingIdx = lines.findIndex(l => !l.fenced && l.text.trim() === '## Related Unit Operations'); + if (headingIdx === -1) return null; + + // The entry region ends at the first `---` break or at ANY heading, so the + // list stays bounded (and block bodies with `- [ ]` / `- [link](..)` lines stay + // out of it) even when the first block has lost its leading `---`. let tocEndIdx = lines.length; for (let j = headingIdx + 1; j < lines.length; j++) { - if (/^##\s/.test(lines[j]) || lines[j].trim() === '---') { + const l = lines[j]; + if (!l.fenced && (/^#{1,6}\s/.test(l.text) || l.text.trim() === '---')) { tocEndIdx = j; break; } @@ -162,38 +204,196 @@ export function rebuildUnitOpToc(md: string): string { // Collect unit-op headings across the body, in document order. TOC lines start // with `- [`, so they never match the `### [` heading pattern. - const newEntries: string[] = []; + const entries: string[] = []; for (let j = headingIdx + 1; j < lines.length; j++) { - const m = lines[j].match(UNIT_OP_HEADING_PATTERN); - if (m) newEntries.push(buildUnitOpTocLine(m[1], m[2].trim(), m[3]?.trim() || undefined)); + if (lines[j].fenced) continue; + const m = lines[j].text.match(UNIT_OP_HEADING_PATTERN); + if (m) entries.push(buildUnitOpTocLine(m[1], m[2].trim(), m[3]?.trim() || undefined)); } - // Collect the individual entry-line indices in the section. We must NOT splice - // the whole first→last span, because any non-entry lines interleaved between - // entries (comments, blockquotes, stray blanks) would be deleted too. Instead - // remove only the `- [..]` lines and reinsert the ordered list where the first - // one began, leaving interleaved content untouched. const entryIdxs: number[] = []; for (let j = headingIdx + 1; j < tocEndIdx; j++) { - if (/^\s*- \[/.test(lines[j])) entryIdxs.push(j); + if (isTocEntry(lines[j])) entryIdxs.push(j); } + return { lines, newline: md.includes('\r\n') ? '\r\n' : '\n', headingIdx, entryIdxs, entries }; +} + +/** + * The single edit that reorders the TOC entry lines, or `null` when they already + * match. We must NOT rewrite interleaved non-entry lines (comments, blockquotes, + * stray blanks) away: the ordered entries go where the first one began and the + * interleaved lines follow them, untouched. + */ +function tocEdit(md: string, scan: UnitOpTocScan): TextEdit | null { + const { lines, newline, headingIdx, entryIdxs, entries } = scan; + if (entryIdxs.length > 0) { - const firstEntry = entryIdxs[0]; - // Remove existing entry lines bottom-up so earlier indices stay valid. - for (let k = entryIdxs.length - 1; k >= 0; k--) { - lines.splice(entryIdxs[k], 1); + const first = entryIdxs[0]; + const last = entryIdxs[entryIdxs.length - 1]; + const kept: string[] = []; + for (let j = first; j <= last; j++) { + if (!isTocEntry(lines[j])) kept.push(lines[j].text); } - lines.splice(firstEntry, 0, ...newEntries); - } else if (newEntries.length > 0) { - let insertAt = headingIdx + 1; - if (lines[insertAt] !== undefined && lines[insertAt].trim() === '') insertAt++; - const toInsert = [...newEntries]; - if (lines[insertAt] !== undefined && lines[insertAt].trim() !== '') toInsert.push(''); - lines.splice(insertAt, 0, ...toInsert); + const from = lines[first].start; + const to = lines[last].end; + const d = minimalReplacement(md.slice(from, to), [...entries, ...kept].join(newline)); + return d && { from: from + d.start, to: from + d.end, insert: d.text }; } - return lines.join(newline); + if (entries.length === 0) return null; + let insertAt = headingIdx + 1; + if (lines[insertAt] !== undefined && lines[insertAt].text.trim() === '') insertAt++; + const target = lines[insertAt]; + if (!target) return { from: md.length, to: md.length, insert: newline + entries.join(newline) }; + // Keep a blank separator before following non-blank prose. + const gap = target.text.trim() !== '' ? newline : ''; + return { from: target.start, to: target.start, insert: entries.join(newline) + newline + gap }; +} + +/** + * True when the TOC holds exactly the headings' entries (as a multiset, so a + * unit op used twice still counts) but in a different order — the signature of + * a moved block. Added, removed or renamed headings are not a reorder. + */ +function isReorder(scan: UnitOpTocScan): boolean { + const current = scan.entryIdxs.map(j => scan.lines[j].text.trim()); + const { entries } = scan; + if (current.length !== entries.length) return false; + if (current.every((c, i) => c === entries[i])) return false; + const a = [...current].sort(); + const b = [...entries].sort(); + return a.every((c, i) => c === b[i]); +} + +/** + * Restore the template's separator shape inside `## Related Unit Operations` + * after a block move: one `---` before every `### [..]` block and none dangling + * at the section end. + * + * An Outline drag moves a heading up to the next same-level heading, so each + * block carries the NEXT block's leading `---` at its tail. Only notes that + * already use separators (some `---` directly precedes a `### [`) are touched; + * a `---` followed by other content is the user's and is kept (runs of + * back-to-back breaks are merged into one). + */ +function separatorEdits(md: string, scan: UnitOpTocScan): TextEdit[] { + const { lines, newline, headingIdx } = scan; + + let sectionEnd = lines.length; + for (let j = headingIdx + 1; j < lines.length; j++) { + if (!lines[j].fenced && /^#{1,2}\s/.test(lines[j].text)) { + sectionEnd = j; + break; + } + } + + // A `---` right under paragraph text is a setext underline, not a break. + const isBreak = (j: number): boolean => { + if (lines[j].fenced || lines[j].text.trim() !== '---') return false; + const prev = lines[j - 1].text.trim(); + return prev === '' || prev === '---' || /^#{1,6}\s/.test(lines[j - 1].text); + }; + + const items: { idx: number; kind: 'break' | 'unitOp' | 'other' }[] = []; + for (let j = headingIdx + 1; j < sectionEnd; j++) { + if (!lines[j].fenced && lines[j].text.trim() === '') continue; + const kind = isBreak(j) + ? 'break' + : !lines[j].fenced && UNIT_OP_HEADING_PATTERN.test(lines[j].text) + ? 'unitOp' + : 'other'; + items.push({ idx: j, kind }); + } + + const usesSeparators = items.some((it, k) => it.kind === 'break' && items[k + 1]?.kind === 'unitOp'); + if (!usesSeparators) return []; + + const edits: TextEdit[] = []; + for (let k = 0; k < items.length; k++) { + const it = items[k]; + if (it.kind === 'unitOp') { + if (items[k - 1]?.kind === 'break') continue; + const line = lines[it.idx]; + // A blank line first, or the `---` would turn the text above into a heading. + const lead = lines[it.idx - 1].text.trim() === '' ? '' : newline; + edits.push({ from: line.start, to: line.start, insert: lead + '---' + newline + newline }); + } else if (it.kind === 'break') { + let last = k; + while (items[last + 1]?.kind === 'break') last++; + if (last + 1 >= items.length) { + // Dangling at the section end: drop the break(s) and the blanks after. + const to = sectionEnd < lines.length ? lines[sectionEnd].start : md.length; + edits.push({ from: lines[it.idx].start, to, insert: '' }); + } else if (last > k) { + edits.push({ from: lines[it.idx].end, to: lines[items[last].idx].end, insert: '' }); + } + k = last; + } + } + return edits; +} + +/** + * Edits that bring a workflow note's `## Related Unit Operations` section in + * line with its `### [opId opName]` blocks, as a sorted, non-overlapping list of + * small {@link TextEdit}s against `md`: + * + * - **TOC:** only the changed span of the entry lines (same result as + * {@link rebuildUnitOpToc}, which is built on this). + * - **Separators:** only when the blocks were reordered (see + * {@link separatorEdits}); additions, removals and renames never touch them. + * + * With `onlyOnReorder`, returns `[]` unless the change is a reorder — the live + * editor filter uses this and leaves other TOC updates to the debounced sync. + * Code fences are ignored; CRLF is preserved in inserted text. Returns `[]` + * when the section is absent. + */ +export function computeUnitOpSyncEdits( + md: string, + opts: { onlyOnReorder?: boolean } = {} +): TextEdit[] { + const scan = scanUnitOpToc(md); + if (!scan) return []; + const reordered = isReorder(scan); + if (opts.onlyOnReorder && !reordered) return []; + + const edits: TextEdit[] = []; + const toc = tocEdit(md, scan); + if (toc) edits.push(toc); + if (reordered) edits.push(...separatorEdits(md, scan)); + return edits; +} + +/** Apply non-overlapping edits (offsets against `md`) and return the result. */ +export function applyTextEdits(md: string, edits: readonly TextEdit[]): string { + const sorted = [...edits].sort((a, b) => a.from - b.from); + let out = md; + for (let k = sorted.length - 1; k >= 0; k--) { + const e = sorted[k]; + out = out.slice(0, e.from) + e.insert + out.slice(e.to); + } + return out; +} + +/** + * Regenerate the entire `## Related Unit Operations` TOC so its entries match + * the **document order** of the `### [opId opName]` unit-op headings. + * + * Unlike {@link appendUnitOpToWorkflowToc} (which always appends), this scans + * the body for the actual headings and rewrites the entry list in that order. + * This keeps the TOC correct even when a unit op is inserted at an arbitrary + * cursor position, and self-heals any previously mis-ordered list. + * + * The edit is confined to the entry lines inside the section (heading → first + * heading or `---`); surrounding blanks and the template hint blockquotes are + * preserved, and headings inside code fences are ignored. Returns the input + * unchanged when the section is absent. CRLF vs LF line endings are preserved. + */ +export function rebuildUnitOpToc(md: string): string { + const scan = scanUnitOpToc(md); + const edit = scan && tocEdit(md, scan); + return edit ? applyTextEdits(md, [edit]) : md; } /** diff --git a/src/l10n.ko.ts b/src/l10n.ko.ts index 0241d88..42acb88 100644 --- a/src/l10n.ko.ts +++ b/src/l10n.ko.ts @@ -158,8 +158,8 @@ const obsidianKo: Record = { 'Vault-relative folder for vault-global samples.': '볼트 전역 샘플을 저장할 볼트 기준 상대 폴더입니다.', 'Auto-sync unit operation TOC': '유닛오퍼레이션 TOC 자동 정렬', - "Reorder a workflow note's Related Unit Operations list to match heading order after edits.": - '편집 후 워크플로 노트의 Related Unit Operations 목록을 헤딩 순서에 맞게 자동 정렬합니다.', + "Reorder a workflow note's Related Unit Operations list to match heading order after edits, and tidy the --- separators immediately when a block is moved.": + '편집 후 워크플로 노트의 Related Unit Operations 목록을 헤딩 순서에 맞게 정렬하고, 블록을 옮기면 `---` 구분선도 즉시 정리합니다.', 'AI provider': 'AI 프로바이더', 'Provider': '프로바이더', 'Disabled': '비활성화됨', diff --git a/src/main.ts b/src/main.ts index 1dd6a70..1dd2ce9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,7 +7,7 @@ * highlighting, sidebar views, the settings tab and the LLM/MCP layer are added * by subsequent modules that all consume the same `LabnoteHost` built here. */ -import { Plugin, Notice, TFile } from 'obsidian'; +import { Plugin, Notice, TFile, editorInfoField } from 'obsidian'; import type { LabnoteHost, Translator } from '@labnoteo/core'; import { findSampleReferenceAt, rebuildUnitOpToc, minimalReplacement } from '@labnoteo/core'; import { getSeoulDateString, getSeoulDateTimeString } from '@labnoteo/core/lib/dateUtils'; @@ -40,6 +40,7 @@ import { type HighlightState, } from './sampleHighlight'; import { createMetaDatePickerExtension } from './dateFieldPicker'; +import { createUnitOpReorderFilter } from './unitOpReorderFilter'; import { WorkflowTreeView, WORKFLOW_VIEW_TYPE } from './views/workflowView'; import { SampleTreeView, SAMPLE_VIEW_TYPE } from './views/sampleView'; import { exportTablesToCsv, exportActiveNoteTablesToCsv } from './exportCsv'; @@ -91,6 +92,12 @@ export default class LabnotePlugin extends Plugin { this.registerEditorMenu(); this.registerWorkflowReadmeSync(); this.registerUnitOpTocAutoSync(); + this.registerEditorExtension( + createUnitOpReorderFilter({ + isEnabled: () => this.settings.autoSyncUnitOpToc, + getFilePath: s => s.field(editorInfoField, false)?.file?.path ?? null, + }) + ); this.addSettingTab(new LabnoteSettingTab(this.app, this)); if (this.settings.mcpEnabled) { @@ -382,8 +389,9 @@ export default class LabnotePlugin extends Plugin { /** * Keep a workflow note's `## Related Unit Operations` TOC ordered to match the - * document order of its `### [..]` headings, so moving a unit-op block updates - * the TOC without re-inserting. Fires on editor edits, debounced per note. + * document order of its `### [..]` headings (added, removed or renamed unit + * ops). Fires on editor edits, debounced per note. Block moves are tidied + * immediately, separators included, by `createUnitOpReorderFilter`. * * Only the changed span is rewritten (via {@link minimalReplacement}) so the * caret stays put; a full-document replace would reset it. The rewrite is @@ -401,6 +409,8 @@ export default class LabnotePlugin extends Plugin { if (prev !== undefined) window.clearTimeout(prev); const handle = window.setTimeout(() => { this.unitOpTocTimers.delete(path); + // The editor may have switched to another note since the edit. + if (info.file?.path !== path) return; const before = editor.getValue(); const after = rebuildUnitOpToc(before); const edit = minimalReplacement(before, after); diff --git a/src/settingsTab.ts b/src/settingsTab.ts index 0f17c36..812aa28 100644 --- a/src/settingsTab.ts +++ b/src/settingsTab.ts @@ -73,7 +73,7 @@ export class LabnoteSettingTab extends PluginSettingTab { .setName(this.plugin.t('Auto-sync unit operation TOC')) .setDesc( this.plugin.t( - "Reorder a workflow note's Related Unit Operations list to match heading order after edits." + "Reorder a workflow note's Related Unit Operations list to match heading order after edits, and tidy the --- separators immediately when a block is moved." ) ) .addToggle(t => diff --git a/src/unitOpReorderFilter.ts b/src/unitOpReorderFilter.ts new file mode 100644 index 0000000..ac39333 --- /dev/null +++ b/src/unitOpReorderFilter.ts @@ -0,0 +1,58 @@ +/** + * Tidy a workflow note in the SAME transaction that reorders its unit-op + * blocks (e.g. an Outline drag): the `## Related Unit Operations` TOC is + * re-ordered and the `---` separators restored, so the move and the cleanup are + * one undo step with no intermediate state. + * + * Only reorders are handled here (`onlyOnReorder`); added, removed or renamed + * headings are left to the debounced TOC sync in `main.ts`. The pure edit + * computation lives in `@labnoteo/core` and is unit-tested there. + */ +import { EditorState, type Extension, type Transaction } from '@codemirror/state'; +import { computeUnitOpSyncEdits } from '@labnoteo/core'; +import { isValidWorkflowPath } from '@labnoteo/core/lib/workflowStructure'; + +const UNIT_OP_HEADING_MARK = '### ['; + +export interface UnitOpReorderFilterOptions { + /** Read live so toggling the setting takes effect without a reload. */ + isEnabled: () => boolean; + /** Vault path of the note being edited, or null (canvas embeds, hover previews, …). */ + getFilePath: (state: EditorState) => string | null; +} + +/** Cheap pre-check: a block move always inserts or deletes a `### [` heading. */ +function touchesUnitOpHeading(tr: Transaction): boolean { + let hit = false; + tr.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => { + if (hit) return; + hit = + inserted.toString().includes(UNIT_OP_HEADING_MARK) || + tr.startState.doc.sliceString(fromA, toA).includes(UNIT_OP_HEADING_MARK); + }); + return hit; +} + +export function createUnitOpReorderFilter(opts: UnitOpReorderFilterOptions): Extension { + return EditorState.transactionFilter.of(tr => { + try { + if (!tr.docChanged || !opts.isEnabled()) return tr; + // Never interfere with IME composition. Undo/redo already restore a tidy + // document, because the move and its cleanup were recorded together. + if (tr.isUserEvent('input.type.compose') || tr.isUserEvent('undo') || tr.isUserEvent('redo')) { + return tr; + } + const path = opts.getFilePath(tr.startState); + if (!path || !isValidWorkflowPath(path)) return tr; + if (!touchesUnitOpHeading(tr)) return tr; + + // CodeMirror documents are `\n`-only, so string offsets map 1:1 to positions. + const edits = computeUnitOpSyncEdits(tr.newDoc.toString(), { onlyOnReorder: true }); + if (edits.length === 0) return tr; + return [tr, { changes: edits, sequential: true }]; + } catch (err) { + console.warn('[labnoteo] unit-op reorder sync failed:', err); + return tr; + } + }); +} diff --git a/tests/plugin/unitOpReorderFilter.test.ts b/tests/plugin/unitOpReorderFilter.test.ts new file mode 100644 index 0000000..f69e716 --- /dev/null +++ b/tests/plugin/unitOpReorderFilter.test.ts @@ -0,0 +1,116 @@ +/** + * The reorder filter must fold the TOC/separator cleanup into the very + * transaction that moved a block (one undo step), and stay out of every other + * edit: IME composition, undo/redo, non-workflow notes and plain typing. + */ +import { describe, it, expect } from 'vitest'; +import { EditorState, type TransactionSpec } from '@codemirror/state'; +import { applyTextEdits, computeUnitOpSyncEdits, findUnitOpInsertOffset, rebuildUnitOpToc } from '@labnoteo/core'; +import { createWorkflowContent } from '@labnoteo/core/lib/workflowStructure'; +import { buildHwUnitOpMarkdown } from '@labnoteo/core/lib/unitOpTemplate'; +import { createUnitOpReorderFilter } from '../../src/unitOpReorderFilter'; + +const WORKFLOW_PATH = 'labnote/001_Exp/001_WD010_Design.labnote.md'; + +function buildNote(): string { + let md = createWorkflowContent({ id: 'WD010', name: 'Design', description: 'desc' }, 'Dr. Kim'); + for (const [opId, opName] of [['UHW010', 'Alpha'], ['UHW020', 'Beta'], ['UHW030', 'Gamma']]) { + const block = buildHwUnitOpMarkdown( + { opId, opName, opDescription: `${opName} step` }, + { experimenter: 'Dr. Kim', dateTime: '2026-09-29 10:00' } + ); + const off = findUnitOpInsertOffset(md); + md = rebuildUnitOpToc(md.slice(0, off) + block + md.slice(off)); + } + return md; +} + +/** Outline drag of Gamma before Alpha, as one transaction (delete + insert). */ +function dragGammaBeforeAlpha(doc: string): TransactionSpec { + const from = doc.indexOf('### [UHW030 Gamma]'); + const to = doc.indexOf('## Conclusions and Discussion'); + const at = doc.indexOf('### [UHW010 Alpha]'); + return { + changes: [ + { from: at, insert: doc.slice(from, to) }, + { from, to }, + ], + }; +} + +function setup(opts: { path?: string | null; enabled?: boolean } = {}) { + const doc = buildNote(); + const state = EditorState.create({ + doc, + extensions: [ + createUnitOpReorderFilter({ + isEnabled: () => opts.enabled ?? true, + getFilePath: () => (opts.path === undefined ? WORKFLOW_PATH : opts.path), + }), + ], + }); + return { doc, state }; +} + +describe('createUnitOpReorderFilter', () => { + it('folds the cleanup into the drag transaction', () => { + const { doc, state } = setup(); + const tr = state.update(dragGammaBeforeAlpha(doc)); + const dragged = EditorState.create({ doc }).update(dragGammaBeforeAlpha(doc)).newDoc.toString(); + const expected = applyTextEdits(dragged, computeUnitOpSyncEdits(dragged)); + + expect(expected).not.toBe(dragged); + expect(tr.newDoc.toString()).toBe(expected); + // One transaction: its change set alone maps the old document to the tidy one. + expect(tr.changes.apply(state.doc).toString()).toBe(expected); + const toc = [...expected.matchAll(/^- \[(UHW\d+) /gm)].map(m => m[1]); + expect(toc).toEqual(['UHW030', 'UHW010', 'UHW020']); + }); + + it('adds nothing once the note is already tidy', () => { + const { doc, state } = setup(); + const tidy = state.update(dragGammaBeforeAlpha(doc)).state; + const at = tidy.doc.toString().indexOf('### [UHW010 Alpha]'); + const tr = tidy.update({ changes: { from: at, to: at + 5, insert: '### [' } }); + expect(tr.newDoc.toString()).toBe(tidy.doc.toString()); + }); + + it.each([ + ['IME composition', { userEvent: 'input.type.compose' }], + ['undo', { userEvent: 'undo' }], + ['redo', { userEvent: 'redo' }], + ])('skips %s', (_label, extra) => { + const { doc, state } = setup(); + const tr = state.update({ ...dragGammaBeforeAlpha(doc), ...extra }); + const plain = EditorState.create({ doc }).update(dragGammaBeforeAlpha(doc)); + expect(tr.newDoc.toString()).toBe(plain.newDoc.toString()); + }); + + it('skips non-workflow notes and editors without a file', () => { + for (const path of ['labnote/001_Exp/README.labnote.md', 'notes/free.md', null]) { + const { doc, state } = setup({ path }); + const tr = state.update(dragGammaBeforeAlpha(doc)); + const plain = EditorState.create({ doc }).update(dragGammaBeforeAlpha(doc)); + expect(tr.newDoc.toString()).toBe(plain.newDoc.toString()); + } + }); + + it('skips when the setting is off', () => { + const { doc, state } = setup({ enabled: false }); + const tr = state.update(dragGammaBeforeAlpha(doc)); + const plain = EditorState.create({ doc }).update(dragGammaBeforeAlpha(doc)); + expect(tr.newDoc.toString()).toBe(plain.newDoc.toString()); + }); + + it('skips single-character typing that does not touch a unit-op heading', () => { + // Start from a mis-ordered TOC: only a heading-touching edit may fix it here. + const { doc } = setup(); + const moved = EditorState.create({ doc }).update(dragGammaBeforeAlpha(doc)).newDoc.toString(); + const state = EditorState.create({ + doc: moved, + extensions: [createUnitOpReorderFilter({ isEnabled: () => true, getFilePath: () => WORKFLOW_PATH })], + }); + const tr = state.update({ changes: { from: moved.length, insert: 'x' }, userEvent: 'input.type' }); + expect(tr.newDoc.toString()).toBe(moved + 'x'); + }); +}); diff --git a/versions.json b/versions.json index 131a5ac..0a5f6a3 100644 --- a/versions.json +++ b/versions.json @@ -22,5 +22,6 @@ "0.87.2": "1.5.0", "0.88.0": "1.5.0", "0.88.1": "1.5.0", - "0.88.2": "1.5.0" + "0.88.2": "1.5.0", + "0.89.0": "1.5.0" }