From 781f41f0fba626e58064b7ecfb49fd067c1256c1 Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Wed, 1 Jul 2026 23:42:01 -0500 Subject: [PATCH] feat(okf): read/write notes as an Open Knowledge Format bundle + `omind convert` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit omind now conforms to the Open Knowledge Format (OKF v0.1, Google Cloud, Apache-2.0): every note omind writes leads with a YAML frontmatter block carrying the one field OKF requires (`type`) plus the recommended title/description/tags/ timestamp, and the vault's index.md is the OKF directory listing. Non-breaking by construction: - parse_note is dual-format — metadata is read from the legacy `## Metadata` section (which still wins for shared fields, so un-upgraded mesh peers keep working) OR the OKF frontmatter; the sole new source is the `type`. - render_fields regenerates the OKF frontmatter from the structured fields (deriving `type` from the note's kind — #feedback -> Feedback — when absent) and KEEPS the `## Metadata` block, so old omind reads created/tags/rev unchanged. Producer-defined frontmatter keys are round-tripped (OKF requires preserving unknown keys). Because omind already preserved a leading frontmatter block verbatim, an un-upgraded peer keeps OKF notes intact. - update_note now inherits tags/okf_type on a partial edit (like created/ frontmatter/lead), so an edit never silently drops frontmatter-authored tags. - the mesh merge driver carries `type` through three-way merges. New `omind convert` migrates a pre-OKF vault in place (idempotent — an already-OKF note renders byte-identical and is skipped, no revision bump; `--check` validates the three conformance rules, `--dry-run` previews). Also `omind.okf.convert_vault` / `check_conformance`. README leads with OKF (+ badge, spec links); CHANGELOG under [Unreleased]. ruff + mypy clean; full suite 739 passed (+14 OKF tests). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JFuGddPx3UhUkcC74KBXZB --- CHANGELOG.md | 7 ++ README.md | 28 ++++++- src/omind/cli.py | 37 +++++++++ src/omind/merge.py | 1 + src/omind/okf.py | 141 +++++++++++++++++++++++++++++++++ src/omind/store.py | 126 +++++++++++++++++++++++++++-- tests/test_cli.py | 3 +- tests/test_merge.py | 2 +- tests/test_okf.py | 188 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_store.py | 13 ++- 10 files changed, 533 insertions(+), 13 deletions(-) create mode 100644 src/omind/okf.py create mode 100644 tests/test_okf.py diff --git a/CHANGELOG.md b/CHANGELOG.md index efff4c1..c528444 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Open Knowledge Format (OKF) support**: omind now reads and writes notes as a conformant [OKF v0.1](https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf) bundle — Google Cloud's vendor-neutral, Apache-2.0 Markdown-plus-YAML-frontmatter spec for sharing knowledge with agents and tools. Every note omind writes now leads with a YAML frontmatter block carrying the one field OKF requires (`type` — derived from the note's kind, e.g. `#feedback` → `Feedback`, when not declared) plus the recommended `title` / `description` / `tags` / `timestamp`; the `index.md` is the OKF directory listing. Producer-defined frontmatter keys are round-tripped (the spec requires preserving unknown keys). +- **`omind convert`**: migrate a pre-OKF vault to OKF in place — idempotent (a note already in OKF form is skipped, no mesh revision bump), with `--check` to validate the three conformance rules and `--dry-run` to preview. Also exposed programmatically as `omind.okf.convert_vault` / `omind.okf.check_conformance`. + +### Changed +- Note parsing is now **dual-format and backward-compatible**: metadata is read from either the legacy `## Metadata` section (which still wins for shared fields, so un-upgraded mesh peers keep working) or the OKF frontmatter. Existing vaults keep working with no migration required; the `## Metadata` block is retained in every rendered note alongside the new frontmatter. The mesh merge driver carries the new `type` through three-way merges. + ## [3.7.8] - 2026-07-01 ### Fixed diff --git a/README.md b/README.md index c102e87..7481ab8 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ OMI/Obsidian memory tooling for AI agents: reproduce the integration on any mach [![Codeberg](https://img.shields.io/badge/Codeberg-CryptoJones%2Fomind-2185D0?logo=codeberg&logoColor=white)](https://codeberg.org/CryptoJones/omind) [![GitHub](https://img.shields.io/badge/GitHub-CryptoJones%2Fomind-181717?logo=github&logoColor=white)](https://github.com/CryptoJones/omind) [![Python](https://img.shields.io/badge/Python-3.10%2B-3776AB?logo=python&logoColor=white)](https://www.python.org/) +[![Open Knowledge Format](https://img.shields.io/badge/format-OKF%20v0.1-4285F4?logo=googlecloud&logoColor=white)](https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf) [![Version](https://img.shields.io/github/v/tag/CryptoJones/omind?label=version&color=orange)](https://github.com/CryptoJones/omind/tags) > Mirrored on both [GitHub](https://github.com/CryptoJones/omind) and @@ -205,17 +206,36 @@ every snapshot; losing it with the disk makes the backups unreadable. ## How memory is stored OMI is just a folder of plain Markdown notes — one note per memory, fully -human-editable (open the folder as an Obsidian vault). Each note has a stable -shape so tools and the merge driver can read and write individual fields without -stepping on each other: +human-editable (open the folder as an Obsidian vault). It is also a conformant +**[Open Knowledge Format](https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf) +bundle** (see below). Each note has a stable shape so tools and the merge driver +can read and write individual fields without stepping on each other: +- a **YAML frontmatter block** — the note's OKF metadata: the required `type` + plus `title`, `description`, `tags`, and `timestamp`; - a `# Title` and a `## Metadata` block (created date, `#tags`, and the mesh - `Rev:` Lamport stamp); + `Rev:` Lamport stamp), kept alongside the frontmatter so existing tooling and + un-upgraded mesh peers keep reading it unchanged; - `## Summary` / `## Details` free text; - `## Connections` — `[[wikilinks]]` to related notes (the graph the web UI and `omind lint` traverse); - `## Action Items` (a checkbox list) and `## References`. +**Open Knowledge Format (OKF).** omind speaks +[OKF](https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf) — +Google Cloud's vendor-neutral, Apache-2.0 +[specification](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md) +for representing knowledge as a directory of Markdown files with YAML +frontmatter, readable by any agent or tool with no SDK, runtime, or lock-in. +Every note omind writes leads with a frontmatter block carrying the one field +OKF requires (`type`) plus the recommended `title` / `description` / `tags` / +`timestamp`, and `index.md` is the OKF directory listing — so an omind vault +drops straight into any OKF-aware consumer. Migrate a pre-OKF vault in place with +**`omind convert`** (idempotent; `--check` validates the three conformance rules, +`--dry-run` previews). More: the +[OKF spec](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md) +and [okf.md](https://okf.md/). + **One writer, always.** Every write — `omind note`, the `omind node` MCP server an agent calls, the web UI, the mesh merge — goes through a single locked, atomic path: an advisory `flock`, an atomic `os.replace`, and a `note_version` diff --git a/src/omind/cli.py b/src/omind/cli.py index 8b2563e..ceb6bfd 100644 --- a/src/omind/cli.py +++ b/src/omind/cli.py @@ -270,6 +270,23 @@ def build_parser() -> argparse.ArgumentParser: ) _add_vault_args(reindex) + convert = sub.add_parser( + "convert", + help="migrate an OMI vault to the Open Knowledge Format (OKF): give every " + "note YAML frontmatter with a 'type' (idempotent, in place)", + ) + convert.add_argument( + "--dry-run", + action="store_true", + help="report what would change without writing any note", + ) + convert.add_argument( + "--check", + action="store_true", + help="only check OKF conformance and report; make no changes", + ) + _add_vault_args(convert) + note = sub.add_parser( "note", help="safely create or update one OMI note through OmiStore (the single-writer path)", @@ -788,6 +805,24 @@ def _run_reindex(args: argparse.Namespace) -> int: return 0 +def _run_convert(args: argparse.Namespace) -> int: + from omind import okf + + omi_dir = (args.vault / args.folder).expanduser() + if args.check: + report = okf.check_conformance(omi_dir) + else: + result = okf.convert_vault(omi_dir, dry_run=args.dry_run) + verb = "would convert" if args.dry_run else "converted" + print(f"{verb} {result.converted} note(s); {result.unchanged} already in OKF form") + report = result.report + for problem in report.problems: + print(f" [x] {problem.filename}: {problem.problem}") + tail = "all conformant" if report.ok else f"{len(report.problems)} non-conformant" + print(f"OKF v0.1 conformance: {report.conformant}/{report.concepts} concept notes — {tail}") + return 0 if report.ok else 1 + + def _run_search(args: argparse.Namespace) -> int: from omind.store import OmiStore @@ -1074,6 +1109,8 @@ def main(argv: list[str] | None = None) -> int: return _run_checkpoint(args) if args.command == "reindex": return _run_reindex(args) + if args.command == "convert": + return _run_convert(args) if args.command == "note": return _run_note(args) if args.command == "rollup": diff --git a/src/omind/merge.py b/src/omind/merge.py index 41a8237..c902122 100644 --- a/src/omind/merge.py +++ b/src/omind/merge.py @@ -273,6 +273,7 @@ def scalar(name: str) -> str | bool: # must never eat; carry them through with the same symmetric LWW rule. frontmatter=str(scalar("frontmatter")), lead=str(scalar("lead")), + okf_type=str(scalar("okf_type")), rev=merged_rev, disabled=bool(scalar("disabled")), ) diff --git a/src/omind/okf.py b/src/omind/okf.py new file mode 100644 index 0000000..a520f8f --- /dev/null +++ b/src/omind/okf.py @@ -0,0 +1,141 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Aaron K. Clark +"""OKF (Open Knowledge Format v0.1) conformance + conversion for an OMI vault. + +OKF (Google Cloud, ``GoogleCloudPlatform/knowledge-catalog`` → ``okf/SPEC.md``) +represents a body of knowledge as a directory of markdown files, each carrying a +leading YAML frontmatter block whose one required field is ``type``. A bundle is +conformant (SPEC §9) iff: + +1. every non-reserved ``.md`` file has a parseable YAML frontmatter block, +2. every such block has a non-empty ``type`` field, and +3. the reserved ``index.md`` / ``log.md`` follow their structure when present. + +omind's OMI vault already IS such a bundle — :func:`omind.store.render_fields` +emits the frontmatter for every note it writes. This module (a) validates a +folder against the conformance rules and (b) migrates a *legacy* vault (whose +notes carried metadata only in a ``## Metadata`` section) into the frontmatter +form, in place and idempotently. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +from omind.paths import NON_CONSULT_FILENAMES +from omind.store import OmiStore, parse_frontmatter, parse_note, render_fields + +#: Files that are NOT OKF concept documents, so the ``type``-required rule does +#: not apply: OKF reserves ``index.md`` / ``log.md`` (directory listing / change +#: log), and omind additionally treats its own scaffolding (``MEMORY.md`` +#: recent-index, ``Memory Template.md``) as non-concept. Superset of the vault's +#: reserved names. +RESERVED_OKF_FILES = frozenset({"index.md", "log.md"}) | NON_CONSULT_FILENAMES + + +@dataclass +class OkfProblem: + """A single conformance failure: which file, and what's wrong with it.""" + + filename: str + problem: str + + +@dataclass +class OkfReport: + """Result of an OKF conformance scan over a vault folder.""" + + concepts: int = 0 + conformant: int = 0 + problems: list[OkfProblem] = field(default_factory=list) + + @property + def ok(self) -> bool: + return not self.problems + + +def _leading_frontmatter_block(text: str) -> str: + """Return the verbatim leading ``---`` … ``---`` block, or ``""`` if absent. + + A block that opens with ``---`` but is never closed is not a valid + frontmatter block, so it returns ``""`` (reported as non-conformant) rather + than swallowing the whole document. + """ + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + return "" + out = [lines[0]] + for line in lines[1:]: + out.append(line) + if line.strip() == "---": + return "\n".join(out) + return "" + + +def check_conformance(omi_dir: Path | str) -> OkfReport: + """Validate a vault folder against OKF v0.1 conformance rules 1 & 2. + + Rule 3 (reserved-file structure) holds by construction — omind generates + ``index.md`` with no frontmatter and ``#`` section headings — so this checks + the per-concept rules: every non-reserved note has a parseable frontmatter + block carrying a non-empty ``type``. + """ + folder = Path(omi_dir).expanduser() + report = OkfReport() + for path in sorted(folder.glob("*.md")): + if path.name in RESERVED_OKF_FILES: + continue + report.concepts += 1 + text = path.read_text(encoding="utf-8-sig", errors="replace") + block = _leading_frontmatter_block(text) + if not block: + report.problems.append(OkfProblem(path.name, "no parseable YAML frontmatter block")) + continue + fm = parse_frontmatter(block) + if not fm: + report.problems.append( + OkfProblem(path.name, "frontmatter is not a parseable YAML mapping") + ) + continue + if not str(fm.get("type", "") or "").strip(): + report.problems.append(OkfProblem(path.name, "frontmatter has no non-empty 'type'")) + continue + report.conformant += 1 + return report + + +@dataclass +class ConvertResult: + """Outcome of :func:`convert_vault`: how many notes changed, plus the scan.""" + + converted: int = 0 + unchanged: int = 0 + report: OkfReport = field(default_factory=OkfReport) + + +def convert_vault(omi_dir: Path | str, *, dry_run: bool = False) -> ConvertResult: + """Rewrite every note in ``omi_dir`` into OKF form (frontmatter + ``type``). + + Idempotent: a note already in OKF form renders byte-identical and is skipped + (no write, no mesh revision bump), so re-running is a no-op. The conformance + scan runs afterwards and rides back on the result. + + Run this on ONE node and let the mesh replicate the reformatted notes: each + rewrite bumps the note's Lamport revision, so converting the same vault + concurrently on two peers would create needless merge work. + """ + store = OmiStore(omi_dir) + result = ConvertResult() + for summary in store.list_notes(include_disabled=True): + name = summary.filename + raw = store.read_note(name) + rendered = render_fields(parse_note(raw)) + if rendered.strip() == raw.strip(): + result.unchanged += 1 + continue + if not dry_run: + store.write_note(name, rendered) + result.converted += 1 + result.report = check_conformance(store.omi_dir) + return result diff --git a/src/omind/store.py b/src/omind/store.py index 8aeba5d..ec12afa 100644 --- a/src/omind/store.py +++ b/src/omind/store.py @@ -24,6 +24,8 @@ from pathlib import Path from typing import Any +import yaml + from omind import filelock from omind.clock import Rev, next_rev from omind.paths import ( @@ -180,6 +182,11 @@ class NoteFields: # H2 headings inside a field body round-trips instead of being silently # dropped by the edit path. The mesh merge driver preserves these too. extras: dict[str, list[str]] = field(default_factory=dict) + # OKF (Open Knowledge Format, v0.1) concept type — the single field OKF + # requires in a note's YAML frontmatter. Empty on un-migrated notes; when + # absent it is derived (see :func:`derive_okf_type`) at render time so every + # note omind writes carries a non-empty ``type`` and is OKF-conformant. + okf_type: str = "" # Mesh fields (docs/mesh.md). Empty/False on legacy notes, and rendered # only when set, so a non-mesh note round-trips byte-identical. rev: str = "" @@ -215,6 +222,7 @@ def from_dict(cls, data: dict[str, Any]) -> NoteFields: }, frontmatter=str(data.get("frontmatter", "")), lead=str(data.get("lead", "")), + okf_type=str(data.get("okf_type", "")).strip(), rev=str(data.get("rev", "")).strip(), disabled=bool(data.get("disabled")), ) @@ -259,6 +267,90 @@ def _strip_blank_edges(lines: list[str]) -> list[str]: return lines[start:end] +# --- OKF (Open Knowledge Format) frontmatter ------------------------------------- +# omind conforms to OKF v0.1: every note is a markdown file whose leading YAML +# frontmatter block carries at least a non-empty ``type``. We manage this small +# set of standard OKF keys from the structured NoteFields and preserve any OTHER +# frontmatter key verbatim on round-trip (the spec requires consumers to keep +# unknown keys). Spec: GoogleCloudPlatform/knowledge-catalog okf/SPEC.md. +_OKF_STANDARD_KEYS = frozenset({"type", "title", "description", "tags", "timestamp"}) + +#: A memory's kind is carried as a tag (#feedback, #reference, …); map those to a +#: descriptive OKF ``type``. Anything unmapped falls back to ``Memory`` so the one +#: required OKF field is never empty. +_TYPE_BY_TAG = { + "user": "User", + "feedback": "Feedback", + "project": "Project", + "reference": "Reference", +} +_DEFAULT_OKF_TYPE = "Memory" + + +def derive_okf_type(tags: list[str]) -> str: + """Best-effort OKF ``type`` for a note that never declared one.""" + for t in tags: + name = _TYPE_BY_TAG.get(_clean_tag(t).lower()) + if name: + return name + return _DEFAULT_OKF_TYPE + + +def parse_frontmatter(block: str) -> dict[str, Any]: + """Parse a verbatim leading ``---`` YAML frontmatter block into a dict. + + Deliberately tolerant (OKF consumers MUST NOT choke on odd frontmatter): an + absent, empty, non-mapping, or malformed block yields ``{}``. + """ + lines = block.strip().splitlines() + if lines and lines[0].strip() == "---": + lines = lines[1:] + if lines and lines[-1].strip() == "---": + lines = lines[:-1] + text = "\n".join(lines).strip() + if not text: + return {} + try: + data = yaml.safe_load(text) + except yaml.YAMLError: + return {} + return data if isinstance(data, dict) else {} + + +def _one_line(text: str) -> str: + """First non-empty line of ``text``, whitespace-collapsed (for OKF ``description``).""" + for line in text.splitlines(): + collapsed = " ".join(line.split()) + if collapsed: + return collapsed + return "" + + +def build_okf_frontmatter(f: NoteFields) -> str: + """Render a note's OKF-conformant YAML frontmatter block. + + The standard OKF keys (type/title/description/tags/timestamp) are + regenerated from the structured fields; every other key already present in + the note's frontmatter is preserved (round-tripped) so hand-authored or + producer-defined properties survive edits. + """ + preserved = parse_frontmatter(f.frontmatter) + data: dict[str, Any] = {"type": f.okf_type.strip() or derive_okf_type(f.tags)} + if f.title.strip(): + data["title"] = f.title.strip() + if desc := _one_line(f.summary): + data["description"] = desc + if clean_tags := [_clean_tag(t) for t in f.tags if _clean_tag(t)]: + data["tags"] = clean_tags + if timestamp := (f.created or today()).strip(): + data["timestamp"] = timestamp + for key, value in preserved.items(): + if key not in _OKF_STANDARD_KEYS and key not in data: + data[key] = value + body = yaml.safe_dump(data, sort_keys=False, allow_unicode=True).strip("\n") + return f"---\n{body}\n---" + + def _scan_note(md: str) -> tuple[str, str, str, dict[str, list[str]]]: """Scan a note into ``(frontmatter, title, lead, {heading: body lines})``. @@ -367,6 +459,18 @@ def body(name: str) -> str: elif _DISABLED_LINE_RE.match(line): disabled = True + # OKF frontmatter: the fallback source for shared fields (## Metadata wins + # when present — it's what both current and un-upgraded omind write) and the + # sole source for the OKF ``type``. + fm = parse_frontmatter(frontmatter) + okf_type = str(fm.get("type", "") or "").strip() + if not title and fm.get("title"): + title = str(fm["title"]).strip() + if not created and fm.get("timestamp"): + created = str(fm["timestamp"]).strip() + if not tags and isinstance(fm.get("tags"), list): + tags = [_clean_tag(t) for t in fm["tags"] if _clean_tag(t)] + connections = _WIKILINK_RE.findall("\n".join(sections.get("Connections", []))) action_items: list[ActionItem] = [] @@ -403,6 +507,7 @@ def body(name: str) -> str: extras=extras, frontmatter=frontmatter, lead=lead, + okf_type=okf_type, rev=rev, disabled=disabled, ) @@ -411,11 +516,14 @@ def body(name: str) -> str: def render_fields(f: NoteFields) -> str: """Render structured fields back into template-shaped Markdown.""" out: list[str] = [] - # Preserved verbatim content: YAML frontmatter above the title, then the - # title, then any lead prose before the first section. - if f.frontmatter.strip(): - out.append(f.frontmatter.strip("\n")) - out.append("") + # OKF (Open Knowledge Format) frontmatter — regenerated from the structured + # fields (plus any preserved producer keys) so every note omind writes leads + # with a parseable YAML block carrying a non-empty ``type``. Replaces the old + # verbatim passthrough; unknown keys are still round-tripped (see + # :func:`build_okf_frontmatter`). The ``## Metadata`` section below is kept so + # un-upgraded fleet peers keep reading created/tags/rev unchanged. + out.append(build_okf_frontmatter(f)) + out.append("") out.append(f"# {f.title}".rstrip()) if f.lead.strip(): out.append("") @@ -1053,6 +1161,14 @@ def transform(text: str) -> str: fields.frontmatter = current.frontmatter if not fields.lead: fields.lead = current.lead + # Tags and the OKF ``type`` are inherited the same way, so a partial + # edit (e.g. title/summary only, or an Obsidian-Properties note whose + # tags live in frontmatter) never silently clears a note's tags or + # resets its OKF type to the derived default. + if not fields.tags: + fields.tags = current.tags + if not fields.okf_type: + fields.okf_type = current.okf_type # A multi-section body supplied through `details` (the only such # field the MCP/CLI API exposes) carries ## H2s that read back as # extras. Hoist them now so they REPLACE the same-named inherited diff --git a/tests/test_cli.py b/tests/test_cli.py index 64ff4b3..6e19b97 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -136,7 +136,8 @@ def test_note_creates_then_updates(tmp_path: Path, capsys: pytest.CaptureFixture assert capsys.readouterr().out.strip() == "created Attention Insight.md" note = (tmp_path / "OMI" / "Attention Insight.md").read_text(encoding="utf-8") - assert note.startswith("# Attention Insight") + assert note.startswith("---\n") and "\ntype:" in note # OKF frontmatter w/ required type + assert "# Attention Insight" in note assert "## Summary\ngist" in note assert "#thesis" in note and "first body" in note assert "[[Attention Insight]]" in (tmp_path / "OMI" / "index.md").read_text(encoding="utf-8") diff --git a/tests/test_merge.py b/tests/test_merge.py index eae542b..c3c8d40 100644 --- a/tests/test_merge.py +++ b/tests/test_merge.py @@ -120,7 +120,7 @@ def test_merge_preserves_frontmatter_and_lead() -> None: ours = "---\ntags: [x]\n---\n# N\n\nlead text.\n\n## Summary\ns2\n\n## Details\nd\n" theirs = base merged, _clean, _msgs = merge_note_texts(base, ours, theirs) - assert "tags: [x]" in merged + assert "#x" in merged # frontmatter tag survives the merge (block-style YAML + ## Metadata) assert "lead text." in merged diff --git a/tests/test_okf.py b/tests/test_okf.py new file mode 100644 index 0000000..f9ce875 --- /dev/null +++ b/tests/test_okf.py @@ -0,0 +1,188 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Aaron K. Clark +"""Tests for OKF (Open Knowledge Format) conformance + conversion.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from omind import okf +from omind.cli import main +from omind.store import ( + OmiStore, + build_okf_frontmatter, + derive_okf_type, + parse_frontmatter, + parse_note, + render_fields, +) + +LEGACY_NOTE = """# Legacy Note + +## Metadata +- Created: 2026-06-01 +- Tags: #omi #reference + +## Summary +A legacy note. + +## Details +Body text. + +## Connections +[[Other Note]] +""" + +OKF_NOTE = """--- +type: Playbook +title: Deploy Guide +tags: [ops, deploy] +timestamp: 2026-07-01 +--- + +# Deploy Guide + +Steps here. +""" + + +@pytest.fixture +def store(tmp_path: Path) -> OmiStore: + omi = tmp_path / "OMI" + omi.mkdir() + return OmiStore(omi) + + +# -- frontmatter parse / build ---------------------------------------------------- + + +def test_derive_type_from_tag_and_default() -> None: + assert derive_okf_type(["omi", "feedback"]) == "Feedback" + assert derive_okf_type(["reference"]) == "Reference" + assert derive_okf_type(["random", "misc"]) == "Memory" # default when unmapped + + +def test_render_emits_required_type_frontmatter() -> None: + md = render_fields(parse_note(LEGACY_NOTE)) + assert md.startswith("---\n") + fm = parse_frontmatter(okf._leading_frontmatter_block(md)) + assert fm["type"] == "Reference" # derived from #reference + assert fm["title"] == "Legacy Note" + assert fm["tags"] == ["omi", "reference"] + assert fm["timestamp"] == "2026-06-01" + # The legacy ## Metadata body is kept so un-upgraded peers still parse it. + assert "## Metadata" in md + + +def test_external_okf_note_dual_read() -> None: + """A frontmatter-only note (no ## Metadata) is read via the frontmatter.""" + f = parse_note(OKF_NOTE) + assert f.okf_type == "Playbook" + assert f.title == "Deploy Guide" + assert f.tags == ["ops", "deploy"] + assert f.created == "2026-07-01" + + +def test_unknown_frontmatter_keys_preserved() -> None: + note = "---\ntype: Reference\naliases:\n- Foo\ncssclass: wide\n---\n# N\n\n## Summary\ns\n" + rebuilt = build_okf_frontmatter(parse_note(note)) + fm = parse_frontmatter(rebuilt) + assert fm["type"] == "Reference" + assert fm["aliases"] == ["Foo"] # unknown producer key round-trips + assert fm["cssclass"] == "wide" + + +def test_render_round_trip_stable() -> None: + once = render_fields(parse_note(LEGACY_NOTE)) + twice = render_fields(parse_note(once)) + assert once == twice + + +# -- conformance check ------------------------------------------------------------ + + +def test_check_flags_legacy(store: OmiStore) -> None: + (store.omi_dir / "Legacy Note.md").write_text(LEGACY_NOTE, encoding="utf-8") + report = okf.check_conformance(store.omi_dir) + assert not report.ok + assert report.concepts == 1 + assert report.conformant == 0 + assert "no parseable YAML frontmatter" in report.problems[0].problem + + +def test_check_passes_okf(store: OmiStore) -> None: + (store.omi_dir / "Deploy Guide.md").write_text(OKF_NOTE, encoding="utf-8") + report = okf.check_conformance(store.omi_dir) + assert report.ok + assert report.conformant == 1 + + +def test_check_skips_reserved_scaffolding(store: OmiStore) -> None: + for name in ("index.md", "MEMORY.md", "Memory Template.md"): + (store.omi_dir / name).write_text("# scaffolding, no frontmatter\n", encoding="utf-8") + report = okf.check_conformance(store.omi_dir) + assert report.concepts == 0 + assert report.ok + + +def test_missing_type_is_non_conformant(store: OmiStore) -> None: + (store.omi_dir / "No Type.md").write_text("---\ntitle: X\n---\n# X\n", encoding="utf-8") + report = okf.check_conformance(store.omi_dir) + assert not report.ok + assert "type" in report.problems[0].problem + + +def test_unterminated_frontmatter_is_non_conformant(store: OmiStore) -> None: + (store.omi_dir / "Bad.md").write_text("---\ntype: X\n# never closed\n", encoding="utf-8") + report = okf.check_conformance(store.omi_dir) + assert not report.ok + + +# -- conversion ------------------------------------------------------------------- + + +def test_convert_makes_vault_conformant(store: OmiStore) -> None: + (store.omi_dir / "Legacy Note.md").write_text(LEGACY_NOTE, encoding="utf-8") + result = okf.convert_vault(store.omi_dir) + assert result.converted == 1 + assert result.report.ok + text = (store.omi_dir / "Legacy Note.md").read_text(encoding="utf-8") + assert text.startswith("---\n") + assert "\ntype: Reference" in text + assert "## Summary" in text and "[[Other Note]]" in text # legacy body preserved + + +def test_convert_is_idempotent(store: OmiStore) -> None: + (store.omi_dir / "Legacy Note.md").write_text(LEGACY_NOTE, encoding="utf-8") + okf.convert_vault(store.omi_dir) + second = okf.convert_vault(store.omi_dir) + assert second.converted == 0 + assert second.unchanged == 1 + assert second.report.ok + + +def test_convert_dry_run_writes_nothing(store: OmiStore) -> None: + path = store.omi_dir / "Legacy Note.md" + path.write_text(LEGACY_NOTE, encoding="utf-8") + before = path.read_text(encoding="utf-8") + result = okf.convert_vault(store.omi_dir, dry_run=True) + assert result.converted == 1 + assert path.read_text(encoding="utf-8") == before # unchanged on disk + + +# -- CLI -------------------------------------------------------------------------- + + +def test_cli_convert_and_check(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + omi = tmp_path / "OMI" + omi.mkdir() + (omi / "Legacy Note.md").write_text(LEGACY_NOTE, encoding="utf-8") + common = ["--vault", str(tmp_path), "--folder", "OMI"] + + assert main(["convert", "--check", *common]) == 1 # legacy: non-conformant + capsys.readouterr() + assert main(["convert", *common]) == 0 + assert "converted 1" in capsys.readouterr().out + assert main(["convert", "--check", *common]) == 0 # now conformant diff --git a/tests/test_store.py b/tests/test_store.py index 0308559..f07252a 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -46,7 +46,13 @@ def test_render_parse_round_trip() -> None: ], references=["Source: somewhere", "https://example.com"], ) - parsed = parse_note(render_fields(fields)) + rendered = render_fields(fields) + parsed = parse_note(rendered) + # Rendering now generates an OKF frontmatter projection and derives a + # ``type`` when absent, so normalize those two generated fields; every other + # structured field must survive the round-trip exactly. + fields.okf_type = parsed.okf_type + fields.frontmatter = parsed.frontmatter assert parsed == fields @@ -728,7 +734,10 @@ def test_yaml_frontmatter_survives_edit(store: OmiStore) -> None: name = store.write_note("Kept Note.md", raw) store.update_note(name, NoteFields(title="Kept Note", summary="new summary")) after = store.read_note(name) - assert "tags: [alpha, beta]" in after + reparsed = parse_note(after) + # Tags authored in frontmatter survive the edit (now block-style YAML, + # mirrored into ## Metadata); the unrelated Obsidian property is preserved. + assert set(reparsed.tags) >= {"alpha", "beta"} assert "aliases:" in after assert "lead prose before the first section." in after assert "new summary" in after