From 7c11d107dd961009d3ad5d91d28e6441cc224a63 Mon Sep 17 00:00:00 2001 From: Aldominguez12 <191896285+Aldominguez12@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:08:42 +0200 Subject: [PATCH 1/2] feat(cli): add migrate-images for KBs ingested before note-relative links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #181: already-ingested KBs keep wiki-root-relative sources/images/... links in their wiki/sources/*.md pages, which render broken in Obsidian, GitHub, and VS Code. `openkb migrate-images` rewrites them to the note-relative images/... form now emitted at ingest (`--dry-run` to preview). Scope matches the writers #181 changed: only .md files directly under wiki/sources/ — long-doc JSON metadata intentionally keeps wiki-root-relative paths, and pages outside sources/ never carried the old prefix. Idempotent, takes the ingest lock, and logs to wiki/log.md. Verified against two real pre-#181 KBs (32 files, 2,061 links): output is byte-identical to a hand-verified migration, and a second run is a no-op. Co-Authored-By: Claude Fable 5 --- README.md | 1 + openkb/cli.py | 41 +++++++++++ openkb/migrate.py | 57 ++++++++++++++++ tests/test_migrate.py | 153 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 252 insertions(+) create mode 100644 openkb/migrate.py create mode 100644 tests/test_migrate.py diff --git a/README.md b/README.md index 0fecbe11..2f55acb2 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,7 @@ OpenKB commands fall into two layers: the **wiki foundation** (compile + manage | openkb remove <doc> | Remove a document and clean up its wiki pages, images, registry, and PageIndex state (`--dry-run` to preview, `--keep-raw` / `--keep-empty` to retain artifacts) | | openkb recompile [<doc>] [--all] | Re-run the compile pipeline on already-indexed docs without re-indexing. Regenerates summaries and rewrites concept pages; manual edits are overwritten (`--dry-run` to preview, `--refresh-schema` to also update `wiki/AGENTS.md`) | | openkb feedback ["msg"] | File feedback by opening a prefilled GitHub issue (`--type bug/feature/question` to tag it) | +| `openkb migrate-images` | One-time migration for KBs ingested before the note-relative image-link change (#181): rewrite old image links in sources pages so they render in Obsidian/GitHub/VS Code (`--dry-run` to preview) | diff --git a/openkb/cli.py b/openkb/cli.py index 6a37350a..e4ebc2ef 100644 --- a/openkb/cli.py +++ b/openkb/cli.py @@ -1962,6 +1962,47 @@ def lint(ctx, fix): asyncio.run(run_lint(kb_dir)) +@cli.command("migrate-images") +@click.option( + "--dry-run", + is_flag=True, + default=False, + help="Report the files and link counts that would change without writing.", +) +@click.pass_context +def migrate_images(ctx, dry_run): + """Migrate old wiki-root-relative image links in sources pages. + + KBs ingested before the note-relative image-link change embedded + images in wiki/sources/.md with ``sources/images/...`` links, + which render broken in Obsidian, GitHub, and VS Code. This one-time + migration rewrites them to the ``images/...`` form now emitted at + ingest. Idempotent — re-running finds nothing to do. + """ + kb_dir = _find_kb_dir(ctx.obj.get("kb_dir_override")) + if kb_dir is None: + click.echo("No knowledge base found. Run `openkb init` first.") + return + from openkb.migrate import migrate_source_image_links + + wiki = kb_dir / "wiki" + if dry_run: + changed = migrate_source_image_links(wiki, dry_run=True) + else: + with kb_ingest_lock(kb_dir / ".openkb"): + changed = migrate_source_image_links(wiki) + if not changed: + click.echo("Nothing to migrate — sources image links are already note-relative.") + return + for path, count in changed: + click.echo(f" {path.relative_to(wiki)}: {count} link(s)") + total = sum(count for _, count in changed) + verb = "Would rewrite" if dry_run else "Rewrote" + click.echo(f"{verb} {total} image link(s) across {len(changed)} file(s).") + if not dry_run: + append_log(wiki, "migrate-images", f"{total} link(s) across {len(changed)} file(s)") + + @cli.command() @click.option( "--open/--no-open", diff --git a/openkb/migrate.py b/openkb/migrate.py new file mode 100644 index 00000000..e294e743 --- /dev/null +++ b/openkb/migrate.py @@ -0,0 +1,57 @@ +"""One-time migrations for knowledge bases created by older OpenKB versions.""" + +from __future__ import annotations + +import re +from pathlib import Path + +from openkb.locks import atomic_write_text + +# Inline-link opener followed by the old wiki-root-relative image prefix. +# Only the prefix is swapped (`](sources/images/` → `](images/`), so the +# path tail up to the closing paren never needs parsing, and image embeds +# (`![alt](...)`) and plain links are handled alike. +_OLD_IMAGE_PREFIX_RE = re.compile(r"\]\(sources/images/") + + +def migrate_source_image_links(wiki: Path, *, dry_run: bool = False) -> list[tuple[Path, int]]: + """Rewrite wiki-root-relative image links in sources pages to note-relative. + + KBs ingested before the ``md_image_ref()`` change embedded images in + ``wiki/sources/.md`` as ``![alt](sources/images//)``. + Renderers resolve links relative to the containing file, so from a + sources page those pointed at the non-existent + ``sources/sources/images/...`` and rendered broken. This rewrites them + to the ``images//`` form now emitted at ingest. + + Only ``.md`` files directly under ``wiki/sources/`` are touched — the + per-page JSON of long documents intentionally keeps wiki-root-relative + paths (internal metadata, resolved against the wiki root), and pages + outside ``sources/`` never carried the old prefix. Idempotent: already + note-relative links don't match the old prefix. + + Args: + wiki: Path to the wiki root directory. + dry_run: When True, report what would change without writing. + + Returns: + List of ``(path, links_rewritten)`` for each file that changed + (or would change, under ``dry_run``). + """ + sources = wiki / "sources" + changed: list[tuple[Path, int]] = [] + if not sources.is_dir(): + return changed + + for md in sorted(sources.glob("*.md")): + try: + text = md.read_text(encoding="utf-8") + except OSError: + continue + migrated, count = _OLD_IMAGE_PREFIX_RE.subn("](images/", text) + if count == 0: + continue + if not dry_run: + atomic_write_text(md, migrated) + changed.append((md, count)) + return changed diff --git a/tests/test_migrate.py b/tests/test_migrate.py new file mode 100644 index 00000000..124f4f92 --- /dev/null +++ b/tests/test_migrate.py @@ -0,0 +1,153 @@ +"""Tests for openkb.migrate and the migrate-images CLI command.""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import patch + +from click.testing import CliRunner + +from openkb.cli import cli +from openkb.migrate import migrate_source_image_links + +OLD_PAGE = ( + "# Doc\n\n" + "Intro text.\n\n" + "![figure 1](sources/images/doc/p1_img1.png)\n\n" + "More text with an inline ![icon](sources/images/doc/p2_img2.png) here.\n" +) + +MIGRATED_PAGE = ( + "# Doc\n\n" + "Intro text.\n\n" + "![figure 1](images/doc/p1_img1.png)\n\n" + "More text with an inline ![icon](images/doc/p2_img2.png) here.\n" +) + + +def _make_wiki(tmp_path: Path) -> Path: + wiki = tmp_path / "wiki" + (wiki / "sources" / "images").mkdir(parents=True) + (wiki / "summaries").mkdir(parents=True) + return wiki + + +class TestMigrateSourceImageLinks: + def test_rewrites_old_links_preserving_alt_and_text(self, tmp_path): + wiki = _make_wiki(tmp_path) + page = wiki / "sources" / "doc.md" + page.write_text(OLD_PAGE, encoding="utf-8") + + changed = migrate_source_image_links(wiki) + + assert changed == [(page, 2)] + assert page.read_text(encoding="utf-8") == MIGRATED_PAGE + + def test_idempotent_on_migrated_page(self, tmp_path): + wiki = _make_wiki(tmp_path) + page = wiki / "sources" / "doc.md" + page.write_text(MIGRATED_PAGE, encoding="utf-8") + + assert migrate_source_image_links(wiki) == [] + assert page.read_text(encoding="utf-8") == MIGRATED_PAGE + + def test_dry_run_reports_without_writing(self, tmp_path): + wiki = _make_wiki(tmp_path) + page = wiki / "sources" / "doc.md" + page.write_text(OLD_PAGE, encoding="utf-8") + + changed = migrate_source_image_links(wiki, dry_run=True) + + assert changed == [(page, 2)] + assert page.read_text(encoding="utf-8") == OLD_PAGE + + def test_long_doc_json_left_untouched(self, tmp_path): + # Per-page JSON keeps wiki-root-relative paths by design. + wiki = _make_wiki(tmp_path) + payload = json.dumps([{"page": 1, "content": "![image](sources/images/doc/p1_img1.png)"}]) + doc_json = wiki / "sources" / "doc.json" + doc_json.write_text(payload, encoding="utf-8") + + assert migrate_source_image_links(wiki) == [] + assert doc_json.read_text(encoding="utf-8") == payload + + def test_pages_outside_sources_left_untouched(self, tmp_path): + # Note-relative resolution differs outside sources/ — out of scope. + wiki = _make_wiki(tmp_path) + summary = wiki / "summaries" / "doc.md" + summary.write_text(OLD_PAGE, encoding="utf-8") + + assert migrate_source_image_links(wiki) == [] + assert summary.read_text(encoding="utf-8") == OLD_PAGE + + def test_missing_sources_dir_is_noop(self, tmp_path): + wiki = tmp_path / "wiki" + wiki.mkdir() + + assert migrate_source_image_links(wiki) == [] + + def test_multiple_files_sorted(self, tmp_path): + wiki = _make_wiki(tmp_path) + page_b = wiki / "sources" / "b.md" + page_a = wiki / "sources" / "a.md" + page_b.write_text("![x](sources/images/b/i.png)", encoding="utf-8") + page_a.write_text(OLD_PAGE, encoding="utf-8") + + changed = migrate_source_image_links(wiki) + + assert changed == [(page_a, 2), (page_b, 1)] + + +class TestMigrateImagesCommand: + def _setup_kb(self, tmp_path: Path) -> Path: + kb_dir = tmp_path + (kb_dir / "wiki" / "sources" / "images").mkdir(parents=True) + (kb_dir / ".openkb").mkdir() + return kb_dir + + def test_no_kb(self, tmp_path): + runner = CliRunner() + with ( + runner.isolated_filesystem(temp_dir=tmp_path), + patch("openkb.cli._find_kb_dir", return_value=None), + ): + result = runner.invoke(cli, ["migrate-images"]) + assert result.exit_code == 0 + assert "No knowledge base found" in result.output + + def test_nothing_to_migrate(self, tmp_path): + kb_dir = self._setup_kb(tmp_path) + (kb_dir / "wiki" / "sources" / "doc.md").write_text(MIGRATED_PAGE, encoding="utf-8") + runner = CliRunner() + with patch("openkb.cli._find_kb_dir", return_value=kb_dir): + result = runner.invoke(cli, ["migrate-images"]) + assert result.exit_code == 0 + assert "Nothing to migrate" in result.output + + def test_migrates_and_logs(self, tmp_path): + kb_dir = self._setup_kb(tmp_path) + page = kb_dir / "wiki" / "sources" / "doc.md" + page.write_text(OLD_PAGE, encoding="utf-8") + runner = CliRunner() + with patch("openkb.cli._find_kb_dir", return_value=kb_dir): + result = runner.invoke(cli, ["migrate-images"]) + assert result.exit_code == 0 + assert "Rewrote 2 image link(s) across 1 file(s)." in result.output + assert "doc.md: 2 link(s)" in result.output + assert page.read_text(encoding="utf-8") == MIGRATED_PAGE + log = kb_dir / "wiki" / "log.md" + assert log.exists() + assert "migrate-images" in log.read_text(encoding="utf-8") + + def test_dry_run_does_not_write(self, tmp_path): + kb_dir = self._setup_kb(tmp_path) + page = kb_dir / "wiki" / "sources" / "doc.md" + page.write_text(OLD_PAGE, encoding="utf-8") + runner = CliRunner() + with patch("openkb.cli._find_kb_dir", return_value=kb_dir): + result = runner.invoke(cli, ["migrate-images", "--dry-run"]) + assert result.exit_code == 0 + assert "Would rewrite 2 image link(s) across 1 file(s)." in result.output + assert page.read_text(encoding="utf-8") == OLD_PAGE + assert not (kb_dir / "wiki" / "log.md").exists() From 29da16066dbb01065b6991ae44edc03c94546795 Mon Sep 17 00:00:00 2001 From: Aldominguez12 <191896285+Aldominguez12@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:37:20 +0200 Subject: [PATCH 2/2] fix(cli): keep migrate-images log append inside the ingest lock Codex review flagged that append_log ran after kb_ingest_lock was released, leaving a wiki/log.md write outside the locking invariant. Move it inside the exclusive block, matching the lint command. Co-Authored-By: Claude Fable 5 --- openkb/cli.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openkb/cli.py b/openkb/cli.py index e4ebc2ef..78babd6b 100644 --- a/openkb/cli.py +++ b/openkb/cli.py @@ -1991,6 +1991,9 @@ def migrate_images(ctx, dry_run): else: with kb_ingest_lock(kb_dir / ".openkb"): changed = migrate_source_image_links(wiki) + if changed: + total = sum(count for _, count in changed) + append_log(wiki, "migrate-images", f"{total} link(s) across {len(changed)} file(s)") if not changed: click.echo("Nothing to migrate — sources image links are already note-relative.") return @@ -1999,8 +2002,6 @@ def migrate_images(ctx, dry_run): total = sum(count for _, count in changed) verb = "Would rewrite" if dry_run else "Rewrote" click.echo(f"{verb} {total} image link(s) across {len(changed)} file(s).") - if not dry_run: - append_log(wiki, "migrate-images", f"{total} link(s) across {len(changed)} file(s)") @cli.command()