Nimm die Quellen-Checkouts aus der Ablage heraus

Andreas 04.09.: "nimm den info-checkout aus silverbullet raus". Die
Quellen liegen jetzt unter <data>/quellen/<name> neben der Ablage — die
Oberfläche sieht sie nicht mehr. Der Index führt sie unter dem
virtuellen Pfad <ns>/doku/<name>/…, read löst ihn auf die Datei im
Checkout auf (mit Wurzelprüfung). Kein Zurückschreiben mehr: geändert
wird im Quell-Repo, gelesen über die gebaute MkDocs-Seite.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 22:14:59 +02:00
co-authored by Claude Fable 5.1
parent a8704fcddd
commit 279d022afd
6 changed files with 58 additions and 41 deletions
+15 -17
View File
@@ -7,21 +7,20 @@ in `gitrepo.py` (`sources_of`) und `runner.py`.
## Bauform ## Bauform
Jede Quelle ist ein eigener Git-Checkout **in** der Ablage: Jede Quelle ist ein eigener Git-Checkout **neben** der Ablage — außerhalb
von SilverBullet (Andreas, 04.09.: „nimm den info-checkout aus silverbullet
raus"):
``` ```
<ablage>/<ns>/doku/<name>/ ← z. B. familie/doku/info/ = Clone von ahmann/info <data>/quellen/<name>/ ← z. B. /srv/km/data/quellen/info = Clone von ahmann/info
``` ```
- SilverBullet sieht die Seiten wie alle anderen; der Index nimmt sie mit - Im Index erscheinen die Seiten unter dem virtuellen Pfad
(`typ: doku`, `projekt: <name>`, `classification` aus dem Frontmatter). `<ns>/doku/<name>/<pfad>` (`typ: doku`, `projekt: <name>`,
- Die Ablage ignoriert `*/doku/` in ihrer `.gitignore`: die Wahrheit dieser `classification` aus dem Frontmatter); `read` löst ihn auf die Datei im
Seiten liegt im Quell-Repo, nicht ein zweites Mal in `ahmann/km`. Checkout auf.
- **Je Takt:** Änderungen aus der Oberfläche werden im Checkout committet - **Je Takt:** `git pull --rebase`, danach der statische Bau (unten). Es
(„km: Änderungen aus der Oberfläche", Autor `KM_GIT_NAME`) und **in das wird nichts zurückgeschrieben — geändert wird im Quell-Repo.
Quell-Repo gepusht**, danach `git pull --rebase`. Ein Commit aus
SilverBullet landet also Minuten später auf `master` von `ahmann/info`
ohne PR und ohne Umlaut-Riegel. Das ist gewollt (Auto-Commit und Push).
## Konfiguration ## Konfiguration
@@ -37,14 +36,13 @@ KM_SOURCES=info=https://git.home/ahmann/info.git@master
Hat die Quelle ein `mkdocs.yml`, baut km sie nach jedem Pull mit Änderung Hat die Quelle ein `mkdocs.yml`, baut km sie nach jedem Pull mit Änderung
mit `python -m mkdocs build` nach `<data>/site/<name>` — die gewohnte mit `python -m mkdocs build` nach `<data>/site/<name>` — die gewohnte
MkDocs-Darstellung, read-only, ohne laufenden Dienst. Caddy liefert sie unter MkDocs-Darstellung, read-only, ohne laufenden Dienst. Caddy liefert sie unter
`/doku/<name>/` aus (deploy/README.md). SilverBullet bleibt r die Ablage `/doku/<name>/` aus (deploy/README.md). SilverBullet zeigt nur die Ablage
selbst; für die Doku ist die gebaute Seite der Leseweg. selbst; für die Doku ist die gebaute Seite der einzige Leseweg.
## Was Andreas wissen sollte ## Was Andreas wissen sollte
- **Alles ist drin**, auch `corp`- und `lan`-Seiten — bis die Quellen - **Alles ist drin**, auch `corp`- und `lan`-Seiten — bis die Quellen
getrennt werden, ist die Familien-Oberfläche damit nicht nur Familie. getrennt werden, zeigt die gebaute Seite der Familie damit nicht nur Familie.
- **Admonitions** (`!!! warning`) aus MkDocs zeigt SilverBullet als Rohtext
(Test vom 04.09., spine/core#8).
- Der Checkout enthält das ganze Repo (`ai/`, `deployment/`, …), nicht nur - Der Checkout enthält das ganze Repo (`ai/`, `deployment/`, …), nicht nur
`docs/`; indexiert werden nur `*.md`. `docs/`; indexiert werden alle `*.md`, gebaut wird nur, was `mkdocs.yml`
kennt.
+19 -3
View File
@@ -55,9 +55,11 @@ class Config:
gitea_password: str | None gitea_password: str | None
gitea_orgs: list[str] = field(default_factory=list) gitea_orgs: list[str] = field(default_factory=list)
gitea_repos: list[str] = field(default_factory=list) gitea_repos: list[str] = field(default_factory=list)
# weitere Quellen als eigene Git-Checkouts unter <ns>/doku/<name>, # weitere Quellen als eigene Git-Checkouts unter <data>/quellen/<name>
# komplett und ungefiltert (Andreas 04.09.: "vorerst alles komplett rein, # (ausserhalb der Ablage, nicht in SilverBullet), im Index unter dem
# sobald hive.corp steht, trennen wir die Quellen"). # virtuellen Pfad <ns>/doku/<name>/…; komplett und ungefiltert (Andreas
# 04.09.: "vorerst alles komplett rein, sobald hive.corp steht, trennen
# wir die Quellen").
# "info=https://git.home/ahmann/info.git@master" # "info=https://git.home/ahmann/info.git@master"
sources: list[tuple[str, str, str]] = field(default_factory=list) sources: list[tuple[str, str, str]] = field(default_factory=list)
interval: int = 300 interval: int = 300
@@ -75,6 +77,20 @@ class Config:
def state(self) -> Path: def state(self) -> Path:
return self.ablage.parent / "km-ingest-state.json" return self.ablage.parent / "km-ingest-state.json"
def source_path(self, name: str) -> Path:
return self.ablage.parent / "quellen" / name
def source_prefix(self, name: str) -> str:
return f"{self.namespace}/doku/{name}/"
def resolve(self, rel: str) -> Path:
"""Index-Pfad -> Datei: Ablage, oder Quelle hinter ihrem virtuellen Praefix."""
for name, _, _ in self.sources:
pre = self.source_prefix(name)
if rel.startswith(pre):
return self.source_path(name) / rel[len(pre):]
return self.ablage / rel
@classmethod @classmethod
def from_env(cls) -> "Config": def from_env(cls) -> "Config":
e = lambda k, d="": _clean(os.environ.get(k)) or d # noqa: E731 e = lambda k, d="": _clean(os.environ.get(k)) or d # noqa: E731
+6 -7
View File
@@ -89,11 +89,9 @@ class Ablage:
# Wahrheit; die Quellen haben ihr eigenes Remote und werden hier ignoriert. # Wahrheit; die Quellen haben ihr eigenes Remote und werden hier ignoriert.
gi = a / ".gitignore" gi = a / ".gitignore"
existing = gi.read_text() if gi.exists() else "" existing = gi.read_text() if gi.exists() else ""
# */doku/ sind die Quellen-Checkouts -- deren Wahrheit liegt im
# jeweiligen Remote (z. B. ahmann/info), nicht ein zweites Mal hier.
# _plug/ bleibt drin: die Plug-Dateien (TreeView) gehoeren zur Ablage, # _plug/ bleibt drin: die Plug-Dateien (TreeView) gehoeren zur Ablage,
# sonst fehlt nach einem frischen Checkout die Seitenleiste. # sonst fehlt nach einem frischen Checkout die Seitenleiste.
for line in (".silverbullet*", "*/doku/"): for line in (".silverbullet*",):
if line not in existing.splitlines(): if line not in existing.splitlines():
existing += line + "\n" existing += line + "\n"
gi.write_text(existing) gi.write_text(existing)
@@ -141,8 +139,9 @@ class Ablage:
def sources_of(cfg: Config) -> list[Ablage]: def sources_of(cfg: Config) -> list[Ablage]:
"""Die Quellen als Checkouts unter <ns>/doku/<name> in der Ablage: die """Die Quellen als Checkouts NEBEN der Ablage (<data>/quellen/<name>):
Oberflaeche sieht sie, km zieht sie je Takt und liefert Aenderungen aus SilverBullet sieht sie nicht (Andreas 04.09.: "nimm den info-checkout aus
der Oberflaeche in ihr eigenes Remote ein.""" silverbullet raus"); km zieht sie je Takt, baut die statische Seite und
return [Ablage(cfg, cfg.ablage / cfg.namespace / "doku" / name, url, branch, name=name) indexiert sie unter dem virtuellen Pfad <ns>/doku/<name>/…"""
return [Ablage(cfg, cfg.source_path(name), url, branch, name=name)
for name, url, branch in cfg.sources] for name, url, branch in cfg.sources]
+5 -4
View File
@@ -224,8 +224,8 @@ def sha256(data: bytes) -> str:
def index_file(con: sqlite3.Connection, ablage: Path, f: Path, default_ns: str, def index_file(con: sqlite3.Connection, ablage: Path, f: Path, default_ns: str,
max_chars: int, overlap: int, default_cls: str = "lan") -> bool: max_chars: int, overlap: int, default_cls: str = "lan", rel: str | None = None) -> bool:
rel = f.relative_to(ablage).as_posix() rel = rel or f.relative_to(ablage).as_posix()
raw = f.read_bytes() raw = f.read_bytes()
digest = sha256(raw) digest = sha256(raw)
row = con.execute("SELECT sha256 FROM docs WHERE path=?", (rel,)).fetchone() row = con.execute("SELECT sha256 FROM docs WHERE path=?", (rel,)).fetchone()
@@ -254,8 +254,9 @@ def index_file(con: sqlite3.Connection, ablage: Path, f: Path, default_ns: str,
return True return True
def remove_missing(con: sqlite3.Connection, ablage: Path) -> int: def remove_missing(con: sqlite3.Connection, ablage: Path, resolve=None) -> int:
gone = [r[0] for r in con.execute("SELECT path FROM docs") if not (ablage / r[0]).exists()] resolve = resolve or (lambda rel: ablage / rel)
gone = [r[0] for r in con.execute("SELECT path FROM docs") if not resolve(r[0]).exists()]
with con: with con:
for rel in gone: for rel in gone:
ids = [r[0] for r in con.execute("SELECT id FROM chunks WHERE path=?", (rel,))] ids = [r[0] for r in con.execute("SELECT id FROM chunks WHERE path=?", (rel,))]
+10 -8
View File
@@ -41,13 +41,8 @@ class Runner:
# Quellen: erst Aenderungen aus der Oberflaeche in ihr Remote, # Quellen: erst Aenderungen aus der Oberflaeche in ihr Remote,
# dann ziehen -- die Wahrheit einer info-Seite bleibt in info. # dann ziehen -- die Wahrheit einer info-Seite bleibt in info.
for src in self.sources: for src in self.sources:
if src.commit_all("km: Änderungen aus der Oberfläche"):
src.push()
result[f"commit_{src.name}"] = src.head()
before = src.head() before = src.head()
src.pull() src.pull()
if src.last_push_error:
result[f"push_fehler_{src.name}"] = src.last_push_error
built = self._build_site(src, changed=src.head() != before) built = self._build_site(src, changed=src.head() != before)
if built: if built:
result[f"site_{src.name}"] = built result[f"site_{src.name}"] = built
@@ -115,9 +110,16 @@ class Runner:
c = self.cfg c = self.cfg
con = km_index.connect(c.db) con = km_index.connect(c.db)
try: try:
files = sorted(p for p in c.ablage.rglob("*.md") if not any(s.startswith(".") for s in p.parts)) def md(root):
changed = sum(km_index.index_file(con, c.ablage, f, c.namespace, 600, 60, c.classification) for f in files) return sorted(p for p in root.rglob("*.md")
gone = km_index.remove_missing(con, c.ablage) if not any(s.startswith(".") for s in p.relative_to(root).parts))
files = [(f, None) for f in md(c.ablage)]
for src in self.sources:
pre = c.source_prefix(src.name)
files += [(f, pre + f.relative_to(src.path).as_posix()) for f in md(src.path)]
changed = sum(km_index.index_file(con, c.ablage, f, c.namespace, 600, 60, c.classification, rel)
for f, rel in files)
gone = km_index.remove_missing(con, c.ablage, c.resolve)
out = {"dateien": len(files), "geaendert": changed, "entfernt": gone} out = {"dateien": len(files), "geaendert": changed, "entfernt": gone}
if c.embed and changed: if c.embed and changed:
out["eingebettet"] = km_index.embed_pending( out["eingebettet"] = km_index.embed_pending(
+3 -2
View File
@@ -112,8 +112,9 @@ class Api:
def read(self, rel: str) -> dict: def read(self, rel: str) -> dict:
if not rel or rel.startswith("/") or ".." in rel.split("/"): if not rel or rel.startswith("/") or ".." in rel.split("/"):
raise ValueError("path ungueltig") raise ValueError("path ungueltig")
f = (self.cfg.ablage / rel).resolve() f = self.cfg.resolve(rel).resolve()
if not str(f).startswith(str(self.cfg.ablage.resolve())) or not f.is_file(): roots = [self.cfg.ablage.resolve(), *[self.cfg.source_path(n).resolve() for n, _, _ in self.cfg.sources]]
if not any(str(f).startswith(str(r)) for r in roots) or not f.is_file():
raise FileNotFoundError(rel) raise FileNotFoundError(rel)
text = f.read_text(encoding="utf-8") text = f.read_text(encoding="utf-8")
meta, body = km_index.parse_frontmatter(text) meta, body = km_index.parse_frontmatter(text)