2 Commits
27 changed files with 1908 additions and 44 deletions
+22 -1
View File
@@ -2,7 +2,28 @@
本專案遵循 [Semantic Versioning](https://semver.org/lang/zh-TW/);版本 `0.x` 表示功能仍可能調整。
## [0.2.0] - Unreleased
## [0.4.0] - Unreleased
### Added
- 每個來源可依標籤、日期及附件類型設定同步範圍。
- Memos 遠端貼文連結、遠端 profile、Token 連線測試與連線狀態。
- Webhook 健康狀態與長時間未收到 webhook 警示。
### Changed
- Pull 同步會以來源規則決定鏡像內容;遠端更新會更新 Hub 鏡像,遠端刪除或移出規則範圍會隱藏鏡像貼文。
## [0.3.0] - Unreleased
### Added
- 分頁與可依內容、標籤、來源、作者、日期及附件篩選的公開搜尋。
- 標籤頁、來源頁、RSS 與 Atom feed,以及公開貼文 Open Graph metadata。
- 安全 Markdown 渲染、GitHub Flavored Markdown 與程式碼高亮。
- 收藏、稍後閱讀、閱讀紀錄、互動通知與通知已讀管理。
## [0.2.0] - 2026-07-19
### Added
+9 -1
View File
@@ -2,7 +2,7 @@
自架的 Memos 公開貼文 Hub。將朋友各自 Memos 中的公開貼文集中展示,同時保留 Hub 內的留言、表情回應與發文功能。
目前開發版本:`v0.2.0`(尚未發布)。版本變更請見 [CHANGELOG.md](CHANGELOG.md)。
目前開發版本:`v0.4.0`(尚未發布)。版本變更請見 [CHANGELOG.md](CHANGELOG.md)。
## 功能
@@ -16,6 +16,10 @@
- 來源建立者可重新命名、停用、刪除或轉移所有權;共享成員可自行離開來源。
- 同步工作具去重、重試、觸發來源與歷史紀錄;管理員可集中檢視異常。
- 內建 SQLite 與附件備份腳本,以及可追蹤的 schema migration。
- 可依內容、標籤、來源、作者、日期與附件篩選公開貼文,並支援分頁、標籤/來源頁、RSS 與 Atom。
- 提供安全 Markdown、程式碼高亮、收藏、稍後閱讀、閱讀紀錄與互動通知。
- 每個來源可設定標籤、日期與附件類型同步規則,並在貼文頁保留可回到原始 Memos 貼文的連結。
- 控制台可測試 Token/Memos 連線、顯示遠端名稱與頭像,並提示 webhook 長時間未收到事件的狀態。
## 快速啟動(WSLDocker
@@ -73,6 +77,8 @@ Web 接收使用者操作和 webhook,將同步需求寫入 SQLite 的 `sync_jo
- **Push**:把 Hub 建立的貼文與本機附件上傳/回寫到選定的 Memos 來源。
- **排程校正**:依 `SYNC_INTERVAL_MINUTES` 定期建立 Pull 工作,避免 webhook 遺漏造成資料不同步。
在來源管理中設定的同步規則會套用到 Pull:多個標籤採「同時符合」篩選,日期以 Memos 貼文建立日為準;附件可選擇全部保留、只保留圖片,或不同步附件。貼文後續在遠端被修改、刪除、改為非公開或不再符合規則時,下一次 Pull 會更新或隱藏 Hub 鏡像。
## Webhook 設定與驗證
1. 來源建立者登入「控制台」。
@@ -81,6 +87,8 @@ Web 接收使用者操作和 webhook,將同步需求寫入 SQLite 的 `sync_jo
4. 在 Memos 發布或更新一篇公開貼文。
5. 回到 Hub:顯示「最近收到」代表 Hub 確實收到 webhook;「上次同步」更新則代表同步已完成。
若 webhook 已設定但超過 7 天未收到事件,控制台會顯示提醒;這不會中斷定期校正同步。來源建立者也可按「測試 Memos 連線」檢查 Token 是否有效,同時更新遠端顯示名稱與頭像。
網址格式如下;`來源 ID``隨機密鑰` 都由系統產生,請勿自行修改:
```text
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth";
import { db } from "@/lib/db";
import { externalUrl } from "@/lib/http";
export async function POST(req: Request) {
try {
const user = await requireUser(); const form = await req.formData(); const postId = Number(form.get("postId")); const kind = String(form.get("kind"));
if (!postId || !["saved", "later"].includes(kind)) throw new Error("Invalid bookmark");
const post = db.prepare("SELECT id FROM posts WHERE id=? AND visibility='PUBLIC' AND hidden=0").get(postId); if (!post) throw new Error("Post not found");
const existing = db.prepare("SELECT kind FROM bookmarks WHERE user_id=? AND post_id=?").get(user.id, postId) as { kind: string } | undefined;
if (existing?.kind === kind) db.prepare("DELETE FROM bookmarks WHERE user_id=? AND post_id=?").run(user.id, postId);
else db.prepare("INSERT INTO bookmarks(user_id,post_id,kind) VALUES(?,?,?) ON CONFLICT(user_id,post_id) DO UPDATE SET kind=excluded.kind,created_at=CURRENT_TIMESTAMP").run(user.id, postId, kind);
return NextResponse.redirect(externalUrl(req, `/posts/${postId}`));
} catch { return NextResponse.redirect(externalUrl(req, "/")); }
}
+17 -2
View File
@@ -1,2 +1,17 @@
import { NextResponse } from "next/server"; import { requireUser } from "@/lib/auth"; import { db } from "@/lib/db"; import { externalUrl } from "@/lib/http";
export async function POST(req:Request){try{const user=await requireUser();const f=await req.formData();const postId=Number(f.get('postId'));const content=String(f.get('content')||'').trim();if(!postId||!content||content.length>5000)throw new Error('Invalid comment');db.prepare('INSERT INTO comments(post_id,author_id,content) VALUES(?,?,?)').run(postId,user.id,content);return NextResponse.redirect(externalUrl(req,`/posts/${postId}`));}catch{return NextResponse.redirect(externalUrl(req,'/'));}}
import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth";
import { db } from "@/lib/db";
import { externalUrl } from "@/lib/http";
import { notify } from "@/lib/notifications";
export async function POST(req: Request) {
try {
const user = await requireUser(); const form = await req.formData(); const postId = Number(form.get("postId")); const content = String(form.get("content") || "").trim();
if (!postId || !content || content.length > 5000) throw new Error("Invalid comment");
const post = db.prepare("SELECT author_id FROM posts WHERE id=? AND hidden=0").get(postId) as { author_id: number } | undefined;
if (!post) throw new Error("Post not found");
db.prepare("INSERT INTO comments(post_id,author_id,content) VALUES(?,?,?)").run(postId, user.id, content);
notify(post.author_id, user.id, postId, "comment", `@${user.username} 留言了你的貼文`);
return NextResponse.redirect(externalUrl(req, `/posts/${postId}`));
} catch { return NextResponse.redirect(externalUrl(req, "/")); }
}
+13
View File
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth";
import { db } from "@/lib/db";
import { externalUrl } from "@/lib/http";
export async function POST(req: Request) {
try {
const user = await requireUser(); const form = await req.formData(); const id = Number(form.get("id"));
if (id) db.prepare("UPDATE notifications SET read_at=CURRENT_TIMESTAMP WHERE id=? AND user_id=?").run(id, user.id);
else db.prepare("UPDATE notifications SET read_at=CURRENT_TIMESTAMP WHERE user_id=? AND read_at IS NULL").run(user.id);
return NextResponse.redirect(externalUrl(req, "/notifications"));
} catch { return NextResponse.redirect(externalUrl(req, "/")); }
}
+19 -2
View File
@@ -1,2 +1,19 @@
import { NextResponse } from "next/server"; import { requireUser } from "@/lib/auth"; import { db } from "@/lib/db"; import { externalUrl } from "@/lib/http";
const allowed=new Set(['👍','❤️','🎉','🤔']);export async function POST(req:Request){try{const user=await requireUser();const f=await req.formData();const postId=Number(f.get('postId'));const emoji=String(f.get('emoji'));if(!postId||!allowed.has(emoji))throw 0;const found=db.prepare('SELECT 1 FROM reactions WHERE post_id=? AND user_id=? AND emoji=?').get(postId,user.id,emoji);if(found)db.prepare('DELETE FROM reactions WHERE post_id=? AND user_id=? AND emoji=?').run(postId,user.id,emoji);else db.prepare('INSERT INTO reactions(post_id,user_id,emoji) VALUES(?,?,?)').run(postId,user.id,emoji);return NextResponse.redirect(externalUrl(req,`/posts/${postId}`));}catch{return NextResponse.redirect(externalUrl(req,'/'));}}
import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth";
import { db } from "@/lib/db";
import { externalUrl } from "@/lib/http";
import { notify } from "@/lib/notifications";
const allowed = new Set(["👍", "❤️", "🎉", "🤔"]);
export async function POST(req: Request) {
try {
const user = await requireUser(); const form = await req.formData(); const postId = Number(form.get("postId")); const emoji = String(form.get("emoji"));
if (!postId || !allowed.has(emoji)) throw new Error("Invalid reaction");
const post = db.prepare("SELECT author_id FROM posts WHERE id=? AND hidden=0").get(postId) as { author_id: number } | undefined;
if (!post) throw new Error("Post not found");
const found = db.prepare("SELECT 1 FROM reactions WHERE post_id=? AND user_id=? AND emoji=?").get(postId, user.id, emoji);
if (found) db.prepare("DELETE FROM reactions WHERE post_id=? AND user_id=? AND emoji=?").run(postId, user.id, emoji);
else { db.prepare("INSERT INTO reactions(post_id,user_id,emoji) VALUES(?,?,?)").run(postId, user.id, emoji); notify(post.author_id, user.id, postId, "reaction", `@${user.username} 對你的貼文給了 ${emoji}`); }
return NextResponse.redirect(externalUrl(req, `/posts/${postId}`));
} catch { return NextResponse.redirect(externalUrl(req, "/")); }
}
+17 -1
View File
@@ -1,13 +1,15 @@
import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth";
import { decrypt } from "@/lib/crypto";
import { db } from "@/lib/db";
import { externalUrl } from "@/lib/http";
import { getMemosIdentity, verifyMemos } from "@/lib/memos";
import { queuePull } from "@/lib/sync";
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await requireUser(); const { id: rawId } = await params; const id = Number(rawId); const form = await req.formData(); const action = String(form.get("action") || "");
const source = db.prepare("SELECT id,user_id FROM sources WHERE id=?").get(id) as { id: number; user_id: number } | undefined;
const source = db.prepare("SELECT id,user_id,base_url,token_encrypted FROM sources WHERE id=?").get(id) as { id: number; user_id: number; base_url: string; token_encrypted: string } | undefined;
const member = db.prepare("SELECT role FROM source_members WHERE source_id=? AND user_id=?").get(id, user.id);
if (!source || !member) throw new Error("Source not found");
const owner = source.user_id === user.id;
@@ -18,6 +20,20 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
if (!owner) throw new Error("Only the owner can change source status"); const enabled = String(form.get("enabled")) === "1";
db.prepare("UPDATE sources SET is_enabled=?,disabled_at=CASE WHEN ? THEN NULL ELSE CURRENT_TIMESTAMP END,sync_status=CASE WHEN ? THEN 'pending' ELSE 'disabled' END WHERE id=?").run(enabled ? 1 : 0, enabled ? 1 : 0, enabled ? 1 : 0, id);
if (enabled) queuePull(id, "manual");
} else if (action === "set-sync-rules") {
if (!owner) throw new Error("Only the owner can change sync rules");
const tags = String(form.get("tags") || "").split(",").map((tag) => tag.trim().replace(/^#/, "")).filter(Boolean).slice(0, 20);
const from = String(form.get("from") || ""); const to = String(form.get("to") || ""); const attachmentMode = String(form.get("attachmentMode") || "all");
if ((from && !/^\d{4}-\d{2}-\d{2}$/.test(from)) || (to && !/^\d{4}-\d{2}-\d{2}$/.test(to)) || (from && to && from > to) || !["all", "images", "none"].includes(attachmentMode)) throw new Error("Invalid sync rules");
db.prepare("UPDATE sources SET sync_tags_json=?,sync_from=?,sync_to=?,sync_attachment_mode=? WHERE id=?").run(JSON.stringify(tags), from || null, to || null, attachmentMode, id);
queuePull(id, "manual");
} else if (action === "test-connection") {
if (!owner) throw new Error("Only the owner can test the connection");
try {
const token = decrypt(source.token_encrypted); await verifyMemos(source.base_url, token); const identity = await getMemosIdentity(source.base_url, token);
const avatar = identity.avatarUrl || identity.avatar || null; const avatarUrl = avatar?.startsWith("/") ? `${source.base_url.replace(/\/$/, "")}${avatar}` : avatar;
db.prepare("UPDATE sources SET last_connection_at=CURRENT_TIMESTAMP,last_connection_error=NULL,remote_display_name=?,remote_avatar_url=? WHERE id=?").run(identity.nickname || identity.username || identity.name, avatarUrl, id);
} catch (connectionError) { const message = connectionError instanceof Error ? connectionError.message : "Connection failed"; db.prepare("UPDATE sources SET last_connection_error=? WHERE id=?").run(message, id); throw connectionError; }
} else if (action === "leave") {
if (owner) throw new Error("Transfer ownership or delete the source before leaving");
db.prepare("DELETE FROM source_members WHERE source_id=? AND user_id=?").run(id, user.id);
+8
View File
@@ -0,0 +1,8 @@
import { db } from "@/lib/db";
const escapeXml = (value: string) => value.replace(/[<>&'\"]/g, (char) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", "'": "&apos;", '"': "&quot;" }[char] || char));
export async function GET() {
const origin = (process.env.NEXT_PUBLIC_APP_URL || "http://localhost:8088").replace(/\/$/, ""); const posts = db.prepare("SELECT p.id,p.content,p.created_at,u.username FROM posts p JOIN users u ON u.id=p.author_id WHERE p.visibility='PUBLIC' AND p.hidden=0 ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT 50").all() as { id: number; content: string; created_at: string; username: string }[]; const updated = posts[0] ? new Date(posts[0].created_at + "Z").toISOString() : new Date().toISOString();
const entries = posts.map((post) => `<entry><id>${origin}/posts/${post.id}</id><title>${escapeXml(`@${post.username} 的貼文`)}</title><link href="${origin}/posts/${post.id}"/><updated>${new Date(post.created_at + "Z").toISOString()}</updated><content type="text">${escapeXml(post.content)}</content></entry>`).join("");
return new Response(`<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom"><title>Mebbling</title><id>${origin}</id><link href="${origin}/atom.xml" rel="self"/><updated>${updated}</updated>${entries}</feed>`, { headers: { "Content-Type": "application/atom+xml; charset=utf-8", "Cache-Control": "public, max-age=300" } });
}
+8
View File
@@ -0,0 +1,8 @@
import ReactMarkdown from "react-markdown";
import rehypeHighlight from "rehype-highlight";
import remarkGfm from "remark-gfm";
/** Raw HTML is intentionally not enabled, so Memos content cannot inject script or markup. */
export function Markdown({ content, compact = false }: { content: string; compact?: boolean }) {
return <div className={`markdown${compact ? " markdown-compact" : ""}`}><ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>{content}</ReactMarkdown></div>;
}
+10
View File
@@ -0,0 +1,10 @@
import Link from "next/link";
import { Attachments } from "./attachments";
import { Markdown } from "./markdown";
export type PublicPost = { id: number; source_id: number | null; content: string; tags_json: string; attachments_json: string; created_at: string; username: string; name: string | null; source_base_url: string | null; comment_count: number; reaction_count: number };
export function PostCard({ post }: { post: PublicPost }) {
let tags: string[] = []; try { tags = JSON.parse(post.tags_json); } catch { /* Ignore malformed legacy tags. */ }
return <article className="card"><div className="space"><Link className="meta post-name-link" href={`/posts/${post.id}`}>@{post.username}{post.name ? ` · ${post.name}` : ""}</Link><span className="meta">{new Date(post.created_at).toLocaleString("zh-TW")}</span></div><Markdown content={post.content} compact /><Attachments json={post.attachments_json} sourceBaseUrl={post.source_base_url} compact /><div className="row">{tags.map((tag) => <Link className="tag" href={`/tags/${encodeURIComponent(tag)}`} key={tag}>#{tag}</Link>)}{post.source_id && <Link className="tag" href={`/sources/${post.source_id}`}></Link>}<Link href={`/posts/${post.id}`}> · 💬 {post.comment_count} 🙂 {post.reaction_count}</Link></div></article>;
}
+5 -5
View File
@@ -4,15 +4,15 @@ import { db } from "@/lib/db";
import { PublishForm } from "./publish-form";
import { WebhookControl } from "./webhook-control";
type Source = { id: number; name: string; base_url: string; sync_status: string; last_synced_at: string | null; last_error: string | null; webhook_secret_hash: string | null; last_webhook_at: string | null; owner_id: number; is_enabled: number; disabled_at: string | null };
type Source = { id: number; name: string; base_url: string; sync_status: string; last_synced_at: string | null; last_error: string | null; webhook_secret_hash: string | null; last_webhook_at: string | null; owner_id: number; is_enabled: number; disabled_at: string | null; sync_tags_json: string; sync_from: string | null; sync_to: string | null; sync_attachment_mode: "all" | "images" | "none"; remote_display_name: string | null; remote_avatar_url: string | null; last_connection_at: string | null; last_connection_error: string | null };
type Job = { id: number; kind: string; trigger: string | null; status: string; attempts: number; last_error: string | null; created_at: string; finished_at: string | null };
export const dynamic = "force-dynamic";
export default async function Dashboard({ searchParams }: { searchParams: Promise<{ error?: string; source?: string; sync?: string }> }) {
const query = await searchParams; const user = await getSession(); if (!user) redirect("/login");
const sourceRows = db.prepare("SELECT s.id,s.name,s.base_url,s.sync_status,s.last_synced_at,s.last_error,s.webhook_secret_hash,s.last_webhook_at,s.user_id AS owner_id,s.is_enabled,s.disabled_at FROM sources s JOIN source_members sm ON sm.source_id=s.id WHERE sm.user_id=? ORDER BY s.id DESC").all(user.id) as Source[];
const sources = sourceRows.map((source) => ({ ...source, members: db.prepare("SELECT u.username,u.id,sm.role FROM source_members sm JOIN users u ON u.id=sm.user_id WHERE sm.source_id=? ORDER BY sm.role DESC,u.username").all(source.id) as { username: string; id: number; role: string }[], jobs: db.prepare("SELECT id,kind,trigger,status,attempts,last_error,created_at,finished_at FROM sync_jobs WHERE source_id=? ORDER BY id DESC LIMIT 5").all(source.id) as Job[] }));
const sourceRows = db.prepare("SELECT s.id,s.name,s.base_url,s.sync_status,s.last_synced_at,s.last_error,s.webhook_secret_hash,s.last_webhook_at,s.user_id AS owner_id,s.is_enabled,s.disabled_at,s.sync_tags_json,s.sync_from,s.sync_to,s.sync_attachment_mode,s.remote_display_name,s.remote_avatar_url,s.last_connection_at,s.last_connection_error FROM sources s JOIN source_members sm ON sm.source_id=s.id WHERE sm.user_id=? ORDER BY s.id DESC").all(user.id) as Source[];
const sources = sourceRows.map((source) => ({ ...source, syncTags: (() => { try { return JSON.parse(source.sync_tags_json) as string[]; } catch { return []; } })(), members: db.prepare("SELECT u.username,u.id,sm.role FROM source_members sm JOIN users u ON u.id=sm.user_id WHERE sm.source_id=? ORDER BY sm.role DESC,u.username").all(source.id) as { username: string; id: number; role: string }[], jobs: db.prepare("SELECT id,kind,trigger,status,attempts,last_error,created_at,finished_at FROM sync_jobs WHERE source_id=? ORDER BY id DESC LIMIT 5").all(source.id) as Job[] }));
const publishSources = sources.filter((source) => source.is_enabled);
return <>
<h1></h1>
@@ -23,10 +23,10 @@ export default async function Dashboard({ searchParams }: { searchParams: Promis
<section className="card"><h2> Memos</h2><form action="/api/sources" method="post"><label><input name="name" required placeholder="我的 Memos" /></label><label>Memos <input name="baseUrl" type="url" required placeholder="https://memos.example.com" /></label><label>Personal Access Token<input name="token" type="password" required /></label><button></button></form><p className="muted">Token 使 Memos </p></section>
<section><h2></h2>{sources.map((source) => <article className="card" key={source.id}>
<div className="space"><strong>{source.name}</strong><span className="tag">{source.is_enabled ? source.sync_status : "disabled"}</span></div>
<p className="meta"> ID{source.id}<br />{source.base_url}<br />{source.members.map((member) => `${member.username}${member.role === "owner" ? "(建立者)" : ""}`).join("、")}<br />{source.last_synced_at || "尚未完成"}<br />Webhook{source.webhook_secret_hash ? (source.last_webhook_at ? `最近收到:${new Date(source.last_webhook_at + "Z").toLocaleString("zh-TW")}` : "已建立 URL,尚未收到呼叫") : "尚未建立 URL"}{!source.is_enabled && <><br />{source.disabled_at ? new Date(source.disabled_at + "Z").toLocaleString("zh-TW") : "是"}</>}{source.last_error && <><br /><span className="error">{source.last_error}</span></>}</p>
<p className="meta"> ID{source.id}<br />{source.base_url}{source.remote_display_name && <><br />Memos {source.remote_avatar_url && <img className="avatar" src={source.remote_avatar_url} alt="" />} {source.remote_display_name}</>}<br />{source.members.map((member) => `${member.username}${member.role === "owner" ? "(建立者)" : ""}`).join("、")}<br />{source.last_synced_at || "尚未完成"}<br />{source.last_connection_at ? `最近成功:${new Date(source.last_connection_at + "Z").toLocaleString("zh-TW")}` : "尚未測試"}<br />Webhook{source.webhook_secret_hash ? (source.last_webhook_at ? (Date.now() - new Date(source.last_webhook_at + "Z").getTime() > 7 * 24 * 60 * 60 * 1000 ? `警示:超過 7 天未收到(最近:${new Date(source.last_webhook_at + "Z").toLocaleString("zh-TW")}` : `健康(最近收到:${new Date(source.last_webhook_at + "Z").toLocaleString("zh-TW")}`) : "已建立 URL,尚未收到呼叫") : "尚未建立 URL"}{!source.is_enabled && <><br />{source.disabled_at ? new Date(source.disabled_at + "Z").toLocaleString("zh-TW") : "是"}</>}{source.last_error && <><br /><span className="error">{source.last_error}</span></>}{source.last_connection_error && <><br /><span className="error">{source.last_connection_error}</span></>}</p>
{source.owner_id === user.id ? <>
<WebhookControl sourceId={source.id} configured={Boolean(source.webhook_secret_hash)} />
<details><summary></summary><form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="rename" /><label><input name="name" defaultValue={source.name} required maxLength={80} /></label><button></button></form><form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="set-enabled" /><input type="hidden" name="enabled" value={source.is_enabled ? "0" : "1"} /><button className={source.is_enabled ? "danger" : ""}>{source.is_enabled ? "停用來源" : "啟用來源"}</button></form>{source.members.length > 1 && <form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="transfer" /><label><select name="username" required defaultValue=""> <option value="" disabled></option>{source.members.filter((member) => member.id !== user.id).map((member) => <option key={member.id} value={member.username}>{member.username}</option>)}</select></label><button></button></form>}<form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="delete" /><button className="danger"></button></form></details>
<details><summary></summary><form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="rename" /><label><input name="name" defaultValue={source.name} required maxLength={80} /></label><button></button></form><form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="set-sync-rules" /><label><input name="tags" defaultValue={source.syncTags.join(", ")} placeholder="旅行, 技術" /></label><div className="row"><label><input name="from" type="date" defaultValue={source.sync_from || ""} /></label><label><input name="to" type="date" defaultValue={source.sync_to || ""} /></label></div><label><select name="attachmentMode" defaultValue={source.sync_attachment_mode}><option value="all"></option><option value="images"></option><option value="none"></option></select></label><button></button></form><form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="test-connection" /><button> Memos </button></form><form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="set-enabled" /><input type="hidden" name="enabled" value={source.is_enabled ? "0" : "1"} /><button className={source.is_enabled ? "danger" : ""}>{source.is_enabled ? "停用來源" : "啟用來源"}</button></form>{source.members.length > 1 && <form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="transfer" /><label><select name="username" required defaultValue=""> <option value="" disabled></option>{source.members.filter((member) => member.id !== user.id).map((member) => <option key={member.id} value={member.username}>{member.username}</option>)}</select></label><button></button></form>}<form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="delete" /><button className="danger"></button></form></details>
</> : <form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="leave" /><button className="danger"></button></form>}
<form action="/api/sync" method="post"><input type="hidden" name="sourceId" value={source.id} /><button disabled={!source.is_enabled}>{source.is_enabled ? "立即同步" : "來源已停用"}</button></form>
<details><summary></summary>{source.jobs.length ? <ul className="job-list">{source.jobs.map((job) => <li key={job.id}><strong>{job.kind}</strong> · {job.trigger || "legacy"} · <span className="tag">{job.status}</span> · {job.attempts} <br /><span className="meta">{new Date(job.created_at + "Z").toLocaleString("zh-TW")}{job.finished_at && `;完成:${new Date(job.finished_at + "Z").toLocaleString("zh-TW")}`}</span>{job.last_error && <><br /><span className="error">{job.last_error}</span></>}</li>)}</ul> : <p className="muted"></p>}</details>
+4 -2
View File
@@ -1,8 +1,10 @@
import "./styles.css";
import Link from "next/link";
import { getSession } from "@/lib/auth";
export const metadata = { title: "Mebbling", description: "Your Memos hub" };
import { db } from "@/lib/db";
export const metadata = { title: "Mebbling", description: "聚合朋友公開筆記的 Memos Hub", alternates: { types: { "application/rss+xml": [{ url: "/rss.xml", title: "Mebbling RSS" }], "application/atom+xml": [{ url: "/atom.xml", title: "Mebbling Atom" }] } }, openGraph: { title: "Mebbling", description: "聚合朋友公開筆記的 Memos Hub", type: "website" } };
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const user = await getSession();
return <html lang="zh-Hant"><body><header><Link href="/" className="brand">Mebbling</Link><nav><Link href="/"></Link>{user ? <><Link href="/dashboard"></Link><Link href="/account"></Link>{user.role === "admin" && <Link href="/admin"></Link>}<form action="/api/auth/logout" method="post"><button></button></form></> : <><Link href="/login"></Link><Link href="/register"></Link></>}</nav></header><main>{children}</main></body></html>;
const unread = user ? Number((db.prepare("SELECT count(*) count FROM notifications WHERE user_id=? AND read_at IS NULL").get(user.id) as { count: number }).count) : 0;
return <html lang="zh-Hant"><body><header><Link href="/" className="brand">Mebbling</Link><nav><Link href="/"></Link>{user ? <><Link href="/reading"></Link><Link href="/notifications">{unread ? ` (${unread})` : ""}</Link><Link href="/dashboard"></Link><Link href="/account"></Link>{user.role === "admin" && <Link href="/admin"></Link>}<form action="/api/auth/logout" method="post"><button></button></form></> : <><Link href="/login"></Link><Link href="/register"></Link></>}</nav></header><main>{children}</main></body></html>;
}
+10
View File
@@ -0,0 +1,10 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import { db } from "@/lib/db";
export default async function NotificationsPage() {
const user = await getSession(); if (!user) redirect("/login");
const notifications = db.prepare("SELECT n.*,u.username AS actor_username FROM notifications n LEFT JOIN users u ON u.id=n.actor_id WHERE n.user_id=? ORDER BY n.created_at DESC LIMIT 100").all(user.id) as any[];
return <><div className="space"><h1></h1><form action="/api/notifications/read" method="post"><button></button></form></div>{notifications.length ? <ul className="reading-list">{notifications.map((item) => <li className={item.read_at ? "" : "unread"} key={item.id}><Link href={`/posts/${item.post_id}`}>{item.message}</Link><br /><span className="meta">{new Date(item.created_at + "Z").toLocaleString("zh-TW")}</span>{!item.read_at && <form action="/api/notifications/read" method="post"><input type="hidden" name="id" value={item.id} /><button></button></form>}</li>)}</ul> : <p className="muted"></p>}</>;
}
+17 -9
View File
@@ -1,11 +1,19 @@
import Link from "next/link"; import { db } from "@/lib/db"; import { Attachments } from "./components/attachments";
import Link from "next/link";
import { db } from "@/lib/db";
import { PostCard, type PublicPost } from "./components/post-card";
export const dynamic = "force-dynamic";
type Post = { id:number; content:string; tags_json:string; attachments_json:string; created_at:string; username:string; name:string|null; source_base_url:string|null; comment_count:number; reaction_count:number };
export default async function Home({ searchParams }: { searchParams: Promise<{ q?: string; tag?: string }> }) {
const query = await searchParams;
const q = query.q?.trim() || ""; const tag = query.tag?.trim() || "";
const where = ["p.visibility = 'PUBLIC'", "p.hidden = 0"]; const args: string[] = [];
if (q) { where.push("p.content LIKE ?"); args.push(`%${q}%`); } if (tag) { where.push("p.tags_json LIKE ?"); args.push(`%${JSON.stringify(tag).slice(1,-1)}%`); }
const posts = db.prepare(`SELECT p.*, u.username, s.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 ${where.join(" AND ")} ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT 100`).all(...args) as Post[];
return <><section className="space"><div><h1> Memos Hub</h1><p className="muted"></p></div><Link className="button" href="/dashboard"></Link></section><form className="row" method="get"><input name="q" defaultValue={q} placeholder="搜尋公開貼文"/><input name="tag" defaultValue={tag} placeholder="標籤"/><button></button></form>{posts.length ? posts.map(p=><article className="card" key={p.id}><div className="space"><Link className="meta post-name-link" href={`/posts/${p.id}`}>@{p.username}{p.name ? ` · ${p.name}` : ""}</Link><span className="meta">{new Date(p.created_at).toLocaleString("zh-TW")}</span></div><pre>{p.content}</pre><Attachments json={p.attachments_json} sourceBaseUrl={p.source_base_url} compact/><div className="row">{JSON.parse(p.tags_json).map((t:string)=><span className="tag" key={t}>#{t}</span>)}<Link href={`/posts/${p.id}`}>💬 {p.comment_count} 🙂 {p.reaction_count}</Link></div></article>) : <p className="muted"></p>}</>;
const pageSize = 20;
type Query = { q?: string; tag?: string; source?: string; author?: string; from?: string; to?: string; attachments?: string; page?: string };
export default async function Home({ searchParams }: { searchParams: Promise<Query> }) {
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)[] = [];
if (q) { where.push("p.content LIKE ?"); args.push(`%${q}%`); } 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 <> '[]'");
const joins = " FROM posts p 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 posts = db.prepare(`SELECT p.*,u.username,s.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 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>}</>;
}
+29 -3
View File
@@ -1,3 +1,29 @@
import { notFound, redirect } from "next/navigation"; import { db } from "@/lib/db"; import { getSession } from "@/lib/auth"; import { Attachments } from "@/app/components/attachments";
export const dynamic="force-dynamic";
export default async function PostPage({params}:{params:Promise<{id:string}>}){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 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;if(!post||post.hidden)notFound();const user=await getSession();if(post.visibility!=='PUBLIC'&&post.author_id!==user?.id)redirect('/');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[];return <article><p className="meta">@{post.username} · {post.name||'Hub'} · {new Date(post.created_at).toLocaleString('zh-TW')}</p><pre className="card">{post.content}</pre><Attachments json={post.attachments_json} sourceBaseUrl={post.source_base_url}/><section className="row">{reactions.map((r:any)=><span className="tag" key={r.emoji}>{r.emoji} {r.count}</span>)}{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>:<p></p>}{comments.map(c=><div className="card" key={c.id}><strong>@{c.username}</strong><p>{c.content}</p><span className="meta">{new Date(c.created_at).toLocaleString('zh-TW')}</span></div>)}</section></article>}
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { db } from "@/lib/db";
import { getSession } from "@/lib/auth";
import { Attachments } from "@/app/components/attachments";
import { Markdown } from "@/app/components/markdown";
export const dynamic = "force-dynamic";
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;
if (!post || post.hidden || post.visibility !== "PUBLIC") return { title: "找不到貼文" };
const description = post.content.replace(/\s+/g, " ").slice(0, 160);
return { title: `@${post.username} 的貼文|Mebbling`, description, openGraph: { title: `@${post.username}${post.name ? ` · ${post.name}` : ""}Mebbling`, description, type: "article" } };
}
export default async function PostPage({ params }: { params: Promise<{ id: string }> }) {
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;
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);
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 reactions = db.prepare("SELECT emoji,count(*) count FROM reactions WHERE post_id=? GROUP BY emoji").all(id) as any[];
return <article><p className="meta">@{post.username} · {post.remote_display_name || post.name || "Hub"} · {new Date(post.created_at).toLocaleString("zh-TW")}{post.remote_url && <> · <a href={post.remote_url} target="_blank" rel="noreferrer"> Memos </a></>}</p><section className="card"><Markdown content={post.content} /></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><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> : <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>;
}
+13
View File
@@ -0,0 +1,13 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import { db } from "@/lib/db";
type Item = { id: number; content: string; username: string; kind?: string; at: string };
function PostList({ items, empty }: { items: Item[]; empty: string }) { return items.length ? <ul className="reading-list">{items.map((item) => <li key={`${item.kind}-${item.id}`}><Link href={`/posts/${item.id}`}>{item.content.slice(0, 120) || "(空白貼文)"}</Link><br /><span className="meta">@{item.username} · {item.kind === "later" ? "稍後閱讀" : item.kind === "saved" ? "收藏" : "最近閱讀"} · {new Date(item.at + "Z").toLocaleString("zh-TW")}</span></li>)}</ul> : <p className="muted">{empty}</p>; }
export default async function ReadingPage() {
const user = await getSession(); if (!user) redirect("/login");
const saved = db.prepare("SELECT p.id,p.content,u.username,b.kind,b.created_at AS at FROM bookmarks b JOIN posts p ON p.id=b.post_id JOIN users u ON u.id=p.author_id WHERE b.user_id=? AND p.hidden=0 ORDER BY b.created_at DESC").all(user.id) as Item[];
const history = db.prepare("SELECT p.id,p.content,u.username,h.last_read_at AS at FROM reading_history h JOIN posts p ON p.id=h.post_id JOIN users u ON u.id=p.author_id WHERE h.user_id=? AND p.hidden=0 ORDER BY h.last_read_at DESC LIMIT 50").all(user.id) as Item[];
return <><h1></h1><section className="card"><h2></h2><PostList items={saved} empty="尚未收藏任何貼文。" /></section><section className="card"><h2></h2><PostList items={history} empty="尚無閱讀紀錄。" /></section></>;
}
+8
View File
@@ -0,0 +1,8 @@
import { db } from "@/lib/db";
const escapeXml = (value: string) => value.replace(/[<>&'\"]/g, (char) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", "'": "&apos;", '"': "&quot;" }[char] || char));
export async function GET() {
const origin = (process.env.NEXT_PUBLIC_APP_URL || "http://localhost:8088").replace(/\/$/, ""); const posts = db.prepare("SELECT p.id,p.content,p.created_at,u.username FROM posts p JOIN users u ON u.id=p.author_id WHERE p.visibility='PUBLIC' AND p.hidden=0 ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT 50").all() as { id: number; content: string; created_at: string; username: string }[];
const items = posts.map((post) => `<item><title>${escapeXml(`@${post.username} 的貼文`)}</title><link>${origin}/posts/${post.id}</link><guid>${origin}/posts/${post.id}</guid><description>${escapeXml(post.content.slice(0, 500))}</description><pubDate>${new Date(post.created_at + "Z").toUTCString()}</pubDate></item>`).join("");
return new Response(`<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>Mebbling</title><link>${origin}</link><description>公開 Memos Hub</description>${items}</channel></rss>`, { headers: { "Content-Type": "application/rss+xml; charset=utf-8", "Cache-Control": "public, max-age=300" } });
}
+11
View File
@@ -0,0 +1,11 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { db } from "@/lib/db";
import { PostCard, type PublicPost } from "@/app/components/post-card";
export const dynamic = "force-dynamic";
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 posts = db.prepare("SELECT p.*,u.username,s.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[];
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>{posts.map((post) => <PostCard key={post.id} post={post} />)}</>;
}
+1 -1
View File
@@ -1 +1 @@
:root{color-scheme:dark;font-family:ui-sans-serif,system-ui;background:#10131a;color:#edf1f8}*{box-sizing:border-box}body{margin:0}header{display:flex;justify-content:space-between;align-items:center;padding:1rem max(1.5rem,calc((100% - 1000px)/2));border-bottom:1px solid #293243;background:#151a23;position:sticky;top:0}main{width:min(900px,calc(100% - 2rem));margin:2rem auto}.brand{font-size:1.35rem;font-weight:700;color:#8ab4ff}nav{display:flex;gap:1rem;align-items:center}a{color:#bcd3ff;text-decoration:none}button,.button{background:#3778e5;color:#fff;border:0;border-radius:.5rem;padding:.55rem .8rem;cursor:pointer;font:inherit}button:hover,.button:hover{filter:brightness(1.1)}form{display:grid;gap:.8rem;max-width:580px}input,textarea,select{width:100%;padding:.65rem;border:1px solid #3a455a;border-radius:.45rem;background:#171d28;color:inherit}textarea{min-height:140px}.card{background:#171d28;border:1px solid #293243;border-radius:.75rem;padding:1rem;margin:.8rem 0}.muted{color:#aab4c5}.row{display:flex;gap:.7rem;align-items:center;flex-wrap:wrap}.space{display:flex;justify-content:space-between;gap:1rem}.error{color:#ff9d9d}.tag{background:#25314a;padding:.15rem .45rem;border-radius:.4rem;font-size:.85rem}pre{white-space:pre-wrap;font-family:inherit}.meta{font-size:.86rem;color:#aab4c5}.danger{background:#aa3746}.attachments{display:flex;flex-wrap:wrap;gap:.65rem;margin:.9rem 0}.attachment-image{display:block;max-width:min(100%,520px);padding:0;background:none;border:0;border-radius:.5rem;overflow:hidden}.attachment-image img{display:block;max-width:100%;max-height:520px;border-radius:.5rem;border:1px solid #3a455a}.attachment-image:hover img{border-color:#8ab4ff}.attachments-compact .attachment-image{max-width:220px}.attachments-compact .attachment-image img{max-height:220px;object-fit:cover}.attachment-file{padding:.45rem .65rem;border:1px solid #3a455a;border-radius:.45rem;background:#202838}.image-lightbox{position:fixed;z-index:100;inset:0;display:grid;place-items:center;padding:2rem;background:rgb(0 0 0 / .88);cursor:zoom-out}.image-lightbox img{display:block;max-width:100%;max-height:100%;object-fit:contain;cursor:default}.image-lightbox-close{position:absolute;top:1rem;right:1rem;width:2.5rem;height:2.5rem;padding:0;border-radius:50%;font-size:2rem;line-height:1;background:#25314a}
:root{color-scheme:dark;font-family:ui-sans-serif,system-ui;background:#10131a;color:#edf1f8}*{box-sizing:border-box}body{margin:0}header{display:flex;justify-content:space-between;align-items:center;padding:1rem max(1.5rem,calc((100% - 1000px)/2));border-bottom:1px solid #293243;background:#151a23;position:sticky;top:0;z-index:10}main{width:min(900px,calc(100% - 2rem));margin:2rem auto}.brand{font-size:1.35rem;font-weight:700;color:#8ab4ff}nav{display:flex;gap:1rem;align-items:center;flex-wrap:wrap}a{color:#bcd3ff;text-decoration:none}button,.button{background:#3778e5;color:#fff;border:0;border-radius:.5rem;padding:.55rem .8rem;cursor:pointer;font:inherit}button:hover,.button:hover{filter:brightness(1.1)}form{display:grid;gap:.8rem;max-width:580px}input,textarea,select{width:100%;padding:.65rem;border:1px solid #3a455a;border-radius:.45rem;background:#171d28;color:inherit}textarea{min-height:140px}.card{background:#171d28;border:1px solid #293243;border-radius:.75rem;padding:1rem;margin:.8rem 0}.muted{color:#aab4c5}.row{display:flex;gap:.7rem;align-items:center;flex-wrap:wrap}.space{display:flex;justify-content:space-between;gap:1rem}.error{color:#ff9d9d}.tag{background:#25314a;padding:.15rem .45rem;border-radius:.4rem;font-size:.85rem}.meta{font-size:.86rem;color:#aab4c5}.danger{background:#aa3746}.attachments{display:flex;flex-wrap:wrap;gap:.65rem;margin:.9rem 0}.attachment-image{display:block;max-width:min(100%,520px);padding:0;background:none;border:0;border-radius:.5rem;overflow:hidden}.attachment-image img{display:block;max-width:100%;max-height:520px;border-radius:.5rem;border:1px solid #3a455a}.attachment-image:hover img{border-color:#8ab4ff}.attachments-compact .attachment-image{max-width:220px}.attachments-compact .attachment-image img{max-height:220px;object-fit:cover}.attachment-file{padding:.45rem .65rem;border:1px solid #3a455a;border-radius:.45rem;background:#202838}.image-lightbox{position:fixed;z-index:100;inset:0;display:grid;place-items:center;padding:2rem;background:rgb(0 0 0 / .88);cursor:zoom-out}.image-lightbox img{display:block;max-width:100%;max-height:100%;object-fit:contain;cursor:default}.image-lightbox-close{position:absolute;top:1rem;right:1rem;width:2.5rem;height:2.5rem;padding:0;border-radius:50%;font-size:2rem;line-height:1;background:#25314a}.markdown{line-height:1.7;overflow-wrap:anywhere}.markdown>*:first-child{margin-top:0}.markdown>*:last-child{margin-bottom:0}.markdown pre{overflow:auto;padding:1rem;border-radius:.5rem;background:#0c1017}.markdown code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}.markdown :not(pre)>code{padding:.1rem .3rem;border-radius:.25rem;background:#25314a}.markdown blockquote{margin-left:0;padding-left:1rem;border-left:3px solid #5278ba;color:#c1cad8}.markdown table{border-collapse:collapse;display:block;overflow:auto}.markdown th,.markdown td{padding:.4rem .6rem;border:1px solid #3a455a}.markdown-compact{max-height:18rem;overflow:hidden;mask-image:linear-gradient(#000 85%,transparent)}.search-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));max-width:none;margin:1rem 0}.search-form label{display:grid;gap:.3rem}.search-form .check{display:flex;align-items:center;gap:.4rem}.search-form .check input{width:auto}.pagination{display:flex;justify-content:center;gap:1rem;align-items:center;margin:2rem 0}.reading-list,.job-list{list-style:none;padding:0;display:grid;gap:.7rem}.reading-list li,.job-list li{padding:.8rem;border:1px solid #293243;border-radius:.5rem}.unread{border-left:3px solid #8ab4ff!important}@media (max-width:700px){header{align-items:flex-start;flex-direction:column}.search-form{grid-template-columns:1fr 1fr}.search-form button{grid-column:span 2}}
+11
View File
@@ -0,0 +1,11 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { db } from "@/lib/db";
import { PostCard, type PublicPost } from "@/app/components/post-card";
export const dynamic = "force-dynamic";
export default async function TagPage({ params }: { params: Promise<{ tag: string }> }) {
const { tag: encoded } = await params; const tag = decodeURIComponent(encoded).trim(); if (!tag) notFound();
const posts = db.prepare("SELECT p.*,u.username,s.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} />)}</>;
}
+31
View File
@@ -19,6 +19,8 @@ CREATE TABLE IF NOT EXISTS sources (
name TEXT NOT NULL, base_url TEXT NOT NULL, token_encrypted TEXT NOT NULL, remote_user TEXT,
webhook_supported INTEGER NOT NULL DEFAULT 0, sync_status TEXT NOT NULL DEFAULT 'pending', last_synced_at TEXT, last_error TEXT,
is_enabled INTEGER NOT NULL DEFAULT 1, disabled_at TEXT,
sync_tags_json TEXT NOT NULL DEFAULT '[]', sync_from TEXT, sync_to TEXT, sync_attachment_mode TEXT NOT NULL DEFAULT 'all',
remote_display_name TEXT, remote_avatar_url TEXT, last_connection_at TEXT, last_connection_error TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id, base_url)
);
CREATE TABLE IF NOT EXISTS posts (
@@ -26,6 +28,7 @@ CREATE TABLE IF NOT EXISTS posts (
author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, remote_memo_name TEXT, content TEXT NOT NULL,
visibility TEXT NOT NULL DEFAULT 'PUBLIC', tags_json TEXT NOT NULL DEFAULT '[]', attachments_json TEXT NOT NULL DEFAULT '[]',
origin TEXT NOT NULL DEFAULT 'memos', remote_created_at TEXT, remote_updated_at TEXT, sync_status TEXT NOT NULL DEFAULT 'synced',
remote_url TEXT,
hidden INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(source_id, remote_memo_name)
);
@@ -54,8 +57,26 @@ CREATE TABLE IF NOT EXISTS source_members (
role TEXT NOT NULL DEFAULT 'member', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(source_id, user_id)
);
CREATE TABLE IF NOT EXISTS bookmarks (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
kind TEXT NOT NULL DEFAULT 'saved', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(user_id, post_id)
);
CREATE TABLE IF NOT EXISTS reading_history (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
last_read_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(user_id, post_id)
);
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
actor_id INTEGER REFERENCES users(id) ON DELETE SET NULL, post_id INTEGER REFERENCES posts(id) ON DELETE CASCADE,
type TEXT NOT NULL, message TEXT NOT NULL, read_at TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS posts_public_idx ON posts(visibility, hidden, created_at DESC);
CREATE INDEX IF NOT EXISTS sync_jobs_idx ON sync_jobs(status, run_after);
CREATE INDEX IF NOT EXISTS notifications_user_idx ON notifications(user_id, read_at, created_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS source_remote_identity_unique ON sources(base_url, remote_user) WHERE remote_user IS NOT NULL;
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
@@ -77,6 +98,16 @@ applyColumnMigration(4, "sources", "disabled_at", "ALTER TABLE sources ADD COLUM
applyColumnMigration(5, "sync_jobs", "trigger", "ALTER TABLE sync_jobs ADD COLUMN trigger TEXT NOT NULL DEFAULT 'manual'");
applyColumnMigration(6, "sync_jobs", "started_at", "ALTER TABLE sync_jobs ADD COLUMN started_at TEXT");
applyColumnMigration(7, "sync_jobs", "finished_at", "ALTER TABLE sync_jobs ADD COLUMN finished_at TEXT");
db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES(8)").run();
applyColumnMigration(9, "sources", "sync_tags_json", "ALTER TABLE sources ADD COLUMN sync_tags_json TEXT NOT NULL DEFAULT '[]'");
applyColumnMigration(10, "sources", "sync_from", "ALTER TABLE sources ADD COLUMN sync_from TEXT");
applyColumnMigration(11, "sources", "sync_to", "ALTER TABLE sources ADD COLUMN sync_to TEXT");
applyColumnMigration(12, "sources", "sync_attachment_mode", "ALTER TABLE sources ADD COLUMN sync_attachment_mode TEXT NOT NULL DEFAULT 'all'");
applyColumnMigration(13, "sources", "remote_display_name", "ALTER TABLE sources ADD COLUMN remote_display_name TEXT");
applyColumnMigration(14, "sources", "remote_avatar_url", "ALTER TABLE sources ADD COLUMN remote_avatar_url TEXT");
applyColumnMigration(15, "sources", "last_connection_at", "ALTER TABLE sources ADD COLUMN last_connection_at TEXT");
applyColumnMigration(16, "sources", "last_connection_error", "ALTER TABLE sources ADD COLUMN last_connection_error TEXT");
applyColumnMigration(17, "posts", "remote_url", "ALTER TABLE posts ADD COLUMN remote_url TEXT");
const admin = process.env.ADMIN_USERNAME;
const adminPassword = process.env.ADMIN_PASSWORD;
+17 -4
View File
@@ -1,4 +1,6 @@
export type MemosMemo = { name: string; content: string; visibility: string; createTime?: string; updateTime?: string; tags?: string[]; attachments?: unknown[]; resources?: unknown[] };
export type MemosMemo = { name: string; content: string; visibility: string; createTime?: string; updateTime?: string; tags?: string[]; attachments?: { type?: string }[]; resources?: { type?: string }[] };
export type MemosIdentity = { name: string; username?: string; nickname?: string; avatarUrl?: string; avatar?: string };
export type MemosSyncRules = { tags?: string[]; from?: string | null; to?: string | null; attachmentMode?: "all" | "images" | "none" };
const base = (url: string) => url.replace(/\/+$/, "") + "/api/v1";
async function request(url: string, token: string, init?: RequestInit) {
const res = await fetch(url, { ...init, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init?.headers || {}) }, cache: "no-store" });
@@ -6,14 +8,25 @@ async function request(url: string, token: string, init?: RequestInit) {
}
export async function verifyMemos(baseUrl: string, token: string) { await request(`${base(baseUrl)}/memos?pageSize=1`, token); }
export async function getMemosIdentity(baseUrl: string, token: string) {
const user = await (await request(`${base(baseUrl)}/auth/status`, token, { method: "POST", body: "{}" })).json() as { name: string; username?: string };
const user = await (await request(`${base(baseUrl)}/auth/status`, token, { method: "POST", body: "{}" })).json() as MemosIdentity;
if (!user.name) throw new Error("Memos did not return an account identity");
return user;
}
export async function listMemos(baseUrl: string, token: string) {
export function memoUrl(baseUrl: string, memoName: string) { const id = memoName.split("/").at(-1); return id ? `${baseUrl.replace(/\/$/, "")}/m/${encodeURIComponent(id)}` : null; }
export async function listMemos(baseUrl: string, token: string, rules: MemosSyncRules = {}) {
const all: MemosMemo[] = []; let pageToken = "";
do { const res = await request(`${base(baseUrl)}/memos?pageSize=100${pageToken ? `&pageToken=${encodeURIComponent(pageToken)}` : ""}`, token); const data = await res.json(); all.push(...(data.memos || [])); pageToken = data.nextPageToken || ""; } while (pageToken);
return all.filter((memo) => memo.visibility === "PUBLIC");
const tags = rules.tags?.filter(Boolean) || []; const mode = rules.attachmentMode || "all";
return all.filter((memo) => {
if (memo.visibility !== "PUBLIC") return false;
if (tags.length && !tags.every((tag) => memo.tags?.includes(tag))) return false;
const created = memo.createTime?.slice(0, 10); if (rules.from && (!created || created < rules.from)) return false; if (rules.to && (!created || created > rules.to)) return false;
return true;
}).map((memo) => {
if (mode === "all") return memo;
const onlyImages = <T extends { type?: string }>(items: T[] | undefined) => mode === "none" ? [] : (items || []).filter((item) => item.type?.startsWith("image/"));
return { ...memo, attachments: onlyImages(memo.attachments), resources: onlyImages(memo.resources) };
});
}
export async function createMemo(baseUrl: string, token: string, memo: Pick<MemosMemo, "content" | "visibility"> & { attachments?: unknown[]; resources?: unknown[] }) {
return (await request(`${base(baseUrl)}/memos`, token, { method: "POST", body: JSON.stringify({ state: "NORMAL", ...memo }) })).json() as Promise<MemosMemo>;
+6
View File
@@ -0,0 +1,6 @@
import { db } from "@/lib/db";
export function notify(userId: number, actorId: number, postId: number, type: "comment" | "reaction", message: string) {
if (userId === actorId) return;
db.prepare("INSERT INTO notifications(user_id,actor_id,post_id,type,message) VALUES(?,?,?,?,?)").run(userId, actorId, postId, type, message);
}
+1586 -4
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mebbling",
"version": "0.2.0",
"version": "0.4.0",
"description": "",
"private": true,
"scripts": {
@@ -16,10 +16,14 @@
"dependencies": {
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.11.1",
"highlight.js": "^11.11.1",
"jose": "^6.2.3",
"next": "^15.5.20",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-markdown": "^10.1.0",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"tsx": "^4.23.1",
"zod": "^4.4.3"
},
+6 -1
View File
@@ -14,12 +14,17 @@ after(() => { database?.close(); rmSync(databasePath, { force: true }); rmSync(`
test("applies tracked migrations and deduplicates active pull jobs", async () => {
const { db } = await import("../lib/db"); database = db;
const { queuePull } = await import("../lib/sync");
const { notify } = await import("../lib/notifications");
const migrations = db.prepare("SELECT version FROM schema_migrations ORDER BY version").all() as { version: number }[];
assert.deepEqual(migrations.map((item) => item.version), [1, 2, 3, 4, 5, 6, 7]);
assert.deepEqual(migrations.map((item) => item.version), Array.from({ length: 17 }, (_, index) => index + 1));
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);
assert.equal(queuePull(sourceId, "manual"), true);
assert.equal(queuePull(sourceId, "webhook", { event: "memo.updated" }), false);
const jobs = db.prepare("SELECT kind,trigger,status FROM sync_jobs WHERE source_id=?").all(sourceId) as { kind: string; trigger: string; status: string }[];
assert.deepEqual(jobs, [{ kind: "pull", trigger: "manual", status: "queued" }]);
const actorId = Number(db.prepare("INSERT INTO users(username,password_hash) VALUES('actor-test','hash')").run().lastInsertRowid);
const postId = Number(db.prepare("INSERT INTO posts(author_id,content) VALUES(?,?)").run(userId, "Notification test").lastInsertRowid);
notify(userId, actorId, postId, "comment", "commented"); notify(userId, userId, postId, "reaction", "ignored");
assert.deepEqual(db.prepare("SELECT type,message FROM notifications WHERE user_id=?").all(userId), [{ type: "comment", message: "commented" }]);
});
+9 -7
View File
@@ -2,23 +2,25 @@ import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { db } from "../lib/db";
import { decrypt } from "../lib/crypto";
import { createMemo, createRemoteFile, listMemos, setMemoAttachments } from "../lib/memos";
import { createMemo, createRemoteFile, getMemosIdentity, listMemos, memoUrl, setMemoAttachments } from "../lib/memos";
type Source = { id: number; user_id: number; base_url: string; token_encrypted: string; is_enabled: number };
type Source = { id: number; user_id: number; base_url: string; token_encrypted: string; is_enabled: number; sync_tags_json: string; sync_from: string | null; sync_to: string | null; sync_attachment_mode: "all" | "images" | "none" };
type Job = { id: number; source_id: number; kind: "pull" | "push"; payload_json: string | null; attempts: number };
function upsertRemote(source: Source, memo: any) {
const tags = JSON.stringify(memo.tags || []), attachments = JSON.stringify(memo.attachments || memo.resources || []);
db.prepare(`INSERT INTO posts(source_id,author_id,remote_memo_name,content,visibility,tags_json,attachments_json,origin,remote_created_at,remote_updated_at,sync_status,hidden) VALUES(?,?,?,?,?,?,?,?,?,?, 'synced',0) ON CONFLICT(source_id,remote_memo_name) DO UPDATE SET content=excluded.content,visibility=excluded.visibility,tags_json=excluded.tags_json,attachments_json=excluded.attachments_json,remote_updated_at=excluded.remote_updated_at,hidden=0,updated_at=CURRENT_TIMESTAMP`).run(source.id, source.user_id, memo.name, memo.content, memo.visibility, tags, attachments, "memos", memo.createTime || null, memo.updateTime || null);
db.prepare(`INSERT INTO posts(source_id,author_id,remote_memo_name,content,visibility,tags_json,attachments_json,origin,remote_created_at,remote_updated_at,sync_status,hidden,remote_url) VALUES(?,?,?,?,?,?,?,?,?,?, 'synced',0,?) ON CONFLICT(source_id,remote_memo_name) DO UPDATE SET content=excluded.content,visibility=excluded.visibility,tags_json=excluded.tags_json,attachments_json=excluded.attachments_json,remote_updated_at=excluded.remote_updated_at,remote_url=excluded.remote_url,hidden=0,updated_at=CURRENT_TIMESTAMP`).run(source.id, source.user_id, memo.name, memo.content, memo.visibility, tags, attachments, "memos", memo.createTime || null, memo.updateTime || null, memoUrl(source.base_url, memo.name));
}
async function pull(source: Source) {
const memos = await listMemos(source.base_url, decrypt(source.token_encrypted));
const token = decrypt(source.token_encrypted); const rules = { tags: JSON.parse(source.sync_tags_json || "[]") as string[], from: source.sync_from, to: source.sync_to, attachmentMode: source.sync_attachment_mode };
const [memos, identity] = await Promise.all([listMemos(source.base_url, token, rules), getMemosIdentity(source.base_url, token)]);
for (const memo of memos) upsertRemote(source, memo);
const names = memos.map((memo) => memo.name);
if (names.length) { const placeholders = names.map(() => "?").join(","); db.prepare(`UPDATE posts SET hidden=1,updated_at=CURRENT_TIMESTAMP WHERE source_id=? AND remote_memo_name IS NOT NULL AND remote_memo_name NOT IN (${placeholders})`).run(source.id, ...names); }
else db.prepare("UPDATE posts SET hidden=1,updated_at=CURRENT_TIMESTAMP WHERE source_id=? AND remote_memo_name IS NOT NULL").run(source.id);
db.prepare("UPDATE sources SET sync_status='synced',last_synced_at=CURRENT_TIMESTAMP,last_error=NULL WHERE id=?").run(source.id);
const avatar = identity.avatarUrl || identity.avatar || null; const avatarUrl = avatar?.startsWith("/") ? `${source.base_url.replace(/\/$/, "")}${avatar}` : avatar;
db.prepare("UPDATE sources SET sync_status='synced',last_synced_at=CURRENT_TIMESTAMP,last_error=NULL,last_connection_at=CURRENT_TIMESTAMP,last_connection_error=NULL,remote_display_name=?,remote_avatar_url=? WHERE id=?").run(identity.nickname || identity.username || identity.name, avatarUrl, source.id);
}
async function push(source: Source, payload: any) {
@@ -33,7 +35,7 @@ async function push(source: Source, payload: any) {
}
const memo = await createMemo(source.base_url, token, { content: post.content, visibility: post.visibility, resources });
if (attachments.length) await setMemoAttachments(source.base_url, token, memo.name, attachments);
db.prepare("UPDATE posts SET remote_memo_name=?,remote_created_at=?,remote_updated_at=?,sync_status='synced',updated_at=CURRENT_TIMESTAMP WHERE id=?").run(memo.name, memo.createTime || null, memo.updateTime || null, post.id);
db.prepare("UPDATE posts SET remote_memo_name=?,remote_created_at=?,remote_updated_at=?,remote_url=?,sync_status='synced',updated_at=CURRENT_TIMESTAMP WHERE id=?").run(memo.name, memo.createTime || null, memo.updateTime || null, memoUrl(source.base_url, memo.name), post.id);
}
async function run() {
@@ -49,7 +51,7 @@ async function run() {
} catch (error) {
const message = error instanceof Error ? error.message : "Sync failure"; const exhausted = job.attempts + 1 >= 5;
db.prepare("UPDATE sync_jobs SET status=?,last_error=?,finished_at=CASE WHEN ? THEN CURRENT_TIMESTAMP ELSE NULL END,run_after=CASE WHEN ? THEN run_after ELSE datetime('now','+5 minutes') END WHERE id=?").run(exhausted ? "failed" : "queued", message, exhausted ? 1 : 0, exhausted ? 1 : 0, job.id);
db.prepare("UPDATE sources SET sync_status='error',last_error=? WHERE id=?").run(message, source.id);
db.prepare("UPDATE sources SET sync_status='error',last_error=?,last_connection_error=? WHERE id=?").run(message, message, source.id);
}
}