import { sqlPublic as sql } from "@/db"; import { APP_URL, INSTITUTION, SITE_NAME } from "@/lib/env"; import { limited, tooMany } from "@/lib/api"; export const dynamic = "force-dynamic"; const esc = (s: string) => s.replace(/&/g, "&").replace(//g, ">"); const REPO = "wp.spboucher.ai"; const PAGE = 100; function envelope(verb: string, args: Record, body: string) { return `\n${new Date().toISOString().replace(/\.\d+Z$/, "Z")} ` ${k}="${esc(v)}"`).join("")}>${APP_URL}/api/oai${body}`; } const err = (args: Record, code: string, msg: string) => new Response(envelope("", args, `${esc(msg)}`), { headers: { "Content-Type": "text/xml; charset=utf-8" } }); function record(p: Record, headerOnly = false) { const date = String(p.updated_at).slice(0, 10); const id = `oai:${REPO}:${p.slug}`; const sets = `${String(p.series_code).toLowerCase()}`; const header = `${id}${date}${sets}`; if (headerOnly) return header; const authors = (p.authors as string[]) ?? []; const kw = (p.keywords as string[]) ?? []; const abstract = ((p.abstract as string[]) ?? []).join("\n\n"); const dc = ` ${esc(String(p.title))}${p.subtitle ? `: ${esc(String(p.subtitle))}` : ""}${authors.map((a) => `${esc(a)}`).join("")}${kw.map((k) => `${esc(k)}`).join("")}${abstract ? `${esc(abstract)}` : ""}${esc(INSTITUTION)}${p.first_released_on ? `${String(p.first_released_on).slice(0, p.first_released_precision === "year" ? 4 : p.first_released_precision === "month" ? 7 : 10)}` : ""}Working paperapplication/pdf${APP_URL}/papers/${p.slug}${esc(String(p.identifier))}${p.ssrn_id ? `https://ssrn.com/abstract=${p.ssrn_id}` : ""}${p.doi ? `doi:${esc(String(p.doi))}` : ""}${esc(String(p.language))}${esc(String(p.series_name_en))}Working paper circulated for discussion; all rights reserved by the authors. `; return `${header}${p.status === "withdrawn" ? "" : dc}`; } const SELECT = sql`SELECT p.slug, p.identifier, p.title, p.subtitle, p.abstract, p.keywords, p.language, p.status, p.ssrn_id, p.doi, p.first_released_on::text, p.first_released_precision, p.updated_at::text, s.code AS series_code, s.name_en AS series_name_en, (SELECT array_agg(a.display_name ORDER BY pa.position) FROM paper_authors pa JOIN authors a ON a.id = pa.author_id WHERE pa.paper_id = p.id) AS authors FROM public_papers p JOIN series s ON s.id = p.series_id`; export async function GET(req: Request) { if (limited(req, "oai", 60)) return tooMany(); const u = new URL(req.url); const args: Record = {}; for (const [k, v] of u.searchParams) args[k] = v; const verb = args.verb; const xml = (body: string) => new Response(envelope(verb, args, body), { headers: { "Content-Type": "text/xml; charset=utf-8", "Cache-Control": "public, max-age=300" } }); if (!verb) return err(args, "badVerb", "verb missing"); if (verb === "Identify") { const [e] = await sql`SELECT min(updated_at)::text AS earliest FROM public_papers`; return xml(`${esc(SITE_NAME)} — Simon-Pierre Boucher Research Observatory${APP_URL}/api/oai2.0simon-pierre.boucher@uqo.ca${String(e?.earliest ?? new Date().toISOString()).slice(0, 10)}persistentYYYY-MM-DDoai${REPO}:oai:${REPO}:wp38`); } if (verb === "ListMetadataFormats") return xml(`oai_dchttp://www.openarchives.org/OAI/2.0/oai_dc.xsdhttp://www.openarchives.org/OAI/2.0/oai_dc/`); if (verb === "ListSets") { const rows = await sql`SELECT code, name_en FROM series WHERE is_active ORDER BY sort`; return xml(`${rows.map((r) => `${String(r.code).toLowerCase()}${esc(r.name_en)}`).join("")}`); } if (verb === "GetRecord") { if (args.metadataPrefix !== "oai_dc") return err(args, "cannotDisseminateFormat", "only oai_dc"); const slug = (args.identifier ?? "").replace(`oai:${REPO}:`, ""); const [p] = await sql`${SELECT} WHERE p.slug = ${slug}`; if (!p) return err(args, "idDoesNotExist", "unknown identifier"); return xml(`${record(p)}`); } if (verb === "ListRecords" || verb === "ListIdentifiers") { let from = args.from, until = args.until, set = args.set, offset = 0; if (args.resumptionToken) { try { const t = JSON.parse(Buffer.from(args.resumptionToken, "base64url").toString()); from = t.from; until = t.until; set = t.set; offset = t.offset; } catch { return err(args, "badResumptionToken", "invalid token"); } } else if (args.metadataPrefix !== "oai_dc") return err(args, "cannotDisseminateFormat", "only oai_dc"); const rows = await sql`${SELECT} WHERE (${from ?? null}::text IS NULL OR p.updated_at >= ${from ?? null}::timestamptz) AND (${until ?? null}::text IS NULL OR p.updated_at < (${until ?? null}::date + 1)) AND (${set ?? null}::text IS NULL OR lower(s.code) = ${set ?? null}) ORDER BY p.updated_at, p.number LIMIT ${PAGE + 1} OFFSET ${offset}`; if (!rows.length) return err(args, "noRecordsMatch", "no records match"); const page = rows.slice(0, PAGE); const more = rows.length > PAGE; const token = more ? `${Buffer.from(JSON.stringify({ from, until, set, offset: offset + PAGE })).toString("base64url")}` : ""; return xml(`<${verb}>${page.map((p) => (verb === "ListRecords" ? record(p) : record(p, true))).join("")}${token}`); } return err(args, "badVerb", "unknown verb"); }