feat(inbox): per-directory indexes with parent-to-child navigation

This commit is contained in:
Kjell Tore Guttormsen 2026-09-01 00:20:02 +02:00
commit 91efd92612
2 changed files with 429 additions and 2 deletions

View file

@ -23,7 +23,7 @@ import hashlib
import unicodedata
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from pathlib import Path
from pathlib import Path, PurePosixPath
from .errors import IngestError, MaterializationError, SegmentationError, SourceError
from .extract import extract_text
@ -36,7 +36,7 @@ from .materialize import (
validate_ingested_at,
write_bytes,
)
from .profiles import DEFAULT, BundleProfile
from .profiles import DEFAULT, BundleProfile, IndexEntry
from .segmentation import (
SegmentationPlan,
SegmentEntry,
@ -728,6 +728,16 @@ def process_inbox(
unicodedata.normalize("NFC", Path(entry.source_file).stem),
profile=profile,
)
elif profile.segmentation is not None:
# The caller scans; the writer projects. `listing` is the WHOLE
# bundle's owned concepts, not this round's, which is what keeps
# rebuild-from-scratch equal to an incremental update.
_reproject_indexes(
bundle,
profile,
listing=_owned_listing(bundle, profile),
root_head=root_head,
)
else:
_reproject_index(bundle, profile)
@ -788,6 +798,165 @@ def _refresh_root_frontmatter(index_path: Path, head: str) -> None:
index_path.write_bytes((head + body).encode("utf-8"))
#: The basename a navigation entry points at. A nav line is a link to a child
#: directory's index, and recognising it by its TARGET -- rather than by a flag
#: carried beside it -- is what lets an entry read back off disk sort exactly
#: like one about to be written.
INDEX_NAV_SUFFIX = DEFAULT.index.name
def _owned_listing(bundle: Path, profile: BundleProfile) -> dict[str, DocumentStructure]:
"""Every inbox-owned concept in the bundle, keyed by bundle-relative path.
The path IS the concept ID (OKF v0.2 §2), so two files called `a.md` in
different directories are two concepts and must not share a key.
"""
listing: dict[str, DocumentStructure] = {}
for path in sorted(bundle.rglob(f"*{profile.paths.concept_suffix}")):
if not path.is_file() or path.name == profile.index.name or not _is_inbox_owned(path):
continue
listing[path.relative_to(bundle).as_posix()] = structure_from_frontmatter(
parse_frontmatter(path)
)
return listing
def _index_sort_key(entry: IndexEntry) -> tuple[bool, str]:
"""The ONE ordering seam for every index this door writes.
Concepts first, navigation last, each group by target. Routed through a
single named helper on purpose: a consumer-controlled ordering is then a
parameter passed to this function, not a refactor of every place that
happened to call `sorted`. Navigation is recognised by its target -- a link
to a child's index -- rather than by a flag carried alongside, so an entry
read back off disk sorts the same way as one about to be written.
"""
return (entry.target.rpartition("/")[2] == INDEX_NAV_SUFFIX, entry.target)
def _reproject_indexes(
bundle: Path,
profile: BundleProfile,
*,
listing: Mapping[str, DocumentStructure],
root_head: str,
) -> None:
"""Write one index per directory, each linking down to its children.
`listing` is supplied by the caller rather than enumerated here -- the same
division `IndexPolicy` records for the judging side. What the caller hands
over is the WHOLE bundle's owned concepts, not just this round's, which is
what keeps this a projection: recomputing every index from the whole set
each round is exactly why a rebuild-from-scratch equals an incremental
update, with nothing diffed and so no diffing algorithm to be wrong.
Managed lines are recognised with the ANCHORED `link_pattern` through
`parse_entry`, never with `link_in_index`'s substring test: once targets are
relative subdirectory paths, `](krav/3-1/a.md)` also contains
`](3-1/a.md)`, and a substring matcher would drop or double an entry.
"""
assert profile.index.facets is not None
resolved = resolve_structure(listing)
# Every directory that holds a concept, plus every ancestor of one: a
# bundle whose middle level had no index would break the walk from the root.
by_directory: dict[str, dict[str, DocumentStructure]] = {}
directories: set[str] = {""}
for relative, document in listing.items():
parent = PurePosixPath(relative).parent
directory = "" if str(parent) == "." else str(parent)
by_directory.setdefault(directory, {})[relative] = document
while directory:
directories.add(directory)
directory = str(PurePosixPath(directory).parent).replace(".", "")
children: dict[str, set[str]] = {}
for directory in directories:
if not directory:
continue
parent = PurePosixPath(directory).parent
children.setdefault("" if str(parent) == "." else str(parent), set()).add(directory)
policy = profile.segmentation
assert policy is not None
for directory in sorted(directories):
entries: list[IndexEntry] = []
for relative, document in by_directory.get(directory, {}).items():
target = PurePosixPath(relative).name
entries.append(
IndexEntry(
label=document.title or target,
target=target,
facets=facet_values(relative, resolved, profile.index.facets.keys),
)
)
for child in children.get(directory, set()):
name = PurePosixPath(child).name
entries.append(
IndexEntry(
label=f"{name} ({policy.nav_label})",
target=f"{name}/{profile.index.name}",
)
)
block = [
profile.index.render_link(entry.label, entry.target, facets=entry.facets or None) + "\n"
for entry in sorted(entries, key=_index_sort_key)
]
_write_index(
bundle,
directory,
profile,
head=root_head if directory == "" else "",
block=block,
)
def _write_index(
bundle: Path,
directory: str,
profile: BundleProfile,
*,
head: str,
block: list[str],
) -> None:
"""Replace one index's managed region, preserving everything else in order."""
index_path = (
bundle / directory / profile.index.name if directory else bundle / profile.index.name
)
index_path.parent.mkdir(parents=True, exist_ok=True)
existing = index_path.read_bytes().decode("utf-8") if index_path.is_file() else ""
kept: list[str] = []
insert_at: int | None = None
for line in existing.splitlines(keepends=True):
if profile.index.parse_entry(line) is not None:
if insert_at is None:
insert_at = len(kept)
continue
kept.append(line)
if insert_at is None:
insert_at = len(kept)
if insert_at > 0 and not kept[insert_at - 1].endswith("\n"):
kept[insert_at - 1] += "\n"
body = "".join(kept[:insert_at] + block + kept[insert_at:])
if head:
# The frontmatter block is re-established rather than appended to, so a
# second round with the same values produces the same bytes.
lines = body.splitlines(keepends=True)
if lines and lines[0].strip() == "---":
for position, line in enumerate(lines[1:], start=1):
if line.strip() == "---":
rest = lines[position + 1 :]
if rest and rest[0].strip() == "":
rest = rest[1:]
body = "".join(rest)
break
body = head + body
index_path.write_bytes(body.encode("utf-8"))
def _reproject_index(bundle: Path, profile: BundleProfile) -> None:
"""Rewrite the managed region of the index from the WHOLE bundle.