Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.en.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Labnote Assistant for Obsidian (labnoteo)

**Version 0.89.0**
**Version 0.89.1**

[한국어](README.md)

Expand Down Expand Up @@ -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).

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Labnote Assistant for Obsidian (labnoteo)

**버전 0.89.0**
**버전 0.89.1**

[English](README.en.md)

Expand Down Expand Up @@ -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 제공자**

Expand Down
2 changes: 1 addition & 1 deletion automation/templates/QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`는 비어 있으니 **작업이 끝나면 값을
Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -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)",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/labnoteo-core/package.json
Original file line number Diff line number Diff line change
@@ -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).",
Expand Down
46 changes: 45 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -75,6 +76,9 @@ export default class LabnotePlugin extends Plugin {
private readonly readmeSyncTimers = new Map<string, number>();
// Pending unit-op TOC auto-sync flushes, coalesced per workflow-note path.
private readonly unitOpTocTimers = new Map<string, number>();
// 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<string, boolean>();

async onload(): Promise<void> {
await this.loadSettings();
Expand All @@ -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));
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand All @@ -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);
Expand Down
50 changes: 50 additions & 0 deletions src/outlineDropTidy.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
}

export interface OutlineDropTidy {
arm(path: string): void;
onModify(path: string): Promise<void>;
}

export function tidyReorderedWorkflow(data: string): string {
return applyTextEdits(data, computeUnitOpSyncEdits(data, { onlyOnReorder: true }));
}

export function createOutlineDropTidy(deps: OutlineDropTidyDeps): OutlineDropTidy {
const armedUntil = new Map<string, number>();
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);
},
};
}
22 changes: 16 additions & 6 deletions src/unitOpReorderFilter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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. */
Expand All @@ -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;

Expand Down
106 changes: 106 additions & 0 deletions tests/plugin/outlineDropTidy.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();
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);
});
});
Loading
Loading