From 205252549006cbdbd1462b1e804375118a49e1b0 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Sat, 7 Nov 2026 04:10:12 +0100 Subject: [PATCH 1/2] feat(store): archive, import_entry and Denkarium migration for PB-11 (#3994) Additive archive/legacy_id columns with migration on open, archive/unarchive/ delete, import_entry with foreign timestamps (idempotent per legacy_id), denkarium/logbuch aliases and an idempotent dry-run-first migration script. Package version intentionally stays 1.0.5 (release freeze). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01THB2F3JrYiwPBeh8QG8Y9H Worker-Model: claude-sonnet (B8) --- CHANGELOG.md | 4 + llm_note/migrate_denkarium.py | 131 +++++++++++++++++++++++++++++++ llm_note/store.py | 132 +++++++++++++++++++++++++++++++- tests/test_archive_import.py | 119 ++++++++++++++++++++++++++++ tests/test_migrate_denkarium.py | 97 +++++++++++++++++++++++ 5 files changed, 479 insertions(+), 4 deletions(-) create mode 100644 llm_note/migrate_denkarium.py create mode 100644 tests/test_archive_import.py create mode 100644 tests/test_migrate_denkarium.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bf73420..459f9f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +- PB-11 (BACH #3994): `NoteStore` can now replace the BACH Denkarium. Additive columns `is_archived`, `archived_reason`, `archived_at`, `legacy_id` are migrated when an existing database is opened; new `archive()`, `unarchive()`, `delete()`, `import_entry()` (keeps foreign timestamps, idempotent per `legacy_id`); `list_entries()`/`search()` hide archived entries unless `include_archived=True`; entry types accept the aliases `denkarium` (note) and `logbuch` (logbook); `Entry` and `stats()` expose the new fields. +- Added the one-off migration `python -m llm_note.migrate_denkarium SOURCE_DB TARGET_DB` (dry run by default; `--apply --snapshot-dir` snapshots the source table, imports, aborts on a count mismatch; the source table is only read). +- The package version stays frozen at `1.0.5` (see RELEASE_GATE.md); a `1.1.0` bump needs a separate release decision. + - Added vector SVG interface preview for Denkarium Local Web GUI (`assets/denkarium-preview.svg`) and integrated it into Section 8 across all six README localizations. - Corrected REST API endpoint documentation in Section 8 and sequence diagrams in Section 4 across all six README files to reference `/api/entries`. - Hardened `pytest.ini` with `norecursedirs` to exclude `references/` from recursive test collection. diff --git a/llm_note/migrate_denkarium.py b/llm_note/migrate_denkarium.py new file mode 100644 index 0000000..828c50f --- /dev/null +++ b/llm_note/migrate_denkarium.py @@ -0,0 +1,131 @@ +"""One-off, idempotent migration of BACH ``denkarium_entries`` into llm-note. + +Dry run is the default. ``--apply`` first snapshots the source table, then imports +via :meth:`NoteStore.import_entry` (keeps timestamps, remembers the old id as +``legacy_id``) and aborts on a count mismatch. The source table is only read. + + python -m llm_note.migrate_denkarium SOURCE_DB TARGET_DB [--apply --snapshot-dir DIR] +""" + +from __future__ import annotations + +import argparse +import json +import sqlite3 +import sys +from datetime import datetime, timezone +from pathlib import Path + +from .store import ENTRY_TYPE_ALIASES, NoteStore + +_TABLE = "denkarium_entries" +_VALID_TYPES = set(ENTRY_TYPE_ALIASES) | set(ENTRY_TYPE_ALIASES.values()) + + +class MigrationError(RuntimeError): + """Raised when the source cannot be mapped losslessly.""" + + +def _read_source(source_db: Path) -> list[dict]: + conn = sqlite3.connect(f"file:{source_db.as_posix()}?mode=ro", uri=True) + conn.row_factory = sqlite3.Row + try: + return [dict(r) for r in conn.execute(f"SELECT * FROM {_TABLE} ORDER BY id")] + finally: + conn.close() + + +def _snapshot(source_db: Path, snapshot_dir: Path) -> Path: + snapshot_dir.mkdir(parents=True, exist_ok=True) + out = snapshot_dir / f"{_TABLE}_{datetime.now(timezone.utc):%Y%m%d-%H%M%S-%f}.db" + src = sqlite3.connect(f"file:{source_db.as_posix()}?mode=ro", uri=True) + dst = sqlite3.connect(out) + try: + rows = src.execute(f"SELECT * FROM {_TABLE}") + cols = [d[0] for d in rows.description] + dst.execute(f"CREATE TABLE {_TABLE} ({', '.join(cols)})") + dst.executemany( + f"INSERT INTO {_TABLE} VALUES ({', '.join('?' * len(cols))})", rows.fetchall() + ) + dst.commit() + finally: + src.close() + dst.close() + return out + + +def _kwargs(row: dict) -> dict: + if row.get("entry_type") not in _VALID_TYPES: + raise MigrationError(f"id {row['id']}: unknown entry_type {row.get('entry_type')!r}") + if not row.get("content"): + raise MigrationError(f"id {row['id']}: empty content") + mood = row.get("mood") + return { + "entry_type": row["entry_type"], + "category": row.get("category") or "note", + "title": row.get("title"), + "mood": mood if isinstance(mood, int) and 1 <= mood <= 5 else None, # 0 -> NULL + "source": row.get("source") or "user", + "tags": row.get("tags"), + "promoted_to": row.get("promoted_to"), + "promoted_id": row.get("promoted_id"), + "created_at": row.get("created_at"), + "updated_at": row.get("updated_at"), + "legacy_id": row["id"], + "is_archived": bool(row.get("is_archived")), + "archived_reason": row.get("archived_reason"), + "archived_at": row.get("archived_at"), + } + + +def migrate( + source_db: str | Path, + target_db: str | Path, + *, + apply: bool = False, + snapshot_dir: str | Path | None = None, +) -> dict: + source_db, target_db = Path(source_db), Path(target_db) + rows = _read_source(source_db) + prepared = [(r["content"], _kwargs(r)) for r in rows] # preflight: all or nothing + report: dict = {"dry_run": not apply, "source_count": len(rows), "imported": 0} + if not apply: + return report + if snapshot_dir is None: + raise MigrationError("apply requires a snapshot_dir (snapshot of the source first)") + report["snapshot"] = str(_snapshot(source_db, Path(snapshot_dir))) + + store = NoteStore(target_db) + # ponytail: list limit 1000 (MAX_QUERY_LIMIT); Denkarium has ~14 rows, add paging if ever larger + known = {e.legacy_id for e in store.list_entries(include_archived=True, limit=1000)} + mapping: dict[str, int] = {} + for content, kw in prepared: + entry = store.import_entry(content, **kw) + mapping[str(kw["legacy_id"])] = entry.id + report["imported"] += kw["legacy_id"] not in known + mig_ids = {e.legacy_id for e in store.list_entries(include_archived=True, limit=1000)} + report["mapping"] = mapping + report["target_count"] = len(mig_ids & {r["id"] for r in rows}) + if report["target_count"] != len(rows): + raise MigrationError(f"count mismatch: source {len(rows)} != target {report['target_count']}") + return report + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("source_db") + ap.add_argument("target_db") + ap.add_argument("--apply", action="store_true", help="really write (default: dry run)") + ap.add_argument("--snapshot-dir") + args = ap.parse_args(argv) + try: + report = migrate(args.source_db, args.target_db, apply=args.apply, snapshot_dir=args.snapshot_dir) + except MigrationError as exc: + print(f"ABORT: {exc}", file=sys.stderr) + return 2 + print(json.dumps(report, indent=2, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/llm_note/store.py b/llm_note/store.py index 4d96b9c..f94be15 100644 --- a/llm_note/store.py +++ b/llm_note/store.py @@ -16,6 +16,14 @@ from uuid import uuid4 MAX_QUERY_LIMIT = 1000 +# BACH Denkarium vocabulary -> llm-note vocabulary +ENTRY_TYPE_ALIASES = {"denkarium": "note", "logbuch": "logbook"} +_ARCHIVE_COLUMNS = { + "is_archived": "INTEGER DEFAULT 0", + "archived_reason": "TEXT", + "archived_at": "TEXT", + "legacy_id": "INTEGER", +} _WINDOWS_RESERVED_NAMES = { "CON", "PRN", @@ -30,6 +38,14 @@ ) +def _norm_type(entry_type: str | None) -> str | None: + return ENTRY_TYPE_ALIASES.get(entry_type, entry_type) if entry_type else entry_type + + +def _now() -> str: + return datetime.now().astimezone().strftime("%Y-%m-%d %H:%M") + + def _validate_limit(limit: int) -> int: if isinstance(limit, bool) or not isinstance(limit, int): raise TypeError("limit must be an integer") @@ -114,6 +130,14 @@ class Entry: mood: int | None = None promoted_to: str | None = None created_at: str | None = None + tags: str | None = None + source: str | None = None + updated_at: str | None = None + is_archived: bool = False + archived_reason: str | None = None + archived_at: str | None = None + legacy_id: int | None = None + promoted_id: int | None = None class NoteStore: @@ -158,6 +182,10 @@ def _init_schema(self) -> None: ) """ ) + have = {r["name"] for r in conn.execute("PRAGMA table_info(note_entries)")} + for name, ddl in _ARCHIVE_COLUMNS.items(): + if name not in have: + conn.execute(f"ALTER TABLE note_entries ADD COLUMN {name} {ddl}") conn.execute( "CREATE INDEX IF NOT EXISTS idx_note_type ON note_entries(entry_type)" ) @@ -178,7 +206,8 @@ def write( mood: int | None = None, source: str = "user", ) -> Entry: - now = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M") + now = _now() + entry_type = _norm_type(entry_type) if entry_type == "logbook" and not title: title = f"Log {now}" @@ -209,14 +238,17 @@ def list_entries( entry_type: str | None = None, category: str | None = None, limit: int = 10, + include_archived: bool = False, ) -> list[Entry]: limit = _validate_limit(limit) query = "SELECT * FROM note_entries" where: list[str] = [] params: list[object] = [] + if not include_archived: + where.append("COALESCE(is_archived, 0) = 0") if entry_type: where.append("entry_type = ?") - params.append(entry_type) + params.append(_norm_type(entry_type)) if category: where.append("category = ?") params.append(category) @@ -235,6 +267,7 @@ def search( entry_type: str | None = None, category: str | None = None, limit: int = 20, + include_archived: bool = False, ) -> list[Entry]: limit = _validate_limit(limit) escaped_term = ( @@ -250,9 +283,11 @@ def search( ) ] params: list[object] = [pattern, pattern, pattern, pattern] + if not include_archived: + where.append("COALESCE(is_archived, 0) = 0") if entry_type: where.append("entry_type = ?") - params.append(entry_type) + params.append(_norm_type(entry_type)) if category: where.append("category = ?") params.append(category) @@ -266,6 +301,79 @@ def search( ).fetchall() return [self._row_to_entry(row) for row in rows] + def import_entry( + self, + content: str, + *, + entry_type: str = "note", + category: str = "note", + title: str | None = None, + mood: int | None = None, + source: str = "user", + tags: str | None = None, + promoted_to: str | None = None, + promoted_id: int | None = None, + created_at: str | None = None, + updated_at: str | None = None, + legacy_id: int | None = None, + is_archived: bool = False, + archived_reason: str | None = None, + archived_at: str | None = None, + ) -> Entry: + """Insert an entry keeping foreign timestamps; idempotent per ``legacy_id``.""" + + with self._connect() as conn: + if legacy_id is not None: + row = conn.execute( + "SELECT id FROM note_entries WHERE legacy_id = ?", (legacy_id,) + ).fetchone() + if row is not None: + return self.get(int(row["id"])) + cur = conn.execute( + """ + INSERT INTO note_entries + (entry_type, title, content, category, tags, source, mood, + promoted_to, promoted_id, created_at, updated_at, legacy_id, + is_archived, archived_reason, archived_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + _norm_type(entry_type), title, content, category, tags, source, + mood, promoted_to, promoted_id, created_at or _now(), updated_at, + legacy_id, int(is_archived), archived_reason, archived_at, + ), + ) + entry_id = int(cur.lastrowid) + return self.get(entry_id) + + def archive(self, entry_id: int, reason: str | None = None) -> Entry: + self.get(entry_id) # KeyError if unknown + with self._connect() as conn: + conn.execute( + "UPDATE note_entries SET is_archived = 1, archived_reason = ?," + " archived_at = ? WHERE id = ?", + (reason, _now(), entry_id), + ) + return self.get(entry_id) + + def unarchive(self, entry_id: int) -> Entry: + self.get(entry_id) + with self._connect() as conn: + conn.execute( + "UPDATE note_entries SET is_archived = 0, archived_reason = NULL," + " archived_at = NULL WHERE id = ?", + (entry_id,), + ) + return self.get(entry_id) + + def delete(self, entry_id: int) -> None: + with self._connect() as conn: + deleted = conn.execute( + "DELETE FROM note_entries WHERE id = ?", (entry_id,) + ).rowcount + if deleted == 0: + raise KeyError(f"Entry not found: {entry_id}") + def brainstorm(self, topic: str) -> Entry: return self.write( f"Topic: {topic}\n\nIdeas:\n- ", @@ -294,7 +402,15 @@ def stats(self) -> dict[str, int]: logbook = conn.execute( "SELECT COUNT(*) FROM note_entries WHERE entry_type = 'logbook'" ).fetchone()[0] - return {"total": int(total), "promoted": int(promoted), "logbook": int(logbook)} + archived = conn.execute( + "SELECT COUNT(*) FROM note_entries WHERE COALESCE(is_archived, 0) = 1" + ).fetchone()[0] + return { + "total": int(total), + "promoted": int(promoted), + "logbook": int(logbook), + "archived": int(archived), + } @staticmethod def _row_to_entry(row: sqlite3.Row) -> Entry: @@ -307,6 +423,14 @@ def _row_to_entry(row: sqlite3.Row) -> Entry: mood=row["mood"], promoted_to=row["promoted_to"], created_at=row["created_at"], + tags=row["tags"], + source=row["source"], + updated_at=row["updated_at"], + is_archived=bool(row["is_archived"]), + archived_reason=row["archived_reason"], + archived_at=row["archived_at"], + legacy_id=row["legacy_id"], + promoted_id=row["promoted_id"], ) diff --git a/tests/test_archive_import.py b/tests/test_archive_import.py new file mode 100644 index 0000000..895e3cc --- /dev/null +++ b/tests/test_archive_import.py @@ -0,0 +1,119 @@ +"""PB-11: archive, import with foreign timestamps and legacy migration.""" + +import sqlite3 +from pathlib import Path + +import pytest + +from llm_note import NoteStore + +OLD_SCHEMA = """ +CREATE TABLE note_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entry_type TEXT NOT NULL DEFAULT 'note', + title TEXT, content TEXT NOT NULL, category TEXT DEFAULT 'note', + tags TEXT, source TEXT DEFAULT 'user', mood INTEGER, + promoted_to TEXT, promoted_id INTEGER, + created_at TEXT DEFAULT (datetime('now', 'localtime')), updated_at TEXT +) +""" + + +def test_archive_hides_entry_and_unarchive_restores(tmp_path: Path) -> None: + store = NoteStore(tmp_path / "n.db") + keep = store.write("keep") + gone = store.write("gone") + + archived = store.archive(gone.id, "stale") + + assert archived.is_archived is True + assert archived.archived_reason == "stale" + assert archived.archived_at + assert [e.id for e in store.list_entries()] == [keep.id] + assert {e.id for e in store.list_entries(include_archived=True)} == {keep.id, gone.id} + assert store.search("gone") == [] + assert [e.id for e in store.search("gone", include_archived=True)] == [gone.id] + assert store.stats()["archived"] == 1 + + restored = store.unarchive(gone.id) + assert restored.is_archived is False + assert restored.archived_reason is None + assert {e.id for e in store.list_entries()} == {keep.id, gone.id} + + +def test_delete_removes_entry_and_unknown_id_raises(tmp_path: Path) -> None: + store = NoteStore(tmp_path / "n.db") + entry = store.write("x") + store.delete(entry.id) + with pytest.raises(KeyError): + store.get(entry.id) + with pytest.raises(KeyError): + store.delete(entry.id) + with pytest.raises(KeyError): + store.archive(999) + + +def test_import_entry_keeps_foreign_timestamps_and_legacy_id(tmp_path: Path) -> None: + store = NoteStore(tmp_path / "n.db") + entry = store.import_entry( + "old thought", + entry_type="denkarium", + category="notiz", + created_at="2026-02-19 08:15", + updated_at="2026-02-20 09:00", + legacy_id=58, + tags="a,b", + ) + assert entry.entry_type == "note" # alias denkarium -> note + assert entry.created_at == "2026-02-19 08:15" + assert entry.updated_at == "2026-02-20 09:00" + assert entry.legacy_id == 58 + assert entry.tags == "a,b" + + +def test_import_entry_is_idempotent_per_legacy_id(tmp_path: Path) -> None: + store = NoteStore(tmp_path / "n.db") + first = store.import_entry("a", legacy_id=7, created_at="2026-01-01 00:00") + again = store.import_entry("a", legacy_id=7, created_at="2026-01-01 00:00") + assert again.id == first.id + assert store.stats()["total"] == 1 + + +def test_import_entry_can_import_archived_state(tmp_path: Path) -> None: + store = NoteStore(tmp_path / "n.db") + entry = store.import_entry( + "z", legacy_id=1, is_archived=True, archived_reason="r", archived_at="2026-03-01 10:00" + ) + assert entry.is_archived and entry.archived_at == "2026-03-01 10:00" + assert store.list_entries() == [] + + +def test_type_aliases_in_write_list_and_search(tmp_path: Path) -> None: + store = NoteStore(tmp_path / "n.db") + store.write("alpha", entry_type="logbuch") + store.write("beta", entry_type="denkarium") + assert [e.content for e in store.list_entries(entry_type="logbuch")] == ["alpha"] + assert [e.content for e in store.list_entries(entry_type="denkarium")] == ["beta"] + assert [e.content for e in store.search("alpha", entry_type="logbuch")] == ["alpha"] + assert store.get(1).entry_type == "logbook" + + +def test_old_database_without_new_columns_is_migrated_on_open(tmp_path: Path) -> None: + db = tmp_path / "old.db" + conn = sqlite3.connect(db) + conn.execute(OLD_SCHEMA) + conn.execute( + "INSERT INTO note_entries (entry_type, content, created_at) VALUES ('note', 'legacy row', '2026-01-02 03:04')" + ) + conn.commit() + conn.close() + + store = NoteStore(db) + + rows = store.list_entries() + assert [e.content for e in rows] == ["legacy row"] + assert rows[0].is_archived is False + assert rows[0].created_at == "2026-01-02 03:04" + store.archive(rows[0].id) + assert store.list_entries() == [] + NoteStore(db) # second open must not fail (idempotent migration) diff --git a/tests/test_migrate_denkarium.py b/tests/test_migrate_denkarium.py new file mode 100644 index 0000000..aa735f3 --- /dev/null +++ b/tests/test_migrate_denkarium.py @@ -0,0 +1,97 @@ +"""PB-11: one-off migration BACH denkarium_entries -> llm-note (synthetic data only).""" + +import sqlite3 +from pathlib import Path + +import pytest + +from llm_note import NoteStore +from llm_note.migrate_denkarium import MigrationError, migrate + +SRC_SCHEMA = """ +CREATE TABLE denkarium_entries ( + id INTEGER PRIMARY KEY, entry_type TEXT, title TEXT, content TEXT, + category TEXT, source TEXT, mood INTEGER, promoted_to TEXT, promoted_id INTEGER, + created_at TEXT, updated_at TEXT, is_archived INTEGER DEFAULT 0, + archived_reason TEXT, archived_at TEXT +) +""" + + +def make_source(path: Path, rows: list[tuple]) -> Path: + conn = sqlite3.connect(path) + conn.execute(SRC_SCHEMA) + conn.executemany( + "INSERT INTO denkarium_entries VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)", rows + ) + conn.commit() + conn.close() + return path + + +ROWS = [ + (10, "denkarium", None, "first", "notiz", "user", 0, None, None, "2026-02-19 08:00", None, 0, None, None), + (11, "logbuch", "Log", "second", "test", "user", 4, "task", 5, "2026-03-01 09:30", "2026-03-02 10:00", 0, None, None), + (12, "denkarium", "T", "third", "brainstorm", "user", None, None, None, "2026-09-15 12:00", None, 1, "weg", "2026-09-16 08:00"), +] + + +def test_dry_run_is_default_and_writes_nothing(tmp_path: Path) -> None: + src = make_source(tmp_path / "source.db", ROWS) + target = tmp_path / "llm-note.db" + + report = migrate(src, target) + + assert report["dry_run"] is True + assert report["source_count"] == 3 + assert not target.exists() + + +def test_apply_keeps_dates_types_mood_archive_and_mapping(tmp_path: Path) -> None: + src = make_source(tmp_path / "source.db", ROWS) + target = tmp_path / "llm-note.db" + + report = migrate(src, target, apply=True, snapshot_dir=tmp_path / "snap") + + store = NoteStore(target) + entries = {e.legacy_id: e for e in store.list_entries(include_archived=True, limit=100)} + assert set(entries) == {10, 11, 12} + assert entries[10].created_at == "2026-02-19 08:00" + assert entries[10].entry_type == "note" and entries[10].mood is None # 0 -> NULL + assert entries[11].entry_type == "logbook" and entries[11].mood == 4 + assert entries[11].promoted_to == "task" and entries[11].promoted_id == 5 + assert entries[12].is_archived and entries[12].archived_reason == "weg" + assert report["mapping"] == {str(k): v.id for k, v in entries.items()} + assert report["target_count"] == 3 + assert list((tmp_path / "snap").glob("denkarium_entries_*.db")) + + +def test_apply_is_idempotent(tmp_path: Path) -> None: + src = make_source(tmp_path / "source.db", ROWS) + target = tmp_path / "llm-note.db" + migrate(src, target, apply=True, snapshot_dir=tmp_path / "snap") + again = migrate(src, target, apply=True, snapshot_dir=tmp_path / "snap") + assert again["imported"] == 0 and again["target_count"] == 3 + + +def test_source_table_is_left_untouched(tmp_path: Path) -> None: + src = make_source(tmp_path / "source.db", ROWS) + migrate(src, tmp_path / "t.db", apply=True, snapshot_dir=tmp_path / "snap") + conn = sqlite3.connect(src) + assert conn.execute("SELECT COUNT(*) FROM denkarium_entries").fetchone()[0] == 3 + conn.close() + + +def test_unmappable_row_aborts_before_any_write(tmp_path: Path) -> None: + bad = ROWS + [(13, "unbekannt", None, "x", "n", "user", None, None, None, "2026-01-01 00:00", None, 0, None, None)] + src = make_source(tmp_path / "source.db", bad) + target = tmp_path / "llm-note.db" + with pytest.raises(MigrationError): + migrate(src, target, apply=True, snapshot_dir=tmp_path / "snap") + assert not target.exists() + + +def test_apply_requires_snapshot_dir(tmp_path: Path) -> None: + src = make_source(tmp_path / "source.db", ROWS) + with pytest.raises(MigrationError, match="snapshot"): + migrate(src, tmp_path / "t.db", apply=True) From 0bde16bc5f1320ad4dc4f971d1dd4d66dc5b8666 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Sat, 7 Nov 2026 04:13:06 +0100 Subject: [PATCH 2/2] feat(store): promote() can record promoted_id (PB-11, #3994) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01THB2F3JrYiwPBeh8QG8Y9H Worker-Model: claude-sonnet (B8) --- CHANGELOG.md | 2 +- llm_note/store.py | 9 ++++++--- tests/test_archive_import.py | 8 ++++++++ 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 459f9f1..a58049d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## [Unreleased] -- PB-11 (BACH #3994): `NoteStore` can now replace the BACH Denkarium. Additive columns `is_archived`, `archived_reason`, `archived_at`, `legacy_id` are migrated when an existing database is opened; new `archive()`, `unarchive()`, `delete()`, `import_entry()` (keeps foreign timestamps, idempotent per `legacy_id`); `list_entries()`/`search()` hide archived entries unless `include_archived=True`; entry types accept the aliases `denkarium` (note) and `logbuch` (logbook); `Entry` and `stats()` expose the new fields. +- PB-11 (BACH #3994): `NoteStore` can now replace the BACH Denkarium. Additive columns `is_archived`, `archived_reason`, `archived_at`, `legacy_id` are migrated when an existing database is opened; new `archive()`, `unarchive()`, `delete()`, `import_entry()` (keeps foreign timestamps, idempotent per `legacy_id`); `list_entries()`/`search()` hide archived entries unless `include_archived=True`; entry types accept the aliases `denkarium` (note) and `logbuch` (logbook); `Entry` and `stats()` expose the new fields; `promote()` can record `promoted_id`. - Added the one-off migration `python -m llm_note.migrate_denkarium SOURCE_DB TARGET_DB` (dry run by default; `--apply --snapshot-dir` snapshots the source table, imports, aborts on a count mismatch; the source table is only read). - The package version stays frozen at `1.0.5` (see RELEASE_GATE.md); a `1.1.0` bump needs a separate release decision. diff --git a/llm_note/store.py b/llm_note/store.py index f94be15..21455e2 100644 --- a/llm_note/store.py +++ b/llm_note/store.py @@ -382,14 +382,17 @@ def brainstorm(self, topic: str) -> Entry: title=f"Brainstorm: {topic}", ) - def promote(self, entry_id: int, target: str) -> Entry: + def promote( + self, entry_id: int, target: str, promoted_id: int | None = None + ) -> Entry: if target not in {"task", "wiki", "issue"}: raise ValueError("target must be task, wiki, or issue") now = datetime.now().astimezone().isoformat(timespec="seconds") with self._connect() as conn: conn.execute( - "UPDATE note_entries SET promoted_to = ?, updated_at = ? WHERE id = ?", - (target, now, entry_id), + "UPDATE note_entries SET promoted_to = ?," + " promoted_id = COALESCE(?, promoted_id), updated_at = ? WHERE id = ?", + (target, promoted_id, now, entry_id), ) return self.get(entry_id) diff --git a/tests/test_archive_import.py b/tests/test_archive_import.py index 895e3cc..c805460 100644 --- a/tests/test_archive_import.py +++ b/tests/test_archive_import.py @@ -117,3 +117,11 @@ def test_old_database_without_new_columns_is_migrated_on_open(tmp_path: Path) -> store.archive(rows[0].id) assert store.list_entries() == [] NoteStore(db) # second open must not fail (idempotent migration) + + +def test_promote_can_record_target_id(tmp_path: Path) -> None: + store = NoteStore(tmp_path / "n.db") + entry = store.write("promote me") + promoted = store.promote(entry.id, "task", promoted_id=42) + assert promoted.promoted_to == "task" and promoted.promoted_id == 42 + assert store.promote(entry.id, "wiki").promoted_id == 42 # not overwritten when omitted