From ca3f706cc1e20470bbac7111aaa540fb3980eb01 Mon Sep 17 00:00:00 2001 From: tangsongdayo Date: Sun, 19 Jul 2026 03:30:19 +0800 Subject: [PATCH] feat: complete v0.4 Memos integration --- CHANGELOG.md | 12 ++++++++++++ README.md | 8 +++++++- app/api/sources/[id]/manage/route.ts | 18 +++++++++++++++++- app/dashboard/page.tsx | 10 +++++----- app/posts/[id]/page.tsx | 4 ++-- app/sources/[id]/page.tsx | 4 ++-- lib/db.ts | 12 ++++++++++++ lib/memos.ts | 21 +++++++++++++++++---- package-lock.json | 4 ++-- package.json | 2 +- tests/sync.test.ts | 2 +- worker/index.ts | 16 +++++++++------- 12 files changed, 87 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c5fbbc..62c0e04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ 本專案遵循 [Semantic Versioning](https://semver.org/lang/zh-TW/);版本 `0.x` 表示功能仍可能調整。 +## [0.4.0] - Unreleased + +### Added + +- 每個來源可依標籤、日期及附件類型設定同步範圍。 +- Memos 遠端貼文連結、遠端 profile、Token 連線測試與連線狀態。 +- Webhook 健康狀態與長時間未收到 webhook 警示。 + +### Changed + +- Pull 同步會以來源規則決定鏡像內容;遠端更新會更新 Hub 鏡像,遠端刪除或移出規則範圍會隱藏鏡像貼文。 + ## [0.3.0] - Unreleased ### Added diff --git a/README.md b/README.md index 3b52358..32c9d68 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 自架的 Memos 公開貼文 Hub。將朋友各自 Memos 中的公開貼文集中展示,同時保留 Hub 內的留言、表情回應與發文功能。 -目前開發版本:`v0.2.0`(尚未發布)。版本變更請見 [CHANGELOG.md](CHANGELOG.md)。 +目前開發版本:`v0.4.0`(尚未發布)。版本變更請見 [CHANGELOG.md](CHANGELOG.md)。 ## 功能 @@ -18,6 +18,8 @@ - 內建 SQLite 與附件備份腳本,以及可追蹤的 schema migration。 - 可依內容、標籤、來源、作者、日期與附件篩選公開貼文,並支援分頁、標籤/來源頁、RSS 與 Atom。 - 提供安全 Markdown、程式碼高亮、收藏、稍後閱讀、閱讀紀錄與互動通知。 +- 每個來源可設定標籤、日期與附件類型同步規則,並在貼文頁保留可回到原始 Memos 貼文的連結。 +- 控制台可測試 Token/Memos 連線、顯示遠端名稱與頭像,並提示 webhook 長時間未收到事件的狀態。 ## 快速啟動(WSL/Docker) @@ -75,6 +77,8 @@ Web 接收使用者操作和 webhook,將同步需求寫入 SQLite 的 `sync_jo - **Push**:把 Hub 建立的貼文與本機附件上傳/回寫到選定的 Memos 來源。 - **排程校正**:依 `SYNC_INTERVAL_MINUTES` 定期建立 Pull 工作,避免 webhook 遺漏造成資料不同步。 +在來源管理中設定的同步規則會套用到 Pull:多個標籤採「同時符合」篩選,日期以 Memos 貼文建立日為準;附件可選擇全部保留、只保留圖片,或不同步附件。貼文後續在遠端被修改、刪除、改為非公開或不再符合規則時,下一次 Pull 會更新或隱藏 Hub 鏡像。 + ## Webhook 設定與驗證 1. 來源建立者登入「控制台」。 @@ -83,6 +87,8 @@ Web 接收使用者操作和 webhook,將同步需求寫入 SQLite 的 `sync_jo 4. 在 Memos 發布或更新一篇公開貼文。 5. 回到 Hub:顯示「最近收到」代表 Hub 確實收到 webhook;「上次同步」更新則代表同步已完成。 +若 webhook 已設定但超過 7 天未收到事件,控制台會顯示提醒;這不會中斷定期校正同步。來源建立者也可按「測試 Memos 連線」檢查 Token 是否有效,同時更新遠端顯示名稱與頭像。 + 網址格式如下;`來源 ID` 與 `隨機密鑰` 都由系統產生,請勿自行修改: ```text diff --git a/app/api/sources/[id]/manage/route.ts b/app/api/sources/[id]/manage/route.ts index fb20bac..aeab9bc 100644 --- a/app/api/sources/[id]/manage/route.ts +++ b/app/api/sources/[id]/manage/route.ts @@ -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); diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 18caf91..03151e9 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -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 <>

控制台

@@ -23,10 +23,10 @@ export default async function Dashboard({ searchParams }: { searchParams: Promis

連接 Memos

Token 會使用伺服器金鑰加密保存。同一個 Memos 帳號與網址會自動共用來源,不會建立重複貼文。

已連接來源

{sources.map((source) =>
{source.name}{source.is_enabled ? source.sync_status : "disabled"}
-

來源 ID:{source.id}
{source.base_url}
成員:{source.members.map((member) => `${member.username}${member.role === "owner" ? "(建立者)" : ""}`).join("、")}
上次同步:{source.last_synced_at || "尚未完成"}
Webhook:{source.webhook_secret_hash ? (source.last_webhook_at ? `最近收到:${new Date(source.last_webhook_at + "Z").toLocaleString("zh-TW")}` : "已建立 URL,尚未收到呼叫") : "尚未建立 URL"}{!source.is_enabled && <>
已停用:{source.disabled_at ? new Date(source.disabled_at + "Z").toLocaleString("zh-TW") : "是"}}{source.last_error && <>
{source.last_error}}

+

來源 ID:{source.id}
{source.base_url}{source.remote_display_name && <>
Memos 帳號:{source.remote_avatar_url && } {source.remote_display_name}}
成員:{source.members.map((member) => `${member.username}${member.role === "owner" ? "(建立者)" : ""}`).join("、")}
上次同步:{source.last_synced_at || "尚未完成"}
連線:{source.last_connection_at ? `最近成功:${new Date(source.last_connection_at + "Z").toLocaleString("zh-TW")}` : "尚未測試"}
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 && <>
已停用:{source.disabled_at ? new Date(source.disabled_at + "Z").toLocaleString("zh-TW") : "是"}}{source.last_error && <>
同步:{source.last_error}}{source.last_connection_error && <>
連線:{source.last_connection_error}}

{source.owner_id === user.id ? <> -
來源管理
{source.members.length > 1 &&
}
+
來源管理
{source.members.length > 1 &&
}
:
}
最近同步工作{source.jobs.length ?
    {source.jobs.map((job) =>
  • {job.kind} · {job.trigger || "legacy"} · {job.status} · 嘗試 {job.attempts} 次
    建立:{new Date(job.created_at + "Z").toLocaleString("zh-TW")}{job.finished_at && `;完成:${new Date(job.finished_at + "Z").toLocaleString("zh-TW")}`}{job.last_error && <>
    {job.last_error}}
  • )}
:

尚無同步工作。

}
diff --git a/app/posts/[id]/page.tsx b/app/posts/[id]/page.tsx index 6f05b06..fd5698d 100644 --- a/app/posts/[id]/page.tsx +++ b/app/posts/[id]/page.tsx @@ -16,13 +16,13 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin 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; + 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

@{post.username} · {post.name || "Hub"} · {new Date(post.created_at).toLocaleString("zh-TW")}

+ return

@{post.username} · {post.remote_display_name || post.name || "Hub"} · {new Date(post.created_at).toLocaleString("zh-TW")}{post.remote_url && <> · 在 Memos 開啟}

{reactions.map((reaction: any) => {reaction.emoji} {reaction.count})}{user && <>
}{user && ["👍", "❤️", "🎉", "🤔"].map((emoji) =>
)}

留言

{user ?