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,read:(client:PoolClient)=>Promise){ 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]); } }); }