"""Удаляет ghost docs из decisions/, sessions/, sources/ в CouchDB — те что есть в CouchDB но отсутствуют в локальном vault. """ import os from pathlib import Path from urllib.parse import quote import requests VAULT = Path(r"C:\Users\user\Documents\gendesign-memory") BASE = "https://obsidian.gendsgn.ru/gendesign-vault" AUTH = ("obsidian", os.environ["COUCHDB_PASSWORD"]) TARGETS = ("decisions/", "sessions/", "sources/") def main() -> None: # 1. Local files (case-insensitive set, forward-slash paths) local: set[str] = set() for top in ("decisions", "sessions", "sources"): d = VAULT / top if d.exists(): for f in d.rglob("*.md"): rel = str(f.relative_to(VAULT)).replace(os.sep, "/").lower() local.add(rel) print(f"Local files in target dirs: {len(local)}") # 2. Fetch ALL CouchDB docs (paginated — _all_docs усекается limit'ом, # а неполный alive-набор приведёт к удалению живых chunks как orphan). # Идём постранично через startkey_docid: last id страницы становится # startkey следующей, дублирующая первая строка отбрасывается. Цикл # завершается, когда страница вернула меньше page_size строк. page_size = 10000 rows: list[dict] = [] startkey_docid: str | None = None while True: params: dict[str, object] = {"limit": page_size} if startkey_docid is not None: # startkey должен быть JSON-строкой params["startkey"] = f'"{startkey_docid}"' params["startkey_docid"] = startkey_docid r = requests.get(f"{BASE}/_all_docs", auth=AUTH, params=params, timeout=60) page = r.json()["rows"] if startkey_docid is not None and page and page[0]["id"] == startkey_docid: # первая строка дублирует startkey предыдущей страницы — отбрасываем page = page[1:] rows.extend(page) # Полная страница (page_size до отбрасывания дубля) => возможно есть ещё. # Если строк меньше — это последняя страница. if len(page) + (1 if startkey_docid is not None else 0) < page_size: break startkey_docid = rows[-1]["id"] print(f"CouchDB docs fetched (paginated): {len(rows)}") # 3. Identify ghosts ghosts: list[tuple[str, str]] = [] keepers: list[str] = [] for row in rows: did = row["id"] if not did.endswith(".md"): continue if not did.startswith(TARGETS): continue if did.lower() in local: keepers.append(did) else: ghosts.append((did, row["value"]["rev"])) print(f"Keepers (matched local): {len(keepers)}") print(f"Ghosts (no local match): {len(ghosts)}") for did, _ in ghosts[:30]: print(f" ghost: {did}") if len(ghosts) > 30: print(f" ... +{len(ghosts) - 30} more") if not ghosts: print("Nothing to do.") return # 4. Сначала найдём chunks которые ссылаются ghosts. Это потенциальные orphans. ghost_chunk_refs: set[str] = set() for did, _ in ghosts: rd = requests.get(f"{BASE}/{quote(did, safe='')}", auth=AUTH, timeout=30) if rd.status_code == 200: d = rd.json() for cid in d.get("children", []): ghost_chunk_refs.add(cid) for cid in d.get("eden", {}): ghost_chunk_refs.add(cid) print(f"Chunks referenced by ghosts: {len(ghost_chunk_refs)}") # 5. Проверяем у скольких из них ещё есть alive parent ghost_ids = {did for did, _ in ghosts} alive_md = [r["id"] for r in rows if r["id"].endswith(".md") and r["id"] not in ghost_ids] print(f"Scanning {len(alive_md)} alive .md docs for chunk refs...") alive_chunks: set[str] = set() for i, did in enumerate(alive_md): if i % 100 == 0 and i > 0: print(f" {i}/{len(alive_md)}") rd = requests.get(f"{BASE}/{quote(did, safe='')}", auth=AUTH, timeout=30) if rd.status_code == 200: d = rd.json() for cid in d.get("children", []): alive_chunks.add(cid) for cid in d.get("eden", {}): alive_chunks.add(cid) orphans = ghost_chunk_refs - alive_chunks print(f"Orphan chunks (only ghosts reference them): {len(orphans)}") # 6. Получаем _rev для orphan chunks chunk_revs: dict[str, str] = {} chunk_id_to_rev = {r["id"]: r["value"]["rev"] for r in rows if r["id"].startswith("h:")} for cid in orphans: if cid in chunk_id_to_rev: chunk_revs[cid] = chunk_id_to_rev[cid] # 7. Bulk delete del_docs = [{"_id": did, "_rev": rev, "_deleted": True} for did, rev in ghosts] del_docs.extend({"_id": cid, "_rev": rev, "_deleted": True} for cid, rev in chunk_revs.items()) print(f"Bulk delete {len(del_docs)} docs ({len(ghosts)} ghost main + " f"{len(chunk_revs)} orphan chunks)...") rb = requests.post(f"{BASE}/_bulk_docs", json={"docs": del_docs}, auth=AUTH, timeout=300) if rb.status_code in (200, 201): ok = sum(1 for x in rb.json() if x.get("ok")) print(f"Deleted: {ok}/{len(del_docs)}") else: print(f"Bulk delete failed: HTTP {rb.status_code} {rb.text[:200]}") if __name__ == "__main__": main()