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
c064c729ed
82 files changed
+13428
No files matched your search
@@ -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