Files
Vijay Janapa Reddi 27d4f8114b binder: fix repo-root, build, doctor and info bugs; clearer errors; dedup
Bug fixes
- Build lock is keyed on books/ so builds started from different
  directories block each other (fixes two tests broken by 89b828efa3).
- ConfigManager resolves the repo root when binder runs from books/ or
  binder/, so bib, newsletter, doctor and render paths are right.
- `build <word> --all` treats intro/scaling/agentic/physical as chapter
  names (it built every volume), and Ctrl-C stops a --all loop.
- doctor checked chapter names that no longer exist and failed on the
  generated _quarto.yml; info defaulted to a deleted books/contents.
- `headings apply --path <typo>` no longer rewrites the whole book;
  `bib clean` stops on unreadable chapters instead of dropping entries.
- Discovery falls back to a line scan on invalid YAML instead of crashing.
- Volume III/IV were labeled Volume II in layout and release output.

Output
- Unexpected errors print the command, exception type, and raising
  file:line (new cli/core/errors.py), with the traceback on stderr.
- Chapter-not-found suggests the closest names and the list command.
- Silent except blocks in bib lookups, info, headings, layout,
  maintenance, preview and debug now say what went wrong.

Cleanup
- Shared qmd_files_under/display_path, file-rewrite loop, doctor result
  helper, preview server, clean helpers, volume display names.
- Removed legacy import fallbacks, dead config attributes, legacy backup
  restore logic, and unused imports.
2026-10-19 19:28:49 +02:00

773 lines
32 KiB
Python

"""
``binder bib`` — Bibliography management.
Subcommands:
list — Show all .bib files with entry counts
mechanical — Apply safe §5 field-level fixes (pre-commit first step)
clean — Remove unused entries from .bib files
update — Run betterbib sync-preserve on .bib files (fetch metadata, keep citekeys stable)
sync — Clean then update (full pipeline)
normalize — Full-tree: same 3 steps as pre-commit (mechanical → tidy → binder bib check)
add — Authenticate, format, and seal a new entry (the reference gateway)
ingest — Verify and promote candidate entries from bibs/inbox.bib
reseal — Re-verify a house-style-edited entry against its source and re-record it
"""
import argparse
import re
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Dict, List, Optional, Set
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from cli.core.bib_mechanical import run_mechanical_fixes
from cli.core.discovery import display_path, qmd_files_under
console = Console()
class BibCommand:
"""Native ``binder bib`` command group."""
def __init__(self, config_manager, chapter_discovery):
"""Store the shared config manager and chapter discovery helpers."""
self.config_manager = config_manager
self.chapter_discovery = chapter_discovery
# ------------------------------------------------------------------
# Entry point
# ------------------------------------------------------------------
def run(self, args: List[str]) -> bool:
"""Parse ``binder bib`` arguments and dispatch to the chosen subcommand.
``mechanical`` works on the positional .bib files. ``normalize`` with no
``--path``/``--vol1``/``--vol2`` covers the whole repo; every other
subcommand needs the resolved path to exist. With no subcommand (or
``help``) prints help and returns True. Returns False on an argparse
error (True for ``-h``/``--help``), a missing path, or subcommand failure.
"""
if args == ["help"]:
self._print_help()
return True
parser = argparse.ArgumentParser(
prog="binder bib",
description="Bibliography management",
)
parser.add_argument(
"subcommand",
nargs="?",
choices=["list", "mechanical", "clean", "update", "sync", "normalize", "add", "ingest", "reseal"],
help="Subcommand to run",
)
parser.add_argument("files", nargs="*", help=".bib file(s) for mechanical fixes")
parser.add_argument("--path", default=None, help="File or directory")
parser.add_argument("--vol1", action="store_true", help="Legacy alias for the shared book bibliography")
parser.add_argument("--vol2", action="store_true", help="Legacy alias for the shared book bibliography")
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would change without modifying files",
)
parser.add_argument(
"--pre-commit",
action="store_true",
help="Mechanical fixes: exit 1 if a file was modified",
)
parser.add_argument("--doi", default=None, help="Add reference by DOI (Crossref/DataCite)")
parser.add_argument("--arxiv", default=None, help="Add reference by arXiv ID")
parser.add_argument("--isbn", default=None, help="Add reference by ISBN (OpenLibrary)")
parser.add_argument("--url", default=None, help="Add reference by official URL")
parser.add_argument("--key", default=None, help="Custom citation key for add; the entry to re-seal for reseal")
parser.add_argument("--note", default=None, help="reseal: what was edited after the gateway seal (required)")
parser.add_argument("--title", default=None, help="Custom title override for --url")
parser.add_argument("--author", default=None, help="Custom author override for --url")
parser.add_argument("--year", default=None, help="Custom year override for --url")
parser.add_argument("--venue", default=None, help="Custom venue override for --url")
parser.add_argument("--inbox", default="bibs/inbox.bib", help="Inbox path for 'ingest' subcommand")
try:
# Pre-commit appends filenames after the hook entry, so the public
# shape is `bib mechanical --pre-commit refs.bib`. Python 3.11's
# regular argparse parser rejects that optional-between-positionals
# form when a `nargs="*"` positional follows the subcommand.
parse = getattr(parser, "parse_intermixed_args", parser.parse_args)
ns = parse(args)
except SystemExit:
return ("-h" in args) or ("--help" in args)
if not ns.subcommand:
self._print_help()
return True
if ns.subcommand == "mechanical":
return self._run_mechanical(
ns.files,
dry_run=ns.dry_run,
pre_commit=ns.pre_commit,
)
if ns.subcommand == "add":
return self._run_add(
doi=ns.doi,
arxiv=ns.arxiv,
isbn=ns.isbn,
url=ns.url,
custom_key=ns.key,
title=ns.title,
author=ns.author,
year=ns.year,
venue=ns.venue,
dry_run=ns.dry_run,
)
if ns.subcommand == "reseal":
return self._run_reseal(
key=ns.key,
note=ns.note,
doi=ns.doi,
arxiv=ns.arxiv,
url=ns.url,
dry_run=ns.dry_run,
)
if ns.subcommand == "ingest":
return self._run_ingest(
inbox=ns.inbox,
dry_run=ns.dry_run,
)
root = self._resolve_path(ns.path, ns.vol1, ns.vol2)
if ns.subcommand == "normalize":
# Full-repo normalize uses git ls-files inside the script; no path needed.
if not ns.path and not ns.vol1 and not ns.vol2:
return self._run_normalize(scoped=False, root=root)
if not root.exists():
console.print(f"[red]Path not found: {root}[/red]")
return False
return self._run_normalize(scoped=True, root=root)
if not root.exists():
console.print(f"[red]Path not found: {root}[/red]")
return False
if ns.subcommand == "list":
return self._run_list(root)
elif ns.subcommand == "clean":
return self._run_clean(root, ns.dry_run)
elif ns.subcommand == "update":
return self._run_update(root, ns.dry_run)
elif ns.subcommand == "sync":
return self._run_sync(root, ns.dry_run)
return False
# ------------------------------------------------------------------
# Help
# ------------------------------------------------------------------
def _print_help(self) -> None:
"""Print the subcommand table and usage examples."""
table = Table(show_header=True, header_style="bold cyan", box=None)
table.add_column("Subcommand", style="cyan", width=14)
table.add_column("Description", style="white", width=50)
table.add_row("add", "Authenticate, format, and seal new citation into ledger")
table.add_row("ingest", "Verify and promote candidate citations from inbox")
table.add_row("reseal", "Re-verify an edited entry against its source; re-record its manifest hash")
table.add_row("list", "Show all .bib files with entry counts")
table.add_row("mechanical", "Apply safe §5 field-level fixes")
table.add_row("clean", "Remove unused entries from .bib files")
table.add_row(
"update",
"Run betterbib sync-preserve (fetch metadata, keep citekeys stable)",
)
table.add_row("sync", "Clean then update (full pipeline)")
table.add_row(
"normalize",
"§5 mechanical fixes + bibtex-tidy + binder check bib",
)
console.print(Panel(table, title="binder bib <subcommand>", border_style="cyan"))
console.print("[dim]Examples:[/dim]")
console.print(" [cyan]./binder/binder bib add --doi 10.1145/3592433[/cyan]")
console.print(" [cyan]./binder/binder bib add --arxiv 1706.03762[/cyan]")
console.print(" [cyan]./binder/binder bib add --url https://github.com/huggingface/lerobot --key cadene2024lerobot[/cyan]")
console.print(" [cyan]./binder/binder bib add --isbn 9780136566953[/cyan]")
console.print(" [cyan]./binder/binder bib ingest --inbox bibs/inbox.bib[/cyan]")
console.print(' [cyan]./binder/binder bib reseal --key vaswani2017attention --note "title case" --dry-run[/cyan]')
console.print(" [cyan]./binder/binder bib list --path bibs/references.bib[/cyan]")
console.print(" [cyan]./binder/binder bib mechanical refs.bib[/cyan]")
console.print(" [cyan]./binder/binder bib clean --dry-run[/cyan]")
console.print(" [cyan]./binder/binder bib update --path bibs/references.bib[/cyan]")
console.print(" [cyan]./binder/binder bib sync --dry-run[/cyan]")
console.print(
" [cyan]./binder/binder bib normalize[/cyan] "
"# all git-tracked .bib (run from repo root)"
)
console.print(" [cyan]./binder/binder bib normalize --path bibs/references.bib[/cyan]")
console.print()
# ------------------------------------------------------------------
# Path resolution
# ------------------------------------------------------------------
def _resolve_path(self, path_arg: Optional[str], vol1: bool, vol2: bool) -> Path:
"""Return ``--path`` (relative paths resolve against the cwd) or the book directory.
``--vol1``/``--vol2`` are legacy aliases that also resolve to the book
directory, which holds the shared bibliography.
"""
if path_arg:
p = Path(path_arg)
return p if p.is_absolute() else Path.cwd() / p
return self.config_manager.book_dir
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _find_bib_files(self, root: Path) -> List[Path]:
"""Find all .bib files under root."""
if root.is_file() and root.suffix == ".bib":
return [root]
return sorted(root.rglob("*.bib"))
def _count_entries(self, bib_path: Path) -> int:
"""Count @type{key, entries in a .bib file."""
content = bib_path.read_text(encoding="utf-8")
return len(re.findall(r"^@\w+\{", content, re.MULTILINE))
def _extract_cited_keys(self, qmd_path: Path) -> Set[str]:
"""Extract all citation keys from a .qmd file.
Read errors propagate: treating an unreadable chapter as citing nothing
would mark its entries unused and let ``bib clean`` delete them.
"""
content = qmd_path.read_text(encoding="utf-8")
# Standard @key citations
keys = set(re.findall(r"@([\w\d:\-_.]+)", content))
# Filter out cross-ref prefixes
keys = {
k.rstrip(".,;:")
for k in keys
if not k.startswith(("fig-", "tbl-", "sec-", "eq-", "lst-", "alg-", "algo-", "Algo-"))
}
return keys
def _extract_bib_keys(self, bib_path: Path) -> Set[str]:
"""Extract all entry keys from a .bib file."""
content = bib_path.read_text(encoding="utf-8")
return set(re.findall(r"^@\w+\{([^,\s]+)", content, re.MULTILINE))
def _bib_for_qmd(self, qmd_path: Path) -> Optional[Path]:
"""Find the .bib file referenced in a .qmd file's YAML front matter."""
content = qmd_path.read_text(encoding="utf-8")
m = re.search(r"^bibliography:\s*([\w\-.]+\.bib)\s*$", content, re.MULTILINE)
if m:
bib = qmd_path.parent / m.group(1)
return bib if bib.exists() else None
return None
# ------------------------------------------------------------------
# Mechanical fixes
# ------------------------------------------------------------------
def _run_mechanical(
self,
files: List[str],
*,
dry_run: bool = False,
pre_commit: bool = False,
) -> bool:
"""Apply safe section 5 mechanical fixes through Binder-native code."""
repo = self.config_manager.root_dir
return (
run_mechanical_fixes(
repo,
files,
dry_run=dry_run,
pre_commit=pre_commit,
)
== 0
)
# ------------------------------------------------------------------
# List
# ------------------------------------------------------------------
def _run_list(self, root: Path) -> bool:
"""Show all .bib files with entry counts."""
bib_files = self._find_bib_files(root)
if not bib_files:
console.print("[yellow]No .bib files found.[/yellow]")
return True
table = Table(show_header=True, header_style="bold cyan", box=None)
table.add_column("File", style="cyan", width=50)
table.add_column("Entries", style="white", width=8, justify="right")
total = 0
for bib in bib_files:
count = self._count_entries(bib)
total += count
table.add_row(display_path(bib, self.config_manager.book_dir), str(count))
console.print(Panel(table, title="Bibliography Files", border_style="cyan"))
console.print(
f"[bold]Total:[/bold] {len(bib_files)} files, {total} entries"
)
return True
# ------------------------------------------------------------------
# Clean
# ------------------------------------------------------------------
def _run_clean(self, root: Path, dry_run: bool = False) -> bool:
"""Remove unused entries from .bib files."""
qmd_files = qmd_files_under(root)
if not qmd_files:
console.print("[yellow]No QMD files found.[/yellow]")
return False
# Collect ALL cited keys from all QMD files in scope
all_cited: Set[str] = set()
for qmd in qmd_files:
all_cited.update(self._extract_cited_keys(qmd))
# Find all .bib files in scope — bibliography is declared at project
# level in Quarto YAML configs, not in individual QMD front matter.
# So we match all bib files under root against all citations under root.
bib_files = self._find_bib_files(root)
# Also check per-QMD bibliography declarations (some chapters have their own)
for qmd in qmd_files:
bib = self._bib_for_qmd(qmd)
if bib and bib not in bib_files:
bib_files.append(bib)
if not bib_files:
console.print("[yellow]No .bib files found.[/yellow]")
return True
# Build mapping: each bib file checked against all cited keys
bib_cited: Dict[Path, Set[str]] = {bib: all_cited for bib in bib_files}
# Warn if scoped to a narrow path — citations from other chapters
# won't be visible, which could cause false positives.
contents_dir = self.config_manager.book_dir
is_narrow = root != contents_dir and not any(
root == contents_dir / v for v in ("vol1", "vol2")
)
if is_narrow:
console.print(
"[yellow]⚠ Narrow scope:[/yellow] Only citations from QMD files "
f"under [cyan]{display_path(root, self.config_manager.book_dir)}[/cyan] are visible.\n"
" Entries cited by other chapters may appear unused. "
"Use the full [cyan]books[/cyan] tree for safe cleaning.\n"
)
console.print(
f"[dim]Collected {len(all_cited)} citation keys from "
f"{len(qmd_files)} QMD files[/dim]\n"
)
total_removed = 0
for bib_path, cited_keys in sorted(bib_cited.items()):
bib_keys = self._extract_bib_keys(bib_path)
unused = bib_keys - cited_keys
rel = display_path(bib_path, self.config_manager.book_dir)
if not unused:
console.print(f" [green]✓[/green] {rel}: all {len(bib_keys)} entries used")
continue
total_removed += len(unused)
if dry_run:
console.print(
f" [yellow]⚠[/yellow] {rel}: {len(unused)} unused of {len(bib_keys)} "
f"[dim](dry run — no changes)[/dim]"
)
for key in sorted(unused)[:10]:
console.print(f" [dim]- {key}[/dim]")
if len(unused) > 10:
console.print(f" [dim]... and {len(unused) - 10} more[/dim]")
else:
# Remove unused entries by rewriting the file
self._remove_entries(bib_path, unused)
console.print(
f" [green]✓[/green] {rel}: removed {len(unused)} unused entries "
f"({len(bib_keys) - len(unused)} remaining)"
)
action = "would remove" if dry_run else "removed"
console.print(f"\n[bold]Total:[/bold] {action} {total_removed} unused entries from {len(bib_cited)} files")
return True
def _remove_entries(self, bib_path: Path, keys_to_remove: Set[str]) -> None:
"""Remove specific entries from a .bib file, preserving formatting."""
content = bib_path.read_text(encoding="utf-8")
lines = content.split("\n")
output: List[str] = []
skip = False
brace_depth = 0
for line in lines:
# Detect entry start
m = re.match(r"^@\w+\{([^,\s]+)", line)
if m:
key = m.group(1)
if key in keys_to_remove:
skip = True
brace_depth = line.count("{") - line.count("}")
continue
else:
skip = False
if skip:
brace_depth += line.count("{") - line.count("}")
if brace_depth <= 0:
skip = False
continue
output.append(line)
# Clean up multiple blank lines
cleaned = re.sub(r"\n{3,}", "\n\n", "\n".join(output))
bib_path.write_text(cleaned.strip() + "\n", encoding="utf-8")
# ------------------------------------------------------------------
# Normalize (mechanical fixes + pre-commit tidy + binder check bib)
# ------------------------------------------------------------------
def _run_normalize(self, scoped: bool, root: Path) -> bool:
"""Run the same pipeline needed for consistent .bib with pre-commit."""
repo = self.config_manager.root_dir
if scoped:
bibs = self._find_bib_files(root)
if not bibs:
console.print("[yellow]No .bib files in scope.[/yellow]")
return True
mechanical_files = [str(path) for path in bibs]
else:
mechanical_files = None
console.print(
"[bold]Step 1/3:[/bold] "
"Mechanical section 5 fixes ([cyan]binder bib mechanical[/cyan])...\n"
)
if run_mechanical_fixes(repo, mechanical_files) != 0:
console.print("[red]Step 1/3 failed: fix the mechanical issues reported above, then rerun "
"./binder/binder bib normalize.[/red]")
return False
if not shutil.which("pre-commit"):
console.print(
"\n[yellow]pre-commit not on PATH — skipped Step 2. "
"Install pre-commit and run: "
"[cyan]pre-commit run bibtex-tidy --all-files[/cyan][/yellow]\n"
)
else:
console.print(
"\n[bold]Step 2/3:[/bold] [cyan]pre-commit run bibtex-tidy --all-files[/cyan] "
"(loop until Pass)…\n"
)
settled = False
for _i in range(12):
p = subprocess.run(
["pre-commit", "run", "bibtex-tidy", "--all-files"],
cwd=str(repo),
)
if p.returncode == 0:
settled = True
break
if not settled:
console.print(
"\n[red]bibtex-tidy did not reach a stable pass in 12 attempts; "
"resolve diff manually.[/red]"
)
return False
binder = repo / "binder" / "binder"
check_cmd = [sys.executable, str(binder), "check", "bib"]
if scoped:
check_cmd.extend(["--path", str(root)])
console.print(
"\n[bold]Step 3/3:[/bold] "
"[cyan]./binder/binder check bib[/cyan] "
"(same curated gate as the pre-commit [cyan]book-check-bib[/cyan] hook)…\n"
)
p2 = subprocess.run(
check_cmd,
cwd=str(repo),
)
if p2.returncode != 0:
console.print(
"\n[red]binder check bib reported new issues. Fix them or update "
"the appropriate reviewed baseline after accepting the debt.[/red]"
)
else:
console.print(
"\n[green]Bib files match the tidy + curated bib checks "
"used in pre-commit.[/green]"
)
return p2.returncode == 0
# ------------------------------------------------------------------
# Update
# ------------------------------------------------------------------
def _run_update(self, root: Path, dry_run: bool = False) -> bool:
"""Run betterbib sync-preserve on .bib files."""
repo = self.config_manager.root_dir
script = repo / "binder" / "tools" / "bib_sync_preserve.py"
if not script.is_file():
console.print(f"[red]Missing {script}[/red]")
return False
# Check if betterbib is available
try:
subprocess.run(
["betterbib", "--version"],
capture_output=True,
check=True,
)
except (FileNotFoundError, subprocess.CalledProcessError):
console.print(
"[red]betterbib not found.[/red] Install with: "
"[cyan]pip install betterbib[/cyan]"
)
return False
bib_files = self._find_bib_files(root)
if not bib_files:
console.print("[yellow]No .bib files found.[/yellow]")
return True
console.print(
f"[bold]Updating {len(bib_files)} .bib file(s) with betterbib sync-preserve...[/bold]\n"
)
cmd = [sys.executable, str(script)]
if dry_run:
cmd.append("--dry-run")
if root.is_file() and root.suffix == ".bib":
cmd.append(str(root))
else:
cmd.extend(str(b) for b in bib_files)
result = subprocess.run(
cmd,
cwd=str(repo),
text=True,
)
if result.returncode == 0:
console.print("\n[green]Done.[/green]")
return True
console.print(f"\n[red]{script.name} exited with code {result.returncode}; "
"see the failures it printed above.[/red]")
return False
# ------------------------------------------------------------------
# Sync (clean + update)
# ------------------------------------------------------------------
def _run_sync(self, root: Path, dry_run: bool = False) -> bool:
"""Full pipeline: clean unused entries, then update with betterbib."""
console.print("[bold cyan]Step 1/2: Cleaning unused entries...[/bold cyan]\n")
clean_ok = self._run_clean(root, dry_run)
if dry_run:
console.print(
"\n[yellow]Dry run — skipping betterbib update. "
"Run without --dry-run to apply changes and update.[/yellow]"
)
return clean_ok
console.print("\n[bold cyan]Step 2/2: Updating with betterbib...[/bold cyan]\n")
update_ok = self._run_update(root)
return clean_ok and update_ok
# ------------------------------------------------------------------
# Add (authoritative gateway)
# ------------------------------------------------------------------
def _run_add(
self,
doi: Optional[str] = None,
arxiv: Optional[str] = None,
isbn: Optional[str] = None,
url: Optional[str] = None,
custom_key: Optional[str] = None,
title: Optional[str] = None,
author: Optional[str] = None,
year: Optional[str] = None,
venue: Optional[str] = None,
dry_run: bool = False,
) -> bool:
"""Authenticate, format, and seal a new reference into the master bibliography."""
from cli.core import bib_gateway
repo = self.config_manager.root_dir
console.print("[bold cyan]Resolving reference via authoritative gateway...[/bold cyan]")
resolved = None
if doi:
console.print(f"[dim]Authenticating DOI '{doi}' via Crossref / DataCite...[/dim]")
resolved = bib_gateway.fetch_doi(doi, custom_key=custom_key)
elif arxiv:
console.print(f"[dim]Authenticating arXiv ID '{arxiv}' via arXiv.org...[/dim]")
resolved = bib_gateway.fetch_arxiv(arxiv, custom_key=custom_key)
elif isbn:
console.print(f"[dim]Authenticating ISBN '{isbn}' via OpenLibrary...[/dim]")
resolved = bib_gateway.fetch_isbn(isbn, custom_key=custom_key)
elif url:
console.print(f"[dim]Authenticating URL '{url}' via HTTP live check...[/dim]")
resolved = bib_gateway.fetch_url(
url,
custom_key=custom_key,
title_override=title or "",
author_override=author or "",
year_override=year or "",
venue_override=venue or "",
)
else:
console.print("[red]Error:[/red] Must provide one of --doi, --arxiv, --isbn, or --url.")
return False
if not resolved:
console.print(
"[red]Authentication failed:[/red] Could not verify or retrieve canonical "
"metadata from primary external registries. Lookup errors, if any, are "
"printed above; check the identifier or pass a different source "
"(--doi, --arxiv, --isbn, --url)."
)
return False
console.print(
f"[green]Authenticated:[/green] {resolved['evidence']['source']} -> "
f"'{resolved['fields'].get('title')}'"
)
console.print(f"[dim]Candidate citekey: @{resolved['entry_type']}{{{resolved['key']}}}[/dim]")
if dry_run:
console.print("\n[yellow]Dry run — entry would be formatted and sealed as follows:[/yellow]")
console.print(bib_gateway.format_bibtex_entry(resolved["entry_type"], resolved["key"], resolved["fields"]))
return True
ok, msg, record = bib_gateway.add_entry(repo, resolved, check_duplicates=True)
if not ok:
console.print(f"[red]Error:[/red] {msg}")
return False
console.print(f"\n[bold green]Success![/bold green] {msg}")
if record:
console.print(f"[dim]Cryptographic SHA-256: {record['entry_hash'][:16]}...[/dim]")
return True
# ------------------------------------------------------------------
# Reseal (house-style edit to a sealed entry)
# ------------------------------------------------------------------
def _run_reseal(
self,
key: Optional[str],
note: Optional[str],
doi: Optional[str] = None,
arxiv: Optional[str] = None,
url: Optional[str] = None,
dry_run: bool = False,
) -> bool:
"""Re-resolve an edited entry's source, check its identity fields, and re-record it.
The source is the identifier passed on the command line, else the one the
manifest record was sealed from (DOI, arXiv ID, or verified URL), else the
entry's own ``doi``/``url`` field. Refuses when any checked field disagrees.
"""
import json as _json
from cli.core import bib_gateway
if not key or not note:
console.print("[red]Error:[/red] reseal needs --key and --note (what was edited after the seal).")
return False
repo = self.config_manager.root_dir
record = {}
manifest_path = repo / "bibs" / "manifest.json"
if manifest_path.is_file():
record = _json.loads(manifest_path.read_text(encoding="utf-8")).get(key) or {}
details = record.get("details") or {}
entry_fields: Dict[str, str] = {}
bib_text = (repo / "bibs" / "references.bib").read_text(encoding="utf-8")
for entry in bib_gateway.bib_lint.parse_bib(bib_text)[0]:
if entry.key == key:
entry_fields = {n: (entry.get(n).value if entry.get(n) else "") for n in ("doi", "url")}
break
if not entry_fields:
console.print(f"[red]Error:[/red] @{{{key}}} not found in bibs/references.bib.")
return False
if not (doi or arxiv or url):
doi = details.get("doi") if details.get("source") in ("crossref", "datacite") else None
arxiv = details.get("arxiv_id")
if not (doi or arxiv) and details.get("source") == "verified_web":
url = details.get("url")
if not (doi or arxiv or url):
m_arx = bib_gateway.ARXIV_REGEX.search(entry_fields.get("url", "")) if "arxiv" in entry_fields.get("url", "") else None
if entry_fields.get("doi"):
doi = entry_fields["doi"]
elif m_arx:
arxiv = m_arx.group(1)
elif entry_fields.get("url"):
url = entry_fields["url"]
if not (doi or arxiv or url):
console.print("[red]Error:[/red] no source identifier found; pass --doi, --arxiv, or --url.")
return False
if doi:
source_desc, resolved = f"DOI {doi}", bib_gateway.fetch_doi(doi)
elif arxiv:
source_desc, resolved = f"arXiv {arxiv}", bib_gateway.fetch_arxiv(arxiv)
else:
source_desc, resolved = f"URL {url}", bib_gateway.fetch_url(url)
if not resolved:
console.print(f"[red]Could not resolve {source_desc}; lookup errors, if any, are printed above.[/red]")
return False
ok, msg, _ = bib_gateway.reseal_entry(repo, key, resolved, note, dry_run=dry_run)
style = "green" if ok else "red"
console.print(f"[{style}]{source_desc}: {msg}[/{style}]")
return ok
# ------------------------------------------------------------------
# Ingest (staging inbox)
# ------------------------------------------------------------------
def _run_ingest(self, inbox: str = "bibs/inbox.bib", dry_run: bool = False) -> bool:
"""Scan staging inbox file, authenticate each entry online, promote verified entries."""
from cli.core import bib_gateway
repo = self.config_manager.root_dir
inbox_path = repo / inbox
if not inbox_path.is_file():
console.print(f"[yellow]Inbox file not found: {inbox_path}[/yellow]")
return True
console.print(f"[bold cyan]Scanning staging inbox {inbox_path}...[/bold cyan]")
added, rejected, messages = bib_gateway.ingest_inbox(repo, inbox_path, dry_run=dry_run)
for msg in messages:
console.print(f" {msg}")
console.print("\n" + "=" * 50)
console.print(f"[bold]Ingestion Summary:[/bold] {added} verified & added, {rejected} rejected/pending.")
return rejected == 0