import { corrections, type Db, papers, paperTopics, paperVersions, publicationEvents, retractions, series, topics, } from "@autoscholar/db"; import type { PublicPaperSummary, PublicSearchQuery } from "@autoscholar/schemas"; import { and, arrayOverlaps, asc, desc, eq, gte, ilike, inArray, lte, or, sql } from "drizzle-orm"; import { notFound } from "./errors.js"; export const STAGING_FILTER = () => process.env.STAGING_MODE === "1" ? undefined : eq(papers.synthetic, false); function toSummary( row: { p: typeof papers.$inferSelect; v: typeof paperVersions.$inferSelect; s: { slug: string } | null }, topicList: Array<{ slug: string; name: string }>, ): PublicPaperSummary { const set = row.v.artifactSet ?? []; return { publicId: row.p.publicId, slug: row.p.slug, title: row.v.title, abstract: row.v.abstract, keywords: row.v.keywords, discipline: row.p.discipline, researchMode: row.p.researchMode, status: row.p.status as PublicPaperSummary["status"], versionNo: row.v.versionNo, firstPublishedAt: row.p.firstPublishedAt.toISOString(), updatedAt: row.p.updatedAt.toISOString(), seriesSlug: row.s?.slug ?? null, topics: topicList, hasCode: set.some((a) => a.kind === "code"), hasData: set.some((a) => a.kind === "data" && !/README|manifest/.test(a.path)), hasLatex: set.some((a) => a.kind === "manuscript" && a.path.endsWith(".tex")), synthetic: row.p.synthetic, }; } async function topicsFor(db: Db, paperIds: string[]) { if (paperIds.length === 0) return new Map>(); const rows = await db .select({ paperId: paperTopics.paperId, slug: topics.slug, name: topics.name }) .from(paperTopics) .innerJoin(topics, eq(paperTopics.topicId, topics.id)) .where(inArray(paperTopics.paperId, paperIds)); const m = new Map>(); for (const r of rows) m.set(r.paperId, [...(m.get(r.paperId) ?? []), { slug: r.slug, name: r.name }]); return m; } export async function searchPapers( db: Db, q: PublicSearchQuery, ): Promise<{ items: PublicPaperSummary[]; total: number; page: number; pageSize: number }> { const conds = []; const staging = STAGING_FILTER(); if (staging) conds.push(staging); if (q.discipline) conds.push(eq(papers.discipline, q.discipline)); if (q.mode) conds.push(eq(papers.researchMode, q.mode)); if (q.status) conds.push(eq(papers.status, q.status)); if (q.series) conds.push(eq(series.slug, q.series)); if (q.from) conds.push(gte(papers.firstPublishedAt, new Date(q.from))); if (q.to) conds.push(lte(papers.firstPublishedAt, new Date(q.to))); if (q.code) conds.push( sql`exists (select 1 from jsonb_array_elements(${paperVersions.artifactSet}) a where a->>'kind' = 'code')`, ); if (q.data) conds.push( sql`exists (select 1 from jsonb_array_elements(${paperVersions.artifactSet}) a where a->>'kind' = 'data' and a->>'path' !~ '(README|manifest)')`, ); const term = q.q?.trim(); let rank = sql`0`; if (term) { const tsq = sql`websearch_to_tsquery('english', ${term})`; conds.push( or( sql`${paperVersions.search} @@ ${tsq}`, ilike(paperVersions.title, `%${term}%`), sql`${papers.publicId} ilike ${`%${term}%`}`, )!, ); rank = sql`ts_rank_cd(${paperVersions.search}, ${tsq})`; } const where = conds.length ? and(...conds) : undefined; const base = db .select({ p: papers, v: paperVersions, s: { slug: series.slug }, rank }) .from(papers) .innerJoin(paperVersions, eq(papers.currentVersionId, paperVersions.id)) .leftJoin(series, eq(papers.seriesId, series.id)) .where(where); const order = q.sort === "title" ? [asc(paperVersions.title)] : q.sort === "relevance" && term ? [desc(rank), desc(papers.firstPublishedAt)] : [desc(papers.firstPublishedAt)]; const rows = await base .orderBy(...order) .limit(q.pageSize) .offset((q.page - 1) * q.pageSize); const [cnt] = await db .select({ n: sql`count(*)::int` }) .from(papers) .innerJoin(paperVersions, eq(papers.currentVersionId, paperVersions.id)) .leftJoin(series, eq(papers.seriesId, series.id)) .where(where); const tmap = await topicsFor( db, rows.map((r) => r.p.id), ); return { items: rows.map((r) => toSummary(r, tmap.get(r.p.id) ?? [])), total: cnt?.n ?? 0, page: q.page, pageSize: q.pageSize, }; } export async function getPaperBySlug(db: Db, slug: string) { const staging = STAGING_FILTER(); const [row] = await db .select({ p: papers, v: paperVersions, s: series }) .from(papers) .innerJoin(paperVersions, eq(papers.currentVersionId, paperVersions.id)) .leftJoin(series, eq(papers.seriesId, series.id)) .where(and(or(eq(papers.slug, slug), eq(papers.publicId, slug.toUpperCase())), staging)) .limit(1); if (!row) throw notFound("paper"); const versions = await db .select({ id: paperVersions.id, versionNo: paperVersions.versionNo, title: paperVersions.title, publishedAt: paperVersions.publishedAt, changelog: paperVersions.changelog, pdfPublicPath: paperVersions.pdfPublicPath, runId: paperVersions.runId, }) .from(paperVersions) .where(eq(paperVersions.paperId, row.p.id)) .orderBy(desc(paperVersions.versionNo)); const tmap = await topicsFor(db, [row.p.id]); const corr = await db .select() .from(corrections) .where(eq(corrections.paperId, row.p.id)) .orderBy(desc(corrections.createdAt)); const [retr] = await db.select().from(retractions).where(eq(retractions.paperId, row.p.id)).limit(1); const events = await db .select() .from(publicationEvents) .where(eq(publicationEvents.paperId, row.p.id)) .orderBy(asc(publicationEvents.createdAt)); const related = await db .select({ p: papers, v: paperVersions, s: { slug: series.slug } }) .from(papers) .innerJoin(paperVersions, eq(papers.currentVersionId, paperVersions.id)) .leftJoin(series, eq(papers.seriesId, series.id)) .where( and( sql`${papers.id} <> ${row.p.id}`, eq(papers.status, "published"), staging, or( eq(papers.discipline, row.p.discipline), row.v.keywords.length ? arrayOverlaps(paperVersions.keywords, row.v.keywords) : sql`false`, ), ), ) .orderBy(desc(papers.firstPublishedAt)) .limit(4); const rtmap = await topicsFor( db, related.map((r) => r.p.id), ); return { summary: toSummary(row, tmap.get(row.p.id) ?? []), paper: row.p, version: row.v, series: row.s, versions, corrections: corr, retraction: retr ?? null, events, related: related.map((r) => toSummary(r, rtmap.get(r.p.id) ?? [])), }; } export async function listTopics(db: Db) { const staging = STAGING_FILTER(); const rows = await db .select({ t: topics, n: sql`(select count(*)::int from paper_topics pt join papers p on p.id = pt.paper_id where pt.topic_id = ${topics.id} ${staging ? sql`and p.synthetic = false` : sql``})`, }) .from(topics) .orderBy(asc(topics.sortOrder), asc(topics.name)); return rows.map((r) => ({ ...r.t, paperCount: r.n })); } export async function listSeries(db: Db) { const staging = STAGING_FILTER(); const rows = await db .select({ s: series, n: sql`(select count(*)::int from papers p where p.series_id = ${series.id} ${staging ? sql`and p.synthetic = false` : sql``})`, }) .from(series) .orderBy(asc(series.name)); return rows.map((r) => ({ ...r.s, paperCount: r.n })); } export async function disciplineCounts(db: Db) { const staging = STAGING_FILTER(); return db .select({ discipline: papers.discipline, n: sql`count(*)::int` }) .from(papers) .where(staging) .groupBy(papers.discipline) .orderBy(desc(sql`count(*)`)); } export async function allPublishedForFeeds(db: Db, limit = 100) { const staging = STAGING_FILTER(); return db .select({ p: papers, v: paperVersions }) .from(papers) .innerJoin(paperVersions, eq(papers.currentVersionId, paperVersions.id)) .where(staging) .orderBy(desc(papers.updatedAt)) .limit(limit); }