ofw-mcp 2.5.0 → 2.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +11 -0
- package/dist/auth-password.js +8 -1
- package/dist/bundle.js +878 -519
- package/dist/cache/node.js +85 -0
- package/dist/cache/store.js +480 -0
- package/dist/client.js +22 -4
- package/dist/config.js +21 -0
- package/dist/index.js +13 -2
- package/dist/ofw-auth.js +26 -0
- package/dist/sync.js +241 -60
- package/dist/tools/attachments.js +66 -0
- package/dist/tools/messages.js +67 -81
- package/package.json +12 -3
- package/server.json +2 -2
- package/skills/ofw/SKILL.md +2 -0
- package/dist/cache.js +0 -345
package/dist/bundle.js
CHANGED
|
@@ -38392,8 +38392,13 @@ async function loginWithPassword(username, password) {
|
|
|
38392
38392
|
}
|
|
38393
38393
|
const contentType = response.headers.get("content-type") ?? "";
|
|
38394
38394
|
if (!contentType.includes("application/json")) {
|
|
38395
|
+
if (contentType.includes("text/html")) {
|
|
38396
|
+
throw new Error(
|
|
38397
|
+
"OFW login failed \u2014 your OurFamilyWizard email or password was not accepted. Check them and try again."
|
|
38398
|
+
);
|
|
38399
|
+
}
|
|
38395
38400
|
const body = await response.text();
|
|
38396
|
-
throw new Error(`OFW login returned unexpected response (${contentType}): ${body.substring(0, 200)}`);
|
|
38401
|
+
throw new Error(`OFW login returned unexpected response (${contentType || "no content-type"}): ${body.substring(0, 200)}`);
|
|
38397
38402
|
}
|
|
38398
38403
|
const data = await response.json();
|
|
38399
38404
|
return {
|
|
@@ -38405,7 +38410,7 @@ async function loginWithPassword(username, password) {
|
|
|
38405
38410
|
// package.json
|
|
38406
38411
|
var package_default = {
|
|
38407
38412
|
name: "ofw-mcp",
|
|
38408
|
-
version: "2.
|
|
38413
|
+
version: "2.6.3",
|
|
38409
38414
|
license: "MIT",
|
|
38410
38415
|
mcpName: "io.github.chrischall/ofw-mcp",
|
|
38411
38416
|
description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
|
|
@@ -38434,7 +38439,10 @@ var package_default = {
|
|
|
38434
38439
|
dev: "node --env-file=.env dist/index.js",
|
|
38435
38440
|
test: "vitest run",
|
|
38436
38441
|
"test:coverage": "vitest run --coverage",
|
|
38437
|
-
"test:watch": "vitest"
|
|
38442
|
+
"test:watch": "vitest",
|
|
38443
|
+
"worker:dev": "wrangler dev",
|
|
38444
|
+
"worker:deploy": "wrangler deploy",
|
|
38445
|
+
"worker:test": "vitest run --config vitest.workers.config.ts"
|
|
38438
38446
|
},
|
|
38439
38447
|
dependencies: {
|
|
38440
38448
|
"@chrischall/mcp-utils": "^0.13.0",
|
|
@@ -38444,11 +38452,17 @@ var package_default = {
|
|
|
38444
38452
|
zod: "^4.4.3"
|
|
38445
38453
|
},
|
|
38446
38454
|
devDependencies: {
|
|
38455
|
+
"@chrischall/mcp-connector": "^0.1.0",
|
|
38456
|
+
"@cloudflare/vitest-pool-workers": "^0.18.4",
|
|
38457
|
+
"@cloudflare/workers-oauth-provider": "^0.0.11",
|
|
38458
|
+
"@cloudflare/workers-types": "^5.20260708.1",
|
|
38447
38459
|
"@types/node": "^26.0.0",
|
|
38448
38460
|
"@vitest/coverage-v8": "^4.1.7",
|
|
38461
|
+
agents: "^0.17.3",
|
|
38449
38462
|
esbuild: "^0.28.0",
|
|
38450
38463
|
typescript: "^7.0.2",
|
|
38451
|
-
vitest: "^4.1.7"
|
|
38464
|
+
vitest: "^4.1.7",
|
|
38465
|
+
wrangler: "^4.110.0"
|
|
38452
38466
|
}
|
|
38453
38467
|
};
|
|
38454
38468
|
|
|
@@ -38514,8 +38528,11 @@ async function resolveAuth() {
|
|
|
38514
38528
|
}
|
|
38515
38529
|
|
|
38516
38530
|
// src/client.ts
|
|
38517
|
-
|
|
38518
|
-
|
|
38531
|
+
try {
|
|
38532
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
38533
|
+
await loadDotenvSafely({ path: join3(dir, "..", ".env") });
|
|
38534
|
+
} catch {
|
|
38535
|
+
}
|
|
38519
38536
|
function parseContentDispositionFilename(cd) {
|
|
38520
38537
|
const extMatch = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(cd);
|
|
38521
38538
|
if (extMatch) {
|
|
@@ -38548,6 +38565,16 @@ var OFWClient = class {
|
|
|
38548
38565
|
// already-expired placeholder token so the first request drives the refresh
|
|
38549
38566
|
// callback — i.e. the original "log in on first request" behavior.
|
|
38550
38567
|
tokenManager;
|
|
38568
|
+
// Optional injected auth resolver. When set, the refresh callback uses it
|
|
38569
|
+
// instead of the module-level global `resolveAuth` (env-var → fetchproxy
|
|
38570
|
+
// priority). A hosted per-user deployment injects its own resolver so each
|
|
38571
|
+
// request carries that user's credentials — see the Cloudflare Worker
|
|
38572
|
+
// deployment. Left undefined by the stdio path, which falls back to the
|
|
38573
|
+
// global resolver, keeping that behaviour byte-for-byte identical.
|
|
38574
|
+
authResolver;
|
|
38575
|
+
constructor(opts) {
|
|
38576
|
+
this.authResolver = opts?.resolveAuth;
|
|
38577
|
+
}
|
|
38551
38578
|
getTokenManager() {
|
|
38552
38579
|
if (!this.tokenManager) {
|
|
38553
38580
|
this.tokenManager = new TokenManager({
|
|
@@ -38559,7 +38586,7 @@ var OFWClient = class {
|
|
|
38559
38586
|
// path uses (the 401-replay covers a wrong guess). We re-arm the
|
|
38560
38587
|
// sentinel so the manager can refresh again later.
|
|
38561
38588
|
refresh: async () => {
|
|
38562
|
-
const { token, expiresAt } = await resolveAuth();
|
|
38589
|
+
const { token, expiresAt } = await (this.authResolver ?? resolveAuth)();
|
|
38563
38590
|
return {
|
|
38564
38591
|
accessToken: token,
|
|
38565
38592
|
refreshToken: OFW_REFRESH_SENTINEL,
|
|
@@ -38732,395 +38759,6 @@ function registerUserTools(server, client2) {
|
|
|
38732
38759
|
});
|
|
38733
38760
|
}
|
|
38734
38761
|
|
|
38735
|
-
// src/cache.ts
|
|
38736
|
-
import { DatabaseSync } from "node:sqlite";
|
|
38737
|
-
import { mkdirSync, chmodSync, existsSync } from "node:fs";
|
|
38738
|
-
import { dirname as dirname2 } from "node:path";
|
|
38739
|
-
|
|
38740
|
-
// src/config.ts
|
|
38741
|
-
import { createHash } from "node:crypto";
|
|
38742
|
-
import { homedir as homedir3 } from "node:os";
|
|
38743
|
-
import { join as join4 } from "node:path";
|
|
38744
|
-
function readCacheIdentity() {
|
|
38745
|
-
return readEnvVar("OFW_CACHE_IDENTITY") ?? readEnvVar("OFW_USERNAME") ?? "_default";
|
|
38746
|
-
}
|
|
38747
|
-
function getCacheDir() {
|
|
38748
|
-
const override = process.env.OFW_CACHE_DIR;
|
|
38749
|
-
if (override && override.trim().length > 0) return override.trim();
|
|
38750
|
-
return join4(homedir3(), ".cache", "ofw-mcp");
|
|
38751
|
-
}
|
|
38752
|
-
function getCacheDbPath() {
|
|
38753
|
-
const identity = readCacheIdentity();
|
|
38754
|
-
const hash2 = createHash("sha256").update(identity).digest("hex").slice(0, 16);
|
|
38755
|
-
return join4(getCacheDir(), `${hash2}.db`);
|
|
38756
|
-
}
|
|
38757
|
-
function getAttachmentsDir() {
|
|
38758
|
-
const override = process.env.OFW_ATTACHMENTS_DIR;
|
|
38759
|
-
if (override && override.trim().length > 0) return override.trim();
|
|
38760
|
-
return join4(homedir3(), "Downloads", "ofw-mcp");
|
|
38761
|
-
}
|
|
38762
|
-
function getWriteMode() {
|
|
38763
|
-
const raw = process.env.OFW_WRITE_MODE;
|
|
38764
|
-
if (typeof raw !== "string" || raw.trim().length === 0) return "all";
|
|
38765
|
-
const mode = raw.trim().toLowerCase();
|
|
38766
|
-
if (mode === "none" || mode === "drafts" || mode === "all") return mode;
|
|
38767
|
-
console.error(
|
|
38768
|
-
`[ofw-mcp] Unrecognized OFW_WRITE_MODE "${raw.trim()}" \u2014 failing closed to "none" (no write tools registered). Valid values: none, drafts, all.`
|
|
38769
|
-
);
|
|
38770
|
-
return "none";
|
|
38771
|
-
}
|
|
38772
|
-
function getCalendarWritesAllowed() {
|
|
38773
|
-
const mode = getWriteMode();
|
|
38774
|
-
if (mode === "all") return true;
|
|
38775
|
-
return mode === "drafts" && parseBoolEnv("OFW_CALENDAR_WRITES");
|
|
38776
|
-
}
|
|
38777
|
-
function getDefaultInlineAttachments() {
|
|
38778
|
-
return parseBoolEnv("OFW_INLINE_ATTACHMENTS");
|
|
38779
|
-
}
|
|
38780
|
-
|
|
38781
|
-
// src/cache.ts
|
|
38782
|
-
var instance = null;
|
|
38783
|
-
var SCHEMA_V1 = `
|
|
38784
|
-
CREATE TABLE IF NOT EXISTS messages (
|
|
38785
|
-
id INTEGER PRIMARY KEY,
|
|
38786
|
-
folder TEXT NOT NULL,
|
|
38787
|
-
subject TEXT NOT NULL,
|
|
38788
|
-
from_user TEXT NOT NULL,
|
|
38789
|
-
sent_at TEXT NOT NULL,
|
|
38790
|
-
recipients_json TEXT NOT NULL,
|
|
38791
|
-
body TEXT,
|
|
38792
|
-
fetched_body_at TEXT,
|
|
38793
|
-
reply_to_id INTEGER,
|
|
38794
|
-
chain_root_id INTEGER,
|
|
38795
|
-
list_data_json TEXT NOT NULL,
|
|
38796
|
-
last_seen_at TEXT NOT NULL
|
|
38797
|
-
);
|
|
38798
|
-
CREATE INDEX IF NOT EXISTS idx_messages_folder_sent_at ON messages(folder, sent_at DESC);
|
|
38799
|
-
CREATE INDEX IF NOT EXISTS idx_messages_chain_root ON messages(chain_root_id);
|
|
38800
|
-
|
|
38801
|
-
CREATE TABLE IF NOT EXISTS drafts (
|
|
38802
|
-
id INTEGER PRIMARY KEY,
|
|
38803
|
-
subject TEXT NOT NULL,
|
|
38804
|
-
body TEXT NOT NULL,
|
|
38805
|
-
recipients_json TEXT NOT NULL,
|
|
38806
|
-
reply_to_id INTEGER,
|
|
38807
|
-
modified_at TEXT NOT NULL,
|
|
38808
|
-
list_data_json TEXT NOT NULL
|
|
38809
|
-
);
|
|
38810
|
-
|
|
38811
|
-
CREATE TABLE IF NOT EXISTS sync_state (
|
|
38812
|
-
folder TEXT PRIMARY KEY,
|
|
38813
|
-
last_sync_at TEXT NOT NULL,
|
|
38814
|
-
newest_id INTEGER
|
|
38815
|
-
);
|
|
38816
|
-
|
|
38817
|
-
CREATE TABLE IF NOT EXISTS meta (
|
|
38818
|
-
key TEXT PRIMARY KEY,
|
|
38819
|
-
value TEXT NOT NULL
|
|
38820
|
-
);
|
|
38821
|
-
`;
|
|
38822
|
-
var SCHEMA_V2 = `
|
|
38823
|
-
CREATE TABLE IF NOT EXISTS attachments (
|
|
38824
|
-
file_id INTEGER PRIMARY KEY,
|
|
38825
|
-
file_name TEXT NOT NULL,
|
|
38826
|
-
label TEXT NOT NULL,
|
|
38827
|
-
mime_type TEXT NOT NULL,
|
|
38828
|
-
size_bytes INTEGER,
|
|
38829
|
-
metadata_json TEXT NOT NULL,
|
|
38830
|
-
message_ids_json TEXT NOT NULL, -- JSON array of message ids that reference this file
|
|
38831
|
-
downloaded_path TEXT, -- absolute path on disk if/when downloaded
|
|
38832
|
-
downloaded_at TEXT,
|
|
38833
|
-
fetched_metadata_at TEXT NOT NULL
|
|
38834
|
-
);
|
|
38835
|
-
`;
|
|
38836
|
-
function migrate(db) {
|
|
38837
|
-
db.exec(SCHEMA_V1);
|
|
38838
|
-
db.exec(SCHEMA_V2);
|
|
38839
|
-
db.prepare(
|
|
38840
|
-
"INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value"
|
|
38841
|
-
).run("schema_version", "2");
|
|
38842
|
-
}
|
|
38843
|
-
function enforceCachePermissions(dbPath) {
|
|
38844
|
-
chmodSync(dirname2(dbPath), 448);
|
|
38845
|
-
chmodSync(dbPath, 384);
|
|
38846
|
-
for (const sibling of [`${dbPath}-wal`, `${dbPath}-shm`]) {
|
|
38847
|
-
if (existsSync(sibling)) chmodSync(sibling, 384);
|
|
38848
|
-
}
|
|
38849
|
-
}
|
|
38850
|
-
function openCache() {
|
|
38851
|
-
if (instance) return instance;
|
|
38852
|
-
const path = getCacheDbPath();
|
|
38853
|
-
mkdirSync(dirname2(path), { recursive: true });
|
|
38854
|
-
const db = new DatabaseSync(path);
|
|
38855
|
-
enforceCachePermissions(path);
|
|
38856
|
-
db.exec("PRAGMA journal_mode = WAL");
|
|
38857
|
-
db.exec("PRAGMA foreign_keys = ON");
|
|
38858
|
-
migrate(db);
|
|
38859
|
-
enforceCachePermissions(path);
|
|
38860
|
-
instance = { db };
|
|
38861
|
-
return instance;
|
|
38862
|
-
}
|
|
38863
|
-
function rowFromDb(r) {
|
|
38864
|
-
return {
|
|
38865
|
-
id: r.id,
|
|
38866
|
-
folder: r.folder,
|
|
38867
|
-
subject: r.subject,
|
|
38868
|
-
fromUser: r.from_user,
|
|
38869
|
-
sentAt: r.sent_at,
|
|
38870
|
-
recipients: JSON.parse(r.recipients_json),
|
|
38871
|
-
body: r.body,
|
|
38872
|
-
fetchedBodyAt: r.fetched_body_at,
|
|
38873
|
-
replyToId: r.reply_to_id,
|
|
38874
|
-
chainRootId: r.chain_root_id,
|
|
38875
|
-
listData: JSON.parse(r.list_data_json)
|
|
38876
|
-
};
|
|
38877
|
-
}
|
|
38878
|
-
function nullish3(v) {
|
|
38879
|
-
return v === void 0 ? null : v;
|
|
38880
|
-
}
|
|
38881
|
-
function requireString(field, v) {
|
|
38882
|
-
if (typeof v === "string") return v;
|
|
38883
|
-
throw new Error(`cache: ${field} is required (got ${v === void 0 ? "undefined" : "null"})`);
|
|
38884
|
-
}
|
|
38885
|
-
function upsertMessage(row) {
|
|
38886
|
-
const { db } = openCache();
|
|
38887
|
-
db.prepare(
|
|
38888
|
-
`INSERT INTO messages (
|
|
38889
|
-
id, folder, subject, from_user, sent_at, recipients_json,
|
|
38890
|
-
body, fetched_body_at, reply_to_id, chain_root_id, list_data_json, last_seen_at
|
|
38891
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
38892
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
38893
|
-
folder=excluded.folder,
|
|
38894
|
-
subject=excluded.subject,
|
|
38895
|
-
from_user=excluded.from_user,
|
|
38896
|
-
sent_at=excluded.sent_at,
|
|
38897
|
-
recipients_json=excluded.recipients_json,
|
|
38898
|
-
body=excluded.body,
|
|
38899
|
-
fetched_body_at=excluded.fetched_body_at,
|
|
38900
|
-
reply_to_id=excluded.reply_to_id,
|
|
38901
|
-
chain_root_id=excluded.chain_root_id,
|
|
38902
|
-
list_data_json=excluded.list_data_json,
|
|
38903
|
-
last_seen_at=excluded.last_seen_at`
|
|
38904
|
-
).run(
|
|
38905
|
-
row.id,
|
|
38906
|
-
requireString("messages.folder", row.folder),
|
|
38907
|
-
requireString("messages.subject", row.subject),
|
|
38908
|
-
requireString("messages.fromUser", row.fromUser),
|
|
38909
|
-
requireString("messages.sentAt", row.sentAt),
|
|
38910
|
-
JSON.stringify(row.recipients ?? []),
|
|
38911
|
-
nullish3(row.body),
|
|
38912
|
-
nullish3(row.fetchedBodyAt),
|
|
38913
|
-
nullish3(row.replyToId),
|
|
38914
|
-
nullish3(row.chainRootId),
|
|
38915
|
-
JSON.stringify(row.listData ?? null),
|
|
38916
|
-
(/* @__PURE__ */ new Date()).toISOString()
|
|
38917
|
-
);
|
|
38918
|
-
}
|
|
38919
|
-
function getMessage(id) {
|
|
38920
|
-
const { db } = openCache();
|
|
38921
|
-
const r = db.prepare("SELECT * FROM messages WHERE id = ?").get(id);
|
|
38922
|
-
return r ? rowFromDb(r) : null;
|
|
38923
|
-
}
|
|
38924
|
-
function deleteMessage(id) {
|
|
38925
|
-
const { db } = openCache();
|
|
38926
|
-
db.prepare("DELETE FROM messages WHERE id = ?").run(id);
|
|
38927
|
-
}
|
|
38928
|
-
function buildMessageFilter(opts) {
|
|
38929
|
-
const wheres = [];
|
|
38930
|
-
const params = [];
|
|
38931
|
-
if (opts.folder !== void 0) {
|
|
38932
|
-
wheres.push("folder = ?");
|
|
38933
|
-
params.push(opts.folder);
|
|
38934
|
-
}
|
|
38935
|
-
if (opts.since !== void 0) {
|
|
38936
|
-
wheres.push("sent_at >= ?");
|
|
38937
|
-
params.push(opts.since);
|
|
38938
|
-
}
|
|
38939
|
-
if (opts.until !== void 0) {
|
|
38940
|
-
wheres.push("sent_at < ?");
|
|
38941
|
-
params.push(opts.until);
|
|
38942
|
-
}
|
|
38943
|
-
if (opts.q !== void 0 && opts.q.length > 0) {
|
|
38944
|
-
const pattern = `%${opts.q}%`;
|
|
38945
|
-
wheres.push("(subject LIKE ? OR body LIKE ?)");
|
|
38946
|
-
params.push(pattern, pattern);
|
|
38947
|
-
}
|
|
38948
|
-
return {
|
|
38949
|
-
where: wheres.length > 0 ? `WHERE ${wheres.join(" AND ")}` : "",
|
|
38950
|
-
params
|
|
38951
|
-
};
|
|
38952
|
-
}
|
|
38953
|
-
function listMessages(opts) {
|
|
38954
|
-
const { db } = openCache();
|
|
38955
|
-
const { where, params } = buildMessageFilter(opts);
|
|
38956
|
-
const offset = (opts.page - 1) * opts.size;
|
|
38957
|
-
const rows = db.prepare(
|
|
38958
|
-
`SELECT * FROM messages ${where}
|
|
38959
|
-
ORDER BY sent_at DESC, id DESC
|
|
38960
|
-
LIMIT ? OFFSET ?`
|
|
38961
|
-
).all(...params, opts.size, offset);
|
|
38962
|
-
return rows.map(rowFromDb);
|
|
38963
|
-
}
|
|
38964
|
-
function countMessages(opts) {
|
|
38965
|
-
const { db } = openCache();
|
|
38966
|
-
const { where, params } = buildMessageFilter(opts);
|
|
38967
|
-
const r = db.prepare(`SELECT COUNT(*) as n FROM messages ${where}`).get(...params);
|
|
38968
|
-
return r?.n ?? 0;
|
|
38969
|
-
}
|
|
38970
|
-
function draftFromDb(r) {
|
|
38971
|
-
return {
|
|
38972
|
-
id: r.id,
|
|
38973
|
-
subject: r.subject,
|
|
38974
|
-
body: r.body,
|
|
38975
|
-
recipients: JSON.parse(r.recipients_json),
|
|
38976
|
-
replyToId: r.reply_to_id,
|
|
38977
|
-
modifiedAt: r.modified_at,
|
|
38978
|
-
listData: JSON.parse(r.list_data_json)
|
|
38979
|
-
};
|
|
38980
|
-
}
|
|
38981
|
-
function upsertDraft(row) {
|
|
38982
|
-
const { db } = openCache();
|
|
38983
|
-
db.prepare(
|
|
38984
|
-
`INSERT INTO drafts (id, subject, body, recipients_json, reply_to_id, modified_at, list_data_json)
|
|
38985
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
38986
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
38987
|
-
subject=excluded.subject,
|
|
38988
|
-
body=excluded.body,
|
|
38989
|
-
recipients_json=excluded.recipients_json,
|
|
38990
|
-
reply_to_id=excluded.reply_to_id,
|
|
38991
|
-
modified_at=excluded.modified_at,
|
|
38992
|
-
list_data_json=excluded.list_data_json`
|
|
38993
|
-
).run(
|
|
38994
|
-
row.id,
|
|
38995
|
-
requireString("drafts.subject", row.subject),
|
|
38996
|
-
requireString("drafts.body", row.body),
|
|
38997
|
-
JSON.stringify(row.recipients ?? []),
|
|
38998
|
-
nullish3(row.replyToId),
|
|
38999
|
-
requireString("drafts.modifiedAt", row.modifiedAt),
|
|
39000
|
-
JSON.stringify(row.listData ?? null)
|
|
39001
|
-
);
|
|
39002
|
-
}
|
|
39003
|
-
function getDraft(id) {
|
|
39004
|
-
const { db } = openCache();
|
|
39005
|
-
const r = db.prepare("SELECT * FROM drafts WHERE id = ?").get(id);
|
|
39006
|
-
return r ? draftFromDb(r) : null;
|
|
39007
|
-
}
|
|
39008
|
-
function listDrafts(opts) {
|
|
39009
|
-
const { db } = openCache();
|
|
39010
|
-
const offset = (opts.page - 1) * opts.size;
|
|
39011
|
-
const rows = db.prepare(
|
|
39012
|
-
"SELECT * FROM drafts ORDER BY modified_at DESC, id DESC LIMIT ? OFFSET ?"
|
|
39013
|
-
).all(opts.size, offset);
|
|
39014
|
-
return rows.map(draftFromDb);
|
|
39015
|
-
}
|
|
39016
|
-
function deleteDraft(id) {
|
|
39017
|
-
const { db } = openCache();
|
|
39018
|
-
db.prepare("DELETE FROM drafts WHERE id = ?").run(id);
|
|
39019
|
-
}
|
|
39020
|
-
function listDraftIds() {
|
|
39021
|
-
const { db } = openCache();
|
|
39022
|
-
const rows = db.prepare("SELECT id FROM drafts").all();
|
|
39023
|
-
return rows.map((r) => r.id);
|
|
39024
|
-
}
|
|
39025
|
-
function setSyncState(folder, state) {
|
|
39026
|
-
const { db } = openCache();
|
|
39027
|
-
db.prepare(
|
|
39028
|
-
`INSERT INTO sync_state (folder, last_sync_at, newest_id) VALUES (?, ?, ?)
|
|
39029
|
-
ON CONFLICT(folder) DO UPDATE SET
|
|
39030
|
-
last_sync_at = excluded.last_sync_at,
|
|
39031
|
-
newest_id = excluded.newest_id`
|
|
39032
|
-
).run(folder, state.lastSyncAt, state.newestId);
|
|
39033
|
-
}
|
|
39034
|
-
function setMeta(key, value) {
|
|
39035
|
-
const { db } = openCache();
|
|
39036
|
-
db.prepare(
|
|
39037
|
-
"INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value"
|
|
39038
|
-
).run(key, value);
|
|
39039
|
-
}
|
|
39040
|
-
function findLatestReplyTip(replyToId) {
|
|
39041
|
-
const { db } = openCache();
|
|
39042
|
-
const parent = db.prepare(
|
|
39043
|
-
"SELECT id, folder, chain_root_id FROM messages WHERE id = ?"
|
|
39044
|
-
).get(replyToId);
|
|
39045
|
-
if (!parent) return replyToId;
|
|
39046
|
-
const chainRoot = parent.chain_root_id ?? parent.id;
|
|
39047
|
-
const tip = db.prepare(
|
|
39048
|
-
`SELECT id FROM messages
|
|
39049
|
-
WHERE folder = 'sent' AND chain_root_id = ?
|
|
39050
|
-
ORDER BY id DESC LIMIT 1`
|
|
39051
|
-
).get(chainRoot);
|
|
39052
|
-
return tip ? tip.id : replyToId;
|
|
39053
|
-
}
|
|
39054
|
-
function attachmentFromDb(r) {
|
|
39055
|
-
return {
|
|
39056
|
-
fileId: r.file_id,
|
|
39057
|
-
fileName: r.file_name,
|
|
39058
|
-
label: r.label,
|
|
39059
|
-
mimeType: r.mime_type,
|
|
39060
|
-
sizeBytes: r.size_bytes,
|
|
39061
|
-
metadata: JSON.parse(r.metadata_json),
|
|
39062
|
-
messageIds: JSON.parse(r.message_ids_json),
|
|
39063
|
-
downloadedPath: r.downloaded_path,
|
|
39064
|
-
downloadedAt: r.downloaded_at
|
|
39065
|
-
};
|
|
39066
|
-
}
|
|
39067
|
-
function getAttachment(fileId) {
|
|
39068
|
-
const { db } = openCache();
|
|
39069
|
-
const r = db.prepare("SELECT * FROM attachments WHERE file_id = ?").get(fileId);
|
|
39070
|
-
return r ? attachmentFromDb(r) : null;
|
|
39071
|
-
}
|
|
39072
|
-
function listAttachmentsForMessage(messageId) {
|
|
39073
|
-
const { db } = openCache();
|
|
39074
|
-
const rows = db.prepare(
|
|
39075
|
-
`SELECT * FROM attachments
|
|
39076
|
-
WHERE EXISTS (SELECT 1 FROM json_each(message_ids_json) WHERE value = ?)
|
|
39077
|
-
ORDER BY file_id`
|
|
39078
|
-
).all(messageId);
|
|
39079
|
-
return rows.map(attachmentFromDb);
|
|
39080
|
-
}
|
|
39081
|
-
function upsertAttachmentForMessage(input) {
|
|
39082
|
-
const { db } = openCache();
|
|
39083
|
-
const existing = db.prepare("SELECT message_ids_json FROM attachments WHERE file_id = ?").get(input.fileId);
|
|
39084
|
-
const prior = existing ? JSON.parse(existing.message_ids_json) : [];
|
|
39085
|
-
let messageIds;
|
|
39086
|
-
if (input.messageId === 0) {
|
|
39087
|
-
messageIds = prior;
|
|
39088
|
-
} else if (prior.includes(input.messageId)) {
|
|
39089
|
-
messageIds = prior;
|
|
39090
|
-
} else {
|
|
39091
|
-
messageIds = [...prior, input.messageId];
|
|
39092
|
-
}
|
|
39093
|
-
db.prepare(
|
|
39094
|
-
`INSERT INTO attachments (
|
|
39095
|
-
file_id, file_name, label, mime_type, size_bytes,
|
|
39096
|
-
metadata_json, message_ids_json, fetched_metadata_at
|
|
39097
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
39098
|
-
ON CONFLICT(file_id) DO UPDATE SET
|
|
39099
|
-
file_name=excluded.file_name,
|
|
39100
|
-
label=excluded.label,
|
|
39101
|
-
mime_type=excluded.mime_type,
|
|
39102
|
-
size_bytes=excluded.size_bytes,
|
|
39103
|
-
metadata_json=excluded.metadata_json,
|
|
39104
|
-
message_ids_json=excluded.message_ids_json,
|
|
39105
|
-
fetched_metadata_at=excluded.fetched_metadata_at`
|
|
39106
|
-
).run(
|
|
39107
|
-
input.fileId,
|
|
39108
|
-
requireString("attachments.fileName", input.fileName),
|
|
39109
|
-
requireString("attachments.label", input.label),
|
|
39110
|
-
requireString("attachments.mimeType", input.mimeType),
|
|
39111
|
-
nullish3(input.sizeBytes),
|
|
39112
|
-
JSON.stringify(input.metadata ?? null),
|
|
39113
|
-
JSON.stringify(messageIds),
|
|
39114
|
-
(/* @__PURE__ */ new Date()).toISOString()
|
|
39115
|
-
);
|
|
39116
|
-
}
|
|
39117
|
-
function markAttachmentDownloaded(fileId, path) {
|
|
39118
|
-
const { db } = openCache();
|
|
39119
|
-
db.prepare(
|
|
39120
|
-
"UPDATE attachments SET downloaded_path = ?, downloaded_at = ? WHERE file_id = ?"
|
|
39121
|
-
).run(path, (/* @__PURE__ */ new Date()).toISOString(), fileId);
|
|
39122
|
-
}
|
|
39123
|
-
|
|
39124
38762
|
// src/sync.ts
|
|
39125
38763
|
var FileMetaSchema = external_exports.looseObject({
|
|
39126
38764
|
fileId: external_exports.number(),
|
|
@@ -39130,13 +38768,13 @@ var FileMetaSchema = external_exports.looseObject({
|
|
|
39130
38768
|
// MIME
|
|
39131
38769
|
fileSize: external_exports.number().optional()
|
|
39132
38770
|
});
|
|
39133
|
-
async function fetchAttachmentMeta(client2, fileId, messageId) {
|
|
38771
|
+
async function fetchAttachmentMeta(client2, fileId, messageId, store) {
|
|
39134
38772
|
const meta3 = parseLenient(
|
|
39135
38773
|
FileMetaSchema,
|
|
39136
38774
|
await client2.request("GET", `/pub/v1/myfiles/${fileId}`),
|
|
39137
38775
|
{ label: "ofw-mcp", context: "GET /pub/v1/myfiles/{fileId}" }
|
|
39138
38776
|
);
|
|
39139
|
-
upsertAttachmentForMessage({
|
|
38777
|
+
await store.upsertAttachmentForMessage({
|
|
39140
38778
|
fileId: meta3.fileId ?? fileId,
|
|
39141
38779
|
fileName: meta3.fileName ?? `file-${fileId}`,
|
|
39142
38780
|
label: meta3.label ?? meta3.fileName ?? `file-${fileId}`,
|
|
@@ -39146,13 +38784,33 @@ async function fetchAttachmentMeta(client2, fileId, messageId) {
|
|
|
39146
38784
|
messageId
|
|
39147
38785
|
});
|
|
39148
38786
|
}
|
|
39149
|
-
async function fetchAttachmentMetaForMessage(client2, messageId, fileIds) {
|
|
39150
|
-
await Promise.allSettled(fileIds.map((fid) => fetchAttachmentMeta(client2, fid, messageId)));
|
|
38787
|
+
async function fetchAttachmentMetaForMessage(client2, messageId, fileIds, store) {
|
|
38788
|
+
await Promise.allSettled(fileIds.map((fid) => fetchAttachmentMeta(client2, fid, messageId, store)));
|
|
38789
|
+
}
|
|
38790
|
+
function makeBudget(max) {
|
|
38791
|
+
let remaining = max;
|
|
38792
|
+
return {
|
|
38793
|
+
take() {
|
|
38794
|
+
if (remaining <= 0) return false;
|
|
38795
|
+
remaining -= 1;
|
|
38796
|
+
return true;
|
|
38797
|
+
}
|
|
38798
|
+
};
|
|
38799
|
+
}
|
|
38800
|
+
async function fetchAttachmentMetaBudgeted(client2, messageId, fileIds, store, budget) {
|
|
38801
|
+
const affordable = [];
|
|
38802
|
+
for (const fid of fileIds) {
|
|
38803
|
+
if (!budget.take()) break;
|
|
38804
|
+
affordable.push(fid);
|
|
38805
|
+
}
|
|
38806
|
+
if (affordable.length > 0) {
|
|
38807
|
+
await fetchAttachmentMetaForMessage(client2, messageId, affordable, store);
|
|
38808
|
+
}
|
|
39151
38809
|
}
|
|
39152
38810
|
var FoldersSchema = external_exports.looseObject({
|
|
39153
38811
|
systemFolders: external_exports.array(external_exports.looseObject({ id: external_exports.string(), folderType: external_exports.string() })).optional()
|
|
39154
38812
|
});
|
|
39155
|
-
async function resolveFolderIds(client2) {
|
|
38813
|
+
async function resolveFolderIds(client2, store) {
|
|
39156
38814
|
const data = parseLenient(
|
|
39157
38815
|
FoldersSchema,
|
|
39158
38816
|
await client2.request("GET", "/pub/v1/messageFolders?includeFolderCounts=true"),
|
|
@@ -39169,7 +38827,8 @@ async function resolveFolderIds(client2) {
|
|
|
39169
38827
|
sent: find("SENT_MESSAGES"),
|
|
39170
38828
|
drafts: find("DRAFTS")
|
|
39171
38829
|
};
|
|
39172
|
-
setMeta("drafts_folder_id", ids.drafts);
|
|
38830
|
+
await store.setMeta("drafts_folder_id", ids.drafts);
|
|
38831
|
+
await store.setMeta("sent_folder_id", ids.sent);
|
|
39173
38832
|
return ids;
|
|
39174
38833
|
}
|
|
39175
38834
|
var ListItemSchema = external_exports.looseObject({
|
|
@@ -39188,12 +38847,17 @@ var DetailResponseSchema = external_exports.looseObject({
|
|
|
39188
38847
|
// endpoint only has an epoch placeholder) — used by the view-status refresh.
|
|
39189
38848
|
recipients: external_exports.array(ApiRecipientSchema).optional()
|
|
39190
38849
|
});
|
|
39191
|
-
|
|
39192
|
-
|
|
39193
|
-
|
|
38850
|
+
var maxId = (a, b) => a === null ? b : b === null ? a : Math.max(a, b);
|
|
38851
|
+
async function walkPages(client2, folder, folderId, opts, store) {
|
|
38852
|
+
const budget = opts.budget;
|
|
38853
|
+
let page = opts.startPage;
|
|
39194
38854
|
let newestId = null;
|
|
38855
|
+
let synced = 0;
|
|
39195
38856
|
const unread = [];
|
|
39196
38857
|
while (true) {
|
|
38858
|
+
if (!budget.take()) {
|
|
38859
|
+
return { synced, unread, newestId, done: false, nextPage: page };
|
|
38860
|
+
}
|
|
39197
38861
|
const path = `/pub/v3/messages?folders=${encodeURIComponent(folderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
|
|
39198
38862
|
const list = parseLenient(
|
|
39199
38863
|
ListResponseSchema,
|
|
@@ -39201,19 +38865,30 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
|
|
|
39201
38865
|
{ label: "ofw-mcp", context: `GET /pub/v3/messages?folders={${folder}}` }
|
|
39202
38866
|
);
|
|
39203
38867
|
const items = list.data ?? [];
|
|
39204
|
-
if (items.length === 0)
|
|
38868
|
+
if (items.length === 0) {
|
|
38869
|
+
return { synced, unread, newestId, done: true, nextPage: null };
|
|
38870
|
+
}
|
|
38871
|
+
const existingById = new Map(
|
|
38872
|
+
(await store.getMessages(items.map((it) => it.id))).map((row) => [row.id, row])
|
|
38873
|
+
);
|
|
38874
|
+
const toUpsert = [];
|
|
39205
38875
|
let pageHadNewItem = false;
|
|
38876
|
+
let pageBudgetHit = false;
|
|
39206
38877
|
for (const item of items) {
|
|
39207
38878
|
if (newestId === null || item.id > newestId) newestId = item.id;
|
|
39208
|
-
const existing =
|
|
38879
|
+
const existing = existingById.get(item.id);
|
|
39209
38880
|
if (existing) {
|
|
39210
38881
|
if (folder === "sent" && item.showNeverViewed === false && !hasRealView(existing.recipients)) {
|
|
38882
|
+
if (!budget.take()) {
|
|
38883
|
+
pageBudgetHit = true;
|
|
38884
|
+
break;
|
|
38885
|
+
}
|
|
39211
38886
|
const detail = parseLenient(
|
|
39212
38887
|
DetailResponseSchema,
|
|
39213
38888
|
await client2.request("GET", `/pub/v3/messages/${item.id}`),
|
|
39214
38889
|
{ label: "ofw-mcp", context: "GET /pub/v3/messages/{id} (view-status refresh)" }
|
|
39215
38890
|
);
|
|
39216
|
-
|
|
38891
|
+
toUpsert.push({ ...existing, recipients: mapRecipients(detail.recipients), listData: item });
|
|
39217
38892
|
synced++;
|
|
39218
38893
|
}
|
|
39219
38894
|
continue;
|
|
@@ -39224,7 +38899,12 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
|
|
|
39224
38899
|
let body = null;
|
|
39225
38900
|
let fetchedBodyAt = null;
|
|
39226
38901
|
let detailFileIds = [];
|
|
38902
|
+
let detailRecipients;
|
|
39227
38903
|
if (shouldFetchBody) {
|
|
38904
|
+
if (!budget.take()) {
|
|
38905
|
+
pageBudgetHit = true;
|
|
38906
|
+
break;
|
|
38907
|
+
}
|
|
39228
38908
|
const detail = parseLenient(
|
|
39229
38909
|
DetailResponseSchema,
|
|
39230
38910
|
await client2.request("GET", `/pub/v3/messages/${item.id}`),
|
|
@@ -39232,6 +38912,7 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
|
|
|
39232
38912
|
);
|
|
39233
38913
|
body = detail.body ?? "";
|
|
39234
38914
|
fetchedBodyAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
38915
|
+
detailRecipients = detail.recipients;
|
|
39235
38916
|
if (Array.isArray(detail.files) && detail.files.length > 0) {
|
|
39236
38917
|
detailFileIds = detail.files;
|
|
39237
38918
|
}
|
|
@@ -39249,27 +38930,72 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
|
|
|
39249
38930
|
subject: item.subject ?? "(no subject)",
|
|
39250
38931
|
fromUser: item.from?.name ?? "",
|
|
39251
38932
|
sentAt: item.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
39252
|
-
recipients: mapRecipients(item.recipients),
|
|
38933
|
+
recipients: mapRecipients(detailRecipients ?? item.recipients),
|
|
39253
38934
|
body,
|
|
39254
38935
|
fetchedBodyAt,
|
|
39255
38936
|
replyToId: null,
|
|
39256
38937
|
chainRootId: null,
|
|
39257
38938
|
listData: item
|
|
39258
38939
|
};
|
|
39259
|
-
|
|
38940
|
+
toUpsert.push(row);
|
|
39260
38941
|
synced++;
|
|
39261
38942
|
if (detailFileIds.length > 0) {
|
|
39262
|
-
await
|
|
38943
|
+
await fetchAttachmentMetaBudgeted(client2, item.id, detailFileIds, store, budget);
|
|
39263
38944
|
}
|
|
39264
38945
|
}
|
|
39265
|
-
|
|
38946
|
+
await store.upsertMessages(toUpsert);
|
|
38947
|
+
if (pageBudgetHit) {
|
|
38948
|
+
return { synced, unread, newestId, done: false, nextPage: page };
|
|
38949
|
+
}
|
|
38950
|
+
if (opts.stopAtCachedPage && !pageHadNewItem) {
|
|
38951
|
+
return { synced, unread, newestId, done: true, nextPage: page };
|
|
38952
|
+
}
|
|
39266
38953
|
page++;
|
|
39267
38954
|
}
|
|
39268
|
-
|
|
38955
|
+
}
|
|
38956
|
+
async function syncMessageFolder(client2, folder, folderId, opts, store) {
|
|
38957
|
+
const budget = opts.budget ?? makeBudget(Number.POSITIVE_INFINITY);
|
|
38958
|
+
const saved = await store.getSyncState(folder);
|
|
38959
|
+
const savedResume = saved?.resumePage ?? null;
|
|
38960
|
+
const fwd = await walkPages(client2, folder, folderId, {
|
|
38961
|
+
startPage: 1,
|
|
38962
|
+
stopAtCachedPage: true,
|
|
38963
|
+
fetchUnreadBodies: opts.fetchUnreadBodies,
|
|
38964
|
+
budget
|
|
38965
|
+
}, store);
|
|
38966
|
+
let synced = fwd.synced;
|
|
38967
|
+
const unread = [...fwd.unread];
|
|
38968
|
+
let newestId = maxId(saved?.newestId ?? null, fwd.newestId);
|
|
38969
|
+
let done;
|
|
38970
|
+
let resumePage;
|
|
38971
|
+
if (!fwd.done) {
|
|
38972
|
+
done = false;
|
|
38973
|
+
resumePage = savedResume === null ? fwd.nextPage : Math.min(fwd.nextPage, savedResume);
|
|
38974
|
+
} else if (fwd.nextPage === null) {
|
|
38975
|
+
done = true;
|
|
38976
|
+
resumePage = null;
|
|
38977
|
+
} else if (savedResume === null && !opts.deep) {
|
|
38978
|
+
done = true;
|
|
38979
|
+
resumePage = null;
|
|
38980
|
+
} else {
|
|
38981
|
+
const bf = await walkPages(client2, folder, folderId, {
|
|
38982
|
+
startPage: savedResume ?? fwd.nextPage,
|
|
38983
|
+
stopAtCachedPage: false,
|
|
38984
|
+
fetchUnreadBodies: opts.fetchUnreadBodies,
|
|
38985
|
+
budget
|
|
38986
|
+
}, store);
|
|
38987
|
+
synced += bf.synced;
|
|
38988
|
+
unread.push(...bf.unread);
|
|
38989
|
+
newestId = maxId(newestId, bf.newestId);
|
|
38990
|
+
done = bf.done;
|
|
38991
|
+
resumePage = bf.done ? null : bf.nextPage;
|
|
38992
|
+
}
|
|
38993
|
+
await store.setSyncState(folder, {
|
|
39269
38994
|
lastSyncAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
39270
|
-
newestId
|
|
38995
|
+
newestId,
|
|
38996
|
+
resumePage
|
|
39271
38997
|
});
|
|
39272
|
-
return { synced, unread };
|
|
38998
|
+
return { synced, unread, done };
|
|
39273
38999
|
}
|
|
39274
39000
|
var DraftListItemSchema = external_exports.looseObject({
|
|
39275
39001
|
id: external_exports.number(),
|
|
@@ -39283,10 +39009,12 @@ var DraftDetailSchema = external_exports.looseObject({
|
|
|
39283
39009
|
body: external_exports.string().optional(),
|
|
39284
39010
|
subject: external_exports.string().optional()
|
|
39285
39011
|
});
|
|
39286
|
-
async function syncDrafts(client2, draftsFolderId) {
|
|
39012
|
+
async function syncDrafts(client2, draftsFolderId, store, budget) {
|
|
39013
|
+
const b = budget ?? makeBudget(Number.POSITIVE_INFINITY);
|
|
39287
39014
|
const items = [];
|
|
39288
39015
|
let page = 1;
|
|
39289
39016
|
while (true) {
|
|
39017
|
+
if (!b.take()) return { synced: 0, done: false };
|
|
39290
39018
|
const path = `/pub/v3/messages?folders=${encodeURIComponent(draftsFolderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
|
|
39291
39019
|
const list = parseLenient(
|
|
39292
39020
|
DraftListResponseSchema,
|
|
@@ -39298,68 +39026,136 @@ async function syncDrafts(client2, draftsFolderId) {
|
|
|
39298
39026
|
if (pageItems.length < 50) break;
|
|
39299
39027
|
page++;
|
|
39300
39028
|
}
|
|
39301
|
-
const
|
|
39302
|
-
let synced = 0;
|
|
39029
|
+
const rows = [];
|
|
39303
39030
|
for (const item of items) {
|
|
39304
|
-
|
|
39305
|
-
const modifiedAt = item.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
39306
|
-
const existing = getDraft(item.id);
|
|
39031
|
+
if (!b.take()) return { synced: 0, done: false };
|
|
39307
39032
|
const detail = parseLenient(
|
|
39308
39033
|
DraftDetailSchema,
|
|
39309
39034
|
await client2.request("GET", `/pub/v3/messages/${item.id}`),
|
|
39310
39035
|
{ label: "ofw-mcp", context: "GET /pub/v3/messages/{id} (drafts sync)" }
|
|
39311
39036
|
);
|
|
39312
|
-
|
|
39037
|
+
rows.push({
|
|
39313
39038
|
id: item.id,
|
|
39314
39039
|
subject: detail.subject ?? item.subject ?? "(no subject)",
|
|
39315
39040
|
body: detail.body ?? "",
|
|
39316
39041
|
recipients: mapRecipients(item.recipients),
|
|
39317
39042
|
replyToId: item.replyToId ?? null,
|
|
39318
|
-
modifiedAt,
|
|
39043
|
+
modifiedAt: item.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
39319
39044
|
listData: item
|
|
39320
|
-
};
|
|
39321
|
-
|
|
39322
|
-
|
|
39045
|
+
});
|
|
39046
|
+
}
|
|
39047
|
+
const ids = items.map((it) => it.id);
|
|
39048
|
+
const existingById = new Map((await store.getDrafts(ids)).map((d) => [d.id, d]));
|
|
39049
|
+
await store.upsertDrafts(rows);
|
|
39050
|
+
for (const stale of await store.getMessages(ids)) {
|
|
39051
|
+
await store.deleteMessage(stale.id);
|
|
39052
|
+
}
|
|
39053
|
+
let synced = 0;
|
|
39054
|
+
for (const row of rows) {
|
|
39055
|
+
const existing = existingById.get(row.id);
|
|
39323
39056
|
if (!existing || existing.body !== row.body || existing.subject !== row.subject || existing.replyToId !== row.replyToId) {
|
|
39324
39057
|
synced++;
|
|
39325
39058
|
}
|
|
39326
39059
|
}
|
|
39327
|
-
|
|
39328
|
-
|
|
39060
|
+
const seenIds = new Set(ids);
|
|
39061
|
+
for (const id of await store.listDraftIds()) {
|
|
39062
|
+
if (!seenIds.has(id)) await store.deleteDraft(id);
|
|
39329
39063
|
}
|
|
39330
|
-
return { synced };
|
|
39064
|
+
return { synced, done: true };
|
|
39331
39065
|
}
|
|
39332
|
-
async function syncAll(client2, opts) {
|
|
39066
|
+
async function syncAll(client2, opts, store) {
|
|
39333
39067
|
const folders = opts.folders ?? ["inbox", "sent", "drafts"];
|
|
39334
|
-
const
|
|
39068
|
+
const budget = makeBudget(opts.maxRequests ?? Number.POSITIVE_INFINITY);
|
|
39069
|
+
budget.take();
|
|
39070
|
+
const ids = await resolveFolderIds(client2, store);
|
|
39335
39071
|
const synced = {};
|
|
39336
39072
|
let unreadInbox = [];
|
|
39073
|
+
let done = true;
|
|
39337
39074
|
for (const folder of folders) {
|
|
39338
39075
|
if (folder === "inbox") {
|
|
39339
39076
|
const r = await syncMessageFolder(client2, "inbox", ids.inbox, {
|
|
39340
39077
|
fetchUnreadBodies: opts.fetchUnreadBodies ?? false,
|
|
39341
|
-
deep: opts.deep ?? false
|
|
39342
|
-
|
|
39078
|
+
deep: opts.deep ?? false,
|
|
39079
|
+
budget
|
|
39080
|
+
}, store);
|
|
39343
39081
|
synced.inbox = r.synced;
|
|
39344
39082
|
unreadInbox = r.unread;
|
|
39083
|
+
if (!r.done) done = false;
|
|
39345
39084
|
} else if (folder === "sent") {
|
|
39346
39085
|
const r = await syncMessageFolder(client2, "sent", ids.sent, {
|
|
39347
39086
|
fetchUnreadBodies: false,
|
|
39348
|
-
deep: opts.deep ?? false
|
|
39349
|
-
|
|
39087
|
+
deep: opts.deep ?? false,
|
|
39088
|
+
budget
|
|
39089
|
+
}, store);
|
|
39350
39090
|
synced.sent = r.synced;
|
|
39091
|
+
if (!r.done) done = false;
|
|
39351
39092
|
} else if (folder === "drafts") {
|
|
39352
|
-
const r = await syncDrafts(client2, ids.drafts);
|
|
39093
|
+
const r = await syncDrafts(client2, ids.drafts, store, budget);
|
|
39353
39094
|
synced.drafts = r.synced;
|
|
39095
|
+
if (!r.done) done = false;
|
|
39354
39096
|
}
|
|
39355
39097
|
}
|
|
39356
|
-
const
|
|
39357
|
-
|
|
39098
|
+
const notes = [];
|
|
39099
|
+
if (unreadInbox.length > 0) {
|
|
39100
|
+
notes.push(`${unreadInbox.length} unread inbox messages cached without bodies. Call ofw_get_message(id) to read them \u2014 this will mark them as read on OFW.`);
|
|
39101
|
+
}
|
|
39102
|
+
if (!done) {
|
|
39103
|
+
notes.push("Paused after the request budget to stay within the hosting limit; more pages remain \u2014 call ofw_sync_messages again with the same arguments to resume where it left off and continue the backfill.");
|
|
39104
|
+
}
|
|
39105
|
+
const note = notes.length > 0 ? notes.join("\n\n") : void 0;
|
|
39106
|
+
return { synced, unreadInbox, done, ...note ? { note } : {} };
|
|
39107
|
+
}
|
|
39108
|
+
|
|
39109
|
+
// src/config.ts
|
|
39110
|
+
import { createHash } from "node:crypto";
|
|
39111
|
+
import { homedir as homedir3 } from "node:os";
|
|
39112
|
+
import { join as join4 } from "node:path";
|
|
39113
|
+
function readCacheIdentity() {
|
|
39114
|
+
return readEnvVar("OFW_CACHE_IDENTITY") ?? readEnvVar("OFW_USERNAME") ?? "_default";
|
|
39115
|
+
}
|
|
39116
|
+
function getCacheDir() {
|
|
39117
|
+
const override = process.env.OFW_CACHE_DIR;
|
|
39118
|
+
if (override && override.trim().length > 0) return override.trim();
|
|
39119
|
+
return join4(homedir3(), ".cache", "ofw-mcp");
|
|
39120
|
+
}
|
|
39121
|
+
function getCacheDbPath() {
|
|
39122
|
+
const identity = readCacheIdentity();
|
|
39123
|
+
const hash2 = createHash("sha256").update(identity).digest("hex").slice(0, 16);
|
|
39124
|
+
return join4(getCacheDir(), `${hash2}.db`);
|
|
39125
|
+
}
|
|
39126
|
+
function getAttachmentsDir() {
|
|
39127
|
+
const override = process.env.OFW_ATTACHMENTS_DIR;
|
|
39128
|
+
if (override && override.trim().length > 0) return override.trim();
|
|
39129
|
+
return join4(homedir3(), "Downloads", "ofw-mcp");
|
|
39130
|
+
}
|
|
39131
|
+
function getWriteMode() {
|
|
39132
|
+
const raw = process.env.OFW_WRITE_MODE;
|
|
39133
|
+
if (typeof raw !== "string" || raw.trim().length === 0) return "all";
|
|
39134
|
+
const mode = raw.trim().toLowerCase();
|
|
39135
|
+
if (mode === "none" || mode === "drafts" || mode === "all") return mode;
|
|
39136
|
+
console.error(
|
|
39137
|
+
`[ofw-mcp] Unrecognized OFW_WRITE_MODE "${raw.trim()}" \u2014 failing closed to "none" (no write tools registered). Valid values: none, drafts, all.`
|
|
39138
|
+
);
|
|
39139
|
+
return "none";
|
|
39140
|
+
}
|
|
39141
|
+
function getCalendarWritesAllowed() {
|
|
39142
|
+
const mode = getWriteMode();
|
|
39143
|
+
if (mode === "all") return true;
|
|
39144
|
+
return mode === "drafts" && parseBoolEnv("OFW_CALENDAR_WRITES");
|
|
39145
|
+
}
|
|
39146
|
+
function getDefaultInlineAttachments() {
|
|
39147
|
+
return parseBoolEnv("OFW_INLINE_ATTACHMENTS");
|
|
39148
|
+
}
|
|
39149
|
+
function getSyncMaxRequests() {
|
|
39150
|
+
const raw = readEnvVar("OFW_SYNC_MAX_REQUESTS");
|
|
39151
|
+
if (raw === void 0) return Number.POSITIVE_INFINITY;
|
|
39152
|
+
const n = Number(raw);
|
|
39153
|
+
if (!Number.isInteger(n) || n <= 0) return Number.POSITIVE_INFINITY;
|
|
39154
|
+
return n;
|
|
39358
39155
|
}
|
|
39359
39156
|
|
|
39360
39157
|
// src/tools/messages.ts
|
|
39361
|
-
import {
|
|
39362
|
-
import { basename, dirname as dirname3, extname, join as join5 } from "node:path";
|
|
39158
|
+
import { basename, join as join5 } from "node:path";
|
|
39363
39159
|
var DateSchema = external_exports.looseObject({ dateTime: external_exports.string() });
|
|
39364
39160
|
var SentDetailSchema = external_exports.looseObject({
|
|
39365
39161
|
subject: external_exports.string().optional(),
|
|
@@ -39382,7 +39178,11 @@ var MessageDetailSchema = external_exports.looseObject({
|
|
|
39382
39178
|
date: DateSchema,
|
|
39383
39179
|
from: external_exports.looseObject({ name: external_exports.string().optional() }).optional(),
|
|
39384
39180
|
files: external_exports.array(external_exports.number()).optional(),
|
|
39385
|
-
recipients: external_exports.array(ApiRecipientSchema).optional()
|
|
39181
|
+
recipients: external_exports.array(ApiRecipientSchema).optional(),
|
|
39182
|
+
// The detail payload carries its own owning folder ({id, name}). We read the
|
|
39183
|
+
// id to label a live-fetched message sent-vs-inbox instead of blindly
|
|
39184
|
+
// defaulting to inbox — see the folder derivation in ofw_get_message.
|
|
39185
|
+
folder: external_exports.looseObject({ id: external_exports.number() }).optional()
|
|
39386
39186
|
});
|
|
39387
39187
|
var DetailFilesSchema = external_exports.looseObject({ files: external_exports.array(external_exports.number()).optional() });
|
|
39388
39188
|
var UploadedFileSchema = external_exports.looseObject({
|
|
@@ -39393,33 +39193,6 @@ var UploadedFileSchema = external_exports.looseObject({
|
|
|
39393
39193
|
sizeInBytes: external_exports.number().optional(),
|
|
39394
39194
|
shareClass: external_exports.string().optional()
|
|
39395
39195
|
});
|
|
39396
|
-
var MIME_BY_EXT = {
|
|
39397
|
-
".pdf": "application/pdf",
|
|
39398
|
-
".png": "image/png",
|
|
39399
|
-
".jpg": "image/jpeg",
|
|
39400
|
-
".jpeg": "image/jpeg",
|
|
39401
|
-
".gif": "image/gif",
|
|
39402
|
-
".webp": "image/webp",
|
|
39403
|
-
".heic": "image/heic",
|
|
39404
|
-
".txt": "text/plain",
|
|
39405
|
-
".md": "text/markdown",
|
|
39406
|
-
".csv": "text/csv",
|
|
39407
|
-
".html": "text/html",
|
|
39408
|
-
".htm": "text/html",
|
|
39409
|
-
".json": "application/json",
|
|
39410
|
-
".xml": "application/xml",
|
|
39411
|
-
".doc": "application/msword",
|
|
39412
|
-
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
39413
|
-
".xls": "application/vnd.ms-excel",
|
|
39414
|
-
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
39415
|
-
".ppt": "application/vnd.ms-powerpoint",
|
|
39416
|
-
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
39417
|
-
".zip": "application/zip",
|
|
39418
|
-
".ics": "text/calendar"
|
|
39419
|
-
};
|
|
39420
|
-
function mimeFromName(name) {
|
|
39421
|
-
return MIME_BY_EXT[extname(name).toLowerCase()] ?? "application/octet-stream";
|
|
39422
|
-
}
|
|
39423
39196
|
function listDataHintsAtFiles(listData) {
|
|
39424
39197
|
if (typeof listData !== "object" || listData === null) return false;
|
|
39425
39198
|
const ld = listData;
|
|
@@ -39427,7 +39200,7 @@ function listDataHintsAtFiles(listData) {
|
|
|
39427
39200
|
if (Array.isArray(ld.files)) return ld.files.length > 0;
|
|
39428
39201
|
return false;
|
|
39429
39202
|
}
|
|
39430
|
-
function registerMessageTools(server, client2) {
|
|
39203
|
+
function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
39431
39204
|
const writeMode = getWriteMode();
|
|
39432
39205
|
const allowSend = writeMode === "all";
|
|
39433
39206
|
const allowDrafts = writeMode !== "none";
|
|
@@ -39463,9 +39236,10 @@ function registerMessageTools(server, client2) {
|
|
|
39463
39236
|
note: 'folderId must be "inbox", "sent", or "both". Numeric OFW folder IDs are not supported by the cache.'
|
|
39464
39237
|
});
|
|
39465
39238
|
}
|
|
39239
|
+
const cache = cacheProvider();
|
|
39466
39240
|
const filter = { folder, since: args.since, until: args.until, q: args.q };
|
|
39467
|
-
const total = countMessages(filter);
|
|
39468
|
-
const messages = listMessages({ ...filter, page, size });
|
|
39241
|
+
const total = await cache.countMessages(filter);
|
|
39242
|
+
const messages = await cache.listMessages({ ...filter, page, size });
|
|
39469
39243
|
const payload = { messages, total, page, size };
|
|
39470
39244
|
if (total === 0) {
|
|
39471
39245
|
payload.note = "No messages match these filters. If you expected results, check ofw_sync_messages was run, or relax the filters.";
|
|
@@ -39482,7 +39256,8 @@ function registerMessageTools(server, client2) {
|
|
|
39482
39256
|
}
|
|
39483
39257
|
}, async (args) => {
|
|
39484
39258
|
const id = Number(args.messageId);
|
|
39485
|
-
const
|
|
39259
|
+
const cache = cacheProvider();
|
|
39260
|
+
const draftRow = await cache.getDraft(id);
|
|
39486
39261
|
if (draftRow !== null) {
|
|
39487
39262
|
return jsonResponse({
|
|
39488
39263
|
id: draftRow.id,
|
|
@@ -39502,7 +39277,7 @@ function registerMessageTools(server, client2) {
|
|
|
39502
39277
|
attachments: []
|
|
39503
39278
|
});
|
|
39504
39279
|
}
|
|
39505
|
-
const cached2 = getMessage(id);
|
|
39280
|
+
const cached2 = await cache.getMessage(id);
|
|
39506
39281
|
if (cached2 && cached2.body !== null) {
|
|
39507
39282
|
let row2 = cached2;
|
|
39508
39283
|
if (cached2.folder === "sent" && !hasRealView(cached2.recipients)) {
|
|
@@ -39518,11 +39293,11 @@ function registerMessageTools(server, client2) {
|
|
|
39518
39293
|
recipients,
|
|
39519
39294
|
listData: { ...cached2.listData, showNeverViewed: !hasRealView(recipients) }
|
|
39520
39295
|
};
|
|
39521
|
-
upsertMessage(row2);
|
|
39296
|
+
await cache.upsertMessage(row2);
|
|
39522
39297
|
} catch {
|
|
39523
39298
|
}
|
|
39524
39299
|
}
|
|
39525
|
-
let attachments2 = listAttachmentsForMessage(id);
|
|
39300
|
+
let attachments2 = await cache.listAttachmentsForMessage(id);
|
|
39526
39301
|
if (attachments2.length === 0 && listDataHintsAtFiles(row2.listData)) {
|
|
39527
39302
|
try {
|
|
39528
39303
|
const detail2 = parseLenient(
|
|
@@ -39531,8 +39306,8 @@ function registerMessageTools(server, client2) {
|
|
|
39531
39306
|
{ label: "ofw-mcp", context: "GET /pub/v3/messages/{id} (attachment backfill)" }
|
|
39532
39307
|
);
|
|
39533
39308
|
if (Array.isArray(detail2.files) && detail2.files.length > 0) {
|
|
39534
|
-
await fetchAttachmentMetaForMessage(client2, id, detail2.files);
|
|
39535
|
-
attachments2 = listAttachmentsForMessage(id);
|
|
39309
|
+
await fetchAttachmentMetaForMessage(client2, id, detail2.files, cache);
|
|
39310
|
+
attachments2 = await cache.listAttachmentsForMessage(id);
|
|
39536
39311
|
}
|
|
39537
39312
|
} catch {
|
|
39538
39313
|
}
|
|
@@ -39544,7 +39319,13 @@ function registerMessageTools(server, client2) {
|
|
|
39544
39319
|
await client2.request("GET", `/pub/v3/messages/${encodeURIComponent(args.messageId)}`),
|
|
39545
39320
|
{ label: "ofw-mcp", context: "GET /pub/v3/messages/{id} (ofw_get_message)" }
|
|
39546
39321
|
);
|
|
39547
|
-
|
|
39322
|
+
let folder = cached2?.folder ?? "inbox";
|
|
39323
|
+
if (!cached2) {
|
|
39324
|
+
const sentFolderId = await cache.getMeta("sent_folder_id");
|
|
39325
|
+
if (sentFolderId !== null && detail.folder?.id != null && String(detail.folder.id) === sentFolderId) {
|
|
39326
|
+
folder = "sent";
|
|
39327
|
+
}
|
|
39328
|
+
}
|
|
39548
39329
|
const row = {
|
|
39549
39330
|
id: detail.id,
|
|
39550
39331
|
folder,
|
|
@@ -39558,11 +39339,11 @@ function registerMessageTools(server, client2) {
|
|
|
39558
39339
|
chainRootId: cached2?.chainRootId ?? null,
|
|
39559
39340
|
listData: cached2?.listData ?? detail
|
|
39560
39341
|
};
|
|
39561
|
-
upsertMessage(row);
|
|
39342
|
+
await cache.upsertMessage(row);
|
|
39562
39343
|
if (Array.isArray(detail.files) && detail.files.length > 0) {
|
|
39563
|
-
await fetchAttachmentMetaForMessage(client2, detail.id, detail.files);
|
|
39344
|
+
await fetchAttachmentMetaForMessage(client2, detail.id, detail.files, cache);
|
|
39564
39345
|
}
|
|
39565
|
-
const attachments = listAttachmentsForMessage(detail.id);
|
|
39346
|
+
const attachments = await cache.listAttachmentsForMessage(detail.id);
|
|
39566
39347
|
return jsonResponse({ ...row, attachments });
|
|
39567
39348
|
});
|
|
39568
39349
|
if (allowSend) server.registerTool("ofw_send_message", {
|
|
@@ -39582,6 +39363,7 @@ function registerMessageTools(server, client2) {
|
|
|
39582
39363
|
throw new Error(`messageId (${args.messageId}) and draftId (${args.draftId}) refer to different drafts; pass only one.`);
|
|
39583
39364
|
}
|
|
39584
39365
|
const draftRef = args.messageId ?? args.draftId;
|
|
39366
|
+
const cache = cacheProvider();
|
|
39585
39367
|
let subject = args.subject;
|
|
39586
39368
|
let body = args.body;
|
|
39587
39369
|
let recipientIds = args.recipientIds;
|
|
@@ -39590,7 +39372,7 @@ function registerMessageTools(server, client2) {
|
|
|
39590
39372
|
let draftFound = false;
|
|
39591
39373
|
if (draftRef !== void 0) {
|
|
39592
39374
|
draftLookupAttempted = true;
|
|
39593
|
-
const draft = getDraft(draftRef);
|
|
39375
|
+
const draft = await cache.getDraft(draftRef);
|
|
39594
39376
|
if (draft !== null) {
|
|
39595
39377
|
draftFound = true;
|
|
39596
39378
|
subject = subject ?? draft.subject;
|
|
@@ -39619,11 +39401,11 @@ function registerMessageTools(server, client2) {
|
|
|
39619
39401
|
let chainRootId = null;
|
|
39620
39402
|
let rewriteNote = null;
|
|
39621
39403
|
if (requestedReplyTo !== null) {
|
|
39622
|
-
resolvedReplyTo = findLatestReplyTip(requestedReplyTo);
|
|
39404
|
+
resolvedReplyTo = await cache.findLatestReplyTip(requestedReplyTo);
|
|
39623
39405
|
if (resolvedReplyTo !== requestedReplyTo) {
|
|
39624
39406
|
rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
|
|
39625
39407
|
}
|
|
39626
|
-
const parent = getMessage(resolvedReplyTo);
|
|
39408
|
+
const parent = await cache.getMessage(resolvedReplyTo);
|
|
39627
39409
|
chainRootId = parent?.chainRootId ?? parent?.id ?? requestedReplyTo;
|
|
39628
39410
|
}
|
|
39629
39411
|
const myFileIDs = args.myFileIDs ?? [];
|
|
@@ -39653,10 +39435,10 @@ function registerMessageTools(server, client2) {
|
|
|
39653
39435
|
chainRootId,
|
|
39654
39436
|
listData: detail
|
|
39655
39437
|
};
|
|
39656
|
-
upsertMessage(persisted);
|
|
39438
|
+
await cache.upsertMessage(persisted);
|
|
39657
39439
|
for (const fileId of myFileIDs) {
|
|
39658
|
-
const existing = getAttachment(fileId);
|
|
39659
|
-
upsertAttachmentForMessage({
|
|
39440
|
+
const existing = await cache.getAttachment(fileId);
|
|
39441
|
+
await cache.upsertAttachmentForMessage({
|
|
39660
39442
|
fileId,
|
|
39661
39443
|
fileName: existing?.fileName ?? `file-${fileId}`,
|
|
39662
39444
|
label: existing?.label ?? existing?.fileName ?? `file-${fileId}`,
|
|
@@ -39673,7 +39455,7 @@ function registerMessageTools(server, client2) {
|
|
|
39673
39455
|
unconfirmedNote = `WARNING: OFW's send response did not include a message id, so the send could not be confirmed. ${draftClause} ourfamilywizard.com to see whether the message went out before retrying.`;
|
|
39674
39456
|
} else if (draftRef !== void 0) {
|
|
39675
39457
|
await deleteOFWMessages(client2, [draftRef]);
|
|
39676
|
-
deleteDraft(draftRef);
|
|
39458
|
+
await cache.deleteDraft(draftRef);
|
|
39677
39459
|
}
|
|
39678
39460
|
const responseObj = persisted ?? raw;
|
|
39679
39461
|
const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Message sent successfully.";
|
|
@@ -39692,7 +39474,7 @@ ${text}` : text);
|
|
|
39692
39474
|
}, async (args) => {
|
|
39693
39475
|
const page = args.page ?? 1;
|
|
39694
39476
|
const size = args.size ?? 50;
|
|
39695
|
-
const drafts = listDrafts({ page, size });
|
|
39477
|
+
const drafts = await cacheProvider().listDrafts({ page, size });
|
|
39696
39478
|
const payload = drafts.length === 0 ? { drafts: [], note: "Cache empty. Call ofw_sync_messages to populate." } : { drafts };
|
|
39697
39479
|
return jsonResponse(payload);
|
|
39698
39480
|
});
|
|
@@ -39708,11 +39490,12 @@ ${text}` : text);
|
|
|
39708
39490
|
myFileIDs: external_exports.array(external_exports.number()).describe("Attachment file ids (from ofw_upload_attachment)").optional()
|
|
39709
39491
|
}
|
|
39710
39492
|
}, async (args) => {
|
|
39493
|
+
const cache = cacheProvider();
|
|
39711
39494
|
const requestedReplyTo = args.replyToId ?? null;
|
|
39712
39495
|
let resolvedReplyTo = requestedReplyTo;
|
|
39713
39496
|
let rewriteNote = null;
|
|
39714
39497
|
if (requestedReplyTo !== null) {
|
|
39715
|
-
resolvedReplyTo = findLatestReplyTip(requestedReplyTo);
|
|
39498
|
+
resolvedReplyTo = await cache.findLatestReplyTip(requestedReplyTo);
|
|
39716
39499
|
if (resolvedReplyTo !== requestedReplyTo) {
|
|
39717
39500
|
rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
|
|
39718
39501
|
}
|
|
@@ -39747,11 +39530,11 @@ ${text}` : text);
|
|
|
39747
39530
|
modifiedAt: detail.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
39748
39531
|
listData: detail
|
|
39749
39532
|
};
|
|
39750
|
-
upsertDraft(persisted);
|
|
39533
|
+
await cache.upsertDraft(persisted);
|
|
39751
39534
|
if (args.messageId !== void 0 && args.messageId !== newId) {
|
|
39752
39535
|
try {
|
|
39753
39536
|
await deleteOFWMessages(client2, [args.messageId]);
|
|
39754
|
-
deleteDraft(args.messageId);
|
|
39537
|
+
await cache.deleteDraft(args.messageId);
|
|
39755
39538
|
replaceNote = `NOTE: ofw_save_draft replaced draft ${args.messageId} via create-then-delete. The new draft id is ${newId}; the old draft has been deleted. (OFW's update-in-place endpoint silently no-ops on subsequent updates, so we never use it. If you cached the old id anywhere, replace it with the new one.)`;
|
|
39756
39539
|
} catch (e) {
|
|
39757
39540
|
replaceNote = `WARNING: New draft ${newId} created successfully, but failed to delete the old draft (${args.messageId}): ${e.message}. You may want to clean it up manually with ofw_delete_draft.`;
|
|
@@ -39773,7 +39556,7 @@ ${text}` : text);
|
|
|
39773
39556
|
}
|
|
39774
39557
|
}, async (args) => {
|
|
39775
39558
|
const data = await deleteOFWMessages(client2, [args.messageId]);
|
|
39776
|
-
deleteDraft(args.messageId);
|
|
39559
|
+
await cacheProvider().deleteDraft(args.messageId);
|
|
39777
39560
|
return data ? jsonResponse(data) : textResponse("Draft deleted.");
|
|
39778
39561
|
});
|
|
39779
39562
|
server.registerTool("ofw_get_unread_sent", {
|
|
@@ -39786,7 +39569,7 @@ ${text}` : text);
|
|
|
39786
39569
|
}, async (args) => {
|
|
39787
39570
|
const page = args.page ?? 1;
|
|
39788
39571
|
const size = args.size ?? 50;
|
|
39789
|
-
const sent = listMessages({ folder: "sent", page, size });
|
|
39572
|
+
const sent = await cacheProvider().listMessages({ folder: "sent", page, size });
|
|
39790
39573
|
if (sent.length === 0) {
|
|
39791
39574
|
return jsonResponse({ note: "Sent cache is empty. Call ofw_sync_messages to populate." });
|
|
39792
39575
|
}
|
|
@@ -39812,13 +39595,9 @@ ${text}` : text);
|
|
|
39812
39595
|
description: external_exports.string().describe("Description shown in OFW My Files (default: filename)").optional()
|
|
39813
39596
|
}
|
|
39814
39597
|
}, async (args) => {
|
|
39815
|
-
const
|
|
39816
|
-
const stat = statSync(abs);
|
|
39817
|
-
if (!stat.isFile()) throw new Error(`Not a file: ${abs}`);
|
|
39818
|
-
const fileName = basename(abs);
|
|
39819
|
-
const mime = mimeFromName(fileName);
|
|
39598
|
+
const { blob, fileName, mimeType: mime, sizeBytes } = await attachmentIO.resolveUpload(args.path);
|
|
39820
39599
|
const form = new FormData();
|
|
39821
|
-
form.append("file",
|
|
39600
|
+
form.append("file", blob, fileName);
|
|
39822
39601
|
form.append("source", "message");
|
|
39823
39602
|
form.append("description", args.description ?? fileName);
|
|
39824
39603
|
form.append("label", args.label ?? fileName);
|
|
@@ -39829,12 +39608,12 @@ ${text}` : text);
|
|
|
39829
39608
|
await client2.request("POST", "/pub/v3/myfiles/multipart", form),
|
|
39830
39609
|
{ label: "ofw-mcp", context: "POST /pub/v3/myfiles/multipart (ofw_upload_attachment)", mode: "strict" }
|
|
39831
39610
|
);
|
|
39832
|
-
upsertAttachmentForMessage({
|
|
39611
|
+
await cacheProvider().upsertAttachmentForMessage({
|
|
39833
39612
|
fileId: meta3.fileId,
|
|
39834
39613
|
fileName: meta3.fileName ?? fileName,
|
|
39835
39614
|
label: meta3.label ?? args.label ?? fileName,
|
|
39836
39615
|
mimeType: meta3.fileType ?? mime,
|
|
39837
|
-
sizeBytes: typeof meta3.sizeInBytes === "number" ? meta3.sizeInBytes :
|
|
39616
|
+
sizeBytes: typeof meta3.sizeInBytes === "number" ? meta3.sizeInBytes : sizeBytes,
|
|
39838
39617
|
metadata: meta3,
|
|
39839
39618
|
messageId: 0
|
|
39840
39619
|
});
|
|
@@ -39842,7 +39621,7 @@ ${text}` : text);
|
|
|
39842
39621
|
fileId: meta3.fileId,
|
|
39843
39622
|
fileName: meta3.fileName ?? fileName,
|
|
39844
39623
|
mimeType: meta3.fileType ?? mime,
|
|
39845
|
-
sizeBytes: meta3.sizeInBytes ??
|
|
39624
|
+
sizeBytes: meta3.sizeInBytes ?? sizeBytes,
|
|
39846
39625
|
shareClass: meta3.shareClass ?? args.shareClass ?? "PRIVATE",
|
|
39847
39626
|
note: "Pass this fileId to ofw_send_message or ofw_save_draft in myFileIDs to attach it."
|
|
39848
39627
|
});
|
|
@@ -39858,11 +39637,12 @@ ${text}` : text);
|
|
|
39858
39637
|
}
|
|
39859
39638
|
}, async (args) => {
|
|
39860
39639
|
const fileId = args.fileId;
|
|
39640
|
+
const cache = cacheProvider();
|
|
39861
39641
|
const inline = args.inline ?? getDefaultInlineAttachments();
|
|
39862
|
-
let cached2 = getAttachment(fileId);
|
|
39642
|
+
let cached2 = await cache.getAttachment(fileId);
|
|
39863
39643
|
if (!cached2) {
|
|
39864
|
-
await fetchAttachmentMeta(client2, fileId, 0);
|
|
39865
|
-
cached2 = getAttachment(fileId);
|
|
39644
|
+
await fetchAttachmentMeta(client2, fileId, 0, cache);
|
|
39645
|
+
cached2 = await cache.getAttachment(fileId);
|
|
39866
39646
|
if (!cached2) throw new Error(`failed to fetch metadata for fileId ${fileId}`);
|
|
39867
39647
|
}
|
|
39868
39648
|
if (inline) {
|
|
@@ -39870,10 +39650,7 @@ ${text}` : text);
|
|
|
39870
39650
|
let mimeType = cached2.mimeType;
|
|
39871
39651
|
let fileName = cached2.fileName;
|
|
39872
39652
|
if (cached2.downloadedPath) {
|
|
39873
|
-
|
|
39874
|
-
bytes = readFileSync(cached2.downloadedPath);
|
|
39875
|
-
} catch {
|
|
39876
|
-
}
|
|
39653
|
+
bytes = attachmentIO.readDownloaded(cached2.downloadedPath);
|
|
39877
39654
|
}
|
|
39878
39655
|
if (bytes === null) {
|
|
39879
39656
|
const response2 = await client2.requestBinary("GET", `/pub/v1/myfiles/${fileId}/data`);
|
|
@@ -39918,9 +39695,8 @@ ${text}` : text);
|
|
|
39918
39695
|
});
|
|
39919
39696
|
}
|
|
39920
39697
|
const response = await client2.requestBinary("GET", `/pub/v1/myfiles/${fileId}/data`);
|
|
39921
|
-
|
|
39922
|
-
|
|
39923
|
-
markAttachmentDownloaded(fileId, dest);
|
|
39698
|
+
attachmentIO.writeDownload(dest, response.body);
|
|
39699
|
+
await cache.markAttachmentDownloaded(fileId, dest);
|
|
39924
39700
|
return jsonResponse({
|
|
39925
39701
|
fileId,
|
|
39926
39702
|
path: dest,
|
|
@@ -39930,19 +39706,21 @@ ${text}` : text);
|
|
|
39930
39706
|
});
|
|
39931
39707
|
});
|
|
39932
39708
|
server.registerTool("ofw_sync_messages", {
|
|
39933
|
-
description: "Sync messages from OurFamilyWizard into the local cache. Returns counts per folder and a list of unread inbox messages whose bodies were NOT fetched (to avoid mark-as-read on OFW). Call ofw_get_message(id) on those to read them. Pass deep:true to walk all OFW pages instead of stopping at the first all-cached page (use to backfill suspected gaps).",
|
|
39709
|
+
description: "Sync messages from OurFamilyWizard into the local cache. Returns counts per folder and a list of unread inbox messages whose bodies were NOT fetched (to avoid mark-as-read on OFW). Call ofw_get_message(id) on those to read them. EVERY call re-checks the newest page first, so new messages are picked up promptly even while an old-history backfill is still running; only then does it spend what is left of its budget advancing that backfill. Pass deep:true to walk all OFW pages instead of stopping at the first all-cached page (use to backfill suspected gaps). Sync is BOUNDED and RESUMABLE: on hosted deployments a per-call OFW-request budget (env OFW_SYNC_MAX_REQUESTS, or the maxRequests argument) caps how far one call walks; when the budget is hit the response reports done:false with a note \u2014 call again with the SAME arguments to resume. done:false means older history is still being backfilled; it does NOT mean recent messages are missing. Local installs are unbounded by default (done is always true).",
|
|
39934
39710
|
annotations: { readOnlyHint: false },
|
|
39935
39711
|
inputSchema: {
|
|
39936
39712
|
folders: external_exports.array(external_exports.enum(["inbox", "sent", "drafts"])).describe("Folders to sync (default: all three)").optional(),
|
|
39937
39713
|
fetchUnreadBodies: external_exports.boolean().describe("If true, also fetch bodies for unread inbox messages (will mark them as read on OFW). Default false.").optional(),
|
|
39938
|
-
deep: external_exports.boolean().describe("If true, walk every OFW page until empty regardless of cache state. Use to backfill gaps. Default false.").optional()
|
|
39714
|
+
deep: external_exports.boolean().describe("If true, walk every OFW page until empty regardless of cache state. Use to backfill gaps. Default false.").optional(),
|
|
39715
|
+
maxRequests: external_exports.number().int().min(1).describe("Maximum OFW requests this single call may make before pausing. When hit, the response reports done:false \u2014 call again with the same arguments to continue. Omit to use the server default (OFW_SYNC_MAX_REQUESTS, or unbounded on local installs).").optional()
|
|
39939
39716
|
}
|
|
39940
39717
|
}, async (args) => {
|
|
39941
39718
|
const result = await syncAll(client2, {
|
|
39942
39719
|
folders: args.folders,
|
|
39943
39720
|
fetchUnreadBodies: args.fetchUnreadBodies,
|
|
39944
|
-
deep: args.deep
|
|
39945
|
-
|
|
39721
|
+
deep: args.deep,
|
|
39722
|
+
maxRequests: args.maxRequests ?? getSyncMaxRequests()
|
|
39723
|
+
}, cacheProvider());
|
|
39946
39724
|
return jsonResponse(result);
|
|
39947
39725
|
});
|
|
39948
39726
|
}
|
|
@@ -40176,6 +39954,584 @@ function registerJournalTools(server, client2) {
|
|
|
40176
39954
|
});
|
|
40177
39955
|
}
|
|
40178
39956
|
|
|
39957
|
+
// src/cache/node.ts
|
|
39958
|
+
import { DatabaseSync } from "node:sqlite";
|
|
39959
|
+
import { mkdirSync, chmodSync, existsSync } from "node:fs";
|
|
39960
|
+
import { dirname as dirname2 } from "node:path";
|
|
39961
|
+
|
|
39962
|
+
// src/cache/store.ts
|
|
39963
|
+
function rowFromDb(r) {
|
|
39964
|
+
return {
|
|
39965
|
+
id: r.id,
|
|
39966
|
+
folder: r.folder,
|
|
39967
|
+
subject: r.subject,
|
|
39968
|
+
fromUser: r.from_user,
|
|
39969
|
+
sentAt: r.sent_at,
|
|
39970
|
+
recipients: JSON.parse(r.recipients_json),
|
|
39971
|
+
body: r.body,
|
|
39972
|
+
fetchedBodyAt: r.fetched_body_at,
|
|
39973
|
+
replyToId: r.reply_to_id,
|
|
39974
|
+
chainRootId: r.chain_root_id,
|
|
39975
|
+
listData: JSON.parse(r.list_data_json)
|
|
39976
|
+
};
|
|
39977
|
+
}
|
|
39978
|
+
function draftFromDb(r) {
|
|
39979
|
+
return {
|
|
39980
|
+
id: r.id,
|
|
39981
|
+
subject: r.subject,
|
|
39982
|
+
body: r.body,
|
|
39983
|
+
recipients: JSON.parse(r.recipients_json),
|
|
39984
|
+
replyToId: r.reply_to_id,
|
|
39985
|
+
modifiedAt: r.modified_at,
|
|
39986
|
+
listData: JSON.parse(r.list_data_json)
|
|
39987
|
+
};
|
|
39988
|
+
}
|
|
39989
|
+
function attachmentFromDb(r) {
|
|
39990
|
+
return {
|
|
39991
|
+
fileId: r.file_id,
|
|
39992
|
+
fileName: r.file_name,
|
|
39993
|
+
label: r.label,
|
|
39994
|
+
mimeType: r.mime_type,
|
|
39995
|
+
sizeBytes: r.size_bytes,
|
|
39996
|
+
metadata: JSON.parse(r.metadata_json),
|
|
39997
|
+
messageIds: JSON.parse(r.message_ids_json),
|
|
39998
|
+
downloadedPath: r.downloaded_path,
|
|
39999
|
+
downloadedAt: r.downloaded_at
|
|
40000
|
+
};
|
|
40001
|
+
}
|
|
40002
|
+
function nullish3(v) {
|
|
40003
|
+
return v === void 0 ? null : v;
|
|
40004
|
+
}
|
|
40005
|
+
function requireString(field, v) {
|
|
40006
|
+
if (typeof v === "string") return v;
|
|
40007
|
+
throw new Error(`cache: ${field} is required (got ${v === void 0 ? "undefined" : "null"})`);
|
|
40008
|
+
}
|
|
40009
|
+
var SCHEMA_STATEMENTS = [
|
|
40010
|
+
`CREATE TABLE IF NOT EXISTS messages (
|
|
40011
|
+
id INTEGER PRIMARY KEY,
|
|
40012
|
+
folder TEXT NOT NULL,
|
|
40013
|
+
subject TEXT NOT NULL,
|
|
40014
|
+
from_user TEXT NOT NULL,
|
|
40015
|
+
sent_at TEXT NOT NULL,
|
|
40016
|
+
recipients_json TEXT NOT NULL,
|
|
40017
|
+
body TEXT,
|
|
40018
|
+
fetched_body_at TEXT,
|
|
40019
|
+
reply_to_id INTEGER,
|
|
40020
|
+
chain_root_id INTEGER,
|
|
40021
|
+
list_data_json TEXT NOT NULL,
|
|
40022
|
+
last_seen_at TEXT NOT NULL
|
|
40023
|
+
)`,
|
|
40024
|
+
`CREATE INDEX IF NOT EXISTS idx_messages_folder_sent_at ON messages(folder, sent_at DESC)`,
|
|
40025
|
+
`CREATE INDEX IF NOT EXISTS idx_messages_chain_root ON messages(chain_root_id)`,
|
|
40026
|
+
`CREATE TABLE IF NOT EXISTS drafts (
|
|
40027
|
+
id INTEGER PRIMARY KEY,
|
|
40028
|
+
subject TEXT NOT NULL,
|
|
40029
|
+
body TEXT NOT NULL,
|
|
40030
|
+
recipients_json TEXT NOT NULL,
|
|
40031
|
+
reply_to_id INTEGER,
|
|
40032
|
+
modified_at TEXT NOT NULL,
|
|
40033
|
+
list_data_json TEXT NOT NULL
|
|
40034
|
+
)`,
|
|
40035
|
+
`CREATE TABLE IF NOT EXISTS sync_state (
|
|
40036
|
+
folder TEXT PRIMARY KEY,
|
|
40037
|
+
last_sync_at TEXT NOT NULL,
|
|
40038
|
+
newest_id INTEGER
|
|
40039
|
+
)`,
|
|
40040
|
+
`CREATE TABLE IF NOT EXISTS meta (
|
|
40041
|
+
key TEXT PRIMARY KEY,
|
|
40042
|
+
value TEXT NOT NULL
|
|
40043
|
+
)`,
|
|
40044
|
+
// v2: attachments table. Idempotent — IF NOT EXISTS.
|
|
40045
|
+
`CREATE TABLE IF NOT EXISTS attachments (
|
|
40046
|
+
file_id INTEGER PRIMARY KEY,
|
|
40047
|
+
file_name TEXT NOT NULL,
|
|
40048
|
+
label TEXT NOT NULL,
|
|
40049
|
+
mime_type TEXT NOT NULL,
|
|
40050
|
+
size_bytes INTEGER,
|
|
40051
|
+
metadata_json TEXT NOT NULL,
|
|
40052
|
+
message_ids_json TEXT NOT NULL,
|
|
40053
|
+
downloaded_path TEXT,
|
|
40054
|
+
downloaded_at TEXT,
|
|
40055
|
+
fetched_metadata_at TEXT NOT NULL
|
|
40056
|
+
)`
|
|
40057
|
+
];
|
|
40058
|
+
var MIGRATIONS = [
|
|
40059
|
+
// Resumable deep-sync cursor. Absent/NULL → SyncState.resumePage null.
|
|
40060
|
+
"ALTER TABLE sync_state ADD COLUMN resume_page INTEGER"
|
|
40061
|
+
];
|
|
40062
|
+
var SCHEMA_VERSION = "2";
|
|
40063
|
+
function buildMessageFilter(opts) {
|
|
40064
|
+
const wheres = [];
|
|
40065
|
+
const params = [];
|
|
40066
|
+
if (opts.folder !== void 0) {
|
|
40067
|
+
wheres.push("folder = ?");
|
|
40068
|
+
params.push(opts.folder);
|
|
40069
|
+
}
|
|
40070
|
+
if (opts.since !== void 0) {
|
|
40071
|
+
wheres.push("sent_at >= ?");
|
|
40072
|
+
params.push(opts.since);
|
|
40073
|
+
}
|
|
40074
|
+
if (opts.until !== void 0) {
|
|
40075
|
+
wheres.push("sent_at < ?");
|
|
40076
|
+
params.push(opts.until);
|
|
40077
|
+
}
|
|
40078
|
+
if (opts.q !== void 0 && opts.q.length > 0) {
|
|
40079
|
+
const pattern = `%${opts.q}%`;
|
|
40080
|
+
wheres.push("(subject LIKE ? OR body LIKE ?)");
|
|
40081
|
+
params.push(pattern, pattern);
|
|
40082
|
+
}
|
|
40083
|
+
return {
|
|
40084
|
+
where: wheres.length > 0 ? `WHERE ${wheres.join(" AND ")}` : "",
|
|
40085
|
+
params
|
|
40086
|
+
};
|
|
40087
|
+
}
|
|
40088
|
+
var OFWCacheCore = class {
|
|
40089
|
+
constructor(db) {
|
|
40090
|
+
this.db = db;
|
|
40091
|
+
for (const stmt of SCHEMA_STATEMENTS) this.db.execScript(stmt);
|
|
40092
|
+
for (const stmt of MIGRATIONS) {
|
|
40093
|
+
try {
|
|
40094
|
+
this.db.execScript(stmt);
|
|
40095
|
+
} catch {
|
|
40096
|
+
}
|
|
40097
|
+
}
|
|
40098
|
+
this.db.run(
|
|
40099
|
+
"INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
|
40100
|
+
["schema_version", SCHEMA_VERSION]
|
|
40101
|
+
);
|
|
40102
|
+
}
|
|
40103
|
+
db;
|
|
40104
|
+
upsertMessage(row) {
|
|
40105
|
+
this.db.run(
|
|
40106
|
+
`INSERT INTO messages (
|
|
40107
|
+
id, folder, subject, from_user, sent_at, recipients_json,
|
|
40108
|
+
body, fetched_body_at, reply_to_id, chain_root_id, list_data_json, last_seen_at
|
|
40109
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
40110
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
40111
|
+
folder=excluded.folder,
|
|
40112
|
+
subject=excluded.subject,
|
|
40113
|
+
from_user=excluded.from_user,
|
|
40114
|
+
sent_at=excluded.sent_at,
|
|
40115
|
+
recipients_json=excluded.recipients_json,
|
|
40116
|
+
body=excluded.body,
|
|
40117
|
+
fetched_body_at=excluded.fetched_body_at,
|
|
40118
|
+
reply_to_id=excluded.reply_to_id,
|
|
40119
|
+
chain_root_id=excluded.chain_root_id,
|
|
40120
|
+
list_data_json=excluded.list_data_json,
|
|
40121
|
+
last_seen_at=excluded.last_seen_at`,
|
|
40122
|
+
[
|
|
40123
|
+
row.id,
|
|
40124
|
+
requireString("messages.folder", row.folder),
|
|
40125
|
+
requireString("messages.subject", row.subject),
|
|
40126
|
+
requireString("messages.fromUser", row.fromUser),
|
|
40127
|
+
requireString("messages.sentAt", row.sentAt),
|
|
40128
|
+
JSON.stringify(row.recipients ?? []),
|
|
40129
|
+
nullish3(row.body),
|
|
40130
|
+
nullish3(row.fetchedBodyAt),
|
|
40131
|
+
nullish3(row.replyToId),
|
|
40132
|
+
nullish3(row.chainRootId),
|
|
40133
|
+
JSON.stringify(row.listData ?? null),
|
|
40134
|
+
(/* @__PURE__ */ new Date()).toISOString()
|
|
40135
|
+
]
|
|
40136
|
+
);
|
|
40137
|
+
}
|
|
40138
|
+
/**
|
|
40139
|
+
* Batch upsert every row in a single transaction — one round-trip's worth of
|
|
40140
|
+
* work (crucial on the Durable Object backend, where each RPC is a subrequest).
|
|
40141
|
+
* Empty array is a no-op (no transaction opened).
|
|
40142
|
+
*/
|
|
40143
|
+
upsertMessages(rows) {
|
|
40144
|
+
if (rows.length === 0) return;
|
|
40145
|
+
this.db.transaction(() => {
|
|
40146
|
+
for (const row of rows) this.upsertMessage(row);
|
|
40147
|
+
});
|
|
40148
|
+
}
|
|
40149
|
+
getMessage(id) {
|
|
40150
|
+
const r = this.db.get("SELECT * FROM messages WHERE id = ?", [id]);
|
|
40151
|
+
return r ? rowFromDb(r) : null;
|
|
40152
|
+
}
|
|
40153
|
+
/**
|
|
40154
|
+
* Batch read: one `SELECT ... WHERE id IN (...)` returning the present rows
|
|
40155
|
+
* (absent ids are simply omitted — order is not guaranteed). Empty ids returns
|
|
40156
|
+
* `[]` without querying.
|
|
40157
|
+
*/
|
|
40158
|
+
getMessages(ids) {
|
|
40159
|
+
if (ids.length === 0) return [];
|
|
40160
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
40161
|
+
const rows = this.db.all(
|
|
40162
|
+
`SELECT * FROM messages WHERE id IN (${placeholders})`,
|
|
40163
|
+
ids
|
|
40164
|
+
);
|
|
40165
|
+
return rows.map(rowFromDb);
|
|
40166
|
+
}
|
|
40167
|
+
/**
|
|
40168
|
+
* Remove a row from the `messages` table. Used by syncDrafts to evict
|
|
40169
|
+
* stale rows that were cached when a draft was previously read through
|
|
40170
|
+
* `ofw_get_message` (which would have wrongly classified it as `inbox`)
|
|
40171
|
+
* — the drafts table is the authoritative source for that id now.
|
|
40172
|
+
*/
|
|
40173
|
+
deleteMessage(id) {
|
|
40174
|
+
this.db.run("DELETE FROM messages WHERE id = ?", [id]);
|
|
40175
|
+
}
|
|
40176
|
+
listMessages(opts) {
|
|
40177
|
+
const { where, params } = buildMessageFilter(opts);
|
|
40178
|
+
const offset = (opts.page - 1) * opts.size;
|
|
40179
|
+
const rows = this.db.all(
|
|
40180
|
+
`SELECT * FROM messages ${where}
|
|
40181
|
+
ORDER BY sent_at DESC, id DESC
|
|
40182
|
+
LIMIT ? OFFSET ?`,
|
|
40183
|
+
[...params, opts.size, offset]
|
|
40184
|
+
);
|
|
40185
|
+
return rows.map(rowFromDb);
|
|
40186
|
+
}
|
|
40187
|
+
countMessages(opts) {
|
|
40188
|
+
const { where, params } = buildMessageFilter(opts);
|
|
40189
|
+
const r = this.db.get(`SELECT COUNT(*) as n FROM messages ${where}`, params);
|
|
40190
|
+
return r?.n ?? 0;
|
|
40191
|
+
}
|
|
40192
|
+
upsertDraft(row) {
|
|
40193
|
+
this.db.run(
|
|
40194
|
+
`INSERT INTO drafts (id, subject, body, recipients_json, reply_to_id, modified_at, list_data_json)
|
|
40195
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
40196
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
40197
|
+
subject=excluded.subject,
|
|
40198
|
+
body=excluded.body,
|
|
40199
|
+
recipients_json=excluded.recipients_json,
|
|
40200
|
+
reply_to_id=excluded.reply_to_id,
|
|
40201
|
+
modified_at=excluded.modified_at,
|
|
40202
|
+
list_data_json=excluded.list_data_json`,
|
|
40203
|
+
[
|
|
40204
|
+
row.id,
|
|
40205
|
+
requireString("drafts.subject", row.subject),
|
|
40206
|
+
requireString("drafts.body", row.body),
|
|
40207
|
+
JSON.stringify(row.recipients ?? []),
|
|
40208
|
+
nullish3(row.replyToId),
|
|
40209
|
+
requireString("drafts.modifiedAt", row.modifiedAt),
|
|
40210
|
+
JSON.stringify(row.listData ?? null)
|
|
40211
|
+
]
|
|
40212
|
+
);
|
|
40213
|
+
}
|
|
40214
|
+
/** Batch upsert every draft in a single transaction. Empty array is a no-op. */
|
|
40215
|
+
upsertDrafts(rows) {
|
|
40216
|
+
if (rows.length === 0) return;
|
|
40217
|
+
this.db.transaction(() => {
|
|
40218
|
+
for (const row of rows) this.upsertDraft(row);
|
|
40219
|
+
});
|
|
40220
|
+
}
|
|
40221
|
+
getDraft(id) {
|
|
40222
|
+
const r = this.db.get("SELECT * FROM drafts WHERE id = ?", [id]);
|
|
40223
|
+
return r ? draftFromDb(r) : null;
|
|
40224
|
+
}
|
|
40225
|
+
/**
|
|
40226
|
+
* Batch read: one `SELECT ... WHERE id IN (...)` returning the present drafts
|
|
40227
|
+
* (absent ids omitted — order not guaranteed). Empty ids returns `[]` without
|
|
40228
|
+
* querying.
|
|
40229
|
+
*/
|
|
40230
|
+
getDrafts(ids) {
|
|
40231
|
+
if (ids.length === 0) return [];
|
|
40232
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
40233
|
+
const rows = this.db.all(
|
|
40234
|
+
`SELECT * FROM drafts WHERE id IN (${placeholders})`,
|
|
40235
|
+
ids
|
|
40236
|
+
);
|
|
40237
|
+
return rows.map(draftFromDb);
|
|
40238
|
+
}
|
|
40239
|
+
listDrafts(opts) {
|
|
40240
|
+
const offset = (opts.page - 1) * opts.size;
|
|
40241
|
+
const rows = this.db.all(
|
|
40242
|
+
"SELECT * FROM drafts ORDER BY modified_at DESC, id DESC LIMIT ? OFFSET ?",
|
|
40243
|
+
[opts.size, offset]
|
|
40244
|
+
);
|
|
40245
|
+
return rows.map(draftFromDb);
|
|
40246
|
+
}
|
|
40247
|
+
deleteDraft(id) {
|
|
40248
|
+
this.db.run("DELETE FROM drafts WHERE id = ?", [id]);
|
|
40249
|
+
}
|
|
40250
|
+
listDraftIds() {
|
|
40251
|
+
const rows = this.db.all("SELECT id FROM drafts", []);
|
|
40252
|
+
return rows.map((r) => r.id);
|
|
40253
|
+
}
|
|
40254
|
+
getSyncState(folder) {
|
|
40255
|
+
const r = this.db.get("SELECT last_sync_at, newest_id, resume_page FROM sync_state WHERE folder = ?", [folder]);
|
|
40256
|
+
if (!r) return null;
|
|
40257
|
+
return { lastSyncAt: r.last_sync_at, newestId: r.newest_id, resumePage: r.resume_page ?? null };
|
|
40258
|
+
}
|
|
40259
|
+
setSyncState(folder, state) {
|
|
40260
|
+
this.db.run(
|
|
40261
|
+
`INSERT INTO sync_state (folder, last_sync_at, newest_id, resume_page) VALUES (?, ?, ?, ?)
|
|
40262
|
+
ON CONFLICT(folder) DO UPDATE SET
|
|
40263
|
+
last_sync_at = excluded.last_sync_at,
|
|
40264
|
+
newest_id = excluded.newest_id,
|
|
40265
|
+
resume_page = excluded.resume_page`,
|
|
40266
|
+
[folder, state.lastSyncAt, nullish3(state.newestId), nullish3(state.resumePage)]
|
|
40267
|
+
);
|
|
40268
|
+
}
|
|
40269
|
+
getMeta(key) {
|
|
40270
|
+
const r = this.db.get("SELECT value FROM meta WHERE key = ?", [key]);
|
|
40271
|
+
return r ? r.value : null;
|
|
40272
|
+
}
|
|
40273
|
+
setMeta(key, value) {
|
|
40274
|
+
this.db.run(
|
|
40275
|
+
"INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
|
40276
|
+
[key, value]
|
|
40277
|
+
);
|
|
40278
|
+
}
|
|
40279
|
+
findLatestReplyTip(replyToId) {
|
|
40280
|
+
const parent = this.db.get("SELECT id, folder, chain_root_id FROM messages WHERE id = ?", [replyToId]);
|
|
40281
|
+
if (!parent) return replyToId;
|
|
40282
|
+
const chainRoot = parent.chain_root_id ?? parent.id;
|
|
40283
|
+
const tip = this.db.get(
|
|
40284
|
+
`SELECT id FROM messages
|
|
40285
|
+
WHERE folder = 'sent' AND chain_root_id = ?
|
|
40286
|
+
ORDER BY id DESC LIMIT 1`,
|
|
40287
|
+
[chainRoot]
|
|
40288
|
+
);
|
|
40289
|
+
return tip ? tip.id : replyToId;
|
|
40290
|
+
}
|
|
40291
|
+
getAttachment(fileId) {
|
|
40292
|
+
const r = this.db.get("SELECT * FROM attachments WHERE file_id = ?", [fileId]);
|
|
40293
|
+
return r ? attachmentFromDb(r) : null;
|
|
40294
|
+
}
|
|
40295
|
+
listAttachmentsForMessage(messageId) {
|
|
40296
|
+
const rows = this.db.all(
|
|
40297
|
+
`SELECT * FROM attachments
|
|
40298
|
+
WHERE EXISTS (SELECT 1 FROM json_each(message_ids_json) WHERE value = ?)
|
|
40299
|
+
ORDER BY file_id`,
|
|
40300
|
+
[messageId]
|
|
40301
|
+
);
|
|
40302
|
+
return rows.map(attachmentFromDb);
|
|
40303
|
+
}
|
|
40304
|
+
upsertAttachmentForMessage(input) {
|
|
40305
|
+
const existing = this.db.get("SELECT message_ids_json FROM attachments WHERE file_id = ?", [input.fileId]);
|
|
40306
|
+
const prior = existing ? JSON.parse(existing.message_ids_json) : [];
|
|
40307
|
+
let messageIds;
|
|
40308
|
+
if (input.messageId === 0) {
|
|
40309
|
+
messageIds = prior;
|
|
40310
|
+
} else if (prior.includes(input.messageId)) {
|
|
40311
|
+
messageIds = prior;
|
|
40312
|
+
} else {
|
|
40313
|
+
messageIds = [...prior, input.messageId];
|
|
40314
|
+
}
|
|
40315
|
+
this.db.run(
|
|
40316
|
+
`INSERT INTO attachments (
|
|
40317
|
+
file_id, file_name, label, mime_type, size_bytes,
|
|
40318
|
+
metadata_json, message_ids_json, fetched_metadata_at
|
|
40319
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
40320
|
+
ON CONFLICT(file_id) DO UPDATE SET
|
|
40321
|
+
file_name=excluded.file_name,
|
|
40322
|
+
label=excluded.label,
|
|
40323
|
+
mime_type=excluded.mime_type,
|
|
40324
|
+
size_bytes=excluded.size_bytes,
|
|
40325
|
+
metadata_json=excluded.metadata_json,
|
|
40326
|
+
message_ids_json=excluded.message_ids_json,
|
|
40327
|
+
fetched_metadata_at=excluded.fetched_metadata_at`,
|
|
40328
|
+
[
|
|
40329
|
+
input.fileId,
|
|
40330
|
+
requireString("attachments.fileName", input.fileName),
|
|
40331
|
+
requireString("attachments.label", input.label),
|
|
40332
|
+
requireString("attachments.mimeType", input.mimeType),
|
|
40333
|
+
nullish3(input.sizeBytes),
|
|
40334
|
+
JSON.stringify(input.metadata ?? null),
|
|
40335
|
+
JSON.stringify(messageIds),
|
|
40336
|
+
(/* @__PURE__ */ new Date()).toISOString()
|
|
40337
|
+
]
|
|
40338
|
+
);
|
|
40339
|
+
}
|
|
40340
|
+
markAttachmentDownloaded(fileId, path) {
|
|
40341
|
+
this.db.run("UPDATE attachments SET downloaded_path = ?, downloaded_at = ? WHERE file_id = ?", [
|
|
40342
|
+
path,
|
|
40343
|
+
(/* @__PURE__ */ new Date()).toISOString(),
|
|
40344
|
+
fileId
|
|
40345
|
+
]);
|
|
40346
|
+
}
|
|
40347
|
+
};
|
|
40348
|
+
var LocalCacheStore = class {
|
|
40349
|
+
constructor(core) {
|
|
40350
|
+
this.core = core;
|
|
40351
|
+
}
|
|
40352
|
+
core;
|
|
40353
|
+
async upsertMessage(row) {
|
|
40354
|
+
this.core.upsertMessage(row);
|
|
40355
|
+
}
|
|
40356
|
+
async upsertMessages(rows) {
|
|
40357
|
+
this.core.upsertMessages(rows);
|
|
40358
|
+
}
|
|
40359
|
+
async getMessage(id) {
|
|
40360
|
+
return this.core.getMessage(id);
|
|
40361
|
+
}
|
|
40362
|
+
async getMessages(ids) {
|
|
40363
|
+
return this.core.getMessages(ids);
|
|
40364
|
+
}
|
|
40365
|
+
async deleteMessage(id) {
|
|
40366
|
+
this.core.deleteMessage(id);
|
|
40367
|
+
}
|
|
40368
|
+
async listMessages(opts) {
|
|
40369
|
+
return this.core.listMessages(opts);
|
|
40370
|
+
}
|
|
40371
|
+
async countMessages(opts) {
|
|
40372
|
+
return this.core.countMessages(opts);
|
|
40373
|
+
}
|
|
40374
|
+
async upsertDraft(row) {
|
|
40375
|
+
this.core.upsertDraft(row);
|
|
40376
|
+
}
|
|
40377
|
+
async upsertDrafts(rows) {
|
|
40378
|
+
this.core.upsertDrafts(rows);
|
|
40379
|
+
}
|
|
40380
|
+
async getDraft(id) {
|
|
40381
|
+
return this.core.getDraft(id);
|
|
40382
|
+
}
|
|
40383
|
+
async getDrafts(ids) {
|
|
40384
|
+
return this.core.getDrafts(ids);
|
|
40385
|
+
}
|
|
40386
|
+
async listDrafts(opts) {
|
|
40387
|
+
return this.core.listDrafts(opts);
|
|
40388
|
+
}
|
|
40389
|
+
async deleteDraft(id) {
|
|
40390
|
+
this.core.deleteDraft(id);
|
|
40391
|
+
}
|
|
40392
|
+
async listDraftIds() {
|
|
40393
|
+
return this.core.listDraftIds();
|
|
40394
|
+
}
|
|
40395
|
+
async getSyncState(folder) {
|
|
40396
|
+
return this.core.getSyncState(folder);
|
|
40397
|
+
}
|
|
40398
|
+
async setSyncState(folder, state) {
|
|
40399
|
+
this.core.setSyncState(folder, state);
|
|
40400
|
+
}
|
|
40401
|
+
async getMeta(key) {
|
|
40402
|
+
return this.core.getMeta(key);
|
|
40403
|
+
}
|
|
40404
|
+
async setMeta(key, value) {
|
|
40405
|
+
this.core.setMeta(key, value);
|
|
40406
|
+
}
|
|
40407
|
+
async findLatestReplyTip(replyToId) {
|
|
40408
|
+
return this.core.findLatestReplyTip(replyToId);
|
|
40409
|
+
}
|
|
40410
|
+
async getAttachment(fileId) {
|
|
40411
|
+
return this.core.getAttachment(fileId);
|
|
40412
|
+
}
|
|
40413
|
+
async listAttachmentsForMessage(messageId) {
|
|
40414
|
+
return this.core.listAttachmentsForMessage(messageId);
|
|
40415
|
+
}
|
|
40416
|
+
async upsertAttachmentForMessage(input) {
|
|
40417
|
+
this.core.upsertAttachmentForMessage(input);
|
|
40418
|
+
}
|
|
40419
|
+
async markAttachmentDownloaded(fileId, path) {
|
|
40420
|
+
this.core.markAttachmentDownloaded(fileId, path);
|
|
40421
|
+
}
|
|
40422
|
+
};
|
|
40423
|
+
|
|
40424
|
+
// src/cache/node.ts
|
|
40425
|
+
var NodeSqlDriver = class {
|
|
40426
|
+
constructor(db) {
|
|
40427
|
+
this.db = db;
|
|
40428
|
+
}
|
|
40429
|
+
db;
|
|
40430
|
+
execScript(sql) {
|
|
40431
|
+
this.db.exec(sql);
|
|
40432
|
+
}
|
|
40433
|
+
run(sql, params) {
|
|
40434
|
+
this.db.prepare(sql).run(...params);
|
|
40435
|
+
}
|
|
40436
|
+
get(sql, params) {
|
|
40437
|
+
return this.db.prepare(sql).get(...params);
|
|
40438
|
+
}
|
|
40439
|
+
all(sql, params) {
|
|
40440
|
+
return this.db.prepare(sql).all(...params);
|
|
40441
|
+
}
|
|
40442
|
+
transaction(fn) {
|
|
40443
|
+
this.db.exec("BEGIN");
|
|
40444
|
+
try {
|
|
40445
|
+
fn();
|
|
40446
|
+
this.db.exec("COMMIT");
|
|
40447
|
+
} catch (e) {
|
|
40448
|
+
this.db.exec("ROLLBACK");
|
|
40449
|
+
throw e;
|
|
40450
|
+
}
|
|
40451
|
+
}
|
|
40452
|
+
};
|
|
40453
|
+
function enforceCachePermissions(dbPath) {
|
|
40454
|
+
chmodSync(dirname2(dbPath), 448);
|
|
40455
|
+
chmodSync(dbPath, 384);
|
|
40456
|
+
for (const sibling of [`${dbPath}-wal`, `${dbPath}-shm`]) {
|
|
40457
|
+
if (existsSync(sibling)) chmodSync(sibling, 384);
|
|
40458
|
+
}
|
|
40459
|
+
}
|
|
40460
|
+
var OFWCache = class _OFWCache extends LocalCacheStore {
|
|
40461
|
+
constructor(db, core) {
|
|
40462
|
+
super(core);
|
|
40463
|
+
this.db = db;
|
|
40464
|
+
}
|
|
40465
|
+
db;
|
|
40466
|
+
static open(path) {
|
|
40467
|
+
const memory = path === ":memory:";
|
|
40468
|
+
if (!memory) mkdirSync(dirname2(path), { recursive: true });
|
|
40469
|
+
const db = new DatabaseSync(path);
|
|
40470
|
+
if (!memory) enforceCachePermissions(path);
|
|
40471
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
40472
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
40473
|
+
const core = new OFWCacheCore(new NodeSqlDriver(db));
|
|
40474
|
+
if (!memory) enforceCachePermissions(path);
|
|
40475
|
+
return new _OFWCache(db, core);
|
|
40476
|
+
}
|
|
40477
|
+
close() {
|
|
40478
|
+
this.db.close();
|
|
40479
|
+
}
|
|
40480
|
+
};
|
|
40481
|
+
|
|
40482
|
+
// src/tools/attachments.ts
|
|
40483
|
+
import { readFileSync, statSync, mkdirSync as mkdirSync2, writeFileSync } from "node:fs";
|
|
40484
|
+
import { basename as basename2, dirname as dirname3, extname } from "node:path";
|
|
40485
|
+
var MIME_BY_EXT = {
|
|
40486
|
+
".pdf": "application/pdf",
|
|
40487
|
+
".png": "image/png",
|
|
40488
|
+
".jpg": "image/jpeg",
|
|
40489
|
+
".jpeg": "image/jpeg",
|
|
40490
|
+
".gif": "image/gif",
|
|
40491
|
+
".webp": "image/webp",
|
|
40492
|
+
".heic": "image/heic",
|
|
40493
|
+
".txt": "text/plain",
|
|
40494
|
+
".md": "text/markdown",
|
|
40495
|
+
".csv": "text/csv",
|
|
40496
|
+
".html": "text/html",
|
|
40497
|
+
".htm": "text/html",
|
|
40498
|
+
".json": "application/json",
|
|
40499
|
+
".xml": "application/xml",
|
|
40500
|
+
".doc": "application/msword",
|
|
40501
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
40502
|
+
".xls": "application/vnd.ms-excel",
|
|
40503
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
40504
|
+
".ppt": "application/vnd.ms-powerpoint",
|
|
40505
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
40506
|
+
".zip": "application/zip",
|
|
40507
|
+
".ics": "text/calendar"
|
|
40508
|
+
};
|
|
40509
|
+
function mimeFromName(name) {
|
|
40510
|
+
return MIME_BY_EXT[extname(name).toLowerCase()] ?? "application/octet-stream";
|
|
40511
|
+
}
|
|
40512
|
+
var NodeAttachmentIO = class {
|
|
40513
|
+
async resolveUpload(path) {
|
|
40514
|
+
const abs = expandPath(path);
|
|
40515
|
+
const stat = statSync(abs);
|
|
40516
|
+
if (!stat.isFile()) throw new Error(`Not a file: ${abs}`);
|
|
40517
|
+
const fileName = basename2(abs);
|
|
40518
|
+
const mimeType = mimeFromName(fileName);
|
|
40519
|
+
const blob = await fileBlob(abs, { type: mimeType });
|
|
40520
|
+
return { blob, fileName, mimeType, sizeBytes: stat.size };
|
|
40521
|
+
}
|
|
40522
|
+
readDownloaded(path) {
|
|
40523
|
+
try {
|
|
40524
|
+
return readFileSync(path);
|
|
40525
|
+
} catch {
|
|
40526
|
+
return null;
|
|
40527
|
+
}
|
|
40528
|
+
}
|
|
40529
|
+
writeDownload(dest, bytes) {
|
|
40530
|
+
mkdirSync2(dirname3(dest), { recursive: true });
|
|
40531
|
+
writeFileSync(dest, bytes);
|
|
40532
|
+
}
|
|
40533
|
+
};
|
|
40534
|
+
|
|
40179
40535
|
// src/index.ts
|
|
40180
40536
|
var originalEmit = process.emit.bind(process);
|
|
40181
40537
|
process.emit = function(event, ...args) {
|
|
@@ -40187,14 +40543,17 @@ process.emit = function(event, ...args) {
|
|
|
40187
40543
|
}
|
|
40188
40544
|
return originalEmit(event, ...args);
|
|
40189
40545
|
};
|
|
40546
|
+
var nodeCache;
|
|
40547
|
+
var nodeCacheProvider = () => nodeCache ??= OFWCache.open(getCacheDbPath());
|
|
40548
|
+
var nodeAttachmentIO = new NodeAttachmentIO();
|
|
40190
40549
|
await runMcp({
|
|
40191
40550
|
name: "ofw",
|
|
40192
|
-
version: "2.
|
|
40551
|
+
version: "2.6.3",
|
|
40193
40552
|
// x-release-please-version
|
|
40194
40553
|
deps: client,
|
|
40195
40554
|
tools: [
|
|
40196
40555
|
registerUserTools,
|
|
40197
|
-
registerMessageTools,
|
|
40556
|
+
(server, deps) => registerMessageTools(server, deps, nodeCacheProvider, nodeAttachmentIO),
|
|
40198
40557
|
registerCalendarTools,
|
|
40199
40558
|
registerExpenseTools,
|
|
40200
40559
|
registerJournalTools
|