From 9986846c36d5953beae8ee2d2e65d64ce4ab0013 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Mon, 26 Oct 2026 00:23:02 +0800 Subject: [PATCH] Flatten note tags and store processed media as ordinary resource links --- Dockerfile | 2 +- apps/wiki/Dockerfile | 2 +- apps/wiki/db/004_flat_notes.sql | 48 ++++++++++++++++ apps/wiki/scripts/wait-snapshots.mjs | 8 --- apps/wiki/src/components/ArticleGallery.tsx | 14 ----- apps/wiki/src/components/ExploreDetail.tsx | 4 +- apps/wiki/src/components/Feed.tsx | 2 +- apps/wiki/src/components/TagInput.tsx | 13 +++++ apps/wiki/src/content/registry.tsx | 9 --- apps/wiki/src/lib/api.ts | 1 - apps/wiki/src/lib/asset-links.ts | 10 ++++ apps/wiki/src/lib/explore.ts | 9 +-- apps/wiki/src/lib/library.ts | 14 ++--- apps/wiki/src/lib/model.ts | 6 +- apps/wiki/src/lib/publish-learning-series.ts | 24 ++++---- apps/wiki/src/lib/snapshots.ts | 57 ------------------- apps/wiki/src/lib/write.ts | 8 +-- apps/wiki/src/pages/api/media/[id].ts | 2 +- .../src/pages/api/posts/[slug]/snapshots.ts | 8 --- apps/wiki/src/pages/api/preview.ts | 2 +- apps/wiki/src/pages/api/tags.ts | 19 ++++--- apps/wiki/src/pages/library/import.tsx | 7 ++- apps/wiki/src/pages/p/[slug].tsx | 37 +----------- apps/wiki/src/pages/p/[slug]/pages/[page].tsx | 6 -- apps/wiki/src/styles/globals.css | 9 +++ apps/wiki/src/worker.ts | 12 +++- apps/wiki/tests/flat-note-migration.ts | 33 +++++++++++ apps/wiki/tests/integration.test.ts | 32 ++++++----- docs/flat-notes-stage.md | 35 ++++++++++++ docs/monorepo.md | 12 +++- scripts/deploy.sh | 19 ++++++- 31 files changed, 255 insertions(+), 209 deletions(-) create mode 100644 apps/wiki/db/004_flat_notes.sql delete mode 100644 apps/wiki/scripts/wait-snapshots.mjs delete mode 100644 apps/wiki/src/components/ArticleGallery.tsx create mode 100644 apps/wiki/src/components/TagInput.tsx delete mode 100644 apps/wiki/src/content/registry.tsx create mode 100644 apps/wiki/src/lib/asset-links.ts delete mode 100644 apps/wiki/src/lib/snapshots.ts delete mode 100644 apps/wiki/src/pages/api/posts/[slug]/snapshots.ts delete mode 100644 apps/wiki/src/pages/p/[slug]/pages/[page].tsx create mode 100644 apps/wiki/tests/flat-note-migration.ts create mode 100644 docs/flat-notes-stage.md diff --git a/Dockerfile b/Dockerfile index a4ed8ce..3c3a09f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,7 @@ ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 \ COPY --from=build --chown=node:node /app/.next/standalone/ ./ COPY --from=build --chown=node:node /app/dist/worker.cjs /app/dist/bootstrap.cjs /app/dist/publish-learning-series.cjs ./ COPY --from=build --chown=node:node /app/db/ ./db/ -COPY --chown=node:node apps/wiki/scripts/container-health.mjs apps/wiki/scripts/wait-snapshots.mjs ./ +COPY --chown=node:node apps/wiki/scripts/container-health.mjs ./ COPY --chown=node:node apps/wiki/scripts/import-character-packs.mjs ./scripts/import-character-packs.mjs COPY --chown=node:node apps/wiki/seed/characters/ ./seed/characters/ USER node diff --git a/apps/wiki/Dockerfile b/apps/wiki/Dockerfile index a4ed8ce..3c3a09f 100644 --- a/apps/wiki/Dockerfile +++ b/apps/wiki/Dockerfile @@ -29,7 +29,7 @@ ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 \ COPY --from=build --chown=node:node /app/.next/standalone/ ./ COPY --from=build --chown=node:node /app/dist/worker.cjs /app/dist/bootstrap.cjs /app/dist/publish-learning-series.cjs ./ COPY --from=build --chown=node:node /app/db/ ./db/ -COPY --chown=node:node apps/wiki/scripts/container-health.mjs apps/wiki/scripts/wait-snapshots.mjs ./ +COPY --chown=node:node apps/wiki/scripts/container-health.mjs ./ COPY --chown=node:node apps/wiki/scripts/import-character-packs.mjs ./scripts/import-character-packs.mjs COPY --chown=node:node apps/wiki/seed/characters/ ./seed/characters/ USER node diff --git a/apps/wiki/db/004_flat_notes.sql b/apps/wiki/db/004_flat_notes.sql new file mode 100644 index 0000000..2d09522 --- /dev/null +++ b/apps/wiki/db/004_flat_notes.sql @@ -0,0 +1,48 @@ +BEGIN; +-- Preserve references before removing the old screenshot catalogue. +INSERT INTO post_assets(post_id,asset_id,position,role) +SELECT ps.post_id,ps.asset_id, + COALESCE((SELECT max(pa.position)+1 FROM post_assets pa WHERE pa.post_id=ps.post_id),0) + +row_number() OVER(PARTITION BY ps.post_id ORDER BY ps.page_index)::integer-1,'content' +FROM page_snapshots ps WHERE NOT EXISTS(SELECT 1 FROM post_assets pa WHERE pa.post_id=ps.post_id AND pa.asset_id=ps.asset_id); +DROP TABLE render_jobs; +DROP TABLE page_snapshots; +ALTER TABLE posts DROP COLUMN page_key; + +-- Every processed file is an ordinary asset, retaining its original storage key. +ALTER TABLE assets ADD COLUMN source_asset_id uuid REFERENCES assets(id) ON DELETE RESTRICT; +ALTER TABLE assets ADD COLUMN derivation text CHECK(derivation IN ('silent_video','audio_track','poster')); +ALTER TABLE assets ADD CONSTRAINT assets_derivation_source CHECK((source_asset_id IS NULL)=(derivation IS NULL)); +CREATE UNIQUE INDEX assets_source_derivation ON assets(source_asset_id,derivation) WHERE source_asset_id IS NOT NULL; +INSERT INTO assets(id,kind,filename,storage_key,mime_type,byte_size,source_asset_id,derivation) +SELECT md5(v.storage_key)::uuid,CASE v.role WHEN 'poster' THEN 'image' WHEN 'audio_track' THEN 'audio' ELSE 'video' END, + v.role,v.storage_key,v.mime_type,v.byte_size,v.asset_id,v.role FROM asset_variants v WHERE true +ON CONFLICT(storage_key) DO UPDATE SET source_asset_id=excluded.source_asset_id,derivation=excluded.derivation; +ALTER TABLE post_assets DROP CONSTRAINT post_assets_role_check; +ALTER TABLE post_assets ADD CONSTRAINT post_assets_role_check CHECK(role IN ('content','caption','silent_video','audio_track','poster')); +INSERT INTO post_assets(post_id,asset_id,position,role) +SELECT sources.post_id,a.id, + COALESCE((SELECT max(pa.position)+1 FROM post_assets pa WHERE pa.post_id=sources.post_id),0) + +row_number() OVER(PARTITION BY sources.post_id ORDER BY a.id)::integer-1,a.derivation +FROM (SELECT DISTINCT post_id,asset_id FROM post_assets WHERE role='content') sources +JOIN assets a ON a.source_asset_id=sources.asset_id +WHERE NOT EXISTS(SELECT 1 FROM post_assets pa WHERE pa.post_id=sources.post_id AND pa.asset_id=a.id); +DROP TABLE asset_variants; + +-- Flatten tags, merging equal names without losing either kind of association. +DROP TRIGGER tags_no_cycles ON tags; +DROP FUNCTION prevent_tag_cycle(); +CREATE TEMP TABLE tag_slugs ON COMMIT DROP AS SELECT id,slug,row_number() OVER(PARTITION BY slug ORDER BY id) AS rank FROM tags; +UPDATE tags SET slug='tag-'||replace(id::text,'-',''); +UPDATE tags SET parent_id=NULL; +CREATE TEMP TABLE tag_merge ON COMMIT DROP AS + SELECT id,first_value(id) OVER(PARTITION BY lower(btrim(name)) ORDER BY id) AS keep_id FROM tags; +INSERT INTO post_tags(post_id,tag_id) SELECT pt.post_id,m.keep_id FROM post_tags pt JOIN tag_merge m ON m.id=pt.tag_id WHERE true ON CONFLICT DO NOTHING; +INSERT INTO asset_tags(asset_id,tag_id) SELECT at.asset_id,m.keep_id FROM asset_tags at JOIN tag_merge m ON m.id=at.tag_id WHERE true ON CONFLICT DO NOTHING; +DELETE FROM tags t USING tag_merge m WHERE t.id=m.id AND m.id<>m.keep_id; +ALTER TABLE tags DROP COLUMN parent_id; +UPDATE tags t SET slug=CASE WHEN s.rank=1 THEN s.slug ELSE 'tag-'||replace(t.id::text,'-','') END FROM tag_slugs s WHERE s.id=t.id; +CREATE UNIQUE INDEX tags_slug_unique ON tags(slug); +CREATE UNIQUE INDEX tags_name_unique ON tags(lower(btrim(name))); +INSERT INTO schema_migrations(version) VALUES('004_flat_notes'); +COMMIT; diff --git a/apps/wiki/scripts/wait-snapshots.mjs b/apps/wiki/scripts/wait-snapshots.mjs deleted file mode 100644 index 365e856..0000000 --- a/apps/wiki/scripts/wait-snapshots.mjs +++ /dev/null @@ -1,8 +0,0 @@ -const slugs=['learning-skills-notes','learning-skills-teaching','learning-skills-english']; -for(let attempt=0;attempt<120;attempt++){ - const states=await Promise.all(slugs.map(async slug=>{const r=await fetch('http://127.0.0.1:3000/api/posts/'+slug+'/snapshots');if(!r.ok)throw Error('Screenshot state unavailable');return r.json()})); - const failed=states.find(s=>s.job?.status==='failed');if(failed)throw Error(failed.job.error||'Screenshot generation failed'); - if(states.every(s=>s.job?.status==='ready'&&s.job.revision===process.env.RELEASE_SHA&&s.pages.length===8&&s.pages.every(p=>p.revision===process.env.RELEASE_SHA))){console.log('Verified 24 current server-rendered page snapshots');process.exit(0)} - await new Promise(r=>setTimeout(r,2000)); -} -throw Error('Screenshot publication timed out'); diff --git a/apps/wiki/src/components/ArticleGallery.tsx b/apps/wiki/src/components/ArticleGallery.tsx deleted file mode 100644 index 4f8bc44..0000000 --- a/apps/wiki/src/components/ArticleGallery.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import {previewUrl} from '@/content/page-links'; -import Link from "next/link"; -import {useEffect,useState} from "react"; -import {ChevronLeft,ChevronRight,RefreshCw,ExternalLink} from "lucide-react"; -import {mediaUrl} from "@/lib/model"; -type State={pages:{page_index:number;asset_id:string;rendered_at:string}[];job:{status:string;error:string|null}|null}; -export function ArticleGallery({slug}:{slug:string}){ - const [state,setState]=useState({pages:[],job:null});const[index,setIndex]=useState(1);const[error,setError]=useState('');const[requesting,setRequesting]=useState(false); - const pending=state.job?.status==='pending'||state.job?.status==='processing'; - useEffect(()=>{let live=true;const load=async()=>{try{const r=await fetch(`/api/posts/${slug}/snapshots`);if(!r.ok)throw Error();const s=await r.json();if(live){setState(s);setError('');}}catch{if(live)setError('截图状态读取失败,请重试。');}};void load();const timer=setInterval(()=>void load(),3000);return()=>{live=false;clearInterval(timer)};},[slug]); - async function refresh(all=false){setRequesting(true);setError('');try{const r=await fetch(`/api/posts/${slug}/snapshots`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(all?{}:{page:index})});if(!r.ok)throw Error();setState(s=>({...s,job:{status:'pending',error:null}}));}catch{setError('刷新没有完成,请重试。')}finally{setRequesting(false)}} - const selected=state.pages.find(p=>p.page_index===index);const url=`/p/${slug}/pages/${index}`; - return
这篇笔记 · 8 张图

{pending?'正在从最新网页重新截图…':selected?`截图更新于 ${new Date(selected.rendered_at).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai'})}`:'等待生成截图'}

{error&&

{error}

}{state.job?.status==='failed'&&

截图未完成,保留上次版本。可以重新刷新。

}
{selected?{`笔记第:
这张图还没有截图。先打开网页原稿 ↗
}
{Array.from({length:8},(_,i)=>{const p=state.pages.find(p=>p.page_index===i+1);return })}
; -} diff --git a/apps/wiki/src/components/ExploreDetail.tsx b/apps/wiki/src/components/ExploreDetail.tsx index 2cb66f4..389cf4d 100644 --- a/apps/wiki/src/components/ExploreDetail.tsx +++ b/apps/wiki/src/components/ExploreDetail.tsx @@ -2,12 +2,12 @@ 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} from 'lucide-react'; +import {X,ChevronLeft,ChevronRight,Link as LinkIcon,FileText,ArrowUpRight} 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
{part.replace(/^```[^\n]*\n?|```$/g,'')}
;if(part.startsWith('## '))return

{part.slice(3)}

;return

{part.split(/(\[[^\]]+\]\([^)]+\))/).map((piece,j)=>{const m=piece.match(/^\[([^\]]+)\]\(([^)]+)\)$/);return m&&/^(https?:\/\/|\/(?!\/))/.test(m[2])?{m[1]}:piece})}

})}} +function TextBody({text}:{text:string}){return <>{text.split(/\n\n+/).map((part,i)=>{if(part.startsWith('# '))return null;if(part.startsWith('```'))return
{part.replace(/^```[^\n]*\n?|```$/g,'')}
;if(part.startsWith('## '))return

{part.slice(3)}

;return

{part.split(/(\[[^\]]+\]\([^)]+\))/).map((piece,j)=>{const m=piece.match(/^\[([^\]]+)\]\(([^)]+)\)$/);return m&&/^(https?:\/\/|\/(?!\/))/.test(m[2])?:piece})}

})}} 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(null);const detailRef=useRef(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('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 {entry.title} · PROMPT
e.stopPropagation()}>
{media?.kind==='image'?{media.title}:media?.kind==='video'?