Render Markdown lists and edit note contents and tags in place
wiki CI and deployment / verify-and-deploy (push) Successful in 22m1s
wiki CI and deployment / verify-and-deploy (push) Successful in 22m1s
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
BEGIN;
|
||||
ALTER TABLE posts ADD COLUMN edited_at timestamptz;
|
||||
-- Code-authored Wiki pages retain their public IDs; saved edits outlive deployments.
|
||||
CREATE TABLE note_edits (
|
||||
page_id text PRIMARY KEY CHECK(page_id ~ '^[0-9a-f]{24}$'),
|
||||
title text NOT NULL CHECK(length(btrim(title))>0), description text NOT NULL DEFAULT '',
|
||||
markdown text NOT NULL DEFAULT '', updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE note_edit_tags (
|
||||
page_id text NOT NULL REFERENCES note_edits(page_id) ON DELETE CASCADE,
|
||||
tag_id uuid NOT NULL REFERENCES tags(id) ON DELETE RESTRICT,
|
||||
PRIMARY KEY(page_id,tag_id)
|
||||
);
|
||||
INSERT INTO schema_migrations(version) VALUES('005_note_editing');
|
||||
COMMIT;
|
||||
@@ -10,7 +10,7 @@
|
||||
"build": "next build && esbuild src/worker.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/worker.cjs && esbuild scripts/bootstrap.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/bootstrap.cjs && esbuild scripts/publish-learning-series.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/publish-learning-series.cjs && node scripts/prepare-standalone.mjs",
|
||||
"start": "next start",
|
||||
"typecheck": "next typegen && tsc --noEmit",
|
||||
"test": "tsx --test tests/health.test.ts tests/model.test.ts tests/learning-series.test.ts tests/skill-docs.test.ts tests/wiki.test.ts tests/explore.test.ts tests/preview.test.ts",
|
||||
"test": "tsx --test tests/health.test.ts tests/model.test.ts tests/learning-series.test.ts tests/skill-docs.test.ts tests/wiki.test.ts tests/explore.test.ts tests/preview.test.ts tests/note-markdown.test.tsx",
|
||||
"test:smoke": "node scripts/smoke.mjs",
|
||||
"storage:prepare": "tsx scripts/bootstrap.ts",
|
||||
"content:publish-learning": "tsx scripts/publish-learning-series.ts",
|
||||
@@ -26,6 +26,8 @@
|
||||
"playwright-core": "1.56.1",
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1",
|
||||
"react-markdown": "10.1.0",
|
||||
"remark-gfm": "4.0.1",
|
||||
"sharp": "0.35.5",
|
||||
"zod": "4.6.7"
|
||||
},
|
||||
|
||||
Generated
+908
File diff suppressed because it is too large
Load Diff
@@ -2,13 +2,44 @@ import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {useRouter} from 'next/router';
|
||||
import {X,ChevronLeft,ChevronRight,Link as LinkIcon,FileText,ArrowUpRight} from 'lucide-react';
|
||||
import {X,ChevronLeft,ChevronRight,Link as LinkIcon,FileText,Pencil} from 'lucide-react';
|
||||
import type {ExploreEntry} from '@/lib/explore';
|
||||
import {previewUrl,exploreUrl} from '@/content/page-links';
|
||||
import {Shell} from './Shell';
|
||||
import {ExploreFeed} from './ExploreFeed';
|
||||
function TextBody({text}:{text:string}){return <>{text.split(/\n\n+/).map((part,i)=>{if(part.startsWith('# '))return null;if(part.startsWith('```'))return <pre key={i}>{part.replace(/^```[^\n]*\n?|```$/g,'')}</pre>;if(part.startsWith('## '))return <h3 key={i}>{part.slice(3)}</h3>;return <p key={i}>{part.split(/(\[[^\]]+\]\([^)]+\))/).map((piece,j)=>{const m=piece.match(/^\[([^\]]+)\]\(([^)]+)\)$/);return m&&/^(https?:\/\/|\/(?!\/))/.test(m[2])?<a className="note-inline-link" key={j} href={m[2].replace(/\?agent_mode=True$/,'')}><LinkIcon size={13} aria-hidden="true"/><span>{m[1]}</span>{/^https?:/.test(m[2])&&<ArrowUpRight size={12} aria-hidden="true"/>}</a>:piece})}</p>})}</>}
|
||||
export function ExploreDetail({entry,background}:{entry:ExploreEntry;background:ExploreEntry[]}){const [index,setIndex]=useState(0);const [copied,setCopied]=useState(false);const router=useRouter();const closeRef=useRef<HTMLButtonElement>(null);const detailRef=useRef<HTMLDivElement>(null);const media=entry.media[index];const close=()=>void router.push('/');useEffect(()=>{setIndex(0);setCopied(false);const previous=document.activeElement as HTMLElement|null;closeRef.current?.focus();const old=document.body.style.overflow;document.body.style.overflow='hidden';function key(e:KeyboardEvent){if(e.key==='Escape')close();if(e.key==='ArrowLeft')setIndex(n=>Math.max(0,n-1));if(e.key==='ArrowRight')setIndex(n=>Math.max(0,Math.min(entry.media.length-1,n+1)));if(e.key==='Tab'){const items=detailRef.current?.querySelectorAll<HTMLElement>('a[href],button:not([disabled]),video,audio');if(!items?.length)return;const first=items[0],last=items[items.length-1];if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus()}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus()}}}window.addEventListener('keydown',key);return()=>{document.body.style.overflow=old;window.removeEventListener('keydown',key);previous?.focus()};},[entry.id]);return <Shell><Head><title>{entry.title} · PROMPT</title><link rel="canonical" href={exploreUrl(entry.id)}/><link rel="alternate" type="text/plain" href={exploreUrl(entry.id)+'?agent_mode=True'}/></Head><div aria-hidden="true" inert><ExploreFeed initial={background}/></div><div className="note-overlay" onClick={close}><div className="note-dialog" ref={detailRef} role="dialog" aria-modal="true" aria-labelledby="note-title" onClick={e=>e.stopPropagation()}><button className="note-close" ref={closeRef} aria-label="关闭详情" onClick={close}><X/></button><section className={'note-stage '+(!media?'note-text-stage':'')}>
|
||||
{media?.kind==='image'?<img key={media.url} src={previewUrl(media.url,1280)} alt={media.title} decoding="async"/>:media?.kind==='video'?<video controls playsInline preload="metadata" src={media.url} poster={media.poster?previewUrl(media.poster,960):undefined}/>:media?.kind==='audio'?<div className="note-audio"><span>♫</span><audio controls preload="metadata" src={media.url}/></div>:<div className="note-title-cover" style={{background:entry.color}}><small>{entry.namespace}</small><strong>{entry.title}</strong><span>灵感笔记</span></div>}
|
||||
{entry.media.length>1&&<><button className="note-prev" aria-label="上一张" disabled={index===0} onClick={()=>setIndex(n=>n-1)}><ChevronLeft/></button><button className="note-next" aria-label="下一张" disabled={index===entry.media.length-1} onClick={()=>setIndex(n=>n+1)}><ChevronRight/></button><div className="note-dots">{entry.media.map((_,i)=><button key={i} className={i===index?'selected':''} aria-label={'查看第 '+(i+1)+' 张'} aria-pressed={i===index} onClick={()=>setIndex(i)}/>)}</div><span className="note-count">{index+1} / {entry.media.length}</span></>}
|
||||
</section><section className="note-info"><div className="note-content"><h1 id="note-title">{entry.title}</h1><p className="note-description">{entry.description}</p><div className="note-tags">{entry.categories.map(c=><Link key={c} href={'/categories?category='+encodeURIComponent(c)}>#{c} </Link>)}</div><TextBody text={entry.markdown}/><div className="note-id">内容 ID · {entry.id}</div></div><footer><button onClick={async()=>{try{const url=window.location.origin+exploreUrl(entry.id);if(navigator.clipboard)await navigator.clipboard.writeText(url);else{const input=document.createElement('textarea');input.value=url;document.body.appendChild(input);input.select();document.execCommand('copy');input.remove();}setCopied(true)}catch{setCopied(false)}}}><LinkIcon size={18}/>{copied?'已复制':'复制链接'}</button><a href={exploreUrl(entry.id)+'?agent_mode=True'}><FileText size={18}/>纯文本</a>{media&&<a href={media.url} target="_blank" rel="noreferrer">原文件 ↗</a>}</footer></section></div></div></Shell>}
|
||||
import {NoteMarkdown} from './NoteMarkdown';
|
||||
import {NoteEditor} from './NoteEditor';
|
||||
export function ExploreDetail({entry:initial,background}:{entry:ExploreEntry;background:ExploreEntry[]}){
|
||||
const [entry,setEntry]=useState(initial),[index,setIndex]=useState(0),[copied,setCopied]=useState(''),[editing,setEditing]=useState(false),[copyError,setCopyError]=useState('');
|
||||
const router=useRouter();const closeRef=useRef<HTMLButtonElement>(null),detailRef=useRef<HTMLDivElement>(null);const media=entry.media[index];
|
||||
useEffect(()=>{setEntry(initial);setIndex(0);setCopied('');setEditing(false)},[initial]);
|
||||
const close=()=>{if(editing){setEditing(false);return;}void router.push('/')};
|
||||
useEffect(()=>{
|
||||
const previous=document.activeElement as HTMLElement|null;if(!editing)closeRef.current?.focus();const old=document.body.style.overflow;document.body.style.overflow='hidden';
|
||||
function key(e:KeyboardEvent){
|
||||
if(e.key==='Escape'){e.preventDefault();close();return;}
|
||||
const typing=e.target instanceof HTMLElement&&!!e.target.closest('input,textarea,select,[contenteditable="true"]');
|
||||
if(!editing&&!typing&&e.key==='ArrowLeft')setIndex(n=>Math.max(0,n-1));
|
||||
if(!editing&&!typing&&e.key==='ArrowRight')setIndex(n=>Math.max(0,Math.min(entry.media.length-1,n+1)));
|
||||
if(e.key==='Tab'){const items=Array.from(detailRef.current?.querySelectorAll<HTMLElement>('a[href],button:not(:disabled),input:not(:disabled),textarea:not(:disabled),select:not(:disabled),summary,video,audio')||[]).filter(el=>el.getClientRects().length);if(!items.length)return;const first=items[0],last=items[items.length-1];if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus()}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus()}}
|
||||
}
|
||||
window.addEventListener('keydown',key);return()=>{document.body.style.overflow=old;window.removeEventListener('keydown',key);previous?.focus()};
|
||||
},[entry.id,editing]);
|
||||
async function copy(mode:'web'|'agent'){
|
||||
try{const url=window.location.origin+exploreUrl(entry.id)+(mode==='agent'?'?agent_mode=True':'');
|
||||
if(navigator.clipboard)await navigator.clipboard.writeText(url);else{const input=document.createElement('textarea');input.value=url;document.body.appendChild(input);input.select();const success=document.execCommand('copy');input.remove();if(!success)throw Error('Copy failed');}
|
||||
setCopied(mode);setCopyError('');
|
||||
}catch{setCopied('');setCopyError('复制失败,请重试。')}
|
||||
}
|
||||
return <Shell><Head><title>{entry.title} · PROMPT</title><link rel="canonical" href={exploreUrl(entry.id)}/><link rel="alternate" type="text/plain" href={exploreUrl(entry.id)+'?agent_mode=True'}/></Head>
|
||||
<div aria-hidden="true" inert><ExploreFeed key={entry.revision} initial={background.map(e=>e.id===entry.id?entry:e)}/></div>
|
||||
<div className="note-overlay" onClick={()=>{if(!editing)close()}}><div className="note-dialog" ref={detailRef} role="dialog" aria-modal="true" aria-labelledby="note-title" onClick={e=>e.stopPropagation()}>
|
||||
<button className="note-close" ref={closeRef} aria-label={editing?'取消编辑':'关闭详情'} onClick={close}><X/></button>
|
||||
<section className={'note-stage '+(!media?'note-text-stage':'')}>
|
||||
{media?.kind==='image'?<img key={media.url} src={previewUrl(media.url,1280)} alt={media.title} decoding="async"/>:media?.kind==='video'?<video controls playsInline preload="metadata" src={media.url} poster={media.poster?previewUrl(media.poster,960):undefined}/>:media?.kind==='audio'?<div className="note-audio"><span>♫</span><audio controls preload="metadata" src={media.url}/></div>:<div className="note-title-cover" style={{background:entry.color}}><small>{entry.namespace}</small><strong>{entry.title}</strong><span>灵感笔记</span></div>}
|
||||
{entry.media.length>1&&<><button className="note-prev" aria-label="上一张" disabled={index===0} onClick={()=>setIndex(n=>n-1)}><ChevronLeft/></button><button className="note-next" aria-label="下一张" disabled={index===entry.media.length-1} onClick={()=>setIndex(n=>n+1)}><ChevronRight/></button><div className="note-dots">{entry.media.map((_,i)=><button key={i} className={i===index?'selected':''} aria-label={'查看第 '+(i+1)+' 张'} aria-pressed={i===index} onClick={()=>setIndex(i)}/>)}</div><span className="note-count">{index+1} / {entry.media.length}</span></>}
|
||||
</section><section className="note-info"><div className="note-content">
|
||||
{editing?<NoteEditor key={entry.id} entry={entry} onCancel={()=>setEditing(false)} onSaved={updated=>{setEntry(updated);setEditing(false);setCopied('')}}/>:<><h1 id="note-title">{entry.title}</h1><p className="note-description">{entry.description}</p><div className="note-tags">{entry.categories.map(c=><Link key={c} href={'/categories?category='+encodeURIComponent(c)}>#{c} </Link>)}</div><NoteMarkdown text={entry.markdown.replace(/^# [^\n]*(?:\n|$)\n?/,'')}/><div className="note-id">内容 ID · {entry.id}</div></>}
|
||||
</div>{!editing&&<footer><button onClick={()=>void copy('web')}><LinkIcon size={18}/>{copied==='web'?'网页链接已复制':'复制网页链接'}</button><button onClick={()=>void copy('agent')}><FileText size={18}/>{copied==='agent'?'纯文本链接已复制':'复制纯文本链接'}</button><button onClick={()=>setEditing(true)}><Pencil size={17}/>编辑</button>{copyError&&<span role="status">{copyError}</span>}</footer>}</section>
|
||||
</div></div></Shell>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import {useEffect,useState,type FormEvent} from 'react';
|
||||
import type {ExploreEntry} from '@/lib/explore';
|
||||
import type {Tag} from '@/lib/model';
|
||||
import {TagInput} from './TagInput';
|
||||
import {NoteMarkdown} from './NoteMarkdown';
|
||||
export function NoteEditor({entry,onSaved,onCancel}:{entry:ExploreEntry;onSaved:(entry:ExploreEntry)=>void;onCancel:()=>void}){
|
||||
const [title,setTitle]=useState(entry.title),[description,setDescription]=useState(entry.description),[markdown,setMarkdown]=useState(entry.editableMarkdown||'');
|
||||
const [allTags,setAllTags]=useState<Tag[]>([]),[selected,setSelected]=useState<string[]>([]),[revision,setRevision]=useState('');
|
||||
const [loading,setLoading]=useState(true),[busy,setBusy]=useState(false),[tagBusy,setTagBusy]=useState(false),[error,setError]=useState('');
|
||||
useEffect(()=>{let live=true;void(async()=>{try{const res=await fetch('/api/notes/'+entry.id);const data=await res.json();if(!res.ok)throw Error(data.error||'无法读取笔记');if(live){setTitle(data.entry.title);setDescription(data.entry.description);setMarkdown(data.entry.editableMarkdown);setRevision(data.entry.revision);setAllTags(data.tags);setSelected(data.tags.filter((t:Tag)=>data.entry.categories.some((name:string)=>name.toLocaleLowerCase()===t.name.toLocaleLowerCase())).map((t:Tag)=>t.id));}}catch(e){if(live)setError(e instanceof Error?e.message:'读取失败');}finally{if(live)setLoading(false)}})();return()=>{live=false}},[entry.id]);
|
||||
async function save(e:FormEvent){e.preventDefault();if(busy||loading||tagBusy||!revision)return;setBusy(true);setError('');try{const res=await fetch('/api/notes/'+entry.id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({title,description,markdown,tags:selected,revision})});const data=await res.json();if(!res.ok)throw Error(data.error||'保存失败');onSaved(data.entry);}catch(e){setError(e instanceof Error?e.message:'保存失败');}finally{setBusy(false)}}
|
||||
return <form className="note-editor" onSubmit={save} aria-label="编辑笔记"><h1 id="note-title">编辑笔记</h1>{loading&&<p role="status">正在读取最新内容…</p>}
|
||||
<fieldset disabled={loading||busy}><div className="form-field"><label htmlFor="note-edit-title">标题</label><input id="note-edit-title" autoFocus required maxLength={200} value={title} onChange={e=>setTitle(e.target.value)}/></div>
|
||||
<div className="form-field"><label htmlFor="note-edit-description">描述</label><textarea id="note-edit-description" rows={3} maxLength={1000} value={description} onChange={e=>setDescription(e.target.value)}/></div>
|
||||
{!loading&&<TagInput initial={allTags} selected={selected} onChange={setSelected} disabled={busy} onBusyChange={setTagBusy}/>}
|
||||
<div className="form-field"><label htmlFor="note-edit-body">正文 <small>支持 Markdown 列表、链接和代码块</small></label><textarea id="note-edit-body" className="note-editor-body" rows={15} maxLength={100000} value={markdown} onChange={e=>setMarkdown(e.target.value)}/></div>
|
||||
<details><summary>预览正文</summary><NoteMarkdown text={markdown}/></details></fieldset>
|
||||
{error&&<p role="alert" className="error-message">{error}</p>}
|
||||
<div className="note-editor-actions"><button type="button" className="secondary-button" disabled={busy||tagBusy} onClick={onCancel}>取消</button><button type="submit" className="primary-button" disabled={loading||busy||tagBusy||!revision}>{busy?'保存中…':'保存修改'}</button></div></form>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import {Link as LinkIcon,ArrowUpRight} from 'lucide-react';
|
||||
export function NoteMarkdown({text}:{text:string}){
|
||||
return <div className="note-markdown"><ReactMarkdown remarkPlugins={[remarkGfm]} components={{
|
||||
a:({href,children})=>{
|
||||
if(!href)return <span>{children}</span>;
|
||||
const destination=href.replace(/\?agent_mode=True$/i,'');
|
||||
return <a className="note-inline-link" href={destination}><LinkIcon size={13} aria-hidden="true"/><span>{children}</span>{/^https?:/.test(destination)&&<ArrowUpRight size={12} aria-hidden="true"/>}</a>;
|
||||
},
|
||||
img:({src,alt})=>typeof src==='string'?<a className="note-inline-link" href={src}><LinkIcon size={13} aria-hidden="true"/>{alt||'查看图片'}</a>:null,
|
||||
}}>{text}</ReactMarkdown></div>;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import {useState} from 'react';
|
||||
import type {Tag} from '@/lib/model';
|
||||
export function TagInput({initial,selected,onChange,disabled=false}:{initial:Tag[];selected:string[];onChange:(ids:string[])=>void;disabled?:boolean}){
|
||||
export function TagInput({initial,selected,onChange,disabled=false,onBusyChange}:{initial:Tag[];selected:string[];onChange:(ids:string[])=>void;disabled?:boolean;onBusyChange?:(busy:boolean)=>void}){
|
||||
const [all,setAll]=useState(initial);const [input,setInput]=useState('');const [busy,setBusy]=useState(false);const [error,setError]=useState('');
|
||||
const name=input.replace(/^#+/,'').trim();
|
||||
const suggestions=all.filter(t=>!selected.includes(t.id)&&(!name||t.name.toLocaleLowerCase().includes(name.toLocaleLowerCase()))).slice(0,12);
|
||||
async function add(){if(!name||busy||disabled||selected.length>=50)return;setBusy(true);setError('');try{const res=await fetch('/api/tags',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})});const tag:Tag&{error?:string}=await res.json();if(!res.ok)throw Error(tag.error||'标签创建失败');setAll(a=>a.some(t=>t.id===tag.id)?a:[...a,tag]);if(!selected.includes(tag.id))onChange([...selected,tag.id]);setInput('');}catch(e){setError(e instanceof Error?e.message:'标签创建失败');}finally{setBusy(false);}}
|
||||
async function add(){if(!name||busy||disabled||selected.length>=50)return;setBusy(true);onBusyChange?.(true);setError('');try{const res=await fetch('/api/tags',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})});const tag:Tag&{error?:string}=await res.json();if(!res.ok)throw Error(tag.error||'标签创建失败');setAll(a=>a.some(t=>t.id===tag.id)?a:[...a,tag]);if(!selected.includes(tag.id))onChange([...selected,tag.id]);setInput('');}catch(e){setError(e instanceof Error?e.message:'标签创建失败');}finally{setBusy(false);onBusyChange?.(false);}}
|
||||
return <div className="form-field"><label htmlFor="tag-input">标签 <small>输入 #标签,按回车添加</small></label>
|
||||
<div className="tag-input-selected">{all.filter(t=>selected.includes(t.id)).map(t=><button key={t.id} type="button" disabled={disabled||busy} aria-label={'移除标签 '+t.name} onClick={()=>onChange(selected.filter(id=>id!==t.id))}>#{t.name}<span aria-hidden="true">×</span></button>)}</div>
|
||||
<div className="tag-input-row"><input id="tag-input" value={input} maxLength={61} disabled={disabled||busy} placeholder="#添加标签" onChange={e=>setInput(e.target.value)} onKeyDown={e=>{if(e.key==='Enter'&&!e.nativeEvent.isComposing){e.preventDefault();void add()}}}/><button type="button" className="secondary-button" disabled={!name||busy||disabled||selected.length>=50} onClick={()=>void add()}>{busy?'添加中…':'添加'}</button></div>
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
import type {PoolClient} from 'pg';
|
||||
import {noteDescriptions} from '@/content/note-descriptions';
|
||||
import {guidanceDocuments,guidanceMarkdown} from '@/content/skills/guidance';
|
||||
import {wikiPages,wikiMarkdown} from '@/content/wiki';
|
||||
import {skillCatalog} from '@/content/skills/catalog';
|
||||
import {pageId,exploreUrl,guidanceUrl} from '@/content/page-links';
|
||||
import {getPostById,feed} from './library';
|
||||
import {applyStaticEdit,staticEdits,withRevision} from './note-edits';
|
||||
export type ExploreMedia={kind:'image'|'video'|'audio';url:string;title:string;poster?:string};
|
||||
export type ExploreEntry={id:string;title:string;description:string;imageCount:number;namespace:string;categories:string[];markdown:string;media:ExploreMedia[];color:string;postSlug?:string};
|
||||
export type ExploreEntry={id:string;title:string;description:string;imageCount:number;namespace:string;categories:string[];markdown:string;media:ExploreMedia[];color:string;postSlug?:string;editableMarkdown?:string;revision?:string};
|
||||
export const staticEntries:ExploreEntry[]=[...guidanceDocuments.map(doc=>{const card=skillCatalog.find(c=>doc.path===c.id);return {id:pageId('guidance:'+doc.path),title:doc.title,description:noteDescriptions[doc.path]||doc.blocks.find(b=>b.type==='paragraph')?.text||doc.title,imageCount:new Set([...(card?[card.image]:[]),...doc.resources.filter(r=>r.kind==='image').map(r=>r.href)]).size,namespace:'Main',categories:[doc.collection],markdown:guidanceMarkdown(doc.path)!,media:Array.from(new Map([...(card?[{kind:'image' as const,url:card.image,title:card.alt}]:[]),...doc.resources.filter(r=>r.kind==='image').map(r=>({kind:'image' as const,url:r.href,title:r.title}))].map(m=>[m.url,m])).values()),color:'#eee5dc'};}),...wikiPages.map(page=>({id:pageId('wiki:'+page.namespace+':'+page.slug),title:page.title,description:page.summary,imageCount:0,namespace:page.namespace,categories:page.categories,markdown:wikiMarkdown(page),media:[],color:page.color}))];
|
||||
// Canonicalize document references, including absolute links; pictures remain explicit links.
|
||||
export function canonicalMarkdown(text:string){return text.replace(/\]\((?:http:\/\/8\.211\.154\.134:18131)?\/skills\/([^ )]+)\)/g,(whole,path:string)=>{const clean=path.replace(/\/index\.md$/,'').replace(/\.md$/,'');return pageId('guidance:'+clean)?']('+guidanceUrl(clean)+'?agent_mode=True)':whole;});}
|
||||
export async function getEntry(id:string):Promise<ExploreEntry|null>{
|
||||
const page=staticEntries.find(e=>e.id===id);if(page)return {...page,markdown:canonicalMarkdown(page.markdown)};
|
||||
export async function getEntry(id:string,client?:PoolClient):Promise<ExploreEntry|null>{
|
||||
const page=staticEntries.find(e=>e.id===id);if(page){const edits=await staticEdits(client);return withRevision(applyStaticEdit({...page,markdown:canonicalMarkdown(page.markdown)},edits.find(e=>e.page_id===id)));}
|
||||
if(!/^[0-9a-f-]{36}$/.test(id))return null;
|
||||
const post=await getPostById(id);if(!post)return null;
|
||||
const post=await getPostById(id,client);if(!post)return null;
|
||||
const media:ExploreMedia[]=post.assets.filter(a=>a.kind!=='text'&&a.role==='content').map(a=>({kind:a.kind as ExploreMedia['kind'],url:'/api/media/'+a.id,title:a.filename||post.title,...(a.kind==='video'&&a.variants.includes('poster')?{poster:'/api/media/'+post.assets.find(p=>p.source_asset_id===a.id&&p.derivation==='poster')?.id}:{})}));
|
||||
const body=post.assets.filter(a=>a.kind==='text').map(a=>a.body||'').join('\n\n')+'\n\n'+post.assets.filter(a=>a.derivation).map(a=>'['+({poster:'视频封面',audio_track:'独立音轨',silent_video:'静音视频'}[a.derivation||'']||a.derivation)+'](/api/media/'+a.id+')').join(' · ');
|
||||
return {id:post.id,title:post.title,description:post.description||body.slice(0,180)||post.title,imageCount:media.filter(m=>m.kind==='image').length,namespace:['image','video','audio'].includes(post.kind)?'File':'Main',categories:post.tags.map(t=>t.name),markdown:'# '+post.title+'\n\n'+body,media,color:'#eee5dc',postSlug:post.slug};
|
||||
const editableMarkdown=post.assets.filter(a=>a.kind==='text').map(a=>a.body||'').join('\n\n');
|
||||
const body=editableMarkdown+'\n\n'+post.assets.filter(a=>a.derivation).map(a=>'['+({poster:'视频封面',audio_track:'独立音轨',silent_video:'静音视频'}[a.derivation||'']||a.derivation)+'](/api/media/'+a.id+')').join(' · ');
|
||||
return withRevision({id:post.id,title:post.title,editableMarkdown,description:post.description||body.slice(0,180)||post.title,imageCount:media.filter(m=>m.kind==='image').length,namespace:['image','video','audio'].includes(post.kind)?'File':'Main',categories:post.tags.map(t=>t.name),markdown:'# '+post.title+'\n\n'+body,media,color:'#eee5dc',postSlug:post.slug});
|
||||
}
|
||||
export async function exploreFeed(q='',category='',cursor?:string){const data=await feed({q,cursor});return {entries:[...(cursor?[]:staticEntries.filter(e=>(!q||(e.title+e.description).includes(q)))),...data.posts.map(post=>{const cover=post.cover_asset_id||post.assets.find(a=>a.kind==='image'&&a.role==='content')?.id;const video=post.assets.find(a=>a.kind==='video');return {id:post.id,title:post.title,description:post.description||post.assets.find(a=>a.kind==='text')?.body?.slice(0,180)||post.title,imageCount:post.assets.filter(a=>a.kind==='image'&&a.role==='content').length,namespace:['image','video','audio'].includes(post.kind)?'File':'Main',categories:post.tags.map(t=>t.name),markdown:'',media:cover?[{kind:'image' as const,url:'/api/media/'+cover,title:post.title}]:video?.variants.includes('poster')?[{kind:'image' as const,url:'/api/media/'+post.assets.find(a=>a.source_asset_id===video.id&&a.derivation==='poster')?.id,title:post.title}]:[],color:'#eee5dc'};})].filter(e=>!category||e.categories.includes(category)||e.namespace===category),nextCursor:data.nextCursor};}
|
||||
export async function exploreFeed(q='',category='',cursor?:string){const [data,edits]=await Promise.all([feed({q,cursor}),staticEdits()]);const pages=staticEntries.map(e=>applyStaticEdit(e,edits.find(edit=>edit.page_id===e.id)));return {entries:[...(cursor?[]:pages.filter(e=>(!q||(e.title+e.description).includes(q)))),...data.posts.map(post=>{const cover=post.cover_asset_id||post.assets.find(a=>a.kind==='image'&&a.role==='content')?.id;const video=post.assets.find(a=>a.kind==='video');return {id:post.id,title:post.title,description:post.description||post.assets.find(a=>a.kind==='text')?.body?.slice(0,180)||post.title,imageCount:post.assets.filter(a=>a.kind==='image'&&a.role==='content').length,namespace:['image','video','audio'].includes(post.kind)?'File':'Main',categories:post.tags.map(t=>t.name),markdown:'',media:cover?[{kind:'image' as const,url:'/api/media/'+cover,title:post.title}]:video?.variants.includes('poster')?[{kind:'image' as const,url:'/api/media/'+post.assets.find(a=>a.source_asset_id===video.id&&a.derivation==='poster')?.id,title:post.title}]:[],color:'#eee5dc'};})].filter(e=>!category||e.categories.includes(category)||e.namespace===category),nextCursor:data.nextCursor};}
|
||||
export function entryText(entry:ExploreEntry){return `${entry.markdown}\n\nDescription: ${entry.description}\nNamespace: ${entry.namespace}\nCategories: ${entry.categories.join(', ')}\n\n`+entry.media.map(m=>`- ${m.kind}: [${m.title}](${m.url})`).join('\n')+'\n\n[网页]('+exploreUrl(entry.id)+')\n';}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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(): Promise<Tag[]> { return (await db().query('SELECT *,slug AS path FROM tags ORDER BY name,id')).rows; }
|
||||
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`;
|
||||
@@ -12,15 +13,15 @@ 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):Promise<Post|null>{
|
||||
const result=await db().query(`${expand} WHERE p.id=$1`,[id]);
|
||||
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 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 length(a.body)>0)))`];
|
||||
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}`);}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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]);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -31,6 +31,9 @@ export async function publishLearningSeries() {
|
||||
}
|
||||
const published:string[]=[];
|
||||
for(const chapter of chapters){
|
||||
const existing=(await client.query('SELECT slug,edited_at FROM posts WHERE id=$1 FOR UPDATE',[stableId(chapter.key)])).rows[0];
|
||||
if(existing?.edited_at){published.push(existing.slug);continue;}
|
||||
|
||||
const child=(await client.query(`INSERT INTO tags(id,slug,name) VALUES($1,$2,$3)
|
||||
ON CONFLICT(lower(btrim(name))) DO UPDATE SET name=tags.name RETURNING id`,[stableId(`tag-${chapter.key}`),chapter.number==="01"?"notes":chapter.number==="02"?"teaching":"english",chapter.number==="01"?"笔记复习":chapter.number==="02"?"教学资料":"英语学习"])).rows[0].id;
|
||||
const result=await client.query(`INSERT INTO posts(id,slug,kind,title,cover_asset_id,description) VALUES($1,$2,'article',$3,$4,$5)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type {NextApiRequest,NextApiResponse} from 'next';
|
||||
import {getEntry} from '@/lib/explore';
|
||||
import {tags} from '@/lib/library';
|
||||
import {noteEditInput,saveNote,NoteEditConflict} from '@/lib/note-edits';
|
||||
import {sameOrigin,apiError} from '@/lib/api';
|
||||
export default async function handler(req:NextApiRequest,res:NextApiResponse){
|
||||
res.setHeader('Cache-Control','no-store');
|
||||
if(!['GET','PUT'].includes(req.method||'')){res.setHeader('Allow','GET, PUT');return res.status(405).end();}
|
||||
try{
|
||||
const id=String(req.query.id||'');const entry=await getEntry(id);if(!entry)return res.status(404).json({error:'笔记不存在'});
|
||||
if(req.method==='GET')return res.json({entry,tags:await tags()});
|
||||
sameOrigin(req);const input=noteEditInput.parse(req.body);await saveNote(id,input,client=>getEntry(id,client));
|
||||
return res.json({entry:await getEntry(id)});
|
||||
}catch(e){if(e instanceof NoteEditConflict)return res.status(409).json({error:e.message});return apiError(res,e);}
|
||||
}
|
||||
export const config={api:{bodyParser:{sizeLimit:'1mb'}}};
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {NextApiRequest,NextApiResponse} from 'next';
|
||||
import {staticEntries} from '@/lib/explore';
|
||||
import {applyStaticEdit,staticEdits} from '@/lib/note-edits';
|
||||
import {exploreUrl} from '@/content/page-links';
|
||||
export default function handler(_:NextApiRequest,res:NextApiResponse){res.setHeader('Content-Type','text/plain; charset=utf-8');res.send('# PROMPT\n\n'+staticEntries.map(e=>`- [${e.title}](${exploreUrl(e.id)}?agent_mode=True)`).join('\n')+'\n\n素材分页索引:/api/explore-feed(使用 nextCursor 翻页)。所有内容均使用 /explore/{id}?agent_mode=True 读取纯文本。\n');}
|
||||
export default async function handler(_:NextApiRequest,res:NextApiResponse){const edits=await staticEdits();const entries=staticEntries.map(e=>applyStaticEdit(e,edits.find(edit=>edit.page_id===e.id)));res.setHeader('Content-Type','text/plain; charset=utf-8');res.send('# PROMPT\n\n'+entries.map(e=>`- [${e.title}](${exploreUrl(e.id)}?agent_mode=True)`).join('\n')+'\n\n素材分页索引:/api/explore-feed(使用 nextCursor 翻页)。所有内容均使用 /explore/{id}?agent_mode=True 读取纯文本。\n');}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type {GetServerSideProps} from 'next';
|
||||
import {ExploreDetail} from '@/components/ExploreDetail';
|
||||
import {getEntry,entryText,staticEntries} from '@/lib/explore';
|
||||
import {getEntry,entryText,exploreFeed} from '@/lib/explore';
|
||||
export default ExploreDetail;
|
||||
export const getServerSideProps:GetServerSideProps=async({params,query,res,req})=>{try{const entry=await getEntry(String(params?.id||''));if(!entry)return {notFound:true};if(String(query.agent_mode||'').toLowerCase()==='true'){res.setHeader('Content-Type','text/plain; charset=utf-8');res.setHeader('Cache-Control','no-cache');res.end(req.method==='HEAD'?undefined:entryText(entry));return {props:{}};}return {props:{entry,background:staticEntries}};}catch{res.statusCode=503;res.setHeader('Retry-After','10');res.end('内容暂时无法读取,请稍后重试');return {props:{}};}};
|
||||
export const getServerSideProps:GetServerSideProps=async({params,query,res,req})=>{try{const entry=await getEntry(String(params?.id||''));if(!entry)return {notFound:true};if(String(query.agent_mode||'').toLowerCase()==='true'){res.setHeader('Content-Type','text/plain; charset=utf-8');res.setHeader('Cache-Control','no-cache');res.end(req.method==='HEAD'?undefined:entryText(entry));return {props:{}};}return {props:{entry,background:(await exploreFeed()).entries}};}catch{res.statusCode=503;res.setHeader('Retry-After','10');res.end('内容暂时无法读取,请稍后重试');return {props:{}};}};
|
||||
|
||||
@@ -27,3 +27,20 @@
|
||||
.tag-input-selected button,.tag-input-suggestions button{border:0;background:#f2f3f4;color:#526373;border-radius:16px;padding:6px 10px;cursor:pointer;font-size:13px}
|
||||
.tag-input-selected span{margin-left:8px;color:#888}
|
||||
.tag-input-row{display:flex;align-items:center;gap:10px}.tag-input-row input{min-width:0;flex:1}.tag-input-row button{flex-shrink:0}
|
||||
|
||||
.note-markdown{font-size:15px;line-height:1.9;overflow-wrap:anywhere}
|
||||
.note-markdown ul,.note-markdown ol{margin:12px 0 22px;padding-left:1.6em;list-style-position:outside}
|
||||
.note-markdown ul{list-style-type:disc}.note-markdown ol{list-style-type:decimal}
|
||||
.note-markdown ul ul{list-style-type:circle}.note-markdown ul ul ul{list-style-type:square}
|
||||
.note-markdown li{display:list-item;margin:5px 0;padding-left:3px}.note-markdown li::marker{color:#777}
|
||||
.note-markdown li>p{margin:0 0 6px;white-space:normal}.note-markdown li>ul,.note-markdown li>ol{margin:5px 0 9px}
|
||||
.note-markdown .contains-task-list{list-style:none;padding-left:0}.note-markdown .task-list-item input{margin-right:8px;accent-color:#607687}
|
||||
.note-markdown h2{font-size:19px;margin:26px 0 12px}.note-markdown h3{font-size:17px}.note-markdown h4{font-size:15px;margin:20px 0 8px}
|
||||
.note-markdown blockquote{margin:18px 0;border-left:3px solid #d9e1e7;padding:3px 0 3px 14px;color:#647583}
|
||||
.note-markdown table{display:block;overflow-x:auto;border-collapse:collapse;margin:16px 0;max-width:100%}.note-markdown th,.note-markdown td{border:1px solid #e4e8ec;padding:7px 10px;white-space:normal}.note-markdown th{background:#f5f7f8}
|
||||
.note-markdown :not(pre)>code{font-size:.87em;background:#f3f4f5;border-radius:4px;padding:2px 4px}.note-markdown pre code{background:none;padding:0}
|
||||
.note-editor fieldset{border:0;padding:0;margin:0;min-width:0}.note-editor .form-field{margin:18px 0}.note-editor .form-field>label{display:block;font-size:14px;margin-bottom:8px}.note-editor label small{color:#999;font-size:11px;font-weight:400}
|
||||
.note-editor input,.note-editor textarea{width:100%;box-sizing:border-box;border:1px solid #dce1e5;border-radius:9px;padding:10px 12px;font:inherit;font-size:14px;line-height:1.7;background:#fff}.note-editor textarea{resize:vertical}.note-editor input:focus,.note-editor textarea:focus{outline:2px solid #aebfcd;outline-offset:1px}
|
||||
.note-editor .note-editor-body{font-family:ui-monospace,SFMono-Regular,monospace;font-size:13px;min-height:260px;tab-size:2}.note-editor details{margin:16px 0}.note-editor summary{cursor:pointer;font-size:13px;color:#526c80}
|
||||
.note-editor-actions{display:flex;justify-content:flex-end;gap:12px;position:sticky;bottom:-28px;padding:14px 0;background:#fff;border-top:1px solid #eee;margin-top:24px}
|
||||
.note-editor .tag-input-row input{width:0}.note-editor .tag-input-selected button,.note-editor .tag-input-suggestions button{font:inherit;font-size:12px}
|
||||
|
||||
@@ -12,6 +12,7 @@ import sharp from 'sharp';
|
||||
import {db} from '../src/lib/db';
|
||||
import {probe,run} from '../src/lib/probe';
|
||||
import {verifyFlatNoteMigration} from './flat-note-migration';
|
||||
import {staticEntries} from '../src/lib/explore';
|
||||
import {publishLearningSeries} from '../src/lib/publish-learning-series';
|
||||
|
||||
test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processing', {timeout:300000}, async()=>{
|
||||
@@ -73,6 +74,32 @@ test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processi
|
||||
const article=await request('/api/posts',{kind:'article',title:'无代码页面的笔记',text:'普通正文与 [引用](https://example.com)'});
|
||||
const articlePage=await fetch(base+'/explore/'+article.id).then(r=>r.text());assert.ok(articlePage.includes('note-inline-link'));
|
||||
const remaining=(await db().query("SELECT to_regclass('asset_variants') AS variants,to_regclass('page_snapshots') AS snapshots,to_regclass('render_jobs') AS renders")).rows[0];assert.deepEqual(remaining,{variants:null,snapshots:null,renders:null});
|
||||
// Both file-backed Wiki pages and media notes can be edited on their stable IDs.
|
||||
const note=staticEntries.find(e=>e.namespace==='Main'&&!e.media.length)!;
|
||||
const editable=await request('/api/notes/'+note.id);
|
||||
const newTag=await request('/api/tags',{name:'#在线编辑'});
|
||||
const updatedBody='## 新内容\n\n- 项目一\n - 子项目\n- [相关笔记](/explore/'+reused.id+')';
|
||||
const edit=async(id:string,payload:unknown)=>fetch(base+'/api/notes/'+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
||||
const payload={title:'已编辑的 Wiki 笔记',description:'修改后的预览描述',markdown:updatedBody,tags:[newTag.id],revision:editable.entry.revision};
|
||||
const saved=await edit(note.id,payload);assert.equal(saved.status,200,await saved.clone().text());
|
||||
assert.equal((await request('/api/notes/'+note.id)).entry.title,payload.title);
|
||||
assert.equal((await edit(note.id,{...payload,title:'旧窗口写入'})).status,409,'Stale editor must not overwrite a newer save');
|
||||
const updatedText=await fetch(base+'/explore/'+note.id+'?agent_mode=True').then(r=>r.text());assert.ok(updatedText.includes(updatedBody));assert.ok(updatedText.includes('在线编辑'));
|
||||
const updatedHtml=await fetch(base+'/explore/'+note.id).then(r=>r.text());assert.ok(updatedHtml.includes('<ul>'));assert.ok(updatedHtml.includes('<li>'));assert.ok(updatedHtml.includes('复制网页链接'));assert.ok(updatedHtml.includes('复制纯文本链接'));assert.ok(!updatedHtml.includes('原文件 ↗'));
|
||||
const editedPreview=(await request('/api/explore-feed?category='+encodeURIComponent('在线编辑'))).entries.find((e:{id:string})=>e.id===note.id);assert.equal(editedPreview.description,payload.description);
|
||||
assert.ok((await fetch(base+'/api/wiki-index').then(r=>r.text())).includes(payload.title));
|
||||
const storedPost=(await request('/api/notes/'+first.id)).entry;
|
||||
const oldCaption=(await db().query("SELECT a.id FROM post_assets pa JOIN assets a ON a.id=pa.asset_id WHERE pa.post_id=$1 AND a.kind='text'",[first.id])).rows[0].id;
|
||||
const captionReuse=await request('/api/posts',{kind:'article',title:'共享文本的笔记',assets:[{id:oldCaption,role:'caption'}]});
|
||||
const savedPost=await edit(first.id,{title:'修改后的图片笔记',description:'图片仍保留',markdown:'新的正文',tags:[newTag.id],revision:storedPost.revision});assert.equal(savedPost.status,200,await savedPost.clone().text());
|
||||
const updatedPost=await request('/api/notes/'+first.id);assert.equal(updatedPost.entry.imageCount,2);assert.equal(updatedPost.entry.editableMarkdown,'新的正文');
|
||||
assert.equal((await request('/api/notes/'+captionReuse.id)).entry.editableMarkdown,'可以复用的独立文字','Editing one note must not mutate shared text');
|
||||
const authored=(await request('/api/feed?section=articles')).posts.find((p:{slug:string})=>p.slug===published.published[0]);const authoredState=await request('/api/notes/'+authored.id);
|
||||
assert.equal((await edit(authored.id,{title:'保留用户修改',description:'部署不能覆盖',markdown:'手动修改的正文',tags:[newTag.id],revision:authoredState.entry.revision})).status,200);
|
||||
await publishLearningSeries();const retained=await request('/api/notes/'+authored.id);assert.equal(retained.entry.title,'保留用户修改');assert.equal(retained.entry.editableMarkdown,'手动修改的正文');
|
||||
assert.equal((await request('/api/notes/'+note.id)).entry.title,payload.title,'Static edits outlive content publication');
|
||||
assert.equal((await fetch(base+'/api/notes/missing')).status,404);
|
||||
assert.equal((await fetch(base+'/api/notes/'+note.id,{method:'DELETE'})).status,405);
|
||||
for(const doc of ['SKILL','style','upload','checks']){const r=await fetch(base+`/skills/street-people/${doc}.md`);assert.equal(r.status,200);assert.ok(r.headers.get('content-type')?.includes('text/markdown'));const text=await r.text();assert.ok(text.startsWith('# '));assert.ok(!text.includes('<html'));}
|
||||
for(const doc of ['SKILL','workflow','visual-grammar','prompt','delivery']){
|
||||
const path=`/skills/shadow-puppet/${doc}.md`;const response=await fetch(base+path);assert.equal(response.status,200,path);assert.ok(response.headers.get('content-type')?.includes('text/markdown'));assert.ok((await response.text()).startsWith('# '));
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {NoteMarkdown} from '../src/components/NoteMarkdown';
|
||||
const render=(text:string)=>renderToStaticMarkup(<NoteMarkdown text={text}/>);
|
||||
test('Markdown renders nested bullets, numbered and task lists as semantic lists',()=>{
|
||||
const html=render('- 第一项\n - 子项目\n- 第二项\n\n3. 第三步\n4. 第四步\n\n- [x] 完成\n- [ ] 待办');
|
||||
assert.match(html,/<ul>/);assert.match(html,/<li>第一项\n<ul>/);assert.match(html,/<ol start="3">/);assert.match(html,/type="checkbox"/);assert.match(html,/checked/);assert.ok(!html.includes('- 第一项'));
|
||||
});
|
||||
test('Markdown keeps fenced code, tables, strong text and subtle links',()=>{
|
||||
const html=render('## 标题\n\n**重点** 和 [相关笔记](/explore/abc?agent_mode=True)\n\n| 名称 | 值 |\n| --- | --- |\n| 条目 | 1 |\n\n```ts\nconst items = [1, 2];\n```');
|
||||
assert.match(html,/<h2>标题/);assert.match(html,/<strong>重点/);assert.match(html,/<table>/);assert.match(html,/<pre><code class="language-ts">/);assert.match(html,/note-inline-link/);assert.match(html,/href="\/explore\/abc"/);
|
||||
});
|
||||
test('Untrusted Markdown cannot execute HTML or javascript links and images stay links',()=>{
|
||||
const html=render('<script>alert(1)</script>\n\n[unsafe](javascript:alert)\n\n');
|
||||
assert.ok(!html.includes('<script>'));assert.ok(!html.includes('href="javascript:'));assert.ok(!html.includes('<img'));assert.match(html,/href="\/api\/media\/example"/);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
# 笔记详情编辑与 Markdown 修复
|
||||
|
||||
本阶段修复列表显示,详情底栏改为复制网页链接、复制纯文本链接、编辑。编辑支持标题、描述、Markdown 正文和 #标签;保存后同步预览和 AI 纯文本。代码中的 Wiki 文档同样可编辑,保存内容存入数据库,媒体与唯一地址保留。
|
||||
|
||||
## 逐文件说明
|
||||
|
||||
- `apps/wiki/package.json`:固定引入 react-markdown、remark-gfm,并将新的渲染测试加入测试命令。
|
||||
- `apps/wiki/pnpm-lock.yaml`:锁定 Markdown 渲染依赖及传递依赖,支持 CI 冻结安装。
|
||||
- `apps/wiki/db/005_note_editing.sql`:新增代码文档编辑记录与标签关联表,为数据库笔记新增手动编辑标记,保证内容可持久保存。
|
||||
- `apps/wiki/src/components/NoteMarkdown.tsx`:新增标准 Markdown 渲染,支持嵌套列表、编号、任务清单、表格、代码块;保留轻量链接样式,图片引用显示为链接,不执行原始 HTML。
|
||||
- `apps/wiki/src/components/NoteEditor.tsx`:新增详情内编辑表单,支持读取最新内容、修改标题/描述/正文、#标签、预览、取消、保存与失败提示。
|
||||
- `apps/wiki/src/components/ExploreDetail.tsx`:接入标准渲染和编辑表单;移除原文件入口,提供两种复制链接;调整编辑中的方向键、焦点循环和遮罩交互。
|
||||
- `apps/wiki/src/components/TagInput.tsx`:通知父表单标签创建状态,避免标签请求尚未结束就保存笔记。
|
||||
- `apps/wiki/src/lib/note-edits.ts`:新增读取代码文档修改、内容版本校验和事务保存;共享文本采用新资源引用,不改动其他笔记使用的文本。
|
||||
- `apps/wiki/src/lib/explore.ts`:详情与列表合并已保存的代码文档修改,提供可编辑正文和版本标识,保留自动生成的媒体资源链接。
|
||||
- `apps/wiki/src/lib/library.ts`:支持在同一事务连接中读取笔记和标签;保留清空正文后仍存在的文字笔记。
|
||||
- `apps/wiki/src/lib/publish-learning-series.ts`:后续部署发布内容时保留用户手动编辑过的笔记,避免覆盖正文和标签。
|
||||
- `apps/wiki/src/pages/api/notes/[id].ts`:新增读取、保存笔记接口,校验来源、字段长度和并发版本;旧版本写入返回 409。
|
||||
- `apps/wiki/src/pages/api/wiki-index.ts`:AI 索引采用编辑后的标题。
|
||||
- `apps/wiki/src/pages/explore/[id].tsx`:详情背景使用最新预览,网页与纯文本读取同一份已保存内容。
|
||||
- `apps/wiki/src/styles/globals.css`:恢复列表标记、缩进和嵌套层级,增加表格、引用、代码、编辑表单和预览样式。
|
||||
- `apps/wiki/tests/note-markdown.test.tsx`:新增列表、编号、任务清单、表格、代码、链接与 HTML 安全渲染测试。
|
||||
- `apps/wiki/tests/integration.test.ts`:新增代码文档与媒体笔记保存、#标签关联、并发冲突、共享文本保护、手动内容发布保护及两个复制入口的验证。
|
||||
- `docs/note-editor-stage.md`:本阶段逐文件说明与验证记录。
|
||||
|
||||
## 验证
|
||||
|
||||
本地 TypeScript 检查及 22 项单元测试通过;完整构建、数据库迁移、在线编辑与媒体保留测试由 Wiki CI 执行。仅 Wiki 项目及其依赖发生变化,Studio、Python 不需要重建。
|
||||
Reference in New Issue
Block a user