spb/wp-spboucher-ai
Public
1import { sqlPublic as sql } from "@/db";2import { APP_URL, INSTITUTION, SITE_NAME } from "@/lib/env";3import { limited, tooMany } from "@/lib/api";4export const dynamic = "force-dynamic";5const esc = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");6const REPO = "wp.spboucher.ai"; const PAGE = 100;7function envelope(verb: string, args: Record<string, string>, body: string) {8 return `<?xml version="1.0" encoding="UTF-8"?>\n<OAI-PMH xmlns="http://www.openarchives.org/OAI/2.0/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/ http://www.openarchives.org/OAI/2.0/OAI-PMH.xsd"><responseDate>${new Date().toISOString().replace(/\.\d+Z$/, "Z")}</responseDate><request${Object.entries(args).map(([k, v]) => ` ${k}="${esc(v)}"`).join("")}>${APP_URL}/api/oai</request>${body}</OAI-PMH>`;9}10const err = (args: Record<string, string>, code: string, msg: string) => new Response(envelope("", args, `<error code="${code}">${esc(msg)}</error>`), { headers: { "Content-Type": "text/xml; charset=utf-8" } });11function record(p: Record<string, unknown>, headerOnly = false) {12 const date = String(p.updated_at).slice(0, 10); const id = `oai:${REPO}:${p.slug}`; const sets = `<setSpec>${String(p.series_code).toLowerCase()}</setSpec>`;13 const header = `<header${p.status === "withdrawn" ? ' status="deleted"' : ""}><identifier>${id}</identifier><datestamp>${date}</datestamp>${sets}</header>`;14 if (headerOnly) return header;15 const authors = (p.authors as string[]) ?? []; const kw = (p.keywords as string[]) ?? []; const abstract = ((p.abstract as string[]) ?? []).join("\n\n");16 const dc = `<metadata><oai_dc:dc xmlns:oai_dc="http://www.openarchives.org/OAI/2.0/oai_dc/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/oai_dc/ http://www.openarchives.org/OAI/2.0/oai_dc.xsd">17<dc:title>${esc(String(p.title))}${p.subtitle ? `: ${esc(String(p.subtitle))}` : ""}</dc:title>${authors.map((a) => `<dc:creator>${esc(a)}</dc:creator>`).join("")}${kw.map((k) => `<dc:subject>${esc(k)}</dc:subject>`).join("")}${abstract ? `<dc:description>${esc(abstract)}</dc:description>` : ""}<dc:publisher>${esc(INSTITUTION)}</dc:publisher>${p.first_released_on ? `<dc:date>${String(p.first_released_on).slice(0, p.first_released_precision === "year" ? 4 : p.first_released_precision === "month" ? 7 : 10)}</dc:date>` : ""}<dc:type>Working paper</dc:type><dc:format>application/pdf</dc:format><dc:identifier>${APP_URL}/papers/${p.slug}</dc:identifier><dc:identifier>${esc(String(p.identifier))}</dc:identifier>${p.ssrn_id ? `<dc:identifier>https://ssrn.com/abstract=${p.ssrn_id}</dc:identifier>` : ""}${p.doi ? `<dc:identifier>doi:${esc(String(p.doi))}</dc:identifier>` : ""}<dc:language>${esc(String(p.language))}</dc:language><dc:relation>${esc(String(p.series_name_en))}</dc:relation><dc:rights>Working paper circulated for discussion; all rights reserved by the authors.</dc:rights>18</oai_dc:dc></metadata>`;19 return `<record>${header}${p.status === "withdrawn" ? "" : dc}</record>`;20}21const 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`;22export async function GET(req: Request) {23 if (limited(req, "oai", 60)) return tooMany();24 const u = new URL(req.url); const args: Record<string, string> = {}; for (const [k, v] of u.searchParams) args[k] = v;25 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" } });26 if (!verb) return err(args, "badVerb", "verb missing");27 if (verb === "Identify") { const [e] = await sql`SELECT min(updated_at)::text AS earliest FROM public_papers`; return xml(`<Identify><repositoryName>${esc(SITE_NAME)} — Simon-Pierre Boucher Research Observatory</repositoryName><baseURL>${APP_URL}/api/oai</baseURL><protocolVersion>2.0</protocolVersion><adminEmail>simon-pierre.boucher@uqo.ca</adminEmail><earliestDatestamp>${String(e?.earliest ?? new Date().toISOString()).slice(0, 10)}</earliestDatestamp><deletedRecord>persistent</deletedRecord><granularity>YYYY-MM-DD</granularity><description><oai-identifier xmlns="http://www.openarchives.org/OAI/2.0/oai-identifier" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/oai-identifier http://www.openarchives.org/OAI/2.0/oai-identifier.xsd"><scheme>oai</scheme><repositoryIdentifier>${REPO}</repositoryIdentifier><delimiter>:</delimiter><sampleIdentifier>oai:${REPO}:wp38</sampleIdentifier></oai-identifier></description></Identify>`); }28 if (verb === "ListMetadataFormats") return xml(`<ListMetadataFormats><metadataFormat><metadataPrefix>oai_dc</metadataPrefix><schema>http://www.openarchives.org/OAI/2.0/oai_dc.xsd</schema><metadataNamespace>http://www.openarchives.org/OAI/2.0/oai_dc/</metadataNamespace></metadataFormat></ListMetadataFormats>`);29 if (verb === "ListSets") { const rows = await sql`SELECT code, name_en FROM series WHERE is_active ORDER BY sort`; return xml(`<ListSets>${rows.map((r) => `<set><setSpec>${String(r.code).toLowerCase()}</setSpec><setName>${esc(r.name_en)}</setName></set>`).join("")}</ListSets>`); }30 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(`<GetRecord>${record(p)}</GetRecord>`); }31 if (verb === "ListRecords" || verb === "ListIdentifiers") {32 let from = args.from, until = args.until, set = args.set, offset = 0;33 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"); } }34 else if (args.metadataPrefix !== "oai_dc") return err(args, "cannotDisseminateFormat", "only oai_dc");35 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}`;36 if (!rows.length) return err(args, "noRecordsMatch", "no records match");37 const page = rows.slice(0, PAGE); const more = rows.length > PAGE;38 const token = more ? `<resumptionToken>${Buffer.from(JSON.stringify({ from, until, set, offset: offset + PAGE })).toString("base64url")}</resumptionToken>` : "";39 return xml(`<${verb}>${page.map((p) => (verb === "ListRecords" ? record(p) : record(p, true))).join("")}${token}</${verb}>`);40 }41 return err(args, "badVerb", "unknown verb");42}43