"""Shared helpers for ARS reference adapters. These helpers are used by folder_scan.py, zotero.py, and obsidian.py. They are also what a user-written adapter would copy and adapt. """ from __future__ import annotations import datetime import io import re from pathlib import Path from typing import Any import yaml def path_to_file_uri(path: Path | str) -> str: """Build an RFC 8089 ``file://`` URI from a filesystem path. Uses ``Path.as_uri()`` after resolving so spaces and reserved characters are percent-encoded (``Lee 2024 paper.pdf`` → ``file:///.../Lee%202024%20paper.pdf``). Naive ``f"file://{path}"`` yields invalid URIs and is the codex-flagged P1 from T7. """ return Path(path).resolve().as_uri() ADAPTER_SPEC_VERSION = "1.0.0" # bump when overview.md contract changes _NON_ALNUM = re.compile(r"[^A-Za-z0-9]+") _TITLE_WORD = re.compile(r"[A-Za-z]+") _STOPWORDS = { "a", "an", "the", "of", "in", "on", "at", "by", "for", "to", "with", "and", "or", "but", "is", "are", "was", "were", } def sanitize_citation_key(raw: str) -> str: """Strip non-alphanumeric characters. Does NOT lowercase.""" return _NON_ALNUM.sub("", raw) def _first_title_word_non_stopword(title_hint: str | None) -> str: if not title_hint: return "" for word in _TITLE_WORD.findall(title_hint): if word.lower() not in _STOPWORDS: return word.lower() return "" def _alpha_suffixes(): """Yield 'a', 'b', ..., 'z', 'aa', 'ab', ..., 'zz'.""" for c in "abcdefghijklmnopqrstuvwxyz": yield c for c1 in "abcdefghijklmnopqrstuvwxyz": for c2 in "abcdefghijklmnopqrstuvwxyz": yield c1 + c2 def make_citation_key( *, family: str, year: int, title_hint: str | None, existing: set[str] ) -> str: """Build a collision-safe citation key. Pattern: {family_lower}{year}{first_title_word_or_empty}. If that is already in `existing`, append `a`, `b`, `c`, ... (then aa, ab, ...). The chosen key is added to `existing` by reference. Falls back to "ref" when the sanitized base is empty (e.g., empty family with year=0 and no title_hint). """ base = f"{family.lower()}{year}{_first_title_word_non_stopword(title_hint)}" base = sanitize_citation_key(base) # citation_key schema requires ^[A-Za-z]... so a pure-digit base # (e.g. family='' year=2024) would fail schema validation. Fall back. if not base or not base[0].isalpha(): base = "ref" if base not in existing: existing.add(base) return base for suffix in _alpha_suffixes(): candidate = f"{base}{suffix}" if candidate not in existing: existing.add(candidate) return candidate raise RuntimeError("exhausted citation key suffix space") # practically unreachable def ensure_unique_citekey(key: str, existing: set[str]) -> str: """Disambiguate a pre-existing citation key against ``existing``. Used by adapters whose source already supplies a citekey (zotero Better BibTeX, obsidian frontmatter ``citekey:``). The returned key is sanitized to satisfy the schema pattern ``^[A-Za-z][A-Za-z0-9_:-]*$`` and is added to ``existing`` by reference. Collisions get an alpha suffix (a, b, ..., z, aa, ...) just like ``make_citation_key``. Empty / leading-digit / leading-punct bases fall back to ``ref``. """ cleaned_chars = [] for ch in key: if ch.isalnum() or ch in "_:-": cleaned_chars.append(ch) base = "".join(cleaned_chars) if not base or not base[0].isalpha(): # Prefix with 'ref' so the result satisfies the schema's # leading-letter constraint while preserving the original # numeric/punctuated tail when one exists. base = f"ref{base}" if base else "ref" if base not in existing: existing.add(base) return base for suffix in _alpha_suffixes(): candidate = f"{base}{suffix}" if candidate not in existing: existing.add(candidate) return candidate raise RuntimeError("exhausted citation key suffix space") def parse_csl_name(raw: str) -> dict[str, str]: """Parse a single name string into a CSL-JSON name object. Rules: - "{Institution Name}" -> {"literal": "Institution Name"} - "Family, Given" -> {"family": "Family", "given": "Given"} - "Family" (single token) -> {"family": "Family"} """ raw = raw.strip() if raw.startswith("{") and raw.endswith("}"): return {"literal": raw[1:-1].strip()} if "," in raw: family, _, given = raw.partition(",") return {"family": family.strip(), "given": given.strip()} return {"family": raw} def parse_semicolon_names(raw: str) -> list[dict[str, str]]: """Parse 'A, B; C, D' into a CSL name list. Empty segments are skipped.""" raw = raw.strip() if not raw: return [] return [parse_csl_name(part) for part in raw.split(";") if part.strip()] def dump_yaml_stable(data: Any) -> str: """Dump YAML with sorted keys and deterministic formatting.""" buf = io.StringIO() yaml.safe_dump( data, buf, sort_keys=True, default_flow_style=False, allow_unicode=True, width=1000, ) return buf.getvalue() def now_iso() -> str: """ISO 8601 in UTC with trailing 'Z' (RFC 3339 compatible).""" return datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ") def write_passport(path: Path, entries: list[dict[str, Any]]) -> None: """Write passport.yaml with literature_corpus[] sorted by citation_key.""" sorted_entries = sorted(entries, key=lambda e: e.get("citation_key", "")) doc = {"literature_corpus": sorted_entries} path.write_text(dump_yaml_stable(doc), encoding="utf-8") def write_rejection_log( path: Path, *, adapter_name: str, adapter_version: str, rejected: list[dict[str, Any]], input_source: str | None = None, total_input: int | None = None, total_accepted: int | None = None, ) -> None: """Write rejection_log.yaml with rejected[] sorted by source. summary.total_rejected is always set from len(rejected). When the caller knows the upstream input or accept count, pass total_input / total_accepted so the summary tells the full story; otherwise those keys are omitted. """ sorted_rejected = sorted(rejected, key=lambda r: r.get("source", "")) summary: dict[str, int] = {"total_rejected": len(sorted_rejected)} if total_input is not None: summary["total_input"] = total_input if total_accepted is not None: summary["total_accepted"] = total_accepted doc: dict[str, Any] = { "adapter_name": adapter_name, "adapter_version": adapter_version, "generated_at": now_iso(), "rejected": sorted_rejected, "summary": summary, } if input_source is not None: doc["input_source"] = input_source path.write_text(dump_yaml_stable(doc), encoding="utf-8")