fix: correct Memos links and author identities

This commit is contained in:
2026-07-19 18:51:16 +08:00
parent d512443d00
commit 08059e2a9d
11 changed files with 41 additions and 21 deletions
+3 -1
View File
@@ -7,10 +7,12 @@
### Fixed ### Fixed
- Constrained remote Memos avatars to a fixed, cropped size so oversized source images cannot break dashboard or source-page layouts. - Constrained remote Memos avatars to a fixed, cropped size so oversized source images cannot break dashboard or source-page layouts.
- Updated Memos article links for UID-based releases while retaining legacy numeric-ID links.
- Standardized article-page and share-metadata author labels with the source account identity.
### Changed ### Changed
- Changed homepage author labels for Memos and Hub-to-Memos posts from duplicated source names to `@name@source-hostname` identities. - Changed author labels for Memos and Hub-to-Memos posts from duplicated source names to `@remote-user@source-hostname` identities.
## [0.8.0] - 2026-07-19 ## [0.8.0] - 2026-07-19
+1 -1
View File
@@ -4,7 +4,7 @@ import { Markdown } from "./markdown";
import { canonicalTags } from "@/lib/tags"; import { canonicalTags } from "@/lib/tags";
import { postAuthorLabel } from "@/lib/display"; import { postAuthorLabel } from "@/lib/display";
export type PublicPost = { id: number; source_id: number | null; origin: string; content: string; tags_json: string; attachments_json: string; created_at: string; remote_created_at?: string | null; username: string; name: string | null; remote_display_name?: string | null; source_base_url: string | null; comment_count: number; reaction_count: number }; export type PublicPost = { id: number; source_id: number | null; origin: string; content: string; tags_json: string; attachments_json: string; created_at: string; remote_created_at?: string | null; username: string; name: string | null; remote_user?: string | null; remote_display_name?: string | null; source_base_url: string | null; comment_count: number; reaction_count: number };
export function PostCard({ post }: { post: PublicPost }) { export function PostCard({ post }: { post: PublicPost }) {
let tags: string[] = []; try { tags = canonicalTags(JSON.parse(post.tags_json)); } catch { /* Ignore malformed legacy tags. */ } let tags: string[] = []; try { tags = canonicalTags(JSON.parse(post.tags_json)); } catch { /* Ignore malformed legacy tags. */ }
+2 -2
View File
@@ -10,10 +10,10 @@ export default async function Home({ searchParams }: { searchParams: Promise<Que
const query = await searchParams; const q = query.q?.trim() || ""; const tag = query.tag?.trim() || ""; const author = query.author?.trim() || ""; const sourceId = Number(query.source) || 0; const from = query.from || ""; const to = query.to || ""; const attachments = query.attachments === "1"; const page = Math.max(1, Number(query.page) || 1); const query = await searchParams; const q = query.q?.trim() || ""; const tag = query.tag?.trim() || ""; const author = query.author?.trim() || ""; const sourceId = Number(query.source) || 0; const from = query.from || ""; const to = query.to || ""; const attachments = query.attachments === "1"; const page = Math.max(1, Number(query.page) || 1);
const where = ["p.visibility='PUBLIC'", "p.hidden=0"]; const args: (string | number)[] = []; const where = ["p.visibility='PUBLIC'", "p.hidden=0"]; const args: (string | number)[] = [];
const ftsQuery = q.split(/\s+/).filter(Boolean).map((term) => `"${term.replaceAll('"', '""')}"`).join(" AND "); const ftsQuery = q.split(/\s+/).filter(Boolean).map((term) => `"${term.replaceAll('"', '""')}"`).join(" AND ");
if (q) { where.push("posts_fts MATCH ?"); args.push(ftsQuery); } if (tag) { where.push("p.tags_json LIKE ?"); args.push(`%${JSON.stringify(tag).slice(1, -1)}%`); } if (author) { where.push("u.username LIKE ?"); args.push(`%${author}%`); } if (sourceId) { where.push("s.id=?"); args.push(sourceId); } if (from) { where.push("date(COALESCE(p.remote_created_at,p.created_at)) >= date(?)"); args.push(from); } if (to) { where.push("date(COALESCE(p.remote_created_at,p.created_at)) <= date(?)"); args.push(to); } if (attachments) where.push("p.attachments_json <> '[]'"); if (q) { where.push("posts_fts MATCH ?"); args.push(ftsQuery); } if (tag) { where.push("p.tags_json LIKE ?"); args.push(`%${JSON.stringify(tag).slice(1, -1)}%`); } if (author) { where.push("(u.username LIKE ? OR s.remote_user LIKE ?)"); args.push(`%${author}%`, `%${author}%`); } if (sourceId) { where.push("s.id=?"); args.push(sourceId); } if (from) { where.push("date(COALESCE(p.remote_created_at,p.created_at)) >= date(?)"); args.push(from); } if (to) { where.push("date(COALESCE(p.remote_created_at,p.created_at)) <= date(?)"); args.push(to); } if (attachments) where.push("p.attachments_json <> '[]'");
const joins = ` FROM posts p ${q ? "JOIN posts_fts ON posts_fts.rowid=p.id" : ""} JOIN users u ON u.id=p.author_id LEFT JOIN sources s ON s.id=p.source_id `; const predicate = ` WHERE ${where.join(" AND ")}`; const joins = ` FROM posts p ${q ? "JOIN posts_fts ON posts_fts.rowid=p.id" : ""} JOIN users u ON u.id=p.author_id LEFT JOIN sources s ON s.id=p.source_id `; const predicate = ` WHERE ${where.join(" AND ")}`;
const total = Number((db.prepare(`SELECT count(*) count${joins}${predicate}`).get(...args) as { count: number }).count); const pages = Math.max(1, Math.ceil(total / pageSize)); const safePage = Math.min(page, pages); const total = Number((db.prepare(`SELECT count(*) count${joins}${predicate}`).get(...args) as { count: number }).count); const pages = Math.max(1, Math.ceil(total / pageSize)); const safePage = Math.min(page, pages);
const posts = db.prepare(`SELECT p.*,u.username,s.name,s.remote_display_name,s.base_url AS source_base_url,(SELECT count(*) FROM comments c WHERE c.post_id=p.id AND c.hidden=0) comment_count,(SELECT count(*) FROM reactions r WHERE r.post_id=p.id) reaction_count${joins}${predicate} ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT ? OFFSET ?`).all(...args, pageSize, (safePage - 1) * pageSize) as PublicPost[]; const posts = db.prepare(`SELECT p.*,u.username,s.name,s.remote_user,s.remote_display_name,s.base_url AS source_base_url,(SELECT count(*) FROM comments c WHERE c.post_id=p.id AND c.hidden=0) comment_count,(SELECT count(*) FROM reactions r WHERE r.post_id=p.id) reaction_count${joins}${predicate} ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT ? OFFSET ?`).all(...args, pageSize, (safePage - 1) * pageSize) as PublicPost[];
const sources = db.prepare("SELECT id,name FROM sources WHERE is_enabled=1 ORDER BY name").all() as { id: number; name: string }[]; const sources = db.prepare("SELECT id,name FROM sources WHERE is_enabled=1 ORDER BY name").all() as { id: number; name: string }[];
const params = new URLSearchParams(); for (const [key, value] of Object.entries(query)) if (value && key !== "page") params.set(key, value); const pageHref = (target: number) => { const next = new URLSearchParams(params); next.set("page", String(target)); return `/?${next}`; }; const params = new URLSearchParams(); for (const [key, value] of Object.entries(query)) if (value && key !== "page") params.set(key, value); const pageHref = (target: number) => { const next = new URLSearchParams(params); next.set("page", String(target)); return `/?${next}`; };
return <><section className="space"><div><h1> Memos Hub</h1><p className="muted"></p></div><Link className="button" href="/dashboard"></Link></section><form className="search-form" method="get"><input name="q" defaultValue={q} placeholder="搜尋公開貼文" /><input name="tag" defaultValue={tag} placeholder="標籤" /><input name="author" defaultValue={author} placeholder="作者" /><select name="source" defaultValue={sourceId || ""}><option value=""></option>{sources.map((source) => <option key={source.id} value={source.id}>{source.name}</option>)}</select><label><input name="from" type="date" defaultValue={from} /></label><label><input name="to" type="date" defaultValue={to} /></label><label className="check"><input name="attachments" type="checkbox" value="1" defaultChecked={attachments} /></label><button></button></form><p className="meta"> {total} </p>{posts.length ? posts.map((post) => <PostCard post={post} key={post.id} />) : <p className="muted"></p>}{pages > 1 && <nav className="pagination" aria-label="貼文分頁">{safePage > 1 && <Link href={pageHref(safePage - 1)}> </Link>}<span> {safePage}{pages} </span>{safePage < pages && <Link href={pageHref(safePage + 1)}> </Link>}</nav>}</>; return <><section className="space"><div><h1> Memos Hub</h1><p className="muted"></p></div><Link className="button" href="/dashboard"></Link></section><form className="search-form" method="get"><input name="q" defaultValue={q} placeholder="搜尋公開貼文" /><input name="tag" defaultValue={tag} placeholder="標籤" /><input name="author" defaultValue={author} placeholder="作者" /><select name="source" defaultValue={sourceId || ""}><option value=""></option>{sources.map((source) => <option key={source.id} value={source.id}>{source.name}</option>)}</select><label><input name="from" type="date" defaultValue={from} /></label><label><input name="to" type="date" defaultValue={to} /></label><label className="check"><input name="attachments" type="checkbox" value="1" defaultChecked={attachments} /></label><button></button></form><p className="meta"> {total} </p>{posts.length ? posts.map((post) => <PostCard post={post} key={post.id} />) : <p className="muted"></p>}{pages > 1 && <nav className="pagination" aria-label="貼文分頁">{safePage > 1 && <Link href={pageHref(safePage - 1)}> </Link>}<span> {safePage}{pages} </span>{safePage < pages && <Link href={pageHref(safePage + 1)}> </Link>}</nav>}</>;
+6 -5
View File
@@ -5,25 +5,26 @@ import { getSession } from "@/lib/auth";
import { Attachments } from "@/app/components/attachments"; import { Attachments } from "@/app/components/attachments";
import { Markdown } from "@/app/components/markdown"; import { Markdown } from "@/app/components/markdown";
import { canonicalTags } from "@/lib/tags"; import { canonicalTags } from "@/lib/tags";
import { postAuthorLabel } from "@/lib/display";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> { export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id: rawId } = await params; const post = db.prepare("SELECT p.content,p.hidden,p.visibility,u.username,s.name FROM posts p JOIN users u ON u.id=p.author_id LEFT JOIN sources s ON s.id=p.source_id WHERE p.id=?").get(Number(rawId)) as { content: string; hidden: number; visibility: string; username: string; name: string | null } | undefined; const { id: rawId } = await params; const post = db.prepare("SELECT p.content,p.hidden,p.visibility,p.origin,u.username,s.name,s.remote_user,s.base_url AS source_base_url FROM posts p JOIN users u ON u.id=p.author_id LEFT JOIN sources s ON s.id=p.source_id WHERE p.id=?").get(Number(rawId)) as { content: string; hidden: number; visibility: string; origin: string; username: string; name: string | null; remote_user: string | null; source_base_url: string | null } | undefined;
if (!post || post.hidden || post.visibility !== "PUBLIC") return { title: "找不到貼文" }; if (!post || post.hidden || post.visibility !== "PUBLIC") return { title: "找不到貼文" };
const description = post.content.replace(/\s+/g, " ").slice(0, 160); const description = post.content.replace(/\s+/g, " ").slice(0, 160); const author = postAuthorLabel(post);
return { title: `@${post.username} 的貼文|Mebbling`, description, openGraph: { title: `@${post.username}${post.name ? ` · ${post.name}` : ""}Mebbling`, description, type: "article" } }; return { title: `${author} 的貼文|Mebbling`, description, openGraph: { title: `${author}Mebbling`, description, type: "article" } };
} }
export default async function PostPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ reported?: string }> }) { export default async function PostPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ reported?: string }> }) {
const { id: rawId } = await params; const id = Number(rawId); const { id: rawId } = await params; const id = Number(rawId);
const post = db.prepare("SELECT p.*,u.username,s.name,s.base_url AS source_base_url,s.remote_display_name FROM posts p JOIN users u ON u.id=p.author_id LEFT JOIN sources s ON s.id=p.source_id WHERE p.id=?").get(id) as any; const query = await searchParams; const post = db.prepare("SELECT p.*,u.username,s.name,s.base_url AS source_base_url,s.remote_user,s.remote_display_name FROM posts p JOIN users u ON u.id=p.author_id LEFT JOIN sources s ON s.id=p.source_id WHERE p.id=?").get(id) as any; const query = await searchParams;
if (!post || post.hidden) notFound(); const user = await getSession(); if (post.visibility !== "PUBLIC" && post.author_id !== user?.id) redirect("/"); if (!post || post.hidden) notFound(); const user = await getSession(); if (post.visibility !== "PUBLIC" && post.author_id !== user?.id) redirect("/");
if (user && post.visibility === "PUBLIC") db.prepare("INSERT INTO reading_history(user_id,post_id) VALUES(?,?) ON CONFLICT(user_id,post_id) DO UPDATE SET last_read_at=CURRENT_TIMESTAMP").run(user.id, id); if (user && post.visibility === "PUBLIC") db.prepare("INSERT INTO reading_history(user_id,post_id) VALUES(?,?) ON CONFLICT(user_id,post_id) DO UPDATE SET last_read_at=CURRENT_TIMESTAMP").run(user.id, id);
const bookmark = user ? db.prepare("SELECT kind FROM bookmarks WHERE user_id=? AND post_id=?").get(user.id, id) as { kind: string } | undefined : undefined; const bookmark = user ? db.prepare("SELECT kind FROM bookmarks WHERE user_id=? AND post_id=?").get(user.id, id) as { kind: string } | undefined : undefined;
const comments = db.prepare("SELECT c.*,u.username FROM comments c JOIN users u ON u.id=c.author_id WHERE c.post_id=? AND c.hidden=0 ORDER BY c.created_at").all(id) as any[]; const comments = db.prepare("SELECT c.*,u.username FROM comments c JOIN users u ON u.id=c.author_id WHERE c.post_id=? AND c.hidden=0 ORDER BY c.created_at").all(id) as any[];
const reactions = db.prepare("SELECT emoji,count(*) count FROM reactions WHERE post_id=? GROUP BY emoji").all(id) as any[]; const reactions = db.prepare("SELECT emoji,count(*) count FROM reactions WHERE post_id=? GROUP BY emoji").all(id) as any[];
let tags: string[] = []; try { tags = canonicalTags(JSON.parse(post.tags_json)); } catch {} const publishedAt = post.remote_created_at || post.created_at; const canExport = Boolean(user && (post.author_id === user.id || (post.source_id && db.prepare("SELECT 1 FROM source_members WHERE source_id=? AND user_id=?").get(post.source_id, user.id)))); return <article><p className="meta">@{post.username} · {post.remote_display_name || post.name || "Hub"} · {new Date(publishedAt).toLocaleString("zh-TW")}{post.remote_url && <> · <a href={post.remote_url} target="_blank" rel="noreferrer"> Memos </a></>}</p>{canExport && <p className="row"><a href={`/api/export/posts/${id}?format=json`}> JSON</a><a href={`/api/export/posts/${id}?format=markdown`}> Markdown</a></p>}<section className="card"><Markdown content={post.content} tags={tags} /></section><Attachments json={post.attachments_json} sourceBaseUrl={post.source_base_url} /> let tags: string[] = []; try { tags = canonicalTags(JSON.parse(post.tags_json)); } catch {} const publishedAt = post.remote_created_at || post.created_at; const canExport = Boolean(user && (post.author_id === user.id || (post.source_id && db.prepare("SELECT 1 FROM source_members WHERE source_id=? AND user_id=?").get(post.source_id, user.id)))); return <article><p className="meta">{postAuthorLabel(post)} · {new Date(publishedAt).toLocaleString("zh-TW")}{post.remote_url && <> · <a href={post.remote_url} target="_blank" rel="noreferrer"> Memos </a></>}</p>{canExport && <p className="row"><a href={`/api/export/posts/${id}?format=json`}> JSON</a><a href={`/api/export/posts/${id}?format=markdown`}> Markdown</a></p>}<section className="card"><Markdown content={post.content} tags={tags} /></section><Attachments json={post.attachments_json} sourceBaseUrl={post.source_base_url} />
<section className="row">{reactions.map((reaction: any) => <span className="tag" key={reaction.emoji}>{reaction.emoji} {reaction.count}</span>)}{user && <><form action="/api/bookmarks" method="post"><input type="hidden" name="postId" value={id} /><input type="hidden" name="kind" value="saved" /><button>{bookmark?.kind === "saved" ? "取消收藏" : "收藏"}</button></form><form action="/api/bookmarks" method="post"><input type="hidden" name="postId" value={id} /><input type="hidden" name="kind" value="later" /><button>{bookmark?.kind === "later" ? "取消稍後閱讀" : "稍後閱讀"}</button></form></>}{user && ["👍", "❤️", "🎉", "🤔"].map((emoji) => <form action="/api/reactions" method="post" key={emoji}><input type="hidden" name="postId" value={id} /><input type="hidden" name="emoji" value={emoji} /><button>{emoji}</button></form>)}</section> <section className="row">{reactions.map((reaction: any) => <span className="tag" key={reaction.emoji}>{reaction.emoji} {reaction.count}</span>)}{user && <><form action="/api/bookmarks" method="post"><input type="hidden" name="postId" value={id} /><input type="hidden" name="kind" value="saved" /><button>{bookmark?.kind === "saved" ? "取消收藏" : "收藏"}</button></form><form action="/api/bookmarks" method="post"><input type="hidden" name="postId" value={id} /><input type="hidden" name="kind" value="later" /><button>{bookmark?.kind === "later" ? "取消稍後閱讀" : "稍後閱讀"}</button></form></>}{user && ["👍", "❤️", "🎉", "🤔"].map((emoji) => <form action="/api/reactions" method="post" key={emoji}><input type="hidden" name="postId" value={id} /><input type="hidden" name="emoji" value={emoji} /><button>{emoji}</button></form>)}</section>
<section><h2></h2>{user ? <><form action="/api/comments" method="post"><input type="hidden" name="postId" value={id} /><textarea name="content" required placeholder="在 Hub 留下留言" /><button></button></form><details><summary></summary>{query.reported && <p></p>}<form action="/api/reports" method="post"><input type="hidden" name="postId" value={id} /><label><input name="reason" required minLength={3} maxLength={500} /></label><button className="danger"></button></form></details></> : <p></p>}{comments.map((comment) => <div className="card" key={comment.id}><strong>@{comment.username}</strong><p>{comment.content}</p><span className="meta">{new Date(comment.created_at).toLocaleString("zh-TW")}</span></div>)}</section> <section><h2></h2>{user ? <><form action="/api/comments" method="post"><input type="hidden" name="postId" value={id} /><textarea name="content" required placeholder="在 Hub 留下留言" /><button></button></form><details><summary></summary>{query.reported && <p></p>}<form action="/api/reports" method="post"><input type="hidden" name="postId" value={id} /><label><input name="reason" required minLength={3} maxLength={500} /></label><button className="danger"></button></form></details></> : <p></p>}{comments.map((comment) => <div className="card" key={comment.id}><strong>@{comment.username}</strong><p>{comment.content}</p><span className="meta">{new Date(comment.created_at).toLocaleString("zh-TW")}</span></div>)}</section>
</article>; </article>;
+1 -1
View File
@@ -7,7 +7,7 @@ import { getSession } from "@/lib/auth";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function SourcePage({ params }: { params: Promise<{ id: string }> }) { export default async function SourcePage({ params }: { params: Promise<{ id: string }> }) {
const { id: rawId } = await params; const id = Number(rawId); const source = db.prepare("SELECT id,name,base_url,remote_display_name,remote_avatar_url FROM sources WHERE id=?").get(id) as { id: number; name: string; base_url: string; remote_display_name: string | null; remote_avatar_url: string | null } | undefined; if (!source) notFound(); const { id: rawId } = await params; const id = Number(rawId); const source = db.prepare("SELECT id,name,base_url,remote_display_name,remote_avatar_url FROM sources WHERE id=?").get(id) as { id: number; name: string; base_url: string; remote_display_name: string | null; remote_avatar_url: string | null } | undefined; if (!source) notFound();
const posts = db.prepare("SELECT p.*,u.username,s.name,s.remote_display_name,s.base_url AS source_base_url,(SELECT count(*) FROM comments c WHERE c.post_id=p.id AND c.hidden=0) comment_count,(SELECT count(*) FROM reactions r WHERE r.post_id=p.id) reaction_count FROM posts p JOIN users u ON u.id=p.author_id LEFT JOIN sources s ON s.id=p.source_id WHERE p.source_id=? AND p.visibility='PUBLIC' AND p.hidden=0 ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT 100").all(id) as PublicPost[]; const posts = db.prepare("SELECT p.*,u.username,s.name,s.remote_user,s.remote_display_name,s.base_url AS source_base_url,(SELECT count(*) FROM comments c WHERE c.post_id=p.id AND c.hidden=0) comment_count,(SELECT count(*) FROM reactions r WHERE r.post_id=p.id) reaction_count FROM posts p JOIN users u ON u.id=p.author_id LEFT JOIN sources s ON s.id=p.source_id WHERE p.source_id=? AND p.visibility='PUBLIC' AND p.hidden=0 ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT 100").all(id) as PublicPost[];
const user = await getSession(); const member = Boolean(user && db.prepare("SELECT 1 FROM source_members WHERE source_id=? AND user_id=?").get(id, user.id)); const user = await getSession(); const member = Boolean(user && db.prepare("SELECT 1 FROM source_members WHERE source_id=? AND user_id=?").get(id, user.id));
return <><p><Link href="/"> </Link></p><h1>{source.name}</h1><p className="meta">{source.remote_avatar_url && <img className="avatar" src={source.remote_avatar_url} alt="" />} {source.remote_display_name || "Memos"}<br />{source.base_url} · {posts.length} </p>{member && <p className="row"><a href={`/api/export/sources/${id}?format=json`}> JSON</a><a href={`/api/export/sources/${id}?format=markdown`}> Markdown</a></p>}{posts.map((post) => <PostCard key={post.id} post={post} />)}</>; return <><p><Link href="/"> </Link></p><h1>{source.name}</h1><p className="meta">{source.remote_avatar_url && <img className="avatar" src={source.remote_avatar_url} alt="" />} {source.remote_display_name || "Memos"}<br />{source.base_url} · {posts.length} </p>{member && <p className="row"><a href={`/api/export/sources/${id}?format=json`}> JSON</a><a href={`/api/export/sources/${id}?format=markdown`}> Markdown</a></p>}{posts.map((post) => <PostCard key={post.id} post={post} />)}</>;
} }
+1 -1
View File
@@ -6,6 +6,6 @@ import { PostCard, type PublicPost } from "@/app/components/post-card";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function TagPage({ params }: { params: Promise<{ tag: string }> }) { export default async function TagPage({ params }: { params: Promise<{ tag: string }> }) {
const { tag: encoded } = await params; const tag = decodeURIComponent(encoded).trim(); if (!tag) notFound(); const { tag: encoded } = await params; const tag = decodeURIComponent(encoded).trim(); if (!tag) notFound();
const posts = db.prepare("SELECT p.*,u.username,s.name,s.remote_display_name,s.base_url AS source_base_url,(SELECT count(*) FROM comments c WHERE c.post_id=p.id AND c.hidden=0) comment_count,(SELECT count(*) FROM reactions r WHERE r.post_id=p.id) reaction_count FROM posts p JOIN users u ON u.id=p.author_id LEFT JOIN sources s ON s.id=p.source_id WHERE p.visibility='PUBLIC' AND p.hidden=0 AND p.tags_json LIKE ? ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT 100").all(`%${JSON.stringify(tag).slice(1, -1)}%`) as PublicPost[]; const posts = db.prepare("SELECT p.*,u.username,s.name,s.remote_user,s.remote_display_name,s.base_url AS source_base_url,(SELECT count(*) FROM comments c WHERE c.post_id=p.id AND c.hidden=0) comment_count,(SELECT count(*) FROM reactions r WHERE r.post_id=p.id) reaction_count FROM posts p JOIN users u ON u.id=p.author_id LEFT JOIN sources s ON s.id=p.source_id WHERE p.visibility='PUBLIC' AND p.hidden=0 AND p.tags_json LIKE ? ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT 100").all(`%${JSON.stringify(tag).slice(1, -1)}%`) as PublicPost[];
return <><p><Link href="/"> </Link></p><h1>#{tag}</h1><p className="muted">{posts.length} </p>{posts.map((post) => <PostCard key={post.id} post={post} />)}</>; return <><p><Link href="/"> </Link></p><h1>#{tag}</h1><p className="muted">{posts.length} </p>{posts.map((post) => <PostCard key={post.id} post={post} />)}</>;
} }
+5
View File
@@ -180,6 +180,11 @@ db.exec("CREATE INDEX IF NOT EXISTS posts_source_visibility_idx ON posts(source_
db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES(40)").run(); db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES(40)").run();
db.exec("CREATE INDEX IF NOT EXISTS alert_deliveries_status_idx ON alert_deliveries(status,run_after)"); db.exec("CREATE INDEX IF NOT EXISTS alert_deliveries_status_idx ON alert_deliveries(status,run_after)");
db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES(41)").run(); db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES(41)").run();
const hasMemoUrlMigration = db.prepare("SELECT 1 FROM schema_migrations WHERE version=42").get();
if (!hasMemoUrlMigration) {
db.exec(`UPDATE posts SET remote_url=(SELECT rtrim(s.base_url,'/') FROM sources s WHERE s.id=posts.source_id) || CASE WHEN substr(remote_memo_name,7)<>'' AND substr(remote_memo_name,7) NOT GLOB '*[^0-9]*' THEN '/m/' || substr(remote_memo_name,7) ELSE '/' || remote_memo_name END WHERE origin='memos' AND remote_memo_name LIKE 'memos/%' AND source_id IS NOT NULL`);
db.prepare("INSERT INTO schema_migrations(version) VALUES(42)").run();
}
const admin = process.env.ADMIN_USERNAME; const admin = process.env.ADMIN_USERNAME;
const adminPassword = process.env.ADMIN_PASSWORD; const adminPassword = process.env.ADMIN_PASSWORD;
+5 -4
View File
@@ -2,15 +2,16 @@ export type AuthorDisplay = {
origin: string; origin: string;
username: string; username: string;
name: string | null; name: string | null;
remote_user?: string | null;
remote_display_name?: string | null; remote_display_name?: string | null;
source_base_url: string | null; source_base_url: string | null;
}; };
export function postAuthorLabel(post: AuthorDisplay) { export function postAuthorLabel(post: AuthorDisplay) {
const linkedMemos = post.origin === "memos" || post.origin === "hub"; const remoteUser = post.remote_user?.split("/").filter(Boolean).at(-1);
const author = linkedMemos ? (post.remote_display_name || post.name || post.username) : (post.name || post.username); const author = remoteUser || (post.origin === "rss" ? post.name : post.username) || post.name || "unknown";
if (linkedMemos && post.source_base_url) { if (post.source_base_url) {
try { return `@${author}@${new URL(post.source_base_url).hostname}`; } catch { /* Keep a readable fallback for legacy malformed URLs. */ } try { return `@${author}@${new URL(post.source_base_url).hostname}`; } catch { /* Keep a readable fallback for legacy malformed URLs. */ }
} }
return `@${author}${post.name ? ` · ${post.name}` : ""}`; return `@${author}`;
} }
+6 -1
View File
@@ -22,7 +22,12 @@ export async function getMemosIdentity(baseUrl: string, token: string) {
if (!user.name) throw new Error("Memos did not return an account identity"); if (!user.name) throw new Error("Memos did not return an account identity");
return user; return user;
} }
export function memoUrl(baseUrl: string, memoName: string) { const id = memoName.split("/").at(-1); return id ? `${baseUrl.replace(/\/$/, "")}/m/${encodeURIComponent(id)}` : null; } export function memoUrl(baseUrl: string, memoName: string) {
const id = memoName.split("/").at(-1);
if (!id) return null;
const path = /^\d+$/.test(id) ? `m/${encodeURIComponent(id)}` : `memos/${encodeURIComponent(id)}`;
return `${baseUrl.replace(/\/$/, "")}/${path}`;
}
export type MemosPage = { memos: MemosMemo[]; nextPageToken: string }; export type MemosPage = { memos: MemosMemo[]; nextPageToken: string };
export async function listMemos(baseUrl: string, token: string, rules: MemosSyncRules = {}, page: { pageToken?: string; pageSize?: number } = {}): Promise<MemosPage> { export async function listMemos(baseUrl: string, token: string, rules: MemosSyncRules = {}, page: { pageToken?: string; pageSize?: number } = {}): Promise<MemosPage> {
let pageToken = page.pageToken || ""; let pageToken = page.pageToken || "";
+10 -4
View File
@@ -1,15 +1,21 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { postAuthorLabel } from "../lib/display"; import { postAuthorLabel } from "../lib/display";
import { memoUrl } from "../lib/memos";
test("formats Memos authors as name at source hostname", () => { test("formats Memos authors as remote user at source hostname", () => {
assert.equal(postAuthorLabel({ origin: "memos", username: "hub-user", name: "codex", remote_display_name: "codex", source_base_url: "https://memos.fishking.studio/path" }), "@codex@memos.fishking.studio"); assert.equal(postAuthorLabel({ origin: "memos", username: "hub-user", name: "唐宋打油", remote_user: "users/tangsong", remote_display_name: "唐宋打油", source_base_url: "https://memos.fishking.studio/path" }), "@tangsong@memos.fishking.studio");
}); });
test("formats Hub posts linked to Memos with the same source identity", () => { test("formats Hub posts linked to Memos with the same source identity", () => {
assert.equal(postAuthorLabel({ origin: "hub", username: "codex", name: "codex", remote_display_name: "codex", source_base_url: "https://memos.fishking.studio" }), "@codex@memos.fishking.studio"); assert.equal(postAuthorLabel({ origin: "hub", username: "codex", name: "唐宋打油", remote_user: "users/tangsong", remote_display_name: "唐宋打油", source_base_url: "https://memos.fishking.studio" }), "@tangsong@memos.fishking.studio");
}); });
test("keeps the existing label for non-Memos posts", () => { test("keeps the existing label for non-Memos posts", () => {
assert.equal(postAuthorLabel({ origin: "hub", username: "codex", name: "Hub", source_base_url: null }), "@Hub · Hub"); assert.equal(postAuthorLabel({ origin: "hub", username: "codex", name: "Hub", source_base_url: null }), "@codex");
});
test("uses current Memos UID links while preserving legacy numeric links", () => {
assert.equal(memoUrl("https://memos.example.com/", "memos/VA54SvJG7yBparf3SiH3Ey"), "https://memos.example.com/memos/VA54SvJG7yBparf3SiH3Ey");
assert.equal(memoUrl("https://memos.example.com", "memos/15"), "https://memos.example.com/m/15");
}); });
+1 -1
View File
@@ -16,7 +16,7 @@ test("applies tracked migrations and deduplicates active pull jobs", async () =>
const { queuePull } = await import("../lib/sync"); const { queuePull } = await import("../lib/sync");
const { notify } = await import("../lib/notifications"); const { notify } = await import("../lib/notifications");
const migrations = db.prepare("SELECT version FROM schema_migrations ORDER BY version").all() as { version: number }[]; const migrations = db.prepare("SELECT version FROM schema_migrations ORDER BY version").all() as { version: number }[];
assert.deepEqual(migrations.map((item) => item.version), Array.from({ length: 41 }, (_, index) => index + 1)); assert.deepEqual(migrations.map((item) => item.version), Array.from({ length: 42 }, (_, index) => index + 1));
const userId = Number(db.prepare("INSERT INTO users(username,password_hash) VALUES('sync-test','hash')").run().lastInsertRowid); const userId = Number(db.prepare("INSERT INTO users(username,password_hash) VALUES('sync-test','hash')").run().lastInsertRowid);
const sourceId = Number(db.prepare("INSERT INTO sources(user_id,name,base_url,token_encrypted,is_enabled) VALUES(?,?,?,?,1)").run(userId, "Test", "https://example.test", "encrypted").lastInsertRowid); const sourceId = Number(db.prepare("INSERT INTO sources(user_id,name,base_url,token_encrypted,is_enabled) VALUES(?,?,?,?,1)").run(userId, "Test", "https://example.test", "encrypted").lastInsertRowid);
assert.equal(queuePull(sourceId, "manual"), true); assert.equal(queuePull(sourceId, "manual"), true);