Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ OpenKB commands fall into two layers: the **wiki foundation** (compile + manage
| <code>openkb&nbsp;remove&nbsp;&lt;doc&gt;</code> | 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) |
| <code>openkb&nbsp;recompile&nbsp;[&lt;doc&gt;]&nbsp;[--all]</code> | 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`) |
| <code>openkb&nbsp;feedback&nbsp;["msg"]</code> | 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) |

</details>

Expand Down
42 changes: 42 additions & 0 deletions openkb/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1962,6 +1962,48 @@ 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/<doc>.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 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
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).")


@cli.command()
@click.option(
"--open/--no-open",
Expand Down
57 changes: 57 additions & 0 deletions openkb/migrate.py
Original file line number Diff line number Diff line change
@@ -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/<doc>.md`` as ``![alt](sources/images/<doc>/<file>)``.
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/<doc>/<file>`` 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
153 changes: 153 additions & 0 deletions tests/test_migrate.py
Original file line number Diff line number Diff line change
@@ -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()
Loading