feat: complete v0.4 Memos integration

This commit is contained in:
2026-07-19 03:30:19 +08:00
parent 9ab09159e3
commit ca3f706cc1
12 changed files with 87 additions and 26 deletions
+12
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)
);
@@ -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;
+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>;