Files
probably-rendering-one-more…/apps/wiki/src/lib/library.ts
T
codex-bot 38ec492ca3
wiki CI and deployment / verify-and-deploy (push) Successful in 16m28s
Publish reference layouts as API-authored text and image notes
2026-10-26 17:20:57 +08:00

36 lines
3.3 KiB
TypeScript

import type {PoolClient} from 'pg';
import { db } from "./db";
import { decodeCursor, encodeCursor, type FeedData, type Post, type PostKind, type Tag } from "./model";
export async function tags(client?:PoolClient): Promise<Tag[]> { return (await (client||db()).query('SELECT *,slug AS path FROM tags ORDER BY name,id')).rows; }
const expand = `SELECT p.*,
COALESCE((SELECT jsonb_agg(ae ORDER BY ae.position) FROM (SELECT a.*,pa.position,pa.role,COALESCE((SELECT jsonb_agg(v.derivation) FROM assets v WHERE v.source_asset_id=a.id),'[]') AS variants FROM post_assets pa JOIN assets a ON a.id=pa.asset_id WHERE pa.post_id=p.id) ae),'[]') AS assets,
COALESCE((SELECT jsonb_agg(t) FROM tags t JOIN post_tags pt ON pt.tag_id=t.id WHERE pt.post_id=p.id),'[]') AS tags FROM posts p`;
function serialize(posts: Post[], allTags: Tag[]): Post[] {
return posts.map(p => ({...p, created_at: new Date(p.created_at).toISOString(), updated_at:new Date(p.updated_at).toISOString(), tags:p.tags.map(t=>allTags.find(a=>a.id===t.id)!).filter(Boolean)}));
}
export async function getPost(slug: string): Promise<Post | null> {
const result = await db().query(`${expand} WHERE p.slug=$1`,[slug]);
return serialize(result.rows,await tags())[0] || null;
}
export async function getPostById(id:string,client?:PoolClient):Promise<Post|null>{
const result=await (client||db()).query(`${expand} WHERE ${/^[0-9a-f]{24}$/.test(id)?'p.public_id':'p.id'}=$1`,[id]);
return serialize(result.rows,await tags(client))[0]||null;
}
export async function feed(options: {kind?: PostKind; path?: string; cursor?: string; q?: string} = {}): Promise<FeedData> {
const allTags=await tags(); const target=options.path ? allTags.find(t=>t.path===options.path) : undefined;
if(options.path&&!target) throw new Error("Tag not found");
const cursor=decodeCursor(options.cursor); const values: unknown[]=[];
const where=[`EXISTS(SELECT 1 FROM post_assets pa JOIN assets a ON a.id=pa.asset_id WHERE pa.post_id=p.id AND pa.role IN ('content','caption') AND ((a.storage_key IS NOT NULL AND a.byte_size>0) OR (a.kind='text' AND a.body IS NOT NULL)))`];
if(options.kind){values.push(options.kind);where.push(`p.kind=$${values.length}`);}
if(target){values.push([target.id]);where.push(`EXISTS(SELECT 1 FROM post_tags WHERE post_id=p.id AND tag_id=ANY($${values.length}::uuid[]))`);}
if(options.q){values.push(`%${options.q.replace(/[\\%_]/g,"\\$&")}%`);where.push(`p.title ILIKE $${values.length}`);}
if(cursor){values.push(...cursor);where.push(`(p.created_at,p.id)<($${values.length-1}::timestamptz,$${values.length}::uuid)`);}
const rows=(await db().query(`${expand} WHERE ${where.join(' AND ')} ORDER BY p.created_at DESC,p.id DESC LIMIT 25`,values)).rows as Post[];
const hasMore=rows.length>24; const posts=serialize(rows.slice(0,24),allTags); const last=posts.at(-1);
const contributions=(await db().query(`SELECT to_char(occurred_at AT TIME ZONE 'Asia/Shanghai','YYYY-MM-DD') AS day,sum(amount)::integer AS total FROM contribution_events WHERE occurred_at>=now()-interval '366 days' GROUP BY day ORDER BY day`)).rows;
return {posts,tags:allTags,contributions,nextCursor:hasMore&&last?encodeCursor(last.created_at,last.id):null};
}
export async function publishedPublicIds():Promise<string[]>{return (await db().query("SELECT public_id FROM posts WHERE public_id IS NOT NULL")).rows.map(p=>p.public_id);}