#!/usr/bin/env python3 """Sync XKB bookmark markdown files into tiege-queue.json. This is the bridge between raw bookmark ingestion and run_bookmark_worker.py. It scans memory/bookmarks/**/*.md, reconciles existing cards, and preserves existing queue state where possible. """ from __future__ import annotations import json import os import re from datetime import datetime, timezone import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import xkb_failures import xkb_paths WORKSPACE = xkb_paths.WORKSPACE BOOKMARKS_DIR = xkb_paths.BOOKMARKS_DIR CARDS_DIR = xkb_paths.CARDS_DIR QUEUE_PATH = xkb_paths.QUEUE_PATH # abandoned 是重試用盡之後的終點(見 xkb_requeue_failed.py)。不列進來的話, # 下面那個「不認得就重設成 todo」會讓已經放棄的項目每次同步都回到佇列, # 於是「放棄」變成一個什麼都不代表的狀態。 VALID_STATUSES = {"todo", "processing", "done", "failed", "skipped", "abandoned"} def now_iso() -> str: return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") def rel(path: Path) -> str: try: return str(path.relative_to(WORKSPACE)) except ValueError: return str(path) def parse_frontmatter_value(text: str, key: str) -> str: m = re.search(rf"^{re.escape(key)}:\s*\"?([^\"\n]+)\"?\s*$", text, re.MULTILINE) return m.group(1).strip() if m else "" def parse_tweet_id(path: Path, text: str) -> str: for key in ("tweet_id", "id"): val = parse_frontmatter_value(text, key) if re.fullmatch(r"\d{10,}", val): return val return path.stem if re.fullmatch(r"\d{10,}", path.stem) else "" def parse_title(path: Path, text: str) -> str: title = parse_frontmatter_value(text, "title") if title: return title m = re.search(r"^#\s+(.+)$", text, re.MULTILINE) if m: return m.group(1).strip() return f"Tweet {path.stem}" if path.stem.isdigit() else path.stem def parse_source_url(text: str, tweet_id: str) -> str: for key in ("source_url", "original_url"): val = parse_frontmatter_value(text, key) if val: return val return f"https://x.com/i/status/{tweet_id}" if tweet_id else "" def category_for(path: Path) -> str: try: parts = path.relative_to(BOOKMARKS_DIR).parts # inbox files are still valid raw bookmarks, but their category is not final yet. if len(parts) >= 2 and parts[0] != "inbox": return parts[0] except Exception: pass return "99-general" def load_existing_queue() -> dict[str, dict]: if not QUEUE_PATH.exists(): return {} try: data = json.loads(QUEUE_PATH.read_text(encoding="utf-8")) return {str(item.get("id")): item for item in data.get("items", []) if item.get("id")} except Exception as err: # 佇列讀不動會被當成空佇列,於是已經處理過的東西全部重排一次。 xkb_failures.note("tiege queue", err, detail=str(QUEUE_PATH)) return {} def should_skip_path(path: Path) -> bool: name = path.name if name.startswith(".") or name in {"INDEX.md", "README.md"}: return True # Generated indexes/backups are not raw bookmarks. if "search_index" in name or "vector_index" in name or name.endswith(".bak"): return True if "notebooklm_exports" in path.parts: return True return False def main() -> int: existing = load_existing_queue() # 去重移走的卡片也算「處理過」,否則來源書籤會被重排、重新產出重複的卡。 existing_card_ids = xkb_paths.carded_ids() ts = now_iso() rows: list[dict] = [] seen: set[str] = set() for path in sorted(BOOKMARKS_DIR.rglob("*.md")): if should_skip_path(path): continue text = path.read_text(encoding="utf-8", errors="ignore") tweet_id = parse_tweet_id(path, text) if not tweet_id or tweet_id in seen: continue seen.add(tweet_id) old = existing.get(tweet_id, {}) status = old.get("status") or "todo" if status not in VALID_STATUSES: status = "todo" worker = old.get("worker", "") started_at = old.get("started_at", "") finished_at = old.get("finished_at", "") error = old.get("error", "") if tweet_id in existing_card_ids and status != "processing": status = "done" worker = worker or "reconcile" finished_at = finished_at or ts error = "" elif status in {"failed", "skipped", "abandoned"}: # Preserve explicit failed/skipped states; operator can reset if needed. pass elif status == "processing": # Avoid clobbering in-flight work. pass else: status = "todo" rows.append({ "id": tweet_id, "title": old.get("title") or parse_title(path, text), "source_path": rel(path), "source_url": old.get("source_url") or parse_source_url(text, tweet_id), "category": old.get("category") or category_for(path), "status": status, "priority": old.get("priority", "normal"), "worker": worker, "started_at": started_at, "finished_at": finished_at, "error": error, }) payload = { "version": 4, "updated_at": ts, "mode": "single-item", "notes": "Canonical single-item queue synced from memory/bookmarks for tiege processing and reconciled against memory/cards.", "items": rows, } QUEUE_PATH.parent.mkdir(parents=True, exist_ok=True) QUEUE_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") todo = sum(1 for r in rows if r["status"] == "todo") done = sum(1 for r in rows if r["status"] == "done") print(f"✅ queue synced: {QUEUE_PATH} ({len(rows)} items, todo={todo}, done={done})") return 0 import xkb_usage # noqa: E402 — 量測誰在跑,見 scripts/xkb_usage.py if __name__ == "__main__": xkb_usage.record(__file__) raise SystemExit(main())