232 lines
6.9 KiB
Elixir
232 lines
6.9 KiB
Elixir
defmodule OrgGarden.Transforms.Citations do
|
|
@moduledoc """
|
|
Markdown transform: resolves org-citar citation keys to hyperlinks.
|
|
|
|
## Recognised citation syntax (as output by ox-hugo from org-citar)
|
|
|
|
[cite:@key] → org-cite / citar standard (most common)
|
|
[cite:@key1;@key2] → multiple citations
|
|
cite:key → older roam-style bare cite syntax
|
|
|
|
## Resolution chain (in order)
|
|
|
|
1. Zotero (live instance via Better BibTeX JSON-RPC) — preferred
|
|
2. BibTeX file (BIBTEX_FILE env var) — fallback
|
|
3. DOI / bare key — always succeeds
|
|
|
|
## Modes (opts.citation_mode)
|
|
|
|
:silent — silently use DOI/bare-key fallback when Zotero+BibTeX fail
|
|
:warn — (default) emit a Logger.warning for unresolved keys
|
|
:strict — raise on unresolved keys (aborts pipeline)
|
|
|
|
## Format
|
|
|
|
Resolved citations are rendered as:
|
|
|
|
[Label](url) when a URL is available
|
|
[Label] when no URL could be determined (bare key fallback)
|
|
|
|
Multiple semicolon-separated keys become space-separated links:
|
|
|
|
[cite:@a;@b] → [Author A, 2020](url_a) [Author B, 2019](url_b)
|
|
|
|
## init/1 callback
|
|
|
|
Loads the BibTeX file (if configured) once before processing begins,
|
|
and probes Zotero availability, emitting warnings as appropriate.
|
|
"""
|
|
|
|
@behaviour OrgGarden.Transform
|
|
|
|
require Logger
|
|
|
|
alias OrgGarden.Resolvers.Zotero
|
|
alias OrgGarden.Resolvers.BibTeX
|
|
alias OrgGarden.Resolvers.DOI
|
|
|
|
# Match [cite:@key] and [cite:@key1;@key2;...] (org-cite / citar style)
|
|
@cite_bracket_regex ~r/\[cite:(@[^\]]+)\]/
|
|
|
|
# Match bare cite:key or cite:@key (older roam style, no brackets, optional @ prefix)
|
|
@cite_bare_regex ~r/(?<![(\[])cite:@?([a-zA-Z0-9_:-]+)/
|
|
|
|
# ------------------------------------------------------------------
|
|
# OrgGarden callbacks
|
|
# ------------------------------------------------------------------
|
|
|
|
@doc """
|
|
Called once before processing any files. Loads BibTeX, probes Zotero.
|
|
Returns a state map passed to every `apply/3` call.
|
|
"""
|
|
def init(opts) do
|
|
bibtex_entries = load_bibtex(opts)
|
|
zotero_available = probe_zotero(opts)
|
|
|
|
if not zotero_available and bibtex_entries == %{} do
|
|
Logger.warning(
|
|
"Citations: neither Zotero nor a BibTeX file is available. " <>
|
|
"All citations will fall back to bare-key rendering. " <>
|
|
"Set BIBTEX_FILE env var or start Zotero with Better BibTeX to resolve citations."
|
|
)
|
|
end
|
|
|
|
%{
|
|
bibtex_entries: bibtex_entries,
|
|
zotero_available: zotero_available,
|
|
zotero_url: Map.get(opts, :zotero_url, "http://localhost:23119"),
|
|
citation_mode: Map.get(opts, :citation_mode, :warn)
|
|
}
|
|
end
|
|
|
|
@doc """
|
|
Apply citation resolution to a single markdown file's content.
|
|
"""
|
|
def apply(content, state, _opts) do
|
|
content
|
|
|> resolve_bracket_citations(state)
|
|
|> resolve_bare_citations(state)
|
|
end
|
|
|
|
# ------------------------------------------------------------------
|
|
# Resolution passes
|
|
# ------------------------------------------------------------------
|
|
|
|
defp resolve_bracket_citations(content, state) do
|
|
Regex.replace(@cite_bracket_regex, content, fn _full, keys_str ->
|
|
keys_str
|
|
|> String.split(";")
|
|
|> Enum.map(&String.trim/1)
|
|
|> Enum.map(fn "@" <> key -> key end)
|
|
|> Enum.map(&resolve_key(&1, state))
|
|
|> Enum.join(" ")
|
|
end)
|
|
end
|
|
|
|
defp resolve_bare_citations(content, state) do
|
|
Regex.replace(@cite_bare_regex, content, fn _full, key ->
|
|
resolve_key(key, state)
|
|
end)
|
|
end
|
|
|
|
# ------------------------------------------------------------------
|
|
# Single-key resolution chain
|
|
# ------------------------------------------------------------------
|
|
|
|
defp resolve_key(key, state) do
|
|
info =
|
|
with :error <- try_zotero(key, state),
|
|
:error <- try_bibtex(key, state) do
|
|
handle_unresolved(key, state)
|
|
else
|
|
{:ok, citation_info} -> citation_info
|
|
end
|
|
|
|
format_result(info)
|
|
end
|
|
|
|
defp try_zotero(_key, %{zotero_available: false}), do: :error
|
|
|
|
defp try_zotero(key, %{zotero_url: url}) do
|
|
Zotero.resolve(key, url)
|
|
end
|
|
|
|
defp try_bibtex(_key, %{bibtex_entries: entries}) when map_size(entries) == 0, do: :error
|
|
|
|
defp try_bibtex(key, %{bibtex_entries: entries}) do
|
|
BibTeX.resolve(key, entries)
|
|
end
|
|
|
|
defp handle_unresolved(key, %{citation_mode: mode}) do
|
|
case mode do
|
|
:strict ->
|
|
raise "Citations: could not resolve citation key '#{key}' and mode is :strict"
|
|
|
|
:warn ->
|
|
Logger.warning("Citations: unresolved citation key '#{key}' — using bare-key fallback")
|
|
{:ok, result} = DOI.resolve(key)
|
|
result
|
|
|
|
:silent ->
|
|
{:ok, result} = DOI.resolve(key)
|
|
result
|
|
end
|
|
end
|
|
|
|
defp format_result(%{label: label, url: nil}), do: "[#{label}]"
|
|
defp format_result(%{label: label, url: url}), do: "[#{label}](#{url})"
|
|
|
|
# ------------------------------------------------------------------
|
|
# Init helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
defp load_bibtex(opts) do
|
|
path = Map.get(opts, :bibtex_file) || System.get_env("BIBTEX_FILE")
|
|
|
|
cond do
|
|
is_nil(path) ->
|
|
Logger.debug("Citations: BIBTEX_FILE not set — BibTeX resolver disabled")
|
|
%{}
|
|
|
|
not File.exists?(path) ->
|
|
Logger.warning("Citations: BIBTEX_FILE=#{path} does not exist — BibTeX resolver disabled")
|
|
%{}
|
|
|
|
true ->
|
|
case BibTeX.load(path) do
|
|
{:ok, entries} -> entries
|
|
{:error, reason} ->
|
|
Logger.warning("Citations: failed to load BibTeX file #{path}: #{inspect(reason)}")
|
|
%{}
|
|
end
|
|
end
|
|
end
|
|
|
|
defp probe_zotero(opts) do
|
|
url = Map.get(opts, :zotero_url, "http://localhost:23119")
|
|
|
|
# Use a no-op JSON-RPC call to probe availability.
|
|
# /better-bibtex/cayw is intentionally avoided — it blocks waiting for
|
|
# user interaction and never returns without a pick.
|
|
payload =
|
|
Jason.encode!(%{
|
|
jsonrpc: "2.0",
|
|
method: "item.search",
|
|
params: [[[]]],
|
|
id: 0
|
|
})
|
|
|
|
result =
|
|
try do
|
|
Req.post(url <> "/better-bibtex/json-rpc",
|
|
body: payload,
|
|
headers: [{"content-type", "application/json"}],
|
|
receive_timeout: 3_000,
|
|
finch: OrgGarden.Finch
|
|
)
|
|
rescue
|
|
e -> {:error, e}
|
|
end
|
|
|
|
case result do
|
|
{:ok, %{status: 200}} ->
|
|
Logger.info("Citations: Zotero Better BibTeX is available at #{url}")
|
|
true
|
|
|
|
{:ok, %{status: status}} ->
|
|
Logger.warning(
|
|
"Citations: Zotero responded HTTP #{status} at #{url} — " <>
|
|
"is Better BibTeX installed?"
|
|
)
|
|
false
|
|
|
|
_ ->
|
|
Logger.warning(
|
|
"Citations: Zotero not reachable at #{url} — " <>
|
|
"start Zotero with Better BibTeX or set BIBTEX_FILE as fallback"
|
|
)
|
|
false
|
|
end
|
|
end
|
|
end
|