forked from github/quartz
183 lines
5.0 KiB
Elixir
183 lines
5.0 KiB
Elixir
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
|