defmodule OrgGarden.Resolvers.Zotero do @moduledoc """ Resolves citation keys via Zotero Better BibTeX's JSON-RPC API. Requires Zotero to be running with the Better BibTeX plugin installed. Default endpoint: http://localhost:23119/better-bibtex/json-rpc Resolution strategy: 1. Search by citation key via `item.search` 2. If found, try to get a PDF attachment link (zotero://open-pdf/...) 3. Fall back to zotero://select/items/@key Returns `{:ok, %{label: "Author, Year", url: "zotero://..."}}` or `:error`. """ require Logger @rpc_path "/better-bibtex/json-rpc" @doc """ Attempt to resolve `key` against a running Zotero instance. `base_url` defaults to `http://localhost:23119`. """ @spec resolve(String.t(), String.t()) :: {:ok, map()} | :error def resolve(key, base_url \\ "http://localhost:23119") do url = base_url <> @rpc_path payload = Jason.encode!(%{ jsonrpc: "2.0", method: "item.search", params: [ [["citationKey", "is", key]] ], id: 1 }) case Req.post(url, body: payload, headers: [{"content-type", "application/json"}], receive_timeout: 5_000, finch: OrgGarden.Finch ) do {:ok, %{status: 200, body: body}} -> parse_response(body, key, base_url) {:ok, %{status: status}} -> Logger.debug("Zotero: unexpected HTTP #{status} for key #{key}") :error {:error, reason} -> Logger.debug("Zotero: connection failed for key #{key}: #{inspect(reason)}") :error other -> Logger.debug("Zotero: unexpected result for key #{key}: #{inspect(other)}") :error end rescue e -> Logger.debug("Zotero: exception resolving key #{key}: #{inspect(e)}") :error end # ------------------------------------------------------------------ # Private helpers # ------------------------------------------------------------------ defp parse_response(%{"result" => [item | _]}, key, base_url) do label = build_label(item) url = resolve_url(item, key, base_url) {:ok, %{label: label, url: url}} end defp parse_response(%{"result" => []}, key, _base_url) do Logger.debug("Zotero: no item found for key #{key}") :error end defp parse_response(%{"error" => err}, key, _base_url) do Logger.debug("Zotero: RPC error for key #{key}: #{inspect(err)}") :error end defp parse_response(body, key, _base_url) do Logger.debug("Zotero: unexpected response shape for key #{key}: #{inspect(body)}") :error end defp fetch_pdf_url(key, base_url) do payload = Jason.encode!(%{ jsonrpc: "2.0", method: "item.attachments", params: [key], id: 2 }) case Req.post(base_url <> @rpc_path, body: payload, headers: [{"content-type", "application/json"}], receive_timeout: 5_000, finch: OrgGarden.Finch ) do {:ok, %{status: 200, body: %{"result" => attachments}}} when is_list(attachments) -> attachments |> Enum.find_value(fn att -> open = Map.get(att, "open", "") path = Map.get(att, "path", "") if String.ends_with?(path, ".pdf"), do: open, else: nil end) _ -> nil end rescue _ -> nil end # CSL-JSON format: authors are under "author" with "family"/"given" keys. # Year is under "issued" -> "date-parts" -> [[year, month, day]]. defp build_label(item) do authors = Map.get(item, "author", []) year = extract_year(item) author_part = case authors do [] -> "Unknown" [single] -> Map.get(single, "family", Map.get(single, "literal", "Unknown")) [first | rest] -> first_name = Map.get(first, "family", Map.get(first, "literal", "Unknown")) last_name = rest |> List.last() |> then(&Map.get(&1, "family", Map.get(&1, "literal", "Unknown"))) "#{first_name} & #{last_name}" end if year, do: "#{author_part}, #{year}", else: author_part end # "issued": {"date-parts": [["2021", 2, 3]]} defp extract_year(item) do case get_in(item, ["issued", "date-parts"]) do [[year | _] | _] -> to_string(year) _ -> nil end end defp resolve_url(item, key, base_url) do # Prefer zotero://open-pdf/... for items with a PDF attachment. # Fall back to zotero://select/library/items/KEY to open the item in Zotero. # The "id" field is a URI like "http://zotero.org/users/123/items/ABCD1234". pdf_url = fetch_pdf_url(key, base_url) if pdf_url do pdf_url else item_key = item |> Map.get("id", "") |> String.split("/") |> List.last() |> non_empty() if item_key do "zotero://select/library/items/#{item_key}" else "zotero://select/items/@#{key}" end end end defp non_empty(nil), do: nil defp non_empty(""), do: nil defp non_empty(v), do: v end