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 ? : 請先登入以留言或表情回應。
}{comments.map((comment) => @{comment.username} {comment.content}
{new Date(comment.created_at).toLocaleString("zh-TW")} )}
;
diff --git a/app/sources/[id]/page.tsx b/app/sources/[id]/page.tsx
index dc2cb33..3f21b6b 100644
--- a/app/sources/[id]/page.tsx
+++ b/app/sources/[id]/page.tsx
@@ -5,7 +5,7 @@ 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 FROM sources WHERE id=?").get(id) as { id: number; name: string; base_url: string } | 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.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 <> ← 探索
{source.name} {source.base_url} · {posts.length} 篇公開貼文
{posts.map((post) => )}>;
+ return <> ← 探索
{source.name} {source.remote_avatar_url && } {source.remote_display_name || "Memos"} {source.base_url} · {posts.length} 篇公開貼文
{posts.map((post) => )}>;
}
diff --git a/lib/db.ts b/lib/db.ts
index 55f064b..290dd21 100644
--- a/lib/db.ts
+++ b/lib/db.ts
@@ -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)
);
@@ -96,6 +99,15 @@ applyColumnMigration(5, "sync_jobs", "trigger", "ALTER TABLE sync_jobs ADD COLUM
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;
diff --git a/lib/memos.ts b/lib/memos.ts
index 042a1f1..2cddf9d 100644
--- a/lib/memos.ts
+++ b/lib/memos.ts
@@ -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 = (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 & { attachments?: unknown[]; resources?: unknown[] }) {
return (await request(`${base(baseUrl)}/memos`, token, { method: "POST", body: JSON.stringify({ state: "NORMAL", ...memo }) })).json() as Promise;
diff --git a/package-lock.json b/package-lock.json
index e2c4e4c..84a56ec 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "mebbling",
- "version": "0.3.0",
+ "version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mebbling",
- "version": "0.3.0",
+ "version": "0.4.0",
"license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {
"bcryptjs": "^3.0.3",
diff --git a/package.json b/package.json
index 478fac5..13f0968 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "mebbling",
- "version": "0.3.0",
+ "version": "0.4.0",
"description": "",
"private": true,
"scripts": {
diff --git a/tests/sync.test.ts b/tests/sync.test.ts
index 4f5c39d..eb82ffe 100644
--- a/tests/sync.test.ts
+++ b/tests/sync.test.ts
@@ -16,7 +16,7 @@ test("applies tracked migrations and deduplicates active pull jobs", async () =>
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, 8]);
+ 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);
diff --git a/worker/index.ts b/worker/index.ts
index 8d42154..62e015f 100644
--- a/worker/index.ts
+++ b/worker/index.ts
@@ -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);
}
}