Files
probably-rendering-one-more…/apps/wiki/src/lib/note-edits.ts
T
codex-bot b0a60ea498
wiki CI and deployment / verify-and-deploy (push) Successful in 22m1s
Render Markdown lists and edit note contents and tags in place
2026-10-26 14:26:34 +08:00

42 lines
3.6 KiB
TypeScript

import type {PoolClient} from 'pg';
import {createHash,randomUUID} from 'node:crypto';
import {z} from 'zod';
import {db,transaction} from './db';
import type {ExploreEntry} from './explore';
export function withoutTitle(markdown:string){return markdown.replace(/^# [^\n]*(?:\n|$)\n?/,'');}
export function withRevision(entry:ExploreEntry):ExploreEntry{
const editableMarkdown=entry.editableMarkdown??withoutTitle(entry.markdown);
const revision=createHash('sha256').update(JSON.stringify([entry.title,entry.description,editableMarkdown,[...entry.categories].sort()])).digest('hex');
return {...entry,editableMarkdown,revision};
}
export async function staticEdits(client?:PoolClient){if(!process.env.DATABASE_URL)return [];return (await (client||db()).query(`SELECT e.*,COALESCE((SELECT jsonb_agg(t.name ORDER BY t.name) FROM note_edit_tags et JOIN tags t ON t.id=et.tag_id WHERE et.page_id=e.page_id),'[]') AS categories FROM note_edits e`)).rows;}
export function applyStaticEdit(entry:ExploreEntry,edit?:{title:string;description:string;markdown:string;categories:string[]}):ExploreEntry{
return edit?{...entry,title:edit.title,description:edit.description,editableMarkdown:edit.markdown,markdown:'# '+edit.title+'\n\n'+edit.markdown,categories:edit.categories}:entry;
}
export const noteEditInput=z.object({title:z.string().trim().min(1).max(200),description:z.string().trim().max(1000),markdown:z.string().max(100000),tags:z.array(z.uuid()).max(50),revision:z.string().regex(/^[0-9a-f]{64}$/)});
export class NoteEditConflict extends Error{}
export async function saveNote(id:string,input:z.infer<typeof noteEditInput>,read:(client:PoolClient)=>Promise<ExploreEntry|null>){
await transaction(async client=>{
await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1,0))',['note-edit:'+id]);
const isPost=/^[0-9a-f-]{36}$/.test(id);
if(isPost)await client.query('SELECT id FROM posts WHERE id=$1 FOR UPDATE',[id]);
const current=await read(client);if(!current)throw Error('Note not found');
if(current.revision!==input.revision)throw new NoteEditConflict('这篇笔记已被修改,请重新打开编辑后再保存。');
const tagIds=[...new Set(input.tags)];
if((await client.query('SELECT id FROM tags WHERE id=ANY($1::uuid[])',[tagIds])).rowCount!==tagIds.length)throw Error('Tag not found');
if(isPost){
await client.query('UPDATE posts SET title=$2,description=$3,updated_at=now(),edited_at=now() WHERE id=$1',[id,input.title,input.description]);
// Replace this note's text link, never modify a text asset shared by other notes.
await client.query("DELETE FROM post_assets pa USING assets a WHERE pa.post_id=$1 AND pa.asset_id=a.id AND a.kind='text'",[id]);
const assetId=randomUUID();await client.query("INSERT INTO assets(id,kind,body) VALUES($1,'text',$2)",[assetId,input.markdown]);
await client.query("INSERT INTO post_assets(post_id,asset_id,position,role) SELECT $1,$2,COALESCE(max(position)+1,0),'caption' FROM post_assets WHERE post_id=$1",[id,assetId]);
await client.query('DELETE FROM post_tags WHERE post_id=$1',[id]);
for(const tagId of tagIds)await client.query('INSERT INTO post_tags(post_id,tag_id) VALUES($1,$2)',[id,tagId]);
}else{
await client.query(`INSERT INTO note_edits(page_id,title,description,markdown) VALUES($1,$2,$3,$4) ON CONFLICT(page_id) DO UPDATE SET title=excluded.title,description=excluded.description,markdown=excluded.markdown,updated_at=now()`,[id,input.title,input.description,input.markdown]);
await client.query('DELETE FROM note_edit_tags WHERE page_id=$1',[id]);
for(const tagId of tagIds)await client.query('INSERT INTO note_edit_tags(page_id,tag_id) VALUES($1,$2)',[id,tagId]);
}
});
}