diff --git a/README.en.md b/README.en.md index cf6a1f9..468c370 100644 --- a/README.en.md +++ b/README.en.md @@ -1,6 +1,6 @@ # Labnote Assistant for Obsidian (labnoteo) -**Version 0.89.0** +**Version 0.89.1** [한국어](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. 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. +**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 right away. The cleanup is saved separately from the move, so undoing back to before the move may take two presses of Ctrl+Z. 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 cb6e561..5b2f5bc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Labnote Assistant for Obsidian (labnoteo) -**버전 0.89.0** +**버전 0.89.1** [English](README.en.md) @@ -99,7 +99,7 @@ Obsidian 설치와 git·GitHub 계정 준비부터 커뮤니티 플러그인· **워크플로** -- **유닛오퍼레이션 TOC 자동 정렬** (`Auto-sync unit operation TOC`): 편집 후 워크플로 노트의 `Related Unit Operations` 목록을 헤딩 순서에 맞게 자동 정렬합니다 — 유닛 오퍼레이션 블록을 옮기면 문서 맨 위 목차도 따라 재정렬됩니다. Obsidian Outline 보기에서 드래그로 블록을 옮기면 목차와 `---` 구분선이 즉시 정리되고, Ctrl+Z 한 번으로 이동까지 되돌릴 수 있습니다. 기본 켜짐. +- **유닛오퍼레이션 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 3a4a07e..a932d97 100644 --- a/automation/templates/QUICKSTART.md +++ b/automation/templates/QUICKSTART.md @@ -243,7 +243,7 @@ README frontmatter: `## Related Unit Operations` 목차가 헤딩 순서대로 다시 만들어집니다. **유닛 오퍼레이션 블록을 옮기거나 지워도** 목차가 자동으로 다시 정렬됩니다(설정 → Labnote Assistant → 워크플로 → `유닛오퍼레이션 TOC 자동 정렬`, 기본 켜짐). -순서는 Obsidian **Outline** 보기에서 헤딩을 드래그해 바꿀 수 있으며, 목차와 `---` 구분선이 자동으로 정리되고 Ctrl+Z 한 번이면 되돌아갑니다. +순서는 Obsidian **Outline** 보기에서 헤딩을 드래그해 바꿀 수 있으며, 목차와 `---` 구분선이 자동으로 정리됩니다. 정리는 이동과 별도로 저장되므로, 이동 전으로 되돌리려면 Ctrl+Z를 두 번 눌러야 할 수 있습니다. `Experimenter`는 같은 폴더 README의 `author`에서 가져옵니다 — 비어 있으면 빈 값입니다. `Start_date`는 삽입 시각이 자동으로, `End_date`는 비어 있으니 **작업이 끝나면 값을 diff --git a/manifest.json b/manifest.json index 3fa80e8..4297f47 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "labnoteo", "name": "Labnote Assistant", - "version": "0.89.0", + "version": "0.89.1", "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 9e0e8aa..0808a79 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "labnoteo", "private": true, - "version": "0.89.0", + "version": "0.89.1", "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 bcfb9b3..dc06e31 100644 --- a/packages/labnoteo-core/package.json +++ b/packages/labnoteo-core/package.json @@ -1,6 +1,6 @@ { "name": "@labnoteo/core", - "version": "0.89.0", + "version": "0.89.1", "private": true, "type": "module", "description": "Platform-neutral core logic for labnoteo (parsers, workflow/sample domain logic).", diff --git a/src/main.ts b/src/main.ts index 1dd2ce9..b0f6182 100644 --- a/src/main.ts +++ b/src/main.ts @@ -41,6 +41,7 @@ import { } from './sampleHighlight'; import { createMetaDatePickerExtension } from './dateFieldPicker'; import { createUnitOpReorderFilter } from './unitOpReorderFilter'; +import { createOutlineDropTidy } from './outlineDropTidy'; import { WorkflowTreeView, WORKFLOW_VIEW_TYPE } from './views/workflowView'; import { SampleTreeView, SAMPLE_VIEW_TYPE } from './views/sampleView'; import { exportTablesToCsv, exportActiveNoteTablesToCsv } from './exportCsv'; @@ -75,6 +76,9 @@ export default class LabnotePlugin extends Plugin { private readonly readmeSyncTimers = new Map(); // Pending unit-op TOC auto-sync flushes, coalesced per workflow-note path. private readonly unitOpTocTimers = new Map(); + // Whether each note's latest editor change was an undo/redo, so the TOC sync + // does not record a fresh edit that would block undoing further back. + private readonly lastChangeFromHistory = new Map(); async onload(): Promise { await this.loadSettings(); @@ -92,10 +96,12 @@ export default class LabnotePlugin extends Plugin { this.registerEditorMenu(); this.registerWorkflowReadmeSync(); this.registerUnitOpTocAutoSync(); + this.registerOutlineDropTidy(); this.registerEditorExtension( createUnitOpReorderFilter({ isEnabled: () => this.settings.autoSyncUnitOpToc, getFilePath: s => s.field(editorInfoField, false)?.file?.path ?? null, + onDocChange: (path, fromHistory) => this.lastChangeFromHistory.set(path, fromHistory), }) ); this.addSettingTab(new LabnoteSettingTab(this.app, this)); @@ -391,7 +397,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 (added, removed or renamed unit * ops). Fires on editor edits, debounced per note. Block moves are tidied - * immediately, separators included, by `createUnitOpReorderFilter`. + * immediately, separators included, by `createUnitOpReorderFilter` (editor + * moves) and `registerOutlineDropTidy` (Outline drags). Skipped right after + * an undo/redo so the history can still be walked back. * * Only the changed span is rewritten (via {@link minimalReplacement}) so the * caret stays put; a full-document replace would reset it. The rewrite is @@ -411,6 +419,7 @@ export default class LabnotePlugin extends Plugin { this.unitOpTocTimers.delete(path); // The editor may have switched to another note since the edit. if (info.file?.path !== path) return; + if (this.lastChangeFromHistory.get(path)) return; const before = editor.getValue(); const after = rebuildUnitOpToc(before); const edit = minimalReplacement(before, after); @@ -426,6 +435,41 @@ export default class LabnotePlugin extends Plugin { ); } + /** + * Tidy a workflow note's TOC and separators after an Outline drag. The drag + * rewrites the file directly, so the cleanup is written to the file too (see + * `outlineDropTidy`); the drop listener runs in the capture phase, before the + * Outline's own read-and-write, to arm the path first. + */ + private registerOutlineDropTidy(): void { + const { vault, workspace } = this.app; + const tidy = createOutlineDropTidy({ + isEnabled: () => this.settings.autoSyncUnitOpToc, + now: () => Date.now(), + process: async (path, fn) => { + const file = vault.getAbstractFileByPath(path); + if (file instanceof TFile) await vault.process(file, fn); + }, + }); + + this.registerDomEvent( + document, + 'drop', + evt => { + const target = evt.target; + if (!(target instanceof Element)) return; + const pane = target.closest('.workspace-leaf-content[data-type="outline"]'); + if (!pane) return; + const leaf = workspace.getLeavesOfType('outline').find(l => l.view.containerEl === pane); + const file = (leaf?.view as { file?: unknown } | undefined)?.file; + const path = file instanceof TFile ? file.path : workspace.getActiveFile()?.path; + if (path) tidy.arm(path); + }, + { capture: true } + ); + this.registerEvent(vault.on('modify', file => void tidy.onModify(file.path))); + } + /** Get (or create) the pending-event queue for an experiment folder. */ private queueReadmeSync(dir: string): { renames: { from: string; to: string }[]; deletes: string[] } { let q = this.readmeSyncQueue.get(dir); diff --git a/src/outlineDropTidy.ts b/src/outlineDropTidy.ts new file mode 100644 index 0000000..5af6529 --- /dev/null +++ b/src/outlineDropTidy.ts @@ -0,0 +1,50 @@ +/** + * Tidy a workflow note after an Outline drag, at the file level. + * + * Obsidian's Outline view moves a section by rewriting the file + * (`vault.read` → splice → `vault.modify`), not through the editor. Tidying it + * in the editor would leave the file — and the cached heading positions the + * Outline navigates by — out of step, so the TOC/separator cleanup is written + * back to the file with `vault.process` instead. + * + * Only the first `modify` after a drop on the Outline pane is handled (the path + * is "armed" by the drop), so editor saves, git pulls, other plugins' writes + * and the save that follows an undo are left alone. + */ +import { applyTextEdits, computeUnitOpSyncEdits } from '@labnoteo/core'; +import { isValidWorkflowPath } from '@labnoteo/core/lib/workflowStructure'; + +/** The drop handler reads and writes the file right away; this only bounds a no-op drop. */ +export const OUTLINE_DROP_ARM_MS = 3000; + +export interface OutlineDropTidyDeps { + isEnabled: () => boolean; + now: () => number; + /** Rewrite the file; `vault.process` skips the write when `fn` returns its input. */ + process: (path: string, fn: (data: string) => string) => Promise; +} + +export interface OutlineDropTidy { + arm(path: string): void; + onModify(path: string): Promise; +} + +export function tidyReorderedWorkflow(data: string): string { + return applyTextEdits(data, computeUnitOpSyncEdits(data, { onlyOnReorder: true })); +} + +export function createOutlineDropTidy(deps: OutlineDropTidyDeps): OutlineDropTidy { + const armedUntil = new Map(); + return { + arm(path) { + armedUntil.set(path, deps.now() + OUTLINE_DROP_ARM_MS); + }, + async onModify(path) { + const until = armedUntil.get(path); + if (until === undefined) return; + armedUntil.delete(path); + if (deps.now() > until || !deps.isEnabled() || !isValidWorkflowPath(path)) return; + await deps.process(path, tidyReorderedWorkflow); + }, + }; +} diff --git a/src/unitOpReorderFilter.ts b/src/unitOpReorderFilter.ts index ac39333..0e66684 100644 --- a/src/unitOpReorderFilter.ts +++ b/src/unitOpReorderFilter.ts @@ -7,6 +7,12 @@ * 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. + * + * An Outline drag is NOT an editor edit: Obsidian rewrites the file and mirrors + * it into the editor as a `set` transaction. Changing that transaction would + * leave the editor out of step with the file (and the heading positions the + * Outline navigates by), so `set` is skipped and `outlineDropTidy` tidies the + * file itself. */ import { EditorState, type Extension, type Transaction } from '@codemirror/state'; import { computeUnitOpSyncEdits } from '@labnoteo/core'; @@ -19,6 +25,8 @@ export interface UnitOpReorderFilterOptions { isEnabled: () => boolean; /** Vault path of the note being edited, or null (canvas embeds, hover previews, …). */ getFilePath: (state: EditorState) => string | null; + /** Called for every document change of a file-backed editor, before any skip. */ + onDocChange?: (path: string, fromHistory: boolean) => void; } /** Cheap pre-check: a block move always inserts or deletes a `### [` heading. */ @@ -36,13 +44,15 @@ function touchesUnitOpHeading(tr: Transaction): boolean { 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; - } + if (!tr.docChanged) return tr; const path = opts.getFilePath(tr.startState); + const fromHistory = tr.isUserEvent('undo') || tr.isUserEvent('redo'); + if (path) opts.onDocChange?.(path, fromHistory); + if (!opts.isEnabled()) return tr; + // Never interfere with IME composition or file reloads (`set`). Undo/redo + // already restore a tidy document, because the move and its cleanup were + // recorded together. + if (tr.isUserEvent('input.type.compose') || tr.isUserEvent('set') || fromHistory) return tr; if (!path || !isValidWorkflowPath(path)) return tr; if (!touchesUnitOpHeading(tr)) return tr; diff --git a/tests/plugin/outlineDropTidy.test.ts b/tests/plugin/outlineDropTidy.test.ts new file mode 100644 index 0000000..ca31665 --- /dev/null +++ b/tests/plugin/outlineDropTidy.test.ts @@ -0,0 +1,106 @@ +/** + * The Outline-drop tidy must rewrite the file only for the first `modify` after + * a drop on the Outline pane, and never for other writes (editor saves, git + * pulls, the save that follows an undo). + */ +import { describe, it, expect } from 'vitest'; +import { findUnitOpInsertOffset, rebuildUnitOpToc } from '@labnoteo/core'; +import { createWorkflowContent } from '@labnoteo/core/lib/workflowStructure'; +import { buildHwUnitOpMarkdown } from '@labnoteo/core/lib/unitOpTemplate'; +import { createOutlineDropTidy, OUTLINE_DROP_ARM_MS } from '../../src/outlineDropTidy'; + +const WORKFLOW_PATH = 'labnote/001_Exp/001_WD010_Design.labnote.md'; + +function buildNote(order: string[]): string { + let md = createWorkflowContent({ id: 'WD010', name: 'Design', description: 'desc' }, 'Dr. Kim'); + for (const opId of order) { + const block = buildHwUnitOpMarkdown( + { opId, opName: `Op ${opId}`, opDescription: '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's Outline drop: move the `from` section before the `before` heading. */ +function outlineDrag(v: string, from: string, before: string): string { + const ensureBlank = (s: string) => (s.endsWith('\n\n') ? s : s.endsWith('\n') ? s + '\n' : s + '\n\n'); + const c = v.indexOf(from); + const bodyStart = v.indexOf('\n', c) + 1; + const u = bodyStart + v.slice(bodyStart).search(/^#{1,3} /m); + const h = v.indexOf(before); + const moved = ensureBlank(v.substring(c, u)); + return ensureBlank(v.substring(0, h)) + moved + v.substring(h, c) + v.substring(u); +} + +function setup(opts: { enabled?: boolean } = {}) { + let clock = 1000; + const files = new Map(); + const processed: string[] = []; + const tidy = createOutlineDropTidy({ + isEnabled: () => opts.enabled ?? true, + now: () => clock, + process: async (path, fn) => { + processed.push(path); + files.set(path, fn(files.get(path) ?? '')); + }, + }); + return { tidy, files, processed, advance: (ms: number) => (clock += ms) }; +} + +const dragged = () => outlineDrag(buildNote(['UHW010', 'UHW020', 'UHW030']), '### [UHW030', '### [UHW010'); +const normBlank = (s: string) => s.replace(/\n{3,}/g, '\n\n'); + +describe('createOutlineDropTidy', () => { + it('tidies the TOC and separators on the first modify after a drop', async () => { + const { tidy, files, processed } = setup(); + files.set(WORKFLOW_PATH, dragged()); + tidy.arm(WORKFLOW_PATH); + await tidy.onModify(WORKFLOW_PATH); + + expect(processed).toEqual([WORKFLOW_PATH]); + expect(normBlank(files.get(WORKFLOW_PATH)!)).toBe(normBlank(buildNote(['UHW030', 'UHW010', 'UHW020']))); + }); + + it('handles only one modify per drop', async () => { + const { tidy, files, processed } = setup(); + files.set(WORKFLOW_PATH, dragged()); + tidy.arm(WORKFLOW_PATH); + await tidy.onModify(WORKFLOW_PATH); + await tidy.onModify(WORKFLOW_PATH); + expect(processed).toHaveLength(1); + }); + + it('ignores modifies without a drop, after expiry, when disabled or for non-workflow notes', async () => { + const plain = setup(); + await plain.tidy.onModify(WORKFLOW_PATH); + expect(plain.processed).toEqual([]); + + const expired = setup(); + expired.tidy.arm(WORKFLOW_PATH); + expired.advance(OUTLINE_DROP_ARM_MS + 1); + await expired.tidy.onModify(WORKFLOW_PATH); + expect(expired.processed).toEqual([]); + + const off = setup({ enabled: false }); + off.tidy.arm(WORKFLOW_PATH); + await off.tidy.onModify(WORKFLOW_PATH); + expect(off.processed).toEqual([]); + + const readme = setup(); + readme.tidy.arm('labnote/001_Exp/README.labnote.md'); + await readme.tidy.onModify('labnote/001_Exp/README.labnote.md'); + expect(readme.processed).toEqual([]); + }); + + it('leaves an already tidy note unchanged', async () => { + const { tidy, files } = setup(); + const note = buildNote(['UHW010', 'UHW020']); + files.set(WORKFLOW_PATH, note); + tidy.arm(WORKFLOW_PATH); + await tidy.onModify(WORKFLOW_PATH); + expect(files.get(WORKFLOW_PATH)).toBe(note); + }); +}); diff --git a/tests/plugin/unitOpReorderFilter.test.ts b/tests/plugin/unitOpReorderFilter.test.ts index f69e716..d965cde 100644 --- a/tests/plugin/unitOpReorderFilter.test.ts +++ b/tests/plugin/unitOpReorderFilter.test.ts @@ -77,6 +77,7 @@ describe('createUnitOpReorderFilter', () => { it.each([ ['IME composition', { userEvent: 'input.type.compose' }], + ['a file reload (Outline drag)', { userEvent: 'set' }], ['undo', { userEvent: 'undo' }], ['redo', { userEvent: 'redo' }], ])('skips %s', (_label, extra) => { @@ -113,4 +114,38 @@ describe('createUnitOpReorderFilter', () => { const tr = state.update({ changes: { from: moved.length, insert: 'x' }, userEvent: 'input.type' }); expect(tr.newDoc.toString()).toBe(moved + 'x'); }); + + it('reports every document change with whether it came from undo/redo', () => { + const calls: [string, boolean][] = []; + const make = (path: string | null, enabled = true) => + EditorState.create({ + doc: 'abc', + extensions: [ + createUnitOpReorderFilter({ + isEnabled: () => enabled, + getFilePath: () => path, + onDocChange: (p, h) => calls.push([p, h]), + }), + ], + }); + const edit = { changes: { from: 0, insert: 'x' } }; + + make(WORKFLOW_PATH).update({ ...edit, userEvent: 'input.type' }); + make(WORKFLOW_PATH).update({ ...edit, userEvent: 'set' }); + make(WORKFLOW_PATH).update({ ...edit, userEvent: 'undo' }); + make(WORKFLOW_PATH).update({ ...edit, userEvent: 'redo' }); + make('notes/free.md').update(edit); + make(WORKFLOW_PATH, false).update({ ...edit, userEvent: 'undo' }); + make(null).update(edit); + make(WORKFLOW_PATH).update({ selection: { anchor: 1 } }); + + expect(calls).toEqual([ + [WORKFLOW_PATH, false], + [WORKFLOW_PATH, false], + [WORKFLOW_PATH, true], + [WORKFLOW_PATH, true], + ['notes/free.md', false], + [WORKFLOW_PATH, true], + ]); + }); }); diff --git a/versions.json b/versions.json index 0a5f6a3..87ce269 100644 --- a/versions.json +++ b/versions.json @@ -23,5 +23,6 @@ "0.88.0": "1.5.0", "0.88.1": "1.5.0", "0.88.2": "1.5.0", - "0.89.0": "1.5.0" + "0.89.0": "1.5.0", + "0.89.1": "1.5.0" }