60 lines
5.9 KiB
TypeScript
60 lines
5.9 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';
|
|
import {linkDerivedAssets} from './asset-links';
|
|
import {postInput} from './write';
|
|
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(),entry.coverAssetId??null,entry.assets?.map(a=>[a.id,a.role])??[]])).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}$/),assets:postInput.shape.assets.unwrap().optional(),coverAssetId:z.uuid().nullable().optional()});
|
|
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 stored=(await client.query(`SELECT id,kind,cover_asset_id FROM posts WHERE ${/^[0-9a-f]{24}$/.test(id)?'public_id':'id'}=$1 FOR UPDATE`,[id])).rows[0];
|
|
const isPost=Boolean(stored),postId=stored?.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){
|
|
if(input.assets!==undefined){
|
|
const ids=[...new Set(input.assets.map(a=>a.id))];
|
|
const assets=ids.length?(await client.query('SELECT id,kind FROM assets WHERE id=ANY($1::uuid[])',[ids])).rows:[];
|
|
if(assets.length!==ids.length||assets.some(a=>a.kind==='text'))throw Error('Referenced asset does not exist');
|
|
if(['image','video','audio'].includes(stored.kind)&&!input.assets.some(a=>a.role==='content'&&assets.some(r=>r.id===a.id&&r.kind===stored.kind)))throw Error('A media post needs matching content');
|
|
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'",[postId]);
|
|
let position=Number((await client.query('SELECT COALESCE(max(position),-1)+1 AS n FROM post_assets WHERE post_id=$1',[postId])).rows[0].n);
|
|
for(const a of input.assets)await client.query('INSERT INTO post_assets(post_id,asset_id,position,role) VALUES($1,$2,$3,$4)',[postId,a.id,position++,a.role]);
|
|
}
|
|
if(input.assets!==undefined&&input.coverAssetId===undefined&&stored.cover_asset_id&&!input.assets.some(a=>a.id===stored.cover_asset_id&&a.role==='content'))await client.query("UPDATE posts SET cover_asset_id=(SELECT a.id FROM post_assets pa JOIN assets a ON a.id=pa.asset_id WHERE pa.post_id=$1 AND pa.role='content' AND a.kind='image' ORDER BY pa.position LIMIT 1) WHERE id=$1",[postId]);
|
|
if(input.coverAssetId!==undefined){
|
|
if(input.coverAssetId!==null&&!(await client.query("SELECT 1 FROM assets a JOIN post_assets pa ON pa.asset_id=a.id WHERE pa.post_id=$1 AND a.id=$2 AND a.kind='image' AND pa.role='content'",[postId,input.coverAssetId])).rowCount)throw Error('Cover must reference an image');
|
|
await client.query('UPDATE posts SET cover_asset_id=$2 WHERE id=$1',[postId,input.coverAssetId]);
|
|
}
|
|
await client.query('UPDATE posts SET title=$2,description=$3,updated_at=now(),edited_at=now() WHERE id=$1',[postId,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'",[postId]);
|
|
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",[postId,assetId]);
|
|
await client.query('DELETE FROM post_tags WHERE post_id=$1',[postId]);
|
|
for(const tagId of tagIds)await client.query('INSERT INTO post_tags(post_id,tag_id) VALUES($1,$2)',[postId,tagId]);
|
|
if(input.assets!==undefined)await linkDerivedAssets(client,postId);
|
|
}else{
|
|
if(input.assets!==undefined||input.coverAssetId!==undefined)throw Error('Media changes require a stored note');
|
|
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]);
|
|
}
|
|
});
|
|
}
|