Add versioned glossary YAML import and export
This commit is contained in:
@@ -35,6 +35,18 @@ class GlossaryEntry:
|
||||
updated_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GlossaryReplacementEntry:
|
||||
"""Validated values used to atomically replace the persisted glossary."""
|
||||
|
||||
id: int
|
||||
canonical_term: str
|
||||
category: str
|
||||
description: str | None
|
||||
is_active: bool
|
||||
aliases: tuple[str, ...]
|
||||
|
||||
|
||||
class GlossaryRepository:
|
||||
"""Small data-access boundary for the local glossary database."""
|
||||
|
||||
@@ -194,6 +206,64 @@ class GlossaryRepository:
|
||||
if cursor.rowcount == 0:
|
||||
raise KeyError(f"Unknown glossary entry: {entry_id}")
|
||||
|
||||
def replace_all(self, entries: Iterable[GlossaryReplacementEntry]) -> None:
|
||||
"""Atomically replace every glossary entry, preserving supplied stable IDs."""
|
||||
replacements = tuple(entries)
|
||||
validated: list[GlossaryReplacementEntry] = []
|
||||
seen_ids: set[int] = set()
|
||||
seen_terms: set[str] = set()
|
||||
for entry in replacements:
|
||||
if type(entry.id) is not int or entry.id <= 0:
|
||||
raise ValueError("Glossary entry IDs must be positive integers.")
|
||||
if entry.id in seen_ids:
|
||||
raise GlossaryConflictError(f"Duplicate glossary entry ID: {entry.id}.")
|
||||
canonical, aliases = self._validate_values(
|
||||
entry.canonical_term, entry.category, entry.aliases
|
||||
)
|
||||
folded_terms = {canonical.casefold(), *(alias.casefold() for alias in aliases)}
|
||||
if seen_terms.intersection(folded_terms):
|
||||
raise GlossaryConflictError("Canonical term or alias already exists.")
|
||||
seen_ids.add(entry.id)
|
||||
seen_terms.update(folded_terms)
|
||||
validated.append(
|
||||
GlossaryReplacementEntry(
|
||||
id=entry.id,
|
||||
canonical_term=canonical,
|
||||
category=entry.category,
|
||||
description=_optional_text(entry.description),
|
||||
is_active=entry.is_active,
|
||||
aliases=aliases,
|
||||
)
|
||||
)
|
||||
|
||||
now = _timestamp()
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute("DELETE FROM glossary_entries")
|
||||
connection.executemany(
|
||||
"""INSERT INTO glossary_entries
|
||||
(id, canonical_term, category, description, is_active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
(
|
||||
entry.id,
|
||||
entry.canonical_term,
|
||||
entry.category,
|
||||
entry.description,
|
||||
entry.is_active,
|
||||
now,
|
||||
now,
|
||||
)
|
||||
for entry in validated
|
||||
),
|
||||
)
|
||||
connection.executemany(
|
||||
"INSERT INTO glossary_aliases (entry_id, alias, created_at) VALUES (?, ?, ?)",
|
||||
((entry.id, alias, now) for entry in validated for alias in entry.aliases),
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise GlossaryConflictError("Canonical term or alias already exists.") from exc
|
||||
|
||||
@contextmanager
|
||||
def _connect(self) -> Iterator[sqlite3.Connection]:
|
||||
connection = sqlite3.connect(self.database_path, timeout=5)
|
||||
|
||||
Reference in New Issue
Block a user