#!/usr/bin/env python3 """Durable local contact cache for the power-user Redmine client. Reuses RedmineClient from redmine_contacts.py. Contacts live under: /contacts/.json /contacts/manifest.json This is intentionally thinner than redmine_contacts.py's full CRM CLI — enough for sync + local search + show next to tickets. """ from __future__ import annotations import json from redmine_cache_io import cache_lock, read_json, atomic_write_json from datetime import datetime, timezone from pathlib import Path from typing import Any, Iterable def now_iso() -> str: return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") class ContactCache: def __init__(self, cache_dir: Path): self.cache_dir = Path(cache_dir) self.contacts_dir = self.cache_dir / "contacts" self.contacts_dir.mkdir(parents=True, exist_ok=True) self.manifest_path = self.contacts_dir / "manifest.json" def contact_path(self, contact_id: int | str) -> Path: return self.contacts_dir / f"{int(contact_id)}.json" def load_manifest(self) -> dict[str, Any]: if not self.manifest_path.is_file(): return {"version": 1, "contacts": {}, "updated_at": None} data = read_json(self.manifest_path) data.setdefault("version", 1) data.setdefault("contacts", {}) return data def save_manifest(self, data: dict[str, Any]) -> None: data = dict(data) data["version"] = 1 data["updated_at"] = now_iso() atomic_write_json(self.manifest_path, data) def should_fetch(self, stub: dict[str, Any]) -> bool: cid = stub.get("id") if cid is None: return False path = self.contact_path(int(cid)) if not path.is_file(): return True manifest = self.load_manifest() entry = (manifest.get("contacts") or {}).get(str(int(cid))) or {} return str(entry.get("updated_on") or "") != str(stub.get("updated_on") or "") def write_contact(self, contact: dict[str, Any]) -> Path: with cache_lock(self.manifest_path): cid = contact.get("id") if cid is None: raise ValueError("contact missing id") path = self.contact_path(int(cid)) atomic_write_json(path, contact) manifest = self.load_manifest() contacts = manifest.setdefault("contacts", {}) contacts[str(int(cid))] = { "id": int(cid), "updated_on": contact.get("updated_on"), "last_synced": now_iso(), "email": primary_email(contact), "name": contact_display_name(contact), } self.save_manifest(manifest) return path def load_contact(self, contact_id: int) -> dict[str, Any]: path = self.contact_path(contact_id) if not path.is_file(): raise FileNotFoundError(f"No cached contact #{contact_id} at {path}") return json.loads(path.read_text(encoding="utf-8")) def iter_contacts(self) -> Iterable[dict[str, Any]]: for path in sorted(self.contacts_dir.glob("*.json")): if path.name == "manifest.json": continue try: yield json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): continue def primary_email(contact: dict[str, Any]) -> str: for key in ("email", "primary_email"): val = contact.get(key) if val: return str(val) emails = contact.get("emails") or contact.get("address") if isinstance(emails, list) and emails: first = emails[0] if isinstance(first, dict): return str(first.get("address") or first.get("email") or "") return str(first) return "" def contact_display_name(contact: dict[str, Any]) -> str: if contact.get("name"): return str(contact["name"]) first = contact.get("first_name") or contact.get("firstname") or "" last = contact.get("last_name") or contact.get("lastname") or "" full = f"{first} {last}".strip() return full or primary_email(contact) or str(contact.get("id") or "") def contact_id_from_issue(issue: dict[str, Any]) -> int | None: """Extract helpdesk contact id from a cached/enriched issue payload.""" helpdesk = issue.get("helpdesk_ticket") if not isinstance(helpdesk, dict): return None if helpdesk.get("contact_id") is not None: try: return int(helpdesk["contact_id"]) except (TypeError, ValueError): pass contact = helpdesk.get("contact") if isinstance(contact, dict) and contact.get("id") is not None: try: return int(contact["id"]) except (TypeError, ValueError): return None return None def contact_text_blob(contact: dict[str, Any]) -> str: parts = [ contact_display_name(contact), primary_email(contact), str(contact.get("company") or ""), str(contact.get("tag_list") or contact.get("tags") or ""), ] return " ".join(parts).lower() def filter_contacts( contacts: list[dict[str, Any]], *, query: str | None = None, email: str | None = None, company: str | None = None, tag: str | None = None, ) -> list[dict[str, Any]]: out = contacts if email: e = email.lower() out = [c for c in out if e in primary_email(c).lower()] if company: co = company.lower() out = [c for c in out if co in str(c.get("company") or "").lower()] if tag: t = tag.lower() def has_tag(c: dict[str, Any]) -> bool: raw = c.get("tag_list") or c.get("tags") or [] if isinstance(raw, str): tags = [x.strip().lower() for x in raw.split(",")] elif isinstance(raw, list): tags = [] for item in raw: if isinstance(item, dict): tags.append(str(item.get("name") or "").lower()) else: tags.append(str(item).lower()) else: tags = [] return t in tags out = [c for c in out if has_tag(c)] if query: q = query.lower() out = [c for c in out if q in contact_text_blob(c)] return out def sync_contacts( client: Any, cache: ContactCache, *, page_size: int = 100, max_contacts: int | None = None, progress: bool = True, dry_run: bool = False, ) -> dict[str, int]: """Fetch project contacts and write changed ones into the local cache.""" if progress: print("Fetching contact list…", flush=True) stubs = client.fetch_contacts(limit=page_size) if max_contacts is not None: stubs = stubs[: int(max_contacts)] result = {"seen": len(stubs), "fetched": 0, "skipped_cached": 0, "written": 0} for index, stub in enumerate(stubs, start=1): cid = stub.get("id") if cid is None: continue if not cache.should_fetch(stub): result["skipped_cached"] += 1 if progress and (index == 1 or index % 50 == 0 or index == len(stubs)): print(f" [{index}/{len(stubs)}] #{cid} skip-cached", flush=True) continue result["fetched"] += 1 if dry_run: if progress: print(f" [{index}/{len(stubs)}] #{cid} would-fetch", flush=True) continue # Prefer full contact payload when list stub is thin try: full = client.get_contact(int(cid)) except Exception: full = stub cache.write_contact(full) result["written"] += 1 if progress and (index == 1 or index % 25 == 0 or index == len(stubs)): print(f" [{index}/{len(stubs)}] #{cid} written", flush=True) if progress: print( f"contacts sync done: seen={result['seen']} fetched={result['fetched']} " f"skipped={result['skipped_cached']} written={result['written']}", flush=True, ) return result