File: C:/Users/fred/.codex/.tmp/plugins/plugins/zotero/skills/zotero/scripts/zotero.py
#!/usr/bin/env python3
"""Operate Zotero Desktop's local API and connector server.
This helper is dependency-free on purpose: it runs with Python 3 stdlib only,
and all Zotero calls go through the local Desktop HTTP surfaces on
http://127.0.0.1:23119.
"""
from __future__ import annotations
import argparse
import configparser
import json
import os
import platform
import re
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any
DEFAULT_BASE_URL = os.environ.get("ZOTERO_LOCAL_BASE_URL", "http://127.0.0.1:23119")
LOCAL_API_PREF = "extensions.zotero.httpServer.localAPI.enabled"
LOCAL_USER = "/api/users/0"
API_VERSION_HEADERS = {"Zotero-API-Version": "3"}
CONNECTOR_HEADERS = {"X-Zotero-Connector-API-Version": "3"}
TEXT_LIMIT = 300
API_PAGE_LIMIT = 100
@dataclass(frozen=True)
class Response:
status: int | None
headers: dict[str, str]
text: str
error: str | None = None
@property
def ok(self) -> bool:
return self.status is not None and 200 <= self.status < 300
@property
def content_type(self) -> str:
return self.headers.get("Content-Type", "")
def dump_json(value: Any) -> None:
print(json.dumps(value, indent=2, sort_keys=False))
def exit_with(message: str) -> None:
raise SystemExit(message)
def zotero_roots() -> list[Path]:
home = Path.home()
system = platform.system()
roots: list[Path] = []
if system == "Darwin":
roots.append(home / "Library/Application Support/Zotero")
elif system == "Windows":
appdata = os.environ.get("APPDATA")
if appdata:
roots.extend([Path(appdata) / "Zotero/Zotero", Path(appdata) / "Zotero"])
else:
roots.extend(
[
home / ".zotero/zotero",
home / ".var/app/org.zotero.Zotero/data/zotero",
]
)
# Useful fallback when scripts run under shells whose platform config is odd.
roots.append(home / "Library/Application Support/Zotero")
return list(dict.fromkeys(roots))
def profiles_ini_path() -> Path | None:
for root in zotero_roots():
candidate = root / "profiles.ini"
if candidate.exists():
return candidate
return None
def profile_dir() -> Path | None:
ini = profiles_ini_path()
if ini is None:
return None
parser = configparser.RawConfigParser()
parser.read(ini)
root = ini.parent
candidates: list[tuple[int, Path]] = []
for section in parser.sections():
if not section.lower().startswith("profile") or not parser.has_option(section, "Path"):
continue
raw_path = parser.get(section, "Path")
path = (
root / raw_path
if parser.get(section, "IsRelative", fallback="1") == "1"
else Path(raw_path)
)
score = 0
if parser.get(section, "Default", fallback="0") == "1":
score += 10
if (path / "prefs.js").exists():
score += 5
candidates.append((score, path))
if candidates:
return max(candidates, key=lambda item: item[0])[1]
profiles = sorted((root / "Profiles").glob("*.default*"))
return profiles[0] if profiles else None
def prefs_file() -> Path | None:
profile = profile_dir()
if profile is None:
return None
candidate = profile / "prefs.js"
return candidate if candidate.exists() else None
def pref_pattern() -> re.Pattern[str]:
return re.compile(r'user_pref\("' + re.escape(LOCAL_API_PREF) + r'",\s*(true|false)\s*\);')
def read_local_api_pref() -> bool | None:
prefs = prefs_file()
if prefs is None:
return None
match = pref_pattern().search(prefs.read_text(encoding="utf-8", errors="replace"))
if match is None:
return None
return match.group(1) == "true"
def set_local_api_pref(enabled: bool) -> Path:
prefs = prefs_file()
if prefs is None:
exit_with("Could not find Zotero prefs.js. Start Zotero once, then retry.")
backup = prefs.with_suffix(prefs.suffix + f".zotero-skill-backup-{int(time.time())}")
shutil.copy2(prefs, backup)
text = prefs.read_text(encoding="utf-8", errors="replace")
new_line = f'user_pref("{LOCAL_API_PREF}", {str(enabled).lower()});'
pattern = pref_pattern()
if pattern.search(text):
text = pattern.sub(new_line, text, count=1)
else:
text = text.rstrip("\n") + "\n" + new_line + "\n"
prefs.write_text(text, encoding="utf-8")
return backup
def url_for(path: str, base_url: str = DEFAULT_BASE_URL) -> str:
return base_url.rstrip("/") + path
def request(
path: str,
*,
method: str = "GET",
data: Any = None,
headers: dict[str, str] | None = None,
timeout: float = 5.0,
) -> Response:
req_headers = dict(headers or {})
body: bytes | None = None
if path.startswith("/api"):
req_headers.update({k: v for k, v in API_VERSION_HEADERS.items() if k not in req_headers})
if path.startswith("/connector"):
req_headers.update({k: v for k, v in CONNECTOR_HEADERS.items() if k not in req_headers})
if data is not None:
if isinstance(data, (dict, list)):
body = json.dumps(data).encode("utf-8")
req_headers.setdefault("Content-Type", "application/json")
elif isinstance(data, bytes):
body = data
else:
body = str(data).encode("utf-8")
try:
req = urllib.request.Request(url_for(path), data=body, method=method, headers=req_headers)
with urllib.request.urlopen(req, timeout=timeout) as response:
return Response(
status=response.status,
headers=dict(response.headers.items()),
text=response.read().decode("utf-8", errors="replace"),
)
except urllib.error.HTTPError as exc:
return Response(
status=exc.code,
headers=dict(exc.headers.items()),
text=exc.read().decode("utf-8", errors="replace"),
error=str(exc),
)
except Exception as exc: # local server down, Zotero closed, malformed URL, etc.
return Response(status=None, headers={}, text="", error=str(exc))
def parse_body(response: Response) -> Any:
if "json" not in response.content_type.lower():
return response.text
try:
return json.loads(response.text or "null")
except json.JSONDecodeError:
return response.text
def require_ok(response: Response, action: str) -> Response:
if response.ok:
return response
detail = response.error or response.text[:TEXT_LIMIT] or "no response body"
exit_with(f"{action} failed: status={response.status} detail={detail}")
raise AssertionError("unreachable")
def api_response(path: str) -> Response:
api_path = path if path.startswith("/api") else "/api" + path
return require_ok(request(api_path), f"GET {api_path}")
def api_get(path: str) -> Any:
return parse_body(api_response(path))
def query(params: dict[str, str | int | bool | None]) -> str:
clean = {key: value for key, value in params.items() if value is not None}
return urllib.parse.urlencode(clean)
def restart_zotero(wait_for_api: bool = True) -> bool:
system = platform.system()
try:
if system == "Darwin":
subprocess.run(
["osascript", "-e", 'tell application "Zotero" to quit'],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=5,
check=False,
)
time.sleep(1)
subprocess.run(["open", "-a", "Zotero"], check=False)
elif system == "Windows":
subprocess.run(
["taskkill", "/IM", "zotero.exe", "/F"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
time.sleep(1)
subprocess.Popen(["zotero.exe"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
else:
subprocess.run(
["pkill", "-f", "zotero"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
time.sleep(1)
subprocess.Popen(["zotero"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception:
return False
if not wait_for_api:
return True
for _ in range(30):
if request("/api/", timeout=1).ok:
return True
time.sleep(0.5)
return False
def creators_from_item(data: dict[str, Any]) -> list[str]:
names: list[str] = []
for creator in data.get("creators", []) or []:
name = creator.get("name") or " ".join(
part for part in [creator.get("firstName"), creator.get("lastName")] if part
)
if name:
names.append(name)
return names
def year_from_date(raw: str | None) -> str | None:
if not raw:
return None
match = re.search(r"(\d{4})", raw)
return match.group(1) if match else None
def summarize_item(item: dict[str, Any]) -> dict[str, Any]:
data = item.get("data", item)
return {
"key": item.get("key") or data.get("key"),
"itemType": data.get("itemType"),
"title": data.get("title"),
"creators": creators_from_item(data),
"year": year_from_date(data.get("date")),
}
def summarize_collection(collection: dict[str, Any]) -> dict[str, Any]:
data = collection.get("data", collection)
return {
"key": collection.get("key") or data.get("key"),
"name": data.get("name"),
"parentCollection": data.get("parentCollection"),
"version": collection.get("version"),
}
def summarize_tag(tag: dict[str, Any]) -> dict[str, Any]:
return {"tag": tag.get("tag"), "numItems": (tag.get("meta") or {}).get("numItems")}
def summarize_group(group: dict[str, Any]) -> dict[str, Any]:
data = group.get("data", group)
return {
"id": group.get("id") or data.get("id"),
"name": data.get("name"),
"type": data.get("type"),
}
def print_items(rows: list[dict[str, Any]]) -> None:
for row in rows:
creators = ", ".join(row.get("creators") or [])
print(
f"{row.get('key') or '':10} "
f"{row.get('itemType') or '':14} "
f"{row.get('year') or '':4} "
f"{row.get('title') or ''} | {creators}"
)
def extract_bibtex_keys(text: str) -> list[str]:
return re.findall(r"@\w+\s*\{\s*([^,\s]+)", text)
def count_bibtex_entries(text: str) -> int:
return len(extract_bibtex_keys(text))
def total_results(response: Response) -> int | None:
raw = response.headers.get("Total-Results")
return int(raw) if raw and raw.isdigit() else None
def export_bibtex(item_key: str | None = None, *, include_children: bool = False) -> str:
if item_key:
params = query({"itemKey": item_key, "format": "bibtex", "limit": API_PAGE_LIMIT})
return api_response(f"{LOCAL_USER}/items?{params}").text
endpoint = "items" if include_children else "items/top"
start = 0
chunks: list[str] = []
while True:
params = query(
{
"format": "bibtex",
"sort": "title",
"direction": "asc",
"limit": API_PAGE_LIMIT,
"start": start,
}
)
response = api_response(f"{LOCAL_USER}/{endpoint}?{params}")
if response.text.strip():
chunks.append(response.text.strip())
total = total_results(response)
start += API_PAGE_LIMIT
if total is not None and start >= total:
break
if total is None and count_bibtex_entries(response.text) < API_PAGE_LIMIT:
break
text = "\n\n".join(chunks)
return text + "\n" if text else ""
def write_text_output(text: str, out: str | None) -> None:
if out is None:
print(text, end="" if text.endswith("\n") else "\n")
return
path = Path(out).expanduser().resolve()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
dump_json(
{
"path": str(path),
"bytes": len(text.encode("utf-8")),
"bibtex_entries": count_bibtex_entries(text),
}
)
def append_bib_entry(bib_path: Path, entry: str) -> tuple[str, bool]:
keys = extract_bibtex_keys(entry)
if not keys:
exit_with("Could not extract a BibTeX key from Zotero export")
key = keys[0]
existing = bib_path.read_text(encoding="utf-8", errors="replace") if bib_path.exists() else ""
already_present = re.search(r"@\w+\s*\{\s*" + re.escape(key) + r"\s*,", existing) is not None
if already_present:
return key, False
bib_path.parent.mkdir(parents=True, exist_ok=True)
prefix = existing.rstrip("\n") + "\n\n" if existing else ""
bib_path.write_text(prefix + entry.strip() + "\n", encoding="utf-8")
return key, True
def insert_citation(target: Path, citation: str, marker: str | None) -> None:
text = target.read_text(encoding="utf-8", errors="replace") if target.exists() else ""
if marker:
if marker not in text:
exit_with(f"Marker not found in {target}: {marker!r}")
target.write_text(text.replace(marker, citation, 1), encoding="utf-8")
return
suffix = "" if not text or text.endswith("\n") else "\n"
target.write_text(text + suffix + citation + "\n", encoding="utf-8")
def find_item(*, item_key: str | None, query_text: str | None) -> dict[str, Any]:
if item_key:
return api_get(f"{LOCAL_USER}/items/{urllib.parse.quote(item_key)}")
if not query_text:
exit_with("Provide --item-key or --query")
params = query({"q": query_text})
matches = api_get(f"{LOCAL_USER}/items/top?{params}")
if not matches:
exit_with(f"No top-level Zotero items matched query: {query_text}")
if len(matches) > 1:
print(
f"warning: {len(matches)} matches; using first result {matches[0].get('key')}",
file=sys.stderr,
)
return matches[0]
def status_payload() -> dict[str, Any]:
root = request("/api/", timeout=2)
connector = request("/connector/ping", timeout=2)
profile = profile_dir()
prefs = prefs_file()
return {
"profile": str(profile) if profile else None,
"prefs_file": str(prefs) if prefs else None,
"local_api_enabled_pref": read_local_api_pref(),
"api_running": root.ok,
"api_status": root.status,
"api_error": root.error,
"zotero_version": root.headers.get("X-Zotero-Version")
or connector.headers.get("X-Zotero-Version"),
"api_version": root.headers.get("Zotero-API-Version"),
"schema_version": root.headers.get("Zotero-Schema-Version"),
"connector_running": connector.ok,
"connector_status": connector.status,
"connector_error": connector.error,
"base_url": DEFAULT_BASE_URL,
}
def cmd_status(args: argparse.Namespace) -> None:
payload = status_payload()
if args.json:
dump_json(payload)
return
print(f"Zotero local API pref: {payload['local_api_enabled_pref']} ({payload['prefs_file']})")
print(
"API running: "
f"{payload['api_running']} status={payload['api_status']} "
f"version={payload['api_version']} zotero={payload['zotero_version']}"
)
print(f"Connector running: {payload['connector_running']} status={payload['connector_status']}")
def cmd_set_pref(args: argparse.Namespace, enabled: bool) -> None:
backup = set_local_api_pref(enabled)
restarted = restart_zotero(wait_for_api=enabled) if args.restart else False
dump_json(
{
"enabled": enabled,
"backup": str(backup),
"restarted": restarted,
"status": status_payload(),
}
)
def cmd_restart(_: argparse.Namespace) -> None:
dump_json({"restarted": restart_zotero(wait_for_api=True)})
def cmd_probe(args: argparse.Namespace) -> None:
endpoints = [
("root", "/api/"),
("schema", "/api/schema"),
("itemTypes", "/api/itemTypes"),
("itemFields", "/api/itemFields"),
("creatorFields", "/api/creatorFields"),
("collections", f"{LOCAL_USER}/collections"),
("topCollections", f"{LOCAL_USER}/collections/top"),
("topItems", f"{LOCAL_USER}/items/top"),
("tags", f"{LOCAL_USER}/tags"),
("searches", f"{LOCAL_USER}/searches"),
("groups", f"{LOCAL_USER}/groups"),
("fulltextVersions", f"{LOCAL_USER}/fulltext?since=0"),
("connectorPing", "/connector/ping"),
]
rows: list[dict[str, Any]] = []
for label, path in endpoints:
response = request(path)
parsed = parse_body(response)
if isinstance(parsed, list):
summary: Any = {"type": "array", "len": len(parsed)}
elif isinstance(parsed, dict):
summary = {"type": "object", "keys": list(parsed)[:8]}
else:
summary = str(parsed)[:160]
rows.append(
{
"label": label,
"path": path,
"status": response.status,
"content_type": response.content_type,
"total": response.headers.get("Total-Results"),
"summary": summary,
}
)
if args.json:
dump_json(rows)
return
for row in rows:
print(
f"{row['status'] or 'ERR':>3} {row['label']:18} {row['path']:45} total={row['total']} {row['summary']}"
)
def cmd_inventory(args: argparse.Namespace) -> None:
endpoint = "items" if args.include_children else "items/top"
params = query({"sort": "title", "direction": "asc"})
rows = [summarize_item(item) for item in api_get(f"{LOCAL_USER}/{endpoint}?{params}")]
dump_json(rows) if args.json else print_items(rows)
def cmd_collections(args: argparse.Namespace) -> None:
rows = [summarize_collection(collection) for collection in api_get(f"{LOCAL_USER}/collections")]
if args.json:
dump_json(rows)
return
for row in rows:
parent = f" parent={row['parentCollection']}" if row.get("parentCollection") else ""
print(f"{row.get('key') or '':10} {row.get('name') or ''}{parent}")
def cmd_tags(args: argparse.Namespace) -> None:
rows = [summarize_tag(tag) for tag in api_get(f"{LOCAL_USER}/tags")]
if args.json:
dump_json(rows)
return
for row in rows:
print(f"{row.get('tag') or ''} ({row.get('numItems') or 0})")
def cmd_groups(args: argparse.Namespace) -> None:
rows = [summarize_group(group) for group in api_get(f"{LOCAL_USER}/groups")]
if args.json:
dump_json(rows)
return
for row in rows:
print(f"{row.get('id') or '':>10} {row.get('type') or '':12} {row.get('name') or ''}")
def cmd_search(args: argparse.Namespace) -> None:
params = query({"q": args.query})
rows = [summarize_item(item) for item in api_get(f"{LOCAL_USER}/items/top?{params}")]
if args.with_bibtex_keys:
for row in rows:
bibtex = export_bibtex(row.get("key")) if row.get("key") else ""
keys = extract_bibtex_keys(bibtex)
row["bibtexKey"] = keys[0] if keys else None
dump_json(rows) if args.json else print_items(rows)
def cmd_export_bibtex(args: argparse.Namespace) -> None:
write_text_output(
export_bibtex(args.item_key, include_children=args.include_children), args.out
)
def cmd_sync_bib(args: argparse.Namespace) -> None:
text = export_bibtex(include_children=args.include_children)
path = Path(args.out).expanduser().resolve()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
dump_json({"path": str(path), "entries": count_bibtex_entries(text)})
def cmd_citations(args: argparse.Namespace) -> None:
params = query({"include": "data,citation", "style": args.style})
rows: list[dict[str, Any]] = []
for item in api_get(f"{LOCAL_USER}/items/top?{params}"):
row = summarize_item(item)
row["citation"] = item.get("citation")
rows.append(row)
if args.json:
dump_json(rows)
return
for row in rows:
print(f"{row.get('key')} {row.get('citation')}")
def cmd_children(args: argparse.Namespace) -> None:
data = api_get(f"{LOCAL_USER}/items/{urllib.parse.quote(args.item_key)}/children")
rows = [summarize_item(item) for item in data]
dump_json(rows) if args.json else print_items(rows)
def cmd_fulltext(args: argparse.Namespace) -> None:
data = api_get(f"{LOCAL_USER}/items/{urllib.parse.quote(args.attachment_key)}/fulltext")
content = data.get("content", "") if isinstance(data, dict) else str(data)
if args.out is None:
print(content)
return
path = Path(args.out).expanduser().resolve()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
dump_json(
{
"path": str(path),
"chars": len(content),
"indexedPages": data.get("indexedPages") if isinstance(data, dict) else None,
"totalPages": data.get("totalPages") if isinstance(data, dict) else None,
}
)
def cmd_file_url(args: argparse.Namespace) -> None:
print(api_get(f"{LOCAL_USER}/items/{urllib.parse.quote(args.attachment_key)}/file/view/url"))
def cmd_cite(args: argparse.Namespace) -> None:
item = find_item(item_key=args.item_key, query_text=args.query)
item_key = item.get("key")
if not item_key:
exit_with("Matched Zotero item has no key")
citekey, added = append_bib_entry(
Path(args.bib).expanduser().resolve(), export_bibtex(item_key)
)
citation = f"\\cite{{{citekey}}}" if args.tex else f"[@{citekey}]"
target = Path(args.tex or args.markdown).expanduser().resolve()
insert_citation(target, citation, args.marker)
dump_json(
{
"item_key": item_key,
"title": summarize_item(item).get("title"),
"bibtex_key": citekey,
"bib_path": str(Path(args.bib).expanduser().resolve()),
"bib_entry_added": added,
"edited_file": str(target),
"inserted": citation,
}
)
def connector_post(path: str, payload: Any, *, content_type: str = "application/json") -> Response:
return request(path, method="POST", data=payload, headers={"Content-Type": content_type})
def cmd_selected_target(args: argparse.Namespace) -> None:
response = require_ok(
connector_post("/connector/getSelectedCollection", {}),
"POST /connector/getSelectedCollection",
)
payload = parse_body(response)
print(json.dumps(payload, indent=2) if args.json else payload)
def cmd_import_records(args: argparse.Namespace, kind: str) -> None:
if not args.yes:
exit_with(
f"Refusing to write to Zotero without --yes. "
f"This imports {kind} into the selected Zotero library/collection."
)
text = Path(args.file).expanduser().read_text(encoding="utf-8") if args.file else args.text
if not text:
exit_with("Provide --file or --text")
session = args.session or f"codex-{uuid.uuid4().hex}"
path = f"/connector/import?{query({'session': session})}"
response = require_ok(connector_post(path, text, content_type="text/plain"), f"POST {path}")
dump_json({"status": response.status, "session": session, "response": parse_body(response)})
def add_json_flag(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--json", action="store_true", help="Print JSON instead of a compact text summary"
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Operate Zotero Desktop local API and connector server."
)
subcommands = parser.add_subparsers(dest="command", required=True)
status = subcommands.add_parser("status", help="Show Zotero local API and connector readiness")
add_json_flag(status)
status.set_defaults(func=cmd_status)
enable = subcommands.add_parser("enable", help="Enable Zotero's local Desktop API preference")
enable.add_argument(
"--restart", action="store_true", help="Restart Zotero after editing prefs.js"
)
enable.set_defaults(func=lambda args: cmd_set_pref(args, True))
disable = subcommands.add_parser(
"disable", help="Disable Zotero's local Desktop API preference"
)
disable.add_argument(
"--restart", action="store_true", help="Restart Zotero after editing prefs.js"
)
disable.set_defaults(func=lambda args: cmd_set_pref(args, False))
restart = subcommands.add_parser("restart", help="Restart Zotero and wait for the local API")
restart.set_defaults(func=cmd_restart)
probe = subcommands.add_parser("probe", help="Probe common safe local API routes")
add_json_flag(probe)
probe.set_defaults(func=cmd_probe)
inventory = subcommands.add_parser("inventory", help="List Zotero items")
inventory.add_argument(
"--include-children", action="store_true", help="Include child notes and attachments"
)
inventory.add_argument(
"--all", action="store_true", dest="include_children", help=argparse.SUPPRESS
)
add_json_flag(inventory)
inventory.set_defaults(func=cmd_inventory)
collections = subcommands.add_parser("collections", help="List collections")
add_json_flag(collections)
collections.set_defaults(func=cmd_collections)
tags = subcommands.add_parser("tags", help="List tags")
add_json_flag(tags)
tags.set_defaults(func=cmd_tags)
groups = subcommands.add_parser("groups", help="List synced group libraries visible locally")
add_json_flag(groups)
groups.set_defaults(func=cmd_groups)
search = subcommands.add_parser("search", help="Search top-level Zotero items")
search.add_argument("query")
search.add_argument("--with-bibtex-keys", action="store_true")
add_json_flag(search)
search.set_defaults(func=cmd_search)
export = subcommands.add_parser("export-bibtex", help="Export Zotero items as BibTeX")
export.add_argument("--item-key")
export.add_argument("--include-children", action="store_true")
export.add_argument(
"--all", action="store_true", dest="include_children", help=argparse.SUPPRESS
)
export.add_argument("--out")
export.set_defaults(func=cmd_export_bibtex)
sync_bib = subcommands.add_parser("sync-bib", help="Write a references.bib export")
sync_bib.add_argument("--out", default="references.bib")
sync_bib.add_argument("--include-children", action="store_true")
sync_bib.add_argument(
"--all", action="store_true", dest="include_children", help=argparse.SUPPRESS
)
sync_bib.set_defaults(func=cmd_sync_bib)
citations = subcommands.add_parser("citations", help="Render formatted citations")
citations.add_argument("--style", default="apa")
add_json_flag(citations)
citations.set_defaults(func=cmd_citations)
children = subcommands.add_parser("children", help="List child notes/attachments for an item")
children.add_argument("item_key")
add_json_flag(children)
children.set_defaults(func=cmd_children)
fulltext = subcommands.add_parser(
"fulltext", help="Print or save indexed full text for an attachment"
)
fulltext.add_argument("attachment_key")
fulltext.add_argument("--out")
fulltext.set_defaults(func=cmd_fulltext)
file_url = subcommands.add_parser(
"file-url", help="Print Zotero's local file URL for an attachment"
)
file_url.add_argument("attachment_key")
file_url.set_defaults(func=cmd_file_url)
cite = subcommands.add_parser(
"cite", help="Insert a citation into a TeX or Markdown file and update a .bib file"
)
source = cite.add_mutually_exclusive_group(required=True)
source.add_argument("--item-key")
source.add_argument("--query")
target = cite.add_mutually_exclusive_group(required=True)
target.add_argument("--tex")
target.add_argument("--markdown")
cite.add_argument("--bib", default="references.bib")
cite.add_argument(
"--marker", help="Replace this marker with the citation; otherwise append the citation"
)
cite.set_defaults(func=cmd_cite)
selected = subcommands.add_parser(
"selected-target", help="Show the currently selected Zotero library/collection"
)
add_json_flag(selected)
selected.set_defaults(func=cmd_selected_target)
for command, kind in [("import-bibtex", "BibTeX"), ("import-ris", "RIS")]:
import_cmd = subcommands.add_parser(command, help=f"Import {kind} through Zotero Connector")
input_group = import_cmd.add_mutually_exclusive_group(required=True)
input_group.add_argument("--file")
input_group.add_argument("--text")
import_cmd.add_argument("--session")
import_cmd.add_argument("--yes", action="store_true", help="Confirm Zotero library write")
import_cmd.set_defaults(func=lambda args, kind=kind: cmd_import_records(args, kind))
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
args.func(args)
return 0
if __name__ == "__main__":
raise SystemExit(main())