spb/autoscholar
Public
1import {2 corrections,3 type Db,4 papers,5 paperTopics,6 paperVersions,7 publicationEvents,8 retractions,9 series,10 topics,11} from "@autoscholar/db";12import type { PublicPaperSummary, PublicSearchQuery } from "@autoscholar/schemas";13import { and, arrayOverlaps, asc, desc, eq, gte, ilike, inArray, lte, or, sql } from "drizzle-orm";14import { notFound } from "./errors.js";1516export const STAGING_FILTER = () =>17 process.env.STAGING_MODE === "1" ? undefined : eq(papers.synthetic, false);1819function toSummary(20 row: { p: typeof papers.$inferSelect; v: typeof paperVersions.$inferSelect; s: { slug: string } | null },21 topicList: Array<{ slug: string; name: string }>,22): PublicPaperSummary {23 const set = row.v.artifactSet ?? [];24 return {25 publicId: row.p.publicId,26 slug: row.p.slug,27 title: row.v.title,28 abstract: row.v.abstract,29 keywords: row.v.keywords,30 discipline: row.p.discipline,31 researchMode: row.p.researchMode,32 status: row.p.status as PublicPaperSummary["status"],33 versionNo: row.v.versionNo,34 firstPublishedAt: row.p.firstPublishedAt.toISOString(),35 updatedAt: row.p.updatedAt.toISOString(),36 seriesSlug: row.s?.slug ?? null,37 topics: topicList,38 hasCode: set.some((a) => a.kind === "code"),39 hasData: set.some((a) => a.kind === "data" && !/README|manifest/.test(a.path)),40 hasLatex: set.some((a) => a.kind === "manuscript" && a.path.endsWith(".tex")),41 synthetic: row.p.synthetic,42 };43}4445async function topicsFor(db: Db, paperIds: string[]) {46 if (paperIds.length === 0) return new Map<string, Array<{ slug: string; name: string }>>();47 const rows = await db48 .select({ paperId: paperTopics.paperId, slug: topics.slug, name: topics.name })49 .from(paperTopics)50 .innerJoin(topics, eq(paperTopics.topicId, topics.id))51 .where(inArray(paperTopics.paperId, paperIds));52 const m = new Map<string, Array<{ slug: string; name: string }>>();53 for (const r of rows) m.set(r.paperId, [...(m.get(r.paperId) ?? []), { slug: r.slug, name: r.name }]);54 return m;55}5657export async function searchPapers(58 db: Db,59 q: PublicSearchQuery,60): Promise<{ items: PublicPaperSummary[]; total: number; page: number; pageSize: number }> {61 const conds = [];62 const staging = STAGING_FILTER();63 if (staging) conds.push(staging);64 if (q.discipline) conds.push(eq(papers.discipline, q.discipline));65 if (q.mode) conds.push(eq(papers.researchMode, q.mode));66 if (q.status) conds.push(eq(papers.status, q.status));67 if (q.series) conds.push(eq(series.slug, q.series));68 if (q.from) conds.push(gte(papers.firstPublishedAt, new Date(q.from)));69 if (q.to) conds.push(lte(papers.firstPublishedAt, new Date(q.to)));70 if (q.code)71 conds.push(72 sql`exists (select 1 from jsonb_array_elements(${paperVersions.artifactSet}) a where a->>'kind' = 'code')`,73 );74 if (q.data)75 conds.push(76 sql`exists (select 1 from jsonb_array_elements(${paperVersions.artifactSet}) a where a->>'kind' = 'data' and a->>'path' !~ '(README|manifest)')`,77 );78 const term = q.q?.trim();79 let rank = sql<number>`0`;80 if (term) {81 const tsq = sql`websearch_to_tsquery('english', ${term})`;82 conds.push(83 or(84 sql`${paperVersions.search} @@ ${tsq}`,85 ilike(paperVersions.title, `%${term}%`),86 sql`${papers.publicId} ilike ${`%${term}%`}`,87 )!,88 );89 rank = sql<number>`ts_rank_cd(${paperVersions.search}, ${tsq})`;90 }91 const where = conds.length ? and(...conds) : undefined;92 const base = db93 .select({ p: papers, v: paperVersions, s: { slug: series.slug }, rank })94 .from(papers)95 .innerJoin(paperVersions, eq(papers.currentVersionId, paperVersions.id))96 .leftJoin(series, eq(papers.seriesId, series.id))97 .where(where);98 const order =99 q.sort === "title"100 ? [asc(paperVersions.title)]101 : q.sort === "relevance" && term102 ? [desc(rank), desc(papers.firstPublishedAt)]103 : [desc(papers.firstPublishedAt)];104 const rows = await base105 .orderBy(...order)106 .limit(q.pageSize)107 .offset((q.page - 1) * q.pageSize);108 const [cnt] = await db109 .select({ n: sql<number>`count(*)::int` })110 .from(papers)111 .innerJoin(paperVersions, eq(papers.currentVersionId, paperVersions.id))112 .leftJoin(series, eq(papers.seriesId, series.id))113 .where(where);114 const tmap = await topicsFor(115 db,116 rows.map((r) => r.p.id),117 );118 return {119 items: rows.map((r) => toSummary(r, tmap.get(r.p.id) ?? [])),120 total: cnt?.n ?? 0,121 page: q.page,122 pageSize: q.pageSize,123 };124}125126export async function getPaperBySlug(db: Db, slug: string) {127 const staging = STAGING_FILTER();128 const [row] = await db129 .select({ p: papers, v: paperVersions, s: series })130 .from(papers)131 .innerJoin(paperVersions, eq(papers.currentVersionId, paperVersions.id))132 .leftJoin(series, eq(papers.seriesId, series.id))133 .where(and(or(eq(papers.slug, slug), eq(papers.publicId, slug.toUpperCase())), staging))134 .limit(1);135 if (!row) throw notFound("paper");136 const versions = await db137 .select({138 id: paperVersions.id,139 versionNo: paperVersions.versionNo,140 title: paperVersions.title,141 publishedAt: paperVersions.publishedAt,142 changelog: paperVersions.changelog,143 pdfPublicPath: paperVersions.pdfPublicPath,144 runId: paperVersions.runId,145 })146 .from(paperVersions)147 .where(eq(paperVersions.paperId, row.p.id))148 .orderBy(desc(paperVersions.versionNo));149 const tmap = await topicsFor(db, [row.p.id]);150 const corr = await db151 .select()152 .from(corrections)153 .where(eq(corrections.paperId, row.p.id))154 .orderBy(desc(corrections.createdAt));155 const [retr] = await db.select().from(retractions).where(eq(retractions.paperId, row.p.id)).limit(1);156 const events = await db157 .select()158 .from(publicationEvents)159 .where(eq(publicationEvents.paperId, row.p.id))160 .orderBy(asc(publicationEvents.createdAt));161 const related = await db162 .select({ p: papers, v: paperVersions, s: { slug: series.slug } })163 .from(papers)164 .innerJoin(paperVersions, eq(papers.currentVersionId, paperVersions.id))165 .leftJoin(series, eq(papers.seriesId, series.id))166 .where(167 and(168 sql`${papers.id} <> ${row.p.id}`,169 eq(papers.status, "published"),170 staging,171 or(172 eq(papers.discipline, row.p.discipline),173 row.v.keywords.length ? arrayOverlaps(paperVersions.keywords, row.v.keywords) : sql`false`,174 ),175 ),176 )177 .orderBy(desc(papers.firstPublishedAt))178 .limit(4);179 const rtmap = await topicsFor(180 db,181 related.map((r) => r.p.id),182 );183 return {184 summary: toSummary(row, tmap.get(row.p.id) ?? []),185 paper: row.p,186 version: row.v,187 series: row.s,188 versions,189 corrections: corr,190 retraction: retr ?? null,191 events,192 related: related.map((r) => toSummary(r, rtmap.get(r.p.id) ?? [])),193 };194}195196export async function listTopics(db: Db) {197 const staging = STAGING_FILTER();198 const rows = await db199 .select({200 t: topics,201 n: sql<number>`(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``})`,202 })203 .from(topics)204 .orderBy(asc(topics.sortOrder), asc(topics.name));205 return rows.map((r) => ({ ...r.t, paperCount: r.n }));206}207208export async function listSeries(db: Db) {209 const staging = STAGING_FILTER();210 const rows = await db211 .select({212 s: series,213 n: sql<number>`(select count(*)::int from papers p where p.series_id = ${series.id} ${staging ? sql`and p.synthetic = false` : sql``})`,214 })215 .from(series)216 .orderBy(asc(series.name));217 return rows.map((r) => ({ ...r.s, paperCount: r.n }));218}219220export async function disciplineCounts(db: Db) {221 const staging = STAGING_FILTER();222 return db223 .select({ discipline: papers.discipline, n: sql<number>`count(*)::int` })224 .from(papers)225 .where(staging)226 .groupBy(papers.discipline)227 .orderBy(desc(sql`count(*)`));228}229230export async function allPublishedForFeeds(db: Db, limit = 100) {231 const staging = STAGING_FILTER();232 return db233 .select({ p: papers, v: paperVersions })234 .from(papers)235 .innerJoin(paperVersions, eq(papers.currentVersionId, paperVersions.id))236 .where(staging)237 .orderBy(desc(papers.updatedAt))238 .limit(limit);239}240