Regelwerk, Agenten, Skills und Werkzeuge veröffentlichen
Erster Stand des öffentlichen Ausschnitts aus dem Vault. Die .gitignore arbeitet als Positivliste: ausgeschlossen ist alles, eingeschlossen nur, was ausdrücklich wieder aufgenommen wird. Notizen, Projekte, Finanzen, das Sitzungsgedächtnis und settings.local.json bleiben damit außen vor. Der Kundenname in AI/Regelwerk/seo.md ist durch eine neutrale Bezeichnung ersetzt; die Regeln selbst sind unverändert. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
commit
2c9a4475fc
97 files changed
+14627
No files matched your search
@@ -0,0 +1,110 @@
|
||||
# Frontmatter-Schema
|
||||
|
||||
Obsidian nennt Frontmatter „Properties". Es ist YAML zwischen zwei `---`-Zeilen
|
||||
am Dateianfang. Der Index liest genau diese Felder — was hier fehlt, muss der
|
||||
Agent sich durch Öffnen der Datei erarbeiten, und genau das kostet Kontext.
|
||||
|
||||
## Pflichtfelder (alle Notiztypen)
|
||||
|
||||
| Feld | Typ | Bedeutung |
|
||||
|---|---|---|
|
||||
| `uid` | String | Stabile ID, Format `YYYYMMDD-slug`. Überlebt Umbenennen und Verschieben; Verweise in Skripten hängen daran. |
|
||||
| `type` | Enum | Steuert Gruppierung, Templates und Abfragen. Gewinnt gegen die Ableitung aus dem Ordner. |
|
||||
| `title` | String | Anzeigename. Fehlt er, nimmt der Index die erste `#`-Überschrift, sonst den Dateinamen. |
|
||||
| `status` | Enum | Lebenszyklus, siehe unten. |
|
||||
| `summary` | String | **Ein Satz, max. 160 Zeichen.** Worum geht es und wofür macht man die Notiz auf. |
|
||||
| `tags` | Liste | Kleingeschrieben, ohne `#`, Technologie/Thema. 3–6 Stück, nicht 20. |
|
||||
| `created` | Datum | `YYYY-MM-DD` |
|
||||
| `updated` | Datum | `YYYY-MM-DD`, bei inhaltlichen Änderungen mitziehen. |
|
||||
|
||||
### Erlaubte `type`-Werte
|
||||
|
||||
`project-code`, `template-project`, `note-template`, `research`, `area`,
|
||||
`resource`, `moc`, `decision`, `inbox`, `archive`
|
||||
|
||||
Neue Typen nur bewusst einführen und dann in `scripts/vaultlib.py` unter
|
||||
`KNOWN_TYPES` eintragen, sonst meldet der Doctor sie als unbekannt.
|
||||
|
||||
### Erlaubte `status`-Werte
|
||||
|
||||
| Wert | Bedeutung |
|
||||
|---|---|
|
||||
| `idea` | notiert, noch nicht begonnen |
|
||||
| `active` | wird gerade bearbeitet |
|
||||
| `paused` | bewusst liegengelassen, kommt zurück |
|
||||
| `done` | abgeschlossen, noch am Platz |
|
||||
| `archived` | abgeschlossen und nach `90_Archive/` verschoben |
|
||||
| `reference` | Dauerhaftes ohne Fortschritt (Areas, Ressourcen, Templates) |
|
||||
|
||||
## Zusätzliche Felder je Typ
|
||||
|
||||
### `project-code`
|
||||
| Feld | Beispiel | Zweck |
|
||||
|---|---|---|
|
||||
| `repo` | `[email protected]:me/auth-service.git` | Sprung ins Repo ohne Suche |
|
||||
| `lang` | `go` | Hauptsprache |
|
||||
| `stack` | `[postgres, keycloak, docker]` | Umgebung auf einen Blick |
|
||||
| `template_of` | `go-service-starter` | aus welchem Template-Projekt entstanden |
|
||||
| `area` | `infrastruktur` | zugehöriger Dauerbereich |
|
||||
|
||||
### `research`
|
||||
| Feld | Beispiel | Zweck |
|
||||
|---|---|---|
|
||||
| `question` | `Welche Rate-Limiting-Strategie passt zu Gateway X?` | die eine Frage |
|
||||
| `sources` | `[https://…, "Buch, S. 40"]` | Belege |
|
||||
| `confidence` | `low` / `medium` / `high` | wie belastbar die Antwort ist |
|
||||
| `project` | `auth-service` | auslösendes Projekt |
|
||||
|
||||
### `template-project`
|
||||
| Feld | Beispiel | Zweck |
|
||||
|---|---|---|
|
||||
| `use_when` | `Neuer Go-Service mit HTTP-API und Postgres` | Auswahl ohne Öffnen |
|
||||
| `stack` | `[go, postgres, docker]` | |
|
||||
| `template_of` | — | bleibt leer; wird in den abgeleiteten Projekten gesetzt |
|
||||
|
||||
### `decision` (ADR)
|
||||
| Feld | Beispiel |
|
||||
|---|---|
|
||||
| `project` | `auth-service` |
|
||||
| `decided` | `2026-08-03` |
|
||||
| `supersedes` | `20260101-mongo-als-store` |
|
||||
|
||||
### `area`, `resource`, `moc`, `note-template`
|
||||
Nur die Pflichtfelder. Bei `moc` ist `summary` besonders wichtig, weil MOCs
|
||||
Einstiegspunkte sind.
|
||||
|
||||
## Beispiel
|
||||
|
||||
```yaml
|
||||
---
|
||||
uid: 20260803-auth-service
|
||||
type: project-code
|
||||
title: Auth Service
|
||||
status: active
|
||||
summary: OIDC-Auth-Service für die interne Toolchain, Go + Keycloak, löst die verteilten Basic-Auth-Setups ab.
|
||||
tags: [go, oidc, keycloak, infrastruktur]
|
||||
repo: [email protected]:me/auth-service.git
|
||||
lang: go
|
||||
stack: [postgres, keycloak, docker]
|
||||
template_of: go-service-starter
|
||||
area: infrastruktur
|
||||
created: 2026-08-03
|
||||
updated: 2026-08-03
|
||||
---
|
||||
```
|
||||
|
||||
## Was nicht ins Frontmatter gehört
|
||||
|
||||
- Fließtext oder mehrzeilige Beschreibungen — dafür ist der Body da.
|
||||
- Werte, die sich täglich ändern (Fortschritt in Prozent, laufende Zahlen).
|
||||
- Secrets, Tokens, Passwörter, Zugangsdaten. Nie, auch nicht „nur kurz".
|
||||
- Duplikate von Wikilinks — Links stehen im Body unter `## Referenzen`.
|
||||
|
||||
## Massenpflege
|
||||
|
||||
`vault_doctor.py --fix-missing` ergänzt fehlende Felder konservativ: `type` aus
|
||||
dem Ordner, `title` aus der ersten Überschrift, `summary` aus dem ersten Absatz,
|
||||
`uid` aus Datum + Slug. Es überschreibt nie vorhandene Werte und rührt den Body
|
||||
nicht an. Standard ist ein Trockenlauf; erst `--apply` schreibt. Danach die
|
||||
generierten `summary`-Werte durchsehen — automatisch abgeleitete Sätze sind
|
||||
brauchbar, aber selten so gut wie ein bewusst geschriebener.
|
||||
@@ -0,0 +1,128 @@
|
||||
# Die Referenzschicht
|
||||
|
||||
## Warum überhaupt ein Index
|
||||
|
||||
Ein Agent, der eine Frage an einen Vault mit 500 Notizen stellt, hat drei
|
||||
Möglichkeiten: alles lesen (sprengt den Kontext), grep (findet Wortformen, aber
|
||||
nicht Bedeutung, und liefert Treffer ohne Einordnung), oder eine
|
||||
Zwischenschicht, die pro Notiz genau so viel weiß, dass sich entscheiden lässt,
|
||||
ob man sie aufmacht. Die dritte Variante ist der Index.
|
||||
|
||||
Größenordnung: 500 Notizen à 800 Wörter sind rund 500.000 Token. Der Index
|
||||
derselben 500 Notizen liegt bei etwa 25.000 Token — und eine typische Abfrage
|
||||
gibt davon 10–40 Zeilen aus, also ein paar hundert Token. Deshalb: nie den Index
|
||||
komplett lesen, immer `vault_query.py` fragen.
|
||||
|
||||
## Erzeugte Dateien
|
||||
|
||||
### `_index/vault-index.json`
|
||||
Kompaktes JSON ohne Einrückung, Kurzschlüssel zur Platzersparnis:
|
||||
|
||||
| Schlüssel | Bedeutung |
|
||||
|---|---|
|
||||
| `p` | Pfad relativ zum Vault |
|
||||
| `u` | uid |
|
||||
| `t` | type |
|
||||
| `s` | status |
|
||||
| `n` | Titel |
|
||||
| `m` | summary |
|
||||
| `g` | tags |
|
||||
| `r` | Projekt-Slug |
|
||||
| `c` / `d` | created / updated |
|
||||
| `h` | Überschriften (max. 12) |
|
||||
| `l` | ausgehende Links |
|
||||
| `w` | Wortzahl (bei Assets: Dateigröße) |
|
||||
|
||||
Leere Felder fehlen ganz. Der `meta`-Block enthält Zählungen, Typverteilung,
|
||||
Statusverteilung, Top-Tags und die Zahl toter Links.
|
||||
|
||||
Das Feld `h` ist der zweite Hebel nach `m`: Bei einer langen Notiz zeigen die
|
||||
Überschriften, welcher Abschnitt gebraucht wird — statt 3.000 Wörter zu lesen,
|
||||
liest man 300.
|
||||
|
||||
### `_index/INDEX.md`
|
||||
Menschenlesbare Übersicht, nach Typ gruppiert, jede Zeile ein Wikilink plus
|
||||
Zusammenfassung. In Obsidian direkt navigierbar. Bei über 200 Notizen enthält
|
||||
die Datei selbst den Hinweis, sie nicht komplett zu lesen.
|
||||
|
||||
### `_index/backlinks.json`
|
||||
`{ "pfad/zur/notiz.md": ["pfad/quelle1.md", …] }`. Basis für `--links-to` und
|
||||
die Waisen-Erkennung.
|
||||
|
||||
## Wann neu bauen
|
||||
|
||||
- nach dem Anlegen, Verschieben, Umbenennen oder Löschen von Notizen
|
||||
- nach Frontmatter-Änderungen
|
||||
- vor einer Arbeitssitzung, wenn zwischendurch in Obsidian gearbeitet wurde
|
||||
|
||||
`vault_query.py` vergleicht die Änderungszeiten und warnt auf stderr, wenn
|
||||
Notizen neuer sind als der Index. Der Lauf über einige tausend Dateien dauert
|
||||
Sekunden, also im Zweifel neu bauen.
|
||||
|
||||
Automatisieren geht per Git-Hook (`.git/hooks/pre-commit`) oder Cron:
|
||||
|
||||
```bash
|
||||
python3 .claude/skills/obsidian-vault-navigator/scripts/build_index.py --vault . --quiet
|
||||
```
|
||||
|
||||
## Abfrage-Rezepte
|
||||
|
||||
```bash
|
||||
Q=".claude/skills/obsidian-vault-navigator/scripts/vault_query.py"
|
||||
|
||||
# Womit habe ich mich zu Thema X beschäftigt?
|
||||
python3 $Q --text x
|
||||
|
||||
# Laufende Code-Projekte, als fertige Wikilink-Liste für eine Antwort
|
||||
python3 $Q --type project-code --status active --format md
|
||||
|
||||
# Alles zu einem Projekt, inklusive Unternotizen und Anhängen
|
||||
python3 $Q --project auth-service
|
||||
|
||||
# Passendes Template-Projekt finden, ohne eines zu öffnen
|
||||
python3 $Q --type template-project
|
||||
|
||||
# Was hat sich diese Woche getan?
|
||||
python3 $Q --since 7 --sort updated
|
||||
|
||||
# Kandidaten fürs Archiv
|
||||
python3 $Q --status active --stale 180
|
||||
|
||||
# Wer verweist auf diese Notiz?
|
||||
python3 $Q --links-to 30_Research/rate-limiting.md
|
||||
|
||||
# Pflegeliste: Notizen ohne summary
|
||||
python3 $Q --no-summary --limit 100 --format paths
|
||||
|
||||
# Struktur einer langen Notiz sehen, bevor man sie liest
|
||||
python3 $Q --text "kubernetes netzwerk" --format headings
|
||||
```
|
||||
|
||||
Mehrere `--text` werden UND-verknüpft, mehrere `--type`/`--tag`/`--status`
|
||||
ODER-verknüpft (bei `--tag` müssen alle angegebenen Tags vorhanden sein).
|
||||
|
||||
## Wenn der Index nichts findet
|
||||
|
||||
1. Filter lockern — oft ist `--type` zu eng gesetzt.
|
||||
2. Anderes Wort probieren; der Index kennt nur Titel, Summary, Tags,
|
||||
Überschriften und Pfad, keinen Fließtext.
|
||||
3. Erst dann Volltext: `grep -ril "begriff" --include="*.md" .`
|
||||
4. Findet grep etwas, das der Index nicht hatte, ist das ein Befund: Der Notiz
|
||||
fehlt ein Tag oder eine bessere `summary`. Gleich nachziehen — der Index wird
|
||||
dadurch mit jedem Miss besser.
|
||||
|
||||
## Grenzen
|
||||
|
||||
Der Index ist Metadaten, keine Semantik. Er findet nichts, was nirgends benannt
|
||||
ist, und er versteht keine Synonyme. Für semantische Suche im großen Vault gibt
|
||||
es lokale Werkzeuge (z. B. Obsidians eigene Suche mit Operatoren oder externe
|
||||
Embedding-Indizes) — die ersetzen diese Schicht nicht, sondern ergänzen sie: Der
|
||||
Index bleibt die billige erste Stufe.
|
||||
|
||||
## Verhältnis zu Obsidian Bases
|
||||
|
||||
Obsidian Bases (`.base`-Dateien) erzeugen Tabellen- und Kartenansichten aus
|
||||
Properties — dieselben Felder, die auch der Index nutzt. Wer im Obsidian-UI
|
||||
dieselben Übersichten will, legt Bases an; für den Agenten bleibt der
|
||||
JSON-Index der billigere Weg. Für die korrekte `.base`-Syntax den offiziellen
|
||||
Skill `obsidian-bases` verwenden.
|
||||
@@ -0,0 +1,96 @@
|
||||
# Vault-Struktur
|
||||
|
||||
## Ordner im Detail
|
||||
|
||||
### `00_Inbox/`
|
||||
Alles, was schnell reinmuss: Fundstücke, Ideen, Gesprächsnotizen. Frontmatter
|
||||
darf hier minimal sein (`type: inbox`, `title`, `created`). Der Ordner ist ein
|
||||
Durchlauferhitzer, kein Lager — was länger als zwei Wochen liegt, meldet
|
||||
`vault_doctor.py`.
|
||||
|
||||
### `10_Projects/<slug>/`
|
||||
Ein Vorhaben mit Ziel und absehbarem Ende. Jedes Projekt ist ein **Ordner**,
|
||||
kein einzelnes File, weil Projekte fast immer mehrere Notizen und Anhänge
|
||||
sammeln:
|
||||
|
||||
```
|
||||
10_Projects/auth-service/
|
||||
├── index.md Projektnotiz — der Einstieg, type: project-code
|
||||
├── decisions/ ADRs, type: decision
|
||||
├── notes/ Arbeitsnotizen, Meetings, Logs
|
||||
└── assets/ Diagramme, Exporte, PDFs
|
||||
```
|
||||
|
||||
`index.md` ist Pflicht und trägt die Projekt-Zusammenfassung. Alle Unternotizen
|
||||
tragen `project: <slug>` im Frontmatter — daran hängt `vault_query.py --project`.
|
||||
|
||||
Slug-Regel: kleingeschrieben, Bindestriche, keine Umlaute, so kurz wie
|
||||
eindeutig (`auth-service`, nicht `Auth Service (neu, 2026)`).
|
||||
|
||||
### `20_Areas/`
|
||||
Verantwortungsbereiche ohne Enddatum: Infrastruktur, Security, Homelab,
|
||||
Weiterbildung, Kunde XY. Eine Area-Notiz ist gleichzeitig Landkarte für ihren
|
||||
Bereich. Projekte verlinken auf ihre Area, nicht umgekehrt hart verdrahtet —
|
||||
die Projektliste einer Area entsteht per Abfrage.
|
||||
|
||||
### `30_Research/`
|
||||
Recherche, die über ein einzelnes Projekt hinaus Bestand hat. Eine Notiz = eine
|
||||
Frage. Ist die Recherche nur für ein Projekt relevant und danach erledigt,
|
||||
gehört sie in `10_Projects/<slug>/notes/`. Faustregel: Würde man das in einem
|
||||
Jahr auch ohne dieses Projekt nochmal aufmachen? Dann `30_Research/`.
|
||||
|
||||
### `40_Templates/`
|
||||
- `note-templates/` — Vorlagen für einzelne Notizen. Auch in Obsidian selbst
|
||||
als Template-Ordner einstellbar (Einstellungen → Templates).
|
||||
- `project-templates/` — **Template-Projekte**: vollständige Boilerplates, aus
|
||||
denen neue Projekte entstehen (Starter-Repo-Struktur, Standard-Setups,
|
||||
wiederkehrende Auftragsformate). Jedes Template-Projekt hat `index.md` mit
|
||||
`use_when` im Frontmatter, damit die Auswahl später ohne Öffnen funktioniert.
|
||||
|
||||
Templates werden vom Doctor nicht als verwaist gemeldet und tauchen im Index
|
||||
als eigene Gruppe auf.
|
||||
|
||||
### `50_Resources/`
|
||||
Wissen ohne Projektbezug: Snippets, Runbooks, Cheatsheets, Konfigurations-
|
||||
Notizen, Tool-Vergleiche. Der Unterschied zu `30_Research/`: Ressourcen sind
|
||||
Nachschlagewerk, Research beantwortet eine Frage.
|
||||
|
||||
### `90_Archive/`
|
||||
Abgeschlossenes bleibt lesbar und indexiert, ist aber aus dem Blick. Beim
|
||||
Archivieren `status: archived` setzen und den kompletten Projektordner
|
||||
verschieben, damit relative Verweise erhalten bleiben.
|
||||
|
||||
### `_index/`
|
||||
Generiert von `build_index.py`. Enthält `vault-index.json` (Maschine),
|
||||
`INDEX.md` (Mensch), `backlinks.json`. Nie von Hand editieren. Wer den Ordner
|
||||
in Obsidian nicht sehen will: Einstellungen → Dateien und Links → „Ausgeschlossene
|
||||
Dateien" auf `_index` setzen (er bleibt auf der Platte und für die Skripte
|
||||
nutzbar).
|
||||
|
||||
## Wohin gehört diese Notiz?
|
||||
|
||||
1. Gehört sie zu einem konkreten Vorhaben mit Ende? → `10_Projects/<slug>/`
|
||||
2. Beantwortet sie eine Frage, die auch ohne das Vorhaben interessant bleibt?
|
||||
→ `30_Research/`
|
||||
3. Ist es eine Vorlage oder ein Boilerplate? → `40_Templates/…`
|
||||
4. Ist es Nachschlagewissen ohne Frage und ohne Vorhaben? → `50_Resources/`
|
||||
5. Ist es ein Dauerbereich, unter dem mehrere Projekte hängen? → `20_Areas/`
|
||||
6. Weiß man es nicht? → `00_Inbox/` und beim nächsten Aufräumen entscheiden.
|
||||
Das ist kein Versagen, sondern der vorgesehene Weg.
|
||||
|
||||
## Dateinamen
|
||||
|
||||
- Dateiname = sprechender Titel, keine Datumspräfixe (Datum steht im
|
||||
Frontmatter und ist dort auch sortierbar).
|
||||
- Ausnahme: Daily-/Log-Notizen, dort `YYYY-MM-DD.md`.
|
||||
- Projekt- und Template-Ordner tragen Slugs, einzelne Notizen dürfen Leerzeichen
|
||||
und Großschreibung haben — Obsidian-Wikilinks kommen damit klar.
|
||||
- Keine Umlaute in Ordnernamen; in Dateinamen sind sie unkritisch, aber Slugs
|
||||
bleiben ASCII.
|
||||
|
||||
## Anpassung der Struktur
|
||||
|
||||
Die Ordnernamen sind nicht heilig, die Zuordnung Pfad → Typ schon: Sie steht in
|
||||
`scripts/vaultlib.py` unter `FOLDER_TYPES`. Wer umbenennt, passt dort an —
|
||||
sonst raten die Skripte falsch, und `type` im Frontmatter muss überall explizit
|
||||
gesetzt sein. Frontmatter gewinnt immer gegen die Ordner-Ableitung.
|
||||
@@ -0,0 +1,128 @@
|
||||
# Workflows
|
||||
|
||||
Alle Pfade unten gehen davon aus, dass der Skill unter
|
||||
`.claude/skills/obsidian-vault-navigator/` im Vault liegt.
|
||||
|
||||
## Frage beantworten („was habe ich zu X?")
|
||||
|
||||
1. `vault_query.py --text x` — bei vielen Treffern mit `--type` oder `--path`
|
||||
nachschärfen.
|
||||
2. Trefferliste dem Nutzer als Wikilink-Liste zeigen (`--format md`), damit er
|
||||
in Obsidian direkt springen kann.
|
||||
3. Reicht die Zusammenfassung für die Antwort, ist hier Schluss.
|
||||
4. Sonst die 1–3 relevantesten Notizen öffnen. Bei langen Notizen vorher
|
||||
`--format headings` und nur den passenden Abschnitt lesen.
|
||||
5. Kommt bei der Antwort etwas Neues heraus, das später wieder gebraucht wird:
|
||||
als Notiz ablegen oder an die bestehende anhängen.
|
||||
|
||||
## Neues Code-Projekt
|
||||
|
||||
1. Prüfen, ob es das schon gibt: `vault_query.py --text "<name>"`.
|
||||
2. Passendes Template-Projekt suchen: `vault_query.py --type template-project`.
|
||||
Die `use_when`-Zeilen reichen zur Auswahl.
|
||||
3. Anlegen:
|
||||
`new_note.py --type project-code --title "Auth Service" --tags go,oidc --summary "…"`
|
||||
4. Wenn ein Template genutzt wird: dessen Ordner nach
|
||||
`10_Projects/<slug>/` kopieren, dann `template_of: <template-slug>` ins
|
||||
Frontmatter des neuen Projekts, und im Template-Projekt unter „Abgeleitete
|
||||
Projekte" den Rückverweis eintragen.
|
||||
5. Area verlinken, `repo`/`lang`/`stack` füllen.
|
||||
6. `build_index.py`.
|
||||
|
||||
## Neue Recherche
|
||||
|
||||
1. Frage formulieren — eine Frage, eine Notiz. Passt die Frage nicht in einen
|
||||
Satz, sind es zwei Notizen.
|
||||
2. `new_note.py --type research --title "…" --project <slug>`
|
||||
3. `question` ins Frontmatter, Antwort zuerst schreiben, Belege darunter.
|
||||
4. `confidence` ehrlich setzen; `low` ist ein nützlicher Wert, kein Makel.
|
||||
5. Vom auslösenden Projekt aus verlinken, damit die Recherche nicht verwaist.
|
||||
6. `build_index.py`.
|
||||
|
||||
## Template-Projekt anlegen
|
||||
|
||||
Ein Template-Projekt entsteht meist rückwärts: Ein Projekt lief gut, die
|
||||
Struktur soll wiederverwendet werden.
|
||||
|
||||
1. Projektordner nach `40_Templates/project-templates/<slug>/` kopieren.
|
||||
2. Projektspezifisches entfernen: Namen, Secrets, Kundendaten, Logs,
|
||||
Zwischenstände.
|
||||
3. Platzhalter einführen und im Template `index.md` unter „Anwendung"
|
||||
dokumentieren, welche ersetzt werden müssen.
|
||||
4. `type: template-project`, `use_when` und `stack` setzen.
|
||||
5. `build_index.py`.
|
||||
|
||||
## Inbox aufräumen
|
||||
|
||||
1. `vault_query.py --path 00_Inbox --sort updated`
|
||||
2. Pro Notiz einen Zielort vorschlagen — mit der Regel aus
|
||||
`references/vault-structure.md` als Begründung.
|
||||
3. Die gesamte Liste als Vorschlag zeigen und **auf Freigabe warten**. Erst dann
|
||||
verschieben, in Gruppen, nicht alles auf einmal.
|
||||
4. Beim Verschieben Frontmatter vervollständigen (`type`, `summary`, `tags`).
|
||||
5. `build_index.py`, danach `vault_doctor.py`, um tote Links zu finden, die
|
||||
durchs Verschieben entstanden sind.
|
||||
|
||||
## Projekt abschließen
|
||||
|
||||
1. `status: done`, `updated` aktualisieren.
|
||||
2. Im `index.md` einen Abschnitt „Ergebnis" ergänzen: was rauskam, was man
|
||||
beim nächsten Mal anders machen würde. Das ist der Teil, der später gelesen
|
||||
wird.
|
||||
3. Lohnt sich ein Template-Projekt daraus? Siehe oben.
|
||||
4. Kompletten Ordner nach `90_Archive/<slug>/` verschieben, `status: archived`.
|
||||
5. `build_index.py`, dann `vault_doctor.py` gegen tote Links.
|
||||
|
||||
## Wöchentliche Pflege
|
||||
|
||||
```bash
|
||||
python3 .claude/skills/obsidian-vault-navigator/scripts/build_index.py --vault .
|
||||
python3 .claude/skills/obsidian-vault-navigator/scripts/vault_doctor.py --vault .
|
||||
python3 .claude/skills/obsidian-vault-navigator/scripts/vault_query.py --since 7 --sort updated
|
||||
python3 .claude/skills/obsidian-vault-navigator/scripts/vault_query.py --status active --stale 90
|
||||
```
|
||||
|
||||
Daraus wird eine kurze Lage: was ist neu, was liegt fest, was fehlt an
|
||||
Metadaten, was sollte ins Archiv. Änderungsvorschläge sammeln und gebündelt
|
||||
vorlegen, nicht einzeln durchführen.
|
||||
|
||||
## Migration eines bestehenden Vaults
|
||||
|
||||
Der heikelste Fall — hier geht es um Dateien, an denen jahrelang gearbeitet
|
||||
wurde. Nie im Bulk, nie ohne Freigabe, und immer mit einer Rückfahrkarte.
|
||||
|
||||
1. **Sicherung**: Vault in Git nehmen (`git init && git add -A && git commit`)
|
||||
oder eine Kopie anlegen. Ohne das nicht anfangen — und den Nutzer das
|
||||
bestätigen lassen.
|
||||
2. **Bestandsaufnahme**: `build_index.py` auf dem Vault, so wie er ist. Der
|
||||
Index läuft auch ohne Frontmatter; Typen werden dann aus Pfaden geraten oder
|
||||
auf `note` gesetzt. Danach `vault_doctor.py` für den Ist-Zustand.
|
||||
3. **Lage zeigen**: Anzahl Notizen, Typverteilung, wie viele ohne Frontmatter,
|
||||
wie viele Waisen, größte Ordner. Erst danach über Struktur reden.
|
||||
4. **Struktur anlegen**, ohne Bestehendes anzufassen: die Zielordner erzeugen,
|
||||
`CLAUDE.md` in die Wurzel, Templates einsortieren.
|
||||
5. **Frontmatter zuerst, Verschieben später.** `vault_doctor.py --fix-missing`
|
||||
erst im Trockenlauf zeigen, dann auf einem Teilbereich mit `--apply`
|
||||
ausprobieren, Ergebnis gemeinsam ansehen, dann den Rest.
|
||||
6. **In Wellen verschieben**, thematisch sortiert: erst die eindeutigen Fälle
|
||||
(klar erkennbare Projekte), dann Research, zuletzt der Rest. Nach jeder Welle
|
||||
`build_index.py` + `vault_doctor.py`, tote Links reparieren, Nutzer draufschauen
|
||||
lassen.
|
||||
7. **Restposten** landen in `00_Inbox/` und werden nach und nach entschieden.
|
||||
Ein Vault muss nicht an einem Tag fertig sortiert sein.
|
||||
8. **Zusammenfassungen nachziehen**: `vault_query.py --no-summary` liefert die
|
||||
Pflegeliste. Automatisch abgeleitete Summaries sind ein Start, kein Ergebnis
|
||||
— die 30 wichtigsten Notizen von Hand nachschärfen bringt mehr als 300
|
||||
maschinell erzeugte Sätze.
|
||||
|
||||
## Wenn der Nutzer in Claude Code arbeitet
|
||||
|
||||
Der Vault als Arbeitsverzeichnis öffnen, damit `CLAUDE.md` greift und die
|
||||
Skript-Pfade stimmen. Ist das Code-Repo eines Projekts das Arbeitsverzeichnis
|
||||
und der Vault liegt woanders, immer `--vault <pfad>` mitgeben — die Skripte
|
||||
brauchen kein Arbeitsverzeichnis im Vault.
|
||||
|
||||
Ein Muster, das sich lohnt: Am Ende einer Coding-Session die Projektnotiz
|
||||
aktualisieren (Stand, offene Punkte, getroffene Entscheidungen) und
|
||||
`build_index.py` laufen lassen. Die nächste Session beginnt dann mit einer
|
||||
Abfrage statt mit dem Wiederlesen des Repos.
|
||||
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Scannt den Vault und erzeugt die Referenzschicht in `_index/`.
|
||||
|
||||
Erzeugt:
|
||||
_index/vault-index.json kompakter Maschinen-Index (Kurzschlüssel, siehe
|
||||
references/indexing.md) — wird von vault_query.py
|
||||
gelesen, nicht direkt vom Agenten
|
||||
_index/INDEX.md menschenlesbare Übersicht mit Wikilinks
|
||||
_index/backlinks.json Rückwärtsverweise pro Notiz
|
||||
|
||||
Beispiel:
|
||||
python3 scripts/build_index.py --vault ~/Vault
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import vaultlib as V # noqa: E402
|
||||
|
||||
TYPE_ORDER = [
|
||||
"moc", "area", "project-code", "research", "decision", "template-project",
|
||||
"resource", "note-template", "inbox", "note", "archive", "asset",
|
||||
]
|
||||
|
||||
TYPE_LABEL = {
|
||||
"moc": "Maps of Content",
|
||||
"area": "Areas",
|
||||
"project-code": "Projekte (Code)",
|
||||
"research": "Research",
|
||||
"decision": "Entscheidungen",
|
||||
"template-project": "Template-Projekte",
|
||||
"resource": "Ressourcen",
|
||||
"note-template": "Notiz-Vorlagen",
|
||||
"inbox": "Inbox",
|
||||
"note": "Sonstige Notizen",
|
||||
"archive": "Archiv",
|
||||
"asset": "Dateien",
|
||||
}
|
||||
|
||||
|
||||
def normalize_link(target: str, notes_by_key: dict) -> str | None:
|
||||
"""Wikilink-Ziel auf einen Index-Pfad abbilden."""
|
||||
t = target.strip().lstrip("./")
|
||||
for cand in (t, t + ".md", t.replace(".md", "") + ".md"):
|
||||
if cand in notes_by_key:
|
||||
return notes_by_key[cand]
|
||||
base = os.path.basename(t)
|
||||
for cand in (base, base + ".md"):
|
||||
if cand in notes_by_key:
|
||||
return notes_by_key[cand]
|
||||
return None
|
||||
|
||||
|
||||
def build(vault: str, include_assets: bool, excludes) -> dict:
|
||||
notes, assets = [], []
|
||||
for ap, rp in V.walk_vault(vault, excludes):
|
||||
ext = os.path.splitext(rp)[1].lower()
|
||||
if ext == ".md":
|
||||
try:
|
||||
notes.append(V.read_note(ap, rp))
|
||||
except OSError as exc:
|
||||
print(f" übersprungen: {rp} ({exc})", file=sys.stderr)
|
||||
elif include_assets and ext in V.ASSET_EXTS:
|
||||
assets.append({
|
||||
"p": rp,
|
||||
"t": "asset",
|
||||
"n": os.path.basename(rp),
|
||||
"r": rp.split("/")[1] if rp.startswith("10_Projects/") and rp.count("/") > 1 else "",
|
||||
"w": os.path.getsize(ap),
|
||||
})
|
||||
|
||||
notes.sort(key=lambda n: n["p"])
|
||||
assets.sort(key=lambda n: n["p"])
|
||||
all_entries = notes + assets
|
||||
|
||||
lookup = {}
|
||||
for n in notes:
|
||||
lookup[n["p"]] = n["p"]
|
||||
lookup[os.path.basename(n["p"])] = n["p"]
|
||||
lookup[os.path.splitext(n["p"])[0]] = n["p"]
|
||||
lookup[os.path.splitext(os.path.basename(n["p"]))[0]] = n["p"]
|
||||
if n.get("n"):
|
||||
lookup.setdefault(n["n"], n["p"])
|
||||
if n.get("u"):
|
||||
lookup.setdefault(n["u"], n["p"])
|
||||
|
||||
backlinks = defaultdict(list)
|
||||
broken = 0
|
||||
for n in notes:
|
||||
for raw in n.get("l", []):
|
||||
tgt = normalize_link(raw, lookup)
|
||||
if tgt:
|
||||
if n["p"] not in backlinks[tgt]:
|
||||
backlinks[tgt].append(n["p"])
|
||||
else:
|
||||
broken += 1
|
||||
|
||||
meta = {
|
||||
"v": 1,
|
||||
"generated": V.iso_now(),
|
||||
"vault": os.path.abspath(vault),
|
||||
"notes": len(notes),
|
||||
"assets": len(assets),
|
||||
"words": sum(n.get("w", 0) for n in notes),
|
||||
"types": dict(Counter(n.get("t", "note") for n in notes).most_common()),
|
||||
"status": dict(Counter(n["s"] for n in notes if n.get("s")).most_common()),
|
||||
"tags": dict(Counter(t for n in notes for t in n.get("g", [])).most_common(60)),
|
||||
"broken_links": broken,
|
||||
}
|
||||
return {"meta": meta, "notes": all_entries, "backlinks": dict(backlinks)}
|
||||
|
||||
|
||||
def render_markdown(index: dict) -> str:
|
||||
meta = index["meta"]
|
||||
by_type = defaultdict(list)
|
||||
for n in index["notes"]:
|
||||
by_type[n.get("t", "note")].append(n)
|
||||
|
||||
out = [
|
||||
"---",
|
||||
"type: moc",
|
||||
"title: Vault-Index",
|
||||
f"summary: Generierte Übersicht aller Notizen ({meta['notes']} Stück). "
|
||||
"Nicht von Hand bearbeiten.",
|
||||
f"updated: {V.today()}",
|
||||
"---",
|
||||
"",
|
||||
"# Vault-Index",
|
||||
"",
|
||||
f"> [!info] Generiert am {meta['generated']} · {meta['notes']} Notizen · "
|
||||
f"{meta['assets']} Dateien",
|
||||
"> Diese Datei wird von `build_index.py` erzeugt. Änderungen gehen verloren.",
|
||||
"",
|
||||
]
|
||||
if meta["notes"] > 200:
|
||||
out += [
|
||||
"> [!warning] Großer Vault",
|
||||
"> Diese Datei nicht komplett lesen. Stattdessen gezielt abfragen:",
|
||||
"> `python3 scripts/vault_query.py --text \"…\"`",
|
||||
"",
|
||||
]
|
||||
|
||||
ordered = [t for t in TYPE_ORDER if t in by_type]
|
||||
ordered += sorted(t for t in by_type if t not in TYPE_ORDER)
|
||||
|
||||
for t in ordered:
|
||||
entries = by_type[t]
|
||||
out.append(f"## {TYPE_LABEL.get(t, t)} ({len(entries)})")
|
||||
out.append("")
|
||||
if t == "asset":
|
||||
for n in entries[:200]:
|
||||
out.append(f"- `{n['p']}`")
|
||||
if len(entries) > 200:
|
||||
out.append(f"- … {len(entries) - 200} weitere")
|
||||
out.append("")
|
||||
continue
|
||||
for n in entries:
|
||||
link = os.path.splitext(n["p"])[0]
|
||||
line = f"- [[{link}|{n.get('n', link)}]]"
|
||||
bits = []
|
||||
if n.get("s"):
|
||||
bits.append(f"`{n['s']}`")
|
||||
if n.get("g"):
|
||||
bits.append(" ".join(f"#{g}" for g in n["g"][:5]))
|
||||
if bits:
|
||||
line += " " + " ".join(bits)
|
||||
if n.get("m"):
|
||||
line += f" — {n['m']}"
|
||||
out.append(line)
|
||||
out.append("")
|
||||
|
||||
if meta["tags"]:
|
||||
out.append("## Top-Tags")
|
||||
out.append("")
|
||||
out.append(" ".join(f"#{t} ({c})" for t, c in list(meta["tags"].items())[:30]))
|
||||
out.append("")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Referenzschicht für den Vault bauen")
|
||||
ap.add_argument("--vault", default=".", help="Pfad zum Vault (Standard: aktuelles Verzeichnis)")
|
||||
ap.add_argument("--no-assets", action="store_true", help="Nicht-Markdown-Dateien nicht indexieren")
|
||||
ap.add_argument("--exclude", action="append", default=[], help="Zusätzlicher Ordnername zum Überspringen (mehrfach möglich)")
|
||||
ap.add_argument("--quiet", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
vault = os.path.abspath(os.path.expanduser(args.vault))
|
||||
if not os.path.isdir(vault):
|
||||
print(f"Vault-Pfad existiert nicht: {vault}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
index = build(vault, not args.no_assets, args.exclude)
|
||||
|
||||
outdir = os.path.join(vault, V.INDEX_DIR)
|
||||
os.makedirs(outdir, exist_ok=True)
|
||||
with open(os.path.join(outdir, V.INDEX_JSON), "w", encoding="utf-8") as fh:
|
||||
json.dump({"meta": index["meta"], "notes": index["notes"]}, fh,
|
||||
ensure_ascii=False, separators=(",", ":"))
|
||||
with open(os.path.join(outdir, "backlinks.json"), "w", encoding="utf-8") as fh:
|
||||
json.dump(index["backlinks"], fh, ensure_ascii=False, separators=(",", ":"))
|
||||
with open(os.path.join(outdir, V.INDEX_MD), "w", encoding="utf-8") as fh:
|
||||
fh.write(render_markdown(index))
|
||||
|
||||
if not args.quiet:
|
||||
m = index["meta"]
|
||||
print(f"Index gebaut: {m['notes']} Notizen, {m['assets']} Dateien, "
|
||||
f"{m['words']} Wörter")
|
||||
print("Typen: " + ", ".join(f"{k}={v}" for k, v in m["types"].items()))
|
||||
missing = [n["p"] for n in index["notes"]
|
||||
if n.get("t") != "asset" and not n.get("m")]
|
||||
if missing:
|
||||
print(f"Ohne summary: {len(missing)} Notizen "
|
||||
f"(vault_doctor.py zeigt welche)")
|
||||
if m["broken_links"]:
|
||||
print(f"Tote Wikilinks: {m['broken_links']} (vault_doctor.py --links)")
|
||||
print(f"Geschrieben nach {os.path.join(V.INDEX_DIR, '')}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Legt eine neue Notiz aus einem Template an — mit korrektem Frontmatter,
|
||||
korrektem Ordner und stabiler uid.
|
||||
|
||||
Von Hand geschriebenes Frontmatter driftet; deshalb dieser Weg. Bestehende
|
||||
Dateien werden nie überschrieben.
|
||||
|
||||
Beispiele:
|
||||
python3 scripts/new_note.py --type project-code --title "Auth Service" \\
|
||||
--tags go,oidc --summary "OIDC-Auth für die interne Toolchain"
|
||||
python3 scripts/new_note.py --type research --title "Rate Limiting Strategien" \\
|
||||
--project auth-service
|
||||
python3 scripts/new_note.py --type decision --title "Postgres statt Mongo" \\
|
||||
--project auth-service
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import vaultlib as V # noqa: E402
|
||||
|
||||
SKILL_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
TEMPLATE_DIR = os.path.join(SKILL_DIR, "assets", "templates")
|
||||
|
||||
# type -> (Zielpfad-Muster, eigener Ordner?)
|
||||
TARGETS = {
|
||||
"project-code": ("10_Projects/{slug}/index.md", True),
|
||||
"template-project": ("40_Templates/project-templates/{slug}/index.md", True),
|
||||
"research": ("30_Research/{slug}.md", False),
|
||||
"area": ("20_Areas/{slug}.md", False),
|
||||
"resource": ("50_Resources/{slug}.md", False),
|
||||
"moc": ("20_Areas/{slug}.md", False),
|
||||
"decision": ("10_Projects/{project}/decisions/{slug}.md", False),
|
||||
"inbox": ("00_Inbox/{slug}.md", False),
|
||||
}
|
||||
|
||||
DEFAULT_STATUS = {
|
||||
"project-code": "active", "research": "active", "area": "reference",
|
||||
"resource": "reference", "moc": "reference", "decision": "done",
|
||||
"template-project": "reference", "inbox": "idea",
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Notiz aus Template anlegen")
|
||||
ap.add_argument("--vault", default=".")
|
||||
ap.add_argument("--type", required=True, choices=sorted(TARGETS))
|
||||
ap.add_argument("--title", required=True)
|
||||
ap.add_argument("--summary", default="")
|
||||
ap.add_argument("--tags", default="")
|
||||
ap.add_argument("--status", default="")
|
||||
ap.add_argument("--project", default="", help="Projekt-Slug (Pflicht bei --type decision)")
|
||||
ap.add_argument("--out", default="", help="Zielpfad relativ zum Vault überschreiben")
|
||||
ap.add_argument("--print-only", action="store_true", help="Inhalt ausgeben statt schreiben")
|
||||
args = ap.parse_args()
|
||||
|
||||
vault = os.path.abspath(os.path.expanduser(args.vault))
|
||||
slug = V.slugify(args.title)
|
||||
pattern, _ = TARGETS[args.type]
|
||||
|
||||
if args.out:
|
||||
rel = args.out
|
||||
else:
|
||||
if "{project}" in pattern and not args.project:
|
||||
print("--project ist bei diesem Typ erforderlich", file=sys.stderr)
|
||||
return 2
|
||||
rel = pattern.format(slug=slug, project=V.slugify(args.project or "misc"))
|
||||
|
||||
dest = os.path.join(vault, rel)
|
||||
if os.path.exists(dest) and not args.print_only:
|
||||
print(f"Existiert bereits: {rel} — nichts geschrieben.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
tpl_path = os.path.join(TEMPLATE_DIR, f"{args.type}.md")
|
||||
if not os.path.exists(tpl_path):
|
||||
tpl_path = os.path.join(TEMPLATE_DIR, "resource.md")
|
||||
with open(tpl_path, "r", encoding="utf-8") as fh:
|
||||
tpl = fh.read()
|
||||
|
||||
today = V.today()
|
||||
values = {
|
||||
"{{uid}}": f"{today.replace('-', '')}-{slug[:40]}",
|
||||
"{{title}}": args.title,
|
||||
"{{summary}}": args.summary or "TODO: ein Satz, worum es geht.",
|
||||
"{{status}}": args.status or DEFAULT_STATUS.get(args.type, "active"),
|
||||
"{{tags}}": ", ".join(V.as_list(args.tags)),
|
||||
"{{project}}": args.project,
|
||||
"{{date}}": today,
|
||||
"{{slug}}": slug,
|
||||
}
|
||||
content = tpl
|
||||
for k, v in values.items():
|
||||
content = content.replace(k, v)
|
||||
|
||||
if args.print_only:
|
||||
print(content)
|
||||
return 0
|
||||
|
||||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||
with open(dest, "w", encoding="utf-8") as fh:
|
||||
fh.write(content)
|
||||
print(rel)
|
||||
print("Angelegt. Danach: summary prüfen, Referenzen ergänzen, "
|
||||
"build_index.py laufen lassen.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prüft den Vault auf alles, was die Referenzschicht unbrauchbar macht.
|
||||
|
||||
Standard ist ein reiner Bericht. `--fix-missing --apply` ergänzt ausschließlich
|
||||
fehlende Frontmatter-Felder (uid, type, created, updated, status) und fasst
|
||||
nie bestehende Werte oder Fließtext an.
|
||||
|
||||
Beispiele:
|
||||
python3 scripts/vault_doctor.py --vault ~/Vault
|
||||
python3 scripts/vault_doctor.py --stale-days 180
|
||||
python3 scripts/vault_doctor.py --fix-missing # Trockenlauf
|
||||
python3 scripts/vault_doctor.py --fix-missing --apply
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import vaultlib as V # noqa: E402
|
||||
|
||||
REQUIRED = ["type", "title", "status", "summary", "updated"]
|
||||
|
||||
|
||||
def collect(vault: str):
|
||||
notes = []
|
||||
for ap, rp in V.walk_vault(vault):
|
||||
if rp.endswith(".md"):
|
||||
with open(ap, "r", encoding="utf-8", errors="replace") as fh:
|
||||
text = fh.read()
|
||||
fm, body = V.parse_frontmatter(text)
|
||||
notes.append({"abs": ap, "rel": rp, "fm": fm, "body": body, "raw": text})
|
||||
return notes
|
||||
|
||||
|
||||
def section(title: str, items, formatter=str, limit=25):
|
||||
if not items:
|
||||
return []
|
||||
out = [f"\n## {title} ({len(items)})"]
|
||||
for it in items[:limit]:
|
||||
out.append(" - " + formatter(it))
|
||||
if len(items) > limit:
|
||||
out.append(f" … {len(items) - limit} weitere")
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Vault-Gesundheitscheck")
|
||||
ap.add_argument("--vault", default=".")
|
||||
ap.add_argument("--stale-days", type=int, default=365)
|
||||
ap.add_argument("--inbox-days", type=int, default=14)
|
||||
ap.add_argument("--fix-missing", action="store_true",
|
||||
help="fehlende Frontmatter-Felder ergänzen (Trockenlauf ohne --apply)")
|
||||
ap.add_argument("--apply", action="store_true", help="Änderungen wirklich schreiben")
|
||||
args = ap.parse_args()
|
||||
|
||||
vault = os.path.abspath(os.path.expanduser(args.vault))
|
||||
notes = collect(vault)
|
||||
if not notes:
|
||||
print("Keine Markdown-Dateien gefunden.")
|
||||
return 1
|
||||
|
||||
lookup = set()
|
||||
for n in notes:
|
||||
rel = n["rel"]
|
||||
lookup |= {rel, os.path.splitext(rel)[0], os.path.basename(rel),
|
||||
os.path.splitext(os.path.basename(rel))[0]}
|
||||
if n["fm"].get("title"):
|
||||
lookup.add(str(n["fm"]["title"]))
|
||||
if n["fm"].get("uid"):
|
||||
lookup.add(str(n["fm"]["uid"]))
|
||||
|
||||
no_fm, missing_fields, bad_type, bad_status = [], [], [], []
|
||||
broken, dup_uid, dup_title, stale, old_inbox = [], defaultdict(list), defaultdict(list), [], []
|
||||
inbound = defaultdict(int)
|
||||
|
||||
import datetime as dt
|
||||
today = dt.date.today()
|
||||
|
||||
for n in notes:
|
||||
fm, rel = n["fm"], n["rel"]
|
||||
if not fm:
|
||||
no_fm.append(rel)
|
||||
else:
|
||||
miss = [f for f in REQUIRED if not str(fm.get(f, "")).strip()]
|
||||
if miss:
|
||||
missing_fields.append((rel, miss))
|
||||
t = str(fm.get("type", "")).strip()
|
||||
if t and t not in V.KNOWN_TYPES:
|
||||
bad_type.append((rel, t))
|
||||
s = str(fm.get("status", "")).strip()
|
||||
if s and s not in V.KNOWN_STATUS:
|
||||
bad_status.append((rel, s))
|
||||
if fm.get("uid"):
|
||||
dup_uid[str(fm["uid"])].append(rel)
|
||||
title = V.derive_title(fm, n["body"], rel)
|
||||
dup_title[title.lower()].append(rel)
|
||||
|
||||
for raw in V.extract_links(n["body"]):
|
||||
t = raw.strip().lstrip("./")
|
||||
if t in lookup or t + ".md" in lookup or os.path.basename(t) in lookup:
|
||||
inbound[t] += 1
|
||||
else:
|
||||
broken.append((rel, raw))
|
||||
|
||||
mtime = dt.date.fromtimestamp(os.path.getmtime(n["abs"]))
|
||||
upd = str(fm.get("updated", "")).strip()
|
||||
try:
|
||||
ref = dt.date.fromisoformat(upd[:10]) if upd else mtime
|
||||
except ValueError:
|
||||
ref = mtime
|
||||
age = (today - ref).days
|
||||
if age > args.stale_days:
|
||||
stale.append((rel, age))
|
||||
if rel.startswith("00_Inbox/") and age > args.inbox_days:
|
||||
old_inbox.append((rel, age))
|
||||
|
||||
linked = set()
|
||||
for n in notes:
|
||||
for raw in V.extract_links(n["body"]):
|
||||
t = raw.strip().lstrip("./")
|
||||
for cand in (t, t + ".md", os.path.basename(t), os.path.basename(t) + ".md"):
|
||||
linked.add(cand)
|
||||
orphans = [
|
||||
n["rel"] for n in notes
|
||||
if not (set([n["rel"], os.path.splitext(n["rel"])[0],
|
||||
os.path.basename(n["rel"]),
|
||||
os.path.splitext(os.path.basename(n["rel"]))[0]]) & linked)
|
||||
and not n["rel"].startswith(("_index/", "40_Templates/"))
|
||||
]
|
||||
|
||||
report = [f"# Vault-Doctor — {len(notes)} Notizen in {vault}"]
|
||||
report += section("Ohne Frontmatter", no_fm)
|
||||
report += section("Pflichtfelder fehlen", missing_fields,
|
||||
lambda x: f"{x[0]} → fehlt: {', '.join(x[1])}")
|
||||
report += section("Unbekannter type", bad_type, lambda x: f"{x[0]} → '{x[1]}'")
|
||||
report += section("Unbekannter status", bad_status, lambda x: f"{x[0]} → '{x[1]}'")
|
||||
report += section("Tote Wikilinks", broken, lambda x: f"{x[0]} → [[{x[1]}]]")
|
||||
report += section("Doppelte uid", [(k, v) for k, v in dup_uid.items() if len(v) > 1],
|
||||
lambda x: f"{x[0]}: {', '.join(x[1])}")
|
||||
report += section("Doppelte Titel", [(k, v) for k, v in dup_title.items() if len(v) > 1],
|
||||
lambda x: f"{x[0]}: {', '.join(x[1])}")
|
||||
report += section("Verwaist (keine eingehenden Links)", orphans)
|
||||
report += section(f"Liegt länger als {args.inbox_days} Tage in der Inbox", old_inbox,
|
||||
lambda x: f"{x[0]} ({x[1]} Tage)")
|
||||
report += section(f"Seit über {args.stale_days} Tagen unangetastet", stale,
|
||||
lambda x: f"{x[0]} ({x[1]} Tage)")
|
||||
|
||||
problems = (len(no_fm) + len(missing_fields) + len(bad_type) + len(bad_status)
|
||||
+ len(broken) + len(orphans))
|
||||
if problems == 0:
|
||||
report.append("\nKeine strukturellen Probleme gefunden.")
|
||||
else:
|
||||
report.append(f"\nBefunde gesamt: {problems}")
|
||||
print("\n".join(report))
|
||||
|
||||
if args.fix_missing:
|
||||
changed = []
|
||||
for n in notes:
|
||||
fm, rel = dict(n["fm"]), n["rel"]
|
||||
before = dict(fm)
|
||||
if not str(fm.get("type", "")).strip():
|
||||
fm["type"] = V.infer_type(rel)
|
||||
if not str(fm.get("title", "")).strip():
|
||||
fm["title"] = V.derive_title(n["fm"], n["body"], rel)
|
||||
if not str(fm.get("uid", "")).strip():
|
||||
import datetime as _d
|
||||
stamp = _d.date.fromtimestamp(os.path.getmtime(n["abs"])).strftime("%Y%m%d")
|
||||
fm["uid"] = f"{stamp}-{V.slugify(fm['title'])[:40]}"
|
||||
if not str(fm.get("status", "")).strip():
|
||||
if rel.startswith("90_Archive/"):
|
||||
fm["status"] = "archived"
|
||||
elif rel.startswith("00_Inbox/"):
|
||||
fm["status"] = "idea"
|
||||
else:
|
||||
fm["status"] = "active"
|
||||
if not str(fm.get("summary", "")).strip():
|
||||
s = V.derive_summary(n["body"])
|
||||
if s:
|
||||
fm["summary"] = s
|
||||
if not str(fm.get("created", "")).strip():
|
||||
import datetime as _d
|
||||
fm["created"] = _d.date.fromtimestamp(os.path.getmtime(n["abs"])).isoformat()
|
||||
if not str(fm.get("updated", "")).strip():
|
||||
fm["updated"] = fm["created"]
|
||||
ordered = {}
|
||||
for k in ("uid", "type", "title", "status", "summary", "tags",
|
||||
"project", "created", "updated"):
|
||||
if k in fm:
|
||||
ordered[k] = fm[k]
|
||||
for k, v in fm.items():
|
||||
ordered.setdefault(k, v)
|
||||
if ordered != before:
|
||||
changed.append((n, ordered))
|
||||
|
||||
print(f"\n## Frontmatter-Ergänzungen: {len(changed)} Datei(en)")
|
||||
for n, fm in changed[:15]:
|
||||
added = [k for k in fm if k not in n["fm"]]
|
||||
print(f" - {n['rel']}: + {', '.join(added) or 'Feldreihenfolge'}")
|
||||
if len(changed) > 15:
|
||||
print(f" … {len(changed) - 15} weitere")
|
||||
if not args.apply:
|
||||
print("\n(Trockenlauf — mit --apply schreiben)")
|
||||
else:
|
||||
for n, fm in changed:
|
||||
body = n["body"] if n["fm"] else n["raw"]
|
||||
with open(n["abs"], "w", encoding="utf-8") as fh:
|
||||
fh.write(V.dump_frontmatter(fm) + "\n" + body.lstrip("\n"))
|
||||
print(f"\n{len(changed)} Datei(en) geschrieben. "
|
||||
"Jetzt build_index.py erneut laufen lassen.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fragt die Referenzschicht ab und gibt kompakte Treffer aus.
|
||||
|
||||
Das ist der Standardeinstieg in den Vault: erst hier suchen, dann nur die
|
||||
Treffer öffnen. Nie `vault-index.json` am Stück lesen.
|
||||
|
||||
Beispiele:
|
||||
python3 scripts/vault_query.py --text "rate limiting"
|
||||
python3 scripts/vault_query.py --type project-code --status active
|
||||
python3 scripts/vault_query.py --tag k8s --since 30 --format md
|
||||
python3 scripts/vault_query.py --links-to 10_Projects/auth-service/index.md
|
||||
python3 scripts/vault_query.py --orphans --type research
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import vaultlib as V # noqa: E402
|
||||
|
||||
|
||||
def matches_text(note: dict, needles) -> bool:
|
||||
hay = " ".join([
|
||||
note.get("n", ""), note.get("m", ""), note.get("p", ""),
|
||||
" ".join(note.get("g", [])), " ".join(note.get("h", [])),
|
||||
note.get("r", ""), note.get("u", ""),
|
||||
]).lower()
|
||||
return all(n in hay for n in needles)
|
||||
|
||||
|
||||
def days_ago(iso_date: str) -> int | None:
|
||||
import datetime as dt
|
||||
try:
|
||||
d = dt.date.fromisoformat(iso_date[:10])
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return (dt.date.today() - d).days
|
||||
|
||||
|
||||
def fmt_compact(n: dict) -> str:
|
||||
bits = [n["p"], n.get("n", "")]
|
||||
tail = n.get("m", "")
|
||||
meta = []
|
||||
if n.get("t"):
|
||||
meta.append(n["t"])
|
||||
if n.get("s"):
|
||||
meta.append(n["s"])
|
||||
head = " :: ".join(b for b in bits if b)
|
||||
if meta:
|
||||
head += f" [{'/'.join(meta)}]"
|
||||
return head + (f" :: {tail}" if tail else "")
|
||||
|
||||
|
||||
def fmt_md(n: dict) -> str:
|
||||
link = os.path.splitext(n["p"])[0]
|
||||
line = f"- [[{link}|{n.get('n', link)}]]"
|
||||
if n.get("s"):
|
||||
line += f" `{n['s']}`"
|
||||
if n.get("m"):
|
||||
line += f" — {n['m']}"
|
||||
return line
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Vault-Index abfragen")
|
||||
ap.add_argument("--vault", default=".")
|
||||
ap.add_argument("--text", action="append", default=[],
|
||||
help="Suchbegriff in Titel/Summary/Pfad/Tags/Überschriften (mehrfach = UND)")
|
||||
ap.add_argument("--type", action="append", default=[])
|
||||
ap.add_argument("--status", action="append", default=[])
|
||||
ap.add_argument("--tag", action="append", default=[])
|
||||
ap.add_argument("--project", default=None)
|
||||
ap.add_argument("--path", default=None, help="Pfadpräfix, z. B. 30_Research")
|
||||
ap.add_argument("--since", type=int, default=None, help="nur Notizen, die in den letzten N Tagen aktualisiert wurden")
|
||||
ap.add_argument("--stale", type=int, default=None, help="nur Notizen, die seit N Tagen nicht angefasst wurden")
|
||||
ap.add_argument("--links-to", default=None, help="Notizen, die auf dieses Ziel verlinken (Pfad oder uid)")
|
||||
ap.add_argument("--orphans", action="store_true", help="nur Notizen ohne eingehende Links")
|
||||
ap.add_argument("--no-summary", action="store_true", help="nur Notizen ohne summary (Pflegeliste)")
|
||||
ap.add_argument("--limit", type=int, default=40)
|
||||
ap.add_argument("--format", choices=["compact", "paths", "md", "json", "headings"],
|
||||
default="compact")
|
||||
ap.add_argument("--sort", choices=["path", "updated", "title"], default="path")
|
||||
args = ap.parse_args()
|
||||
|
||||
vault = os.path.abspath(os.path.expanduser(args.vault))
|
||||
index = V.load_index(vault)
|
||||
notes = index["notes"]
|
||||
meta = index.get("meta", {})
|
||||
|
||||
backlinks = {}
|
||||
bl_path = os.path.join(vault, V.INDEX_DIR, "backlinks.json")
|
||||
if os.path.exists(bl_path):
|
||||
with open(bl_path, "r", encoding="utf-8") as fh:
|
||||
backlinks = json.load(fh)
|
||||
|
||||
needles = [t.lower() for t in args.text]
|
||||
types = {t.lower() for t in args.type}
|
||||
stati = {s.lower() for s in args.status}
|
||||
tags = {t.lower().lstrip("#") for t in args.tag}
|
||||
|
||||
hits = []
|
||||
for n in notes:
|
||||
if types and n.get("t", "").lower() not in types:
|
||||
continue
|
||||
if stati and n.get("s", "").lower() not in stati:
|
||||
continue
|
||||
if tags and not tags.issubset({g.lower().lstrip("#") for g in n.get("g", [])}):
|
||||
continue
|
||||
if args.project and n.get("r", "").lower() != args.project.lower():
|
||||
continue
|
||||
if args.path and not n["p"].startswith(args.path.rstrip("/")):
|
||||
continue
|
||||
if needles and not matches_text(n, needles):
|
||||
continue
|
||||
is_asset = n.get("t") == "asset"
|
||||
if args.no_summary and (n.get("m") or (is_asset and "asset" not in types)):
|
||||
continue
|
||||
age = days_ago(n.get("d", ""))
|
||||
if args.since is not None and (age is None or age > args.since):
|
||||
continue
|
||||
if args.stale is not None and (age is None or age < args.stale):
|
||||
continue
|
||||
if args.orphans and (backlinks.get(n["p"]) or (is_asset and "asset" not in types)):
|
||||
continue
|
||||
if args.links_to:
|
||||
target = args.links_to
|
||||
resolved = None
|
||||
for cand in notes:
|
||||
if target in (cand.get("p"), cand.get("u"), cand.get("n")):
|
||||
resolved = cand["p"]
|
||||
break
|
||||
resolved = resolved or target
|
||||
if n["p"] not in backlinks.get(resolved, []):
|
||||
continue
|
||||
hits.append(n)
|
||||
|
||||
if args.sort == "updated":
|
||||
hits.sort(key=lambda n: n.get("d", ""), reverse=True)
|
||||
elif args.sort == "title":
|
||||
hits.sort(key=lambda n: n.get("n", "").lower())
|
||||
|
||||
total = len(hits)
|
||||
hits = hits[: args.limit]
|
||||
|
||||
if args.format == "json":
|
||||
print(json.dumps(hits, ensure_ascii=False, indent=1))
|
||||
elif args.format == "paths":
|
||||
for n in hits:
|
||||
print(n["p"])
|
||||
elif args.format == "md":
|
||||
for n in hits:
|
||||
print(fmt_md(n))
|
||||
elif args.format == "headings":
|
||||
for n in hits:
|
||||
print(fmt_compact(n))
|
||||
for h in n.get("h", []):
|
||||
print(f" # {h}")
|
||||
else:
|
||||
for n in hits:
|
||||
print(fmt_compact(n))
|
||||
|
||||
if not hits:
|
||||
print("(keine Treffer — Filter lockern oder als Fallback grep über den Vault)",
|
||||
file=sys.stderr)
|
||||
if total > len(hits):
|
||||
print(f"… {total - len(hits)} weitere Treffer (--limit erhöhen)", file=sys.stderr)
|
||||
|
||||
newer = V.count_newer_than(vault, meta.get("generated", ""))
|
||||
if newer:
|
||||
print(f"[Hinweis] {newer} Notiz(en) sind neuer als der Index — "
|
||||
"build_index.py erneut laufen lassen.", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,323 @@
|
||||
"""Gemeinsame Helfer für den obsidian-vault-navigator.
|
||||
|
||||
Bewusst ohne externe Abhängigkeiten (kein PyYAML), damit die Skripte überall
|
||||
laufen: lokal, in Claude Code, in CI. Der YAML-Parser deckt genau die Teilmenge
|
||||
ab, die Obsidian-Properties benutzen: Skalare, Inline-Listen und Block-Listen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
# --- Konfiguration -----------------------------------------------------------
|
||||
|
||||
INDEX_DIR = "_index"
|
||||
INDEX_JSON = "vault-index.json"
|
||||
INDEX_MD = "INDEX.md"
|
||||
|
||||
SKIP_DIRS = {
|
||||
".obsidian", ".git", ".trash", ".claude", ".agents", ".venv",
|
||||
"node_modules", "__pycache__", ".svelte-kit", "dist", "build",
|
||||
INDEX_DIR,
|
||||
}
|
||||
|
||||
ASSET_EXTS = {
|
||||
".pdf", ".docx", ".doc", ".xlsx", ".xls", ".pptx", ".csv", ".json",
|
||||
".yaml", ".yml", ".toml", ".png", ".jpg", ".jpeg", ".svg", ".webp",
|
||||
".canvas", ".base", ".sql", ".sh", ".py", ".ts", ".js", ".go", ".rs",
|
||||
}
|
||||
|
||||
# Pfadpräfix -> Typ. Frontmatter `type` gewinnt immer gegen diese Ableitung.
|
||||
FOLDER_TYPES = [
|
||||
("40_Templates/note-templates", "note-template"),
|
||||
("40_Templates/project-templates", "template-project"),
|
||||
("40_Templates", "note-template"),
|
||||
("00_Inbox", "inbox"),
|
||||
("10_Projects", "project-code"),
|
||||
("20_Areas", "area"),
|
||||
("30_Research", "research"),
|
||||
("50_Resources", "resource"),
|
||||
("90_Archive", "archive"),
|
||||
]
|
||||
|
||||
KNOWN_TYPES = {
|
||||
"project-code", "template-project", "note-template", "research", "area",
|
||||
"resource", "moc", "decision", "inbox", "archive", "asset", "note",
|
||||
}
|
||||
|
||||
KNOWN_STATUS = {"idea", "active", "paused", "done", "archived", "reference"}
|
||||
|
||||
SUMMARY_MAX = 160
|
||||
MAX_HEADINGS = 12
|
||||
MAX_LINKS = 40
|
||||
|
||||
# --- Kleiner YAML-Parser -----------------------------------------------------
|
||||
|
||||
_FM_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*(?:\n|\Z)", re.DOTALL)
|
||||
_WIKILINK_RE = re.compile(r"\[\[([^\]\|#\^]+)")
|
||||
_MDLINK_RE = re.compile(r"\[[^\]]*\]\(([^)\s]+\.md)\)")
|
||||
_HEADING_RE = re.compile(r"^(#{1,3})\s+(.+?)\s*$", re.MULTILINE)
|
||||
|
||||
|
||||
def _scalar(raw: str):
|
||||
v = raw.strip()
|
||||
if not v:
|
||||
return ""
|
||||
if v[0] in "\"'" and len(v) > 1 and v[-1] == v[0]:
|
||||
return v[1:-1]
|
||||
if v.startswith("[") and v.endswith("]"):
|
||||
inner = v[1:-1].strip()
|
||||
if not inner:
|
||||
return []
|
||||
return [_scalar(x) for x in _split_inline(inner)]
|
||||
low = v.lower()
|
||||
if low in ("true", "yes"):
|
||||
return True
|
||||
if low in ("false", "no"):
|
||||
return False
|
||||
if low in ("null", "~", "none"):
|
||||
return None
|
||||
if re.fullmatch(r"-?\d+", v):
|
||||
try:
|
||||
return int(v)
|
||||
except ValueError:
|
||||
return v
|
||||
return v
|
||||
|
||||
|
||||
def _split_inline(s: str):
|
||||
out, buf, quote = [], "", None
|
||||
for ch in s:
|
||||
if quote:
|
||||
if ch == quote:
|
||||
quote = None
|
||||
buf += ch
|
||||
elif ch in "\"'":
|
||||
quote = ch
|
||||
buf += ch
|
||||
elif ch == ",":
|
||||
out.append(buf)
|
||||
buf = ""
|
||||
else:
|
||||
buf += ch
|
||||
if buf.strip():
|
||||
out.append(buf)
|
||||
return [x for x in (i.strip() for i in out) if x]
|
||||
|
||||
|
||||
def parse_frontmatter(text: str):
|
||||
"""Gibt (dict, body) zurück. Ohne Frontmatter: ({}, text)."""
|
||||
m = _FM_RE.match(text)
|
||||
if not m:
|
||||
return {}, text
|
||||
block, body = m.group(1), text[m.end():]
|
||||
data, key, pending = {}, None, None
|
||||
for line in block.split("\n"):
|
||||
if not line.strip() or line.lstrip().startswith("#"):
|
||||
continue
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("- ") and key is not None:
|
||||
if pending is None:
|
||||
pending = []
|
||||
data[key] = pending
|
||||
pending.append(_scalar(stripped[2:]))
|
||||
continue
|
||||
if ":" in line and not line.startswith(" "):
|
||||
k, _, v = line.partition(":")
|
||||
key = k.strip()
|
||||
pending = None
|
||||
val = _scalar(v)
|
||||
data[key] = val
|
||||
if val == "":
|
||||
pending = None
|
||||
return data, body
|
||||
|
||||
|
||||
def dump_frontmatter(data: dict) -> str:
|
||||
lines = ["---"]
|
||||
for k, v in data.items():
|
||||
if isinstance(v, list):
|
||||
inner = ", ".join(str(x) for x in v)
|
||||
lines.append(f"{k}: [{inner}]")
|
||||
elif isinstance(v, bool):
|
||||
lines.append(f"{k}: {'true' if v else 'false'}")
|
||||
elif v is None:
|
||||
lines.append(f"{k}:")
|
||||
else:
|
||||
s = str(v)
|
||||
if ":" in s or s.strip() != s:
|
||||
s = '"' + s.replace('"', "'") + '"'
|
||||
lines.append(f"{k}: {s}")
|
||||
lines.append("---")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
# --- Ableitungen -------------------------------------------------------------
|
||||
|
||||
|
||||
def slugify(text: str) -> str:
|
||||
text = unicodedata.normalize("NFKD", str(text))
|
||||
text = text.replace("ß", "ss")
|
||||
text = "".join(c for c in text if not unicodedata.combining(c))
|
||||
text = re.sub(r"[^A-Za-z0-9]+", "-", text).strip("-").lower()
|
||||
return text or "note"
|
||||
|
||||
|
||||
def infer_type(rel_path: str) -> str:
|
||||
p = rel_path.replace(os.sep, "/")
|
||||
for prefix, t in FOLDER_TYPES:
|
||||
if p.startswith(prefix + "/") or p == prefix:
|
||||
return t
|
||||
return "note"
|
||||
|
||||
|
||||
def clean_inline(text: str) -> str:
|
||||
text = re.sub(r"!?\[\[([^\]\|]*\|)?([^\]]+)\]\]", r"\2", text)
|
||||
text = re.sub(r"!?\[([^\]]*)\]\([^)]*\)", r"\1", text)
|
||||
text = re.sub(r"[*_`>]+", "", text)
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
|
||||
def derive_summary(body: str) -> str:
|
||||
for para in re.split(r"\n\s*\n", body):
|
||||
para = para.strip()
|
||||
if not para or para.startswith("#") or para.startswith("---"):
|
||||
continue
|
||||
if para.startswith(("- ", "* ", "|", "```", ">")):
|
||||
continue
|
||||
s = clean_inline(para)
|
||||
if not s:
|
||||
continue
|
||||
if len(s) > SUMMARY_MAX:
|
||||
s = s[: SUMMARY_MAX - 1].rsplit(" ", 1)[0] + "…"
|
||||
return s
|
||||
return ""
|
||||
|
||||
|
||||
def derive_title(fm: dict, body: str, rel_path: str) -> str:
|
||||
if fm.get("title"):
|
||||
return str(fm["title"])
|
||||
m = re.search(r"^#\s+(.+)$", body, re.MULTILINE)
|
||||
if m:
|
||||
return clean_inline(m.group(1))
|
||||
return os.path.splitext(os.path.basename(rel_path))[0]
|
||||
|
||||
|
||||
def extract_headings(body: str):
|
||||
out = []
|
||||
for m in _HEADING_RE.finditer(body):
|
||||
h = clean_inline(m.group(2))
|
||||
if h and h not in out:
|
||||
out.append(h[:60])
|
||||
if len(out) >= MAX_HEADINGS:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def extract_links(body: str):
|
||||
out = []
|
||||
for m in _WIKILINK_RE.finditer(body):
|
||||
t = m.group(1).strip()
|
||||
if t and t not in out:
|
||||
out.append(t)
|
||||
for m in _MDLINK_RE.finditer(body):
|
||||
t = m.group(1).strip()
|
||||
if t and t not in out:
|
||||
out.append(t)
|
||||
return out[:MAX_LINKS]
|
||||
|
||||
|
||||
def as_list(v):
|
||||
if v is None or v == "":
|
||||
return []
|
||||
if isinstance(v, list):
|
||||
return [str(x) for x in v if str(x).strip()]
|
||||
return [x.strip() for x in str(v).replace(";", ",").split(",") if x.strip()]
|
||||
|
||||
|
||||
def today() -> str:
|
||||
return _dt.date.today().isoformat()
|
||||
|
||||
|
||||
def iso_now() -> str:
|
||||
return _dt.datetime.now().replace(microsecond=0).isoformat()
|
||||
|
||||
|
||||
# --- Vault-Scan --------------------------------------------------------------
|
||||
|
||||
|
||||
def walk_vault(vault: str, extra_excludes=()):
|
||||
"""Liefert (abs_path, rel_path) für alle Dateien außerhalb der Skip-Ordner."""
|
||||
excludes = set(SKIP_DIRS) | set(extra_excludes)
|
||||
for root, dirs, files in os.walk(vault):
|
||||
dirs[:] = [
|
||||
d for d in dirs
|
||||
if d not in excludes and not d.startswith(".")
|
||||
]
|
||||
for f in sorted(files):
|
||||
if f.startswith("."):
|
||||
continue
|
||||
ap = os.path.join(root, f)
|
||||
rp = os.path.relpath(ap, vault).replace(os.sep, "/")
|
||||
yield ap, rp
|
||||
|
||||
|
||||
def read_note(abs_path: str, rel_path: str) -> dict:
|
||||
with open(abs_path, "r", encoding="utf-8", errors="replace") as fh:
|
||||
text = fh.read()
|
||||
fm, body = parse_frontmatter(text)
|
||||
ntype = str(fm.get("type") or "").strip() or infer_type(rel_path)
|
||||
summary = str(fm.get("summary") or "").strip() or derive_summary(body)
|
||||
if len(summary) > SUMMARY_MAX:
|
||||
summary = summary[: SUMMARY_MAX - 1] + "…"
|
||||
mtime = _dt.date.fromtimestamp(os.path.getmtime(abs_path)).isoformat()
|
||||
project = str(fm.get("project") or "").strip()
|
||||
if not project and rel_path.startswith("10_Projects/"):
|
||||
parts = rel_path.split("/")
|
||||
if len(parts) > 2:
|
||||
project = parts[1]
|
||||
note = {
|
||||
"p": rel_path,
|
||||
"u": str(fm.get("uid") or "").strip(),
|
||||
"t": ntype,
|
||||
"s": str(fm.get("status") or "").strip(),
|
||||
"n": derive_title(fm, body, rel_path),
|
||||
"m": summary,
|
||||
"g": as_list(fm.get("tags")),
|
||||
"r": project,
|
||||
"c": str(fm.get("created") or "").strip(),
|
||||
"d": str(fm.get("updated") or "").strip() or mtime,
|
||||
"h": extract_headings(body),
|
||||
"l": extract_links(body),
|
||||
"w": len(body.split()),
|
||||
}
|
||||
return {k: v for k, v in note.items() if v not in ("", [], None)}
|
||||
|
||||
|
||||
def load_index(vault: str) -> dict:
|
||||
path = os.path.join(vault, INDEX_DIR, INDEX_JSON)
|
||||
if not os.path.exists(path):
|
||||
raise SystemExit(
|
||||
f"Kein Index gefunden unter {path}.\n"
|
||||
"Zuerst bauen: python3 scripts/build_index.py --vault "
|
||||
f"{vault}"
|
||||
)
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
def count_newer_than(vault: str, iso_ts: str) -> int:
|
||||
"""Wie viele .md-Dateien wurden nach dem Indexlauf geändert?"""
|
||||
try:
|
||||
ts = _dt.datetime.fromisoformat(iso_ts).timestamp()
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
n = 0
|
||||
for ap, rp in walk_vault(vault):
|
||||
if rp.endswith(".md") and os.path.getmtime(ap) > ts + 1:
|
||||
n += 1
|
||||
return n
|
||||
Reference in new issue
Block a user