ofw-mcp 2.7.1 → 2.9.0
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 +58 -4
- package/dist/bundle.js +1651 -165
- package/dist/cache/store.js +79 -1
- package/dist/config.js +60 -0
- package/dist/extract/document.js +83 -0
- package/dist/extract/index.js +222 -0
- package/dist/extract/inflate.js +55 -0
- package/dist/extract/ooxml.js +58 -0
- package/dist/extract/pdf.js +278 -0
- package/dist/extract/presentation.js +54 -0
- package/dist/extract/spreadsheet.js +258 -0
- package/dist/extract/types.js +4 -0
- package/dist/extract/xml.js +61 -0
- package/dist/extract/zip.js +110 -0
- package/dist/index.js +1 -1
- package/dist/sync.js +11 -2
- package/dist/tools/delivery.js +99 -0
- package/dist/tools/draft-freshness.js +47 -9
- package/dist/tools/lifecycle.js +277 -0
- package/dist/tools/messages.js +571 -177
- package/package.json +1 -1
- package/server.json +14 -2
- package/skills/ofw/SKILL.md +34 -15
package/dist/bundle.js
CHANGED
|
@@ -13672,9 +13672,9 @@ var ZodSet = class _ZodSet extends ZodType {
|
|
|
13672
13672
|
}
|
|
13673
13673
|
}
|
|
13674
13674
|
const valueType = this._def.valueType;
|
|
13675
|
-
function finalizeSet(
|
|
13675
|
+
function finalizeSet(elements3) {
|
|
13676
13676
|
const parsedSet = /* @__PURE__ */ new Set();
|
|
13677
|
-
for (const element of
|
|
13677
|
+
for (const element of elements3) {
|
|
13678
13678
|
if (element.status === "aborted")
|
|
13679
13679
|
return INVALID;
|
|
13680
13680
|
if (element.status === "dirty")
|
|
@@ -13683,11 +13683,11 @@ var ZodSet = class _ZodSet extends ZodType {
|
|
|
13683
13683
|
}
|
|
13684
13684
|
return { status: status.value, value: parsedSet };
|
|
13685
13685
|
}
|
|
13686
|
-
const
|
|
13686
|
+
const elements2 = [...ctx.data.values()].map((item, i) => valueType._parse(new ParseInputLazyPath(ctx, item, ctx.path, i)));
|
|
13687
13687
|
if (ctx.common.async) {
|
|
13688
|
-
return Promise.all(
|
|
13688
|
+
return Promise.all(elements2).then((elements3) => finalizeSet(elements3));
|
|
13689
13689
|
} else {
|
|
13690
|
-
return finalizeSet(
|
|
13690
|
+
return finalizeSet(elements2);
|
|
13691
13691
|
}
|
|
13692
13692
|
}
|
|
13693
13693
|
min(minSize, message) {
|
|
@@ -38407,7 +38407,7 @@ async function loginWithPassword(username, password) {
|
|
|
38407
38407
|
// package.json
|
|
38408
38408
|
var package_default = {
|
|
38409
38409
|
name: "ofw-mcp",
|
|
38410
|
-
version: "2.
|
|
38410
|
+
version: "2.9.0",
|
|
38411
38411
|
license: "MIT",
|
|
38412
38412
|
mcpName: "io.github.chrischall/ofw-mcp",
|
|
38413
38413
|
description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
|
|
@@ -38856,6 +38856,7 @@ async function resolveFolderIds(client2, store) {
|
|
|
38856
38856
|
};
|
|
38857
38857
|
await store.setMeta("drafts_folder_id", ids.drafts);
|
|
38858
38858
|
await store.setMeta("sent_folder_id", ids.sent);
|
|
38859
|
+
await store.setMeta("inbox_folder_id", ids.inbox);
|
|
38859
38860
|
return ids;
|
|
38860
38861
|
}
|
|
38861
38862
|
var ListItemSchema = external_exports.looseObject({
|
|
@@ -38972,7 +38973,7 @@ async function walkPages(client2, folder, folderId, opts, store) {
|
|
|
38972
38973
|
await fetchAttachmentMetaBudgeted(client2, item.id, detailFileIds, store, budget);
|
|
38973
38974
|
}
|
|
38974
38975
|
}
|
|
38975
|
-
await store.upsertMessages(toUpsert);
|
|
38976
|
+
if (toUpsert.length > 0) await store.upsertMessages(toUpsert);
|
|
38976
38977
|
if (pageBudgetHit) {
|
|
38977
38978
|
return { synced, unread, newestId, pagesFetched, done: false, nextPage: page };
|
|
38978
38979
|
}
|
|
@@ -39235,6 +39236,23 @@ function getCalendarWritesAllowed() {
|
|
|
39235
39236
|
if (mode === "all") return true;
|
|
39236
39237
|
return mode === "drafts" && parseBoolEnv("OFW_CALENDAR_WRITES");
|
|
39237
39238
|
}
|
|
39239
|
+
function getAllowMarkRead() {
|
|
39240
|
+
const raw = process.env.OFW_ALLOW_MARK_READ;
|
|
39241
|
+
if (typeof raw !== "string" || raw.trim().length === 0) return true;
|
|
39242
|
+
const value = raw.trim().toLowerCase();
|
|
39243
|
+
if (["1", "true", "yes", "on"].includes(value)) return true;
|
|
39244
|
+
if (["0", "false", "no", "off"].includes(value)) return false;
|
|
39245
|
+
console.error(
|
|
39246
|
+
`[ofw-mcp] Unrecognized OFW_ALLOW_MARK_READ "${raw.trim()}" \u2014 failing closed to "false" (no tool may mark a message read on OFW). Valid values: true, false.`
|
|
39247
|
+
);
|
|
39248
|
+
return false;
|
|
39249
|
+
}
|
|
39250
|
+
function getFetchUnreadBodies() {
|
|
39251
|
+
return parseBoolEnv("OFW_FETCH_UNREAD_BODIES");
|
|
39252
|
+
}
|
|
39253
|
+
function getAutoRefreshStaleReads() {
|
|
39254
|
+
return parseBoolEnv("OFW_AUTO_REFRESH");
|
|
39255
|
+
}
|
|
39238
39256
|
function getDefaultInlineAttachments() {
|
|
39239
39257
|
return parseBoolEnv("OFW_INLINE_ATTACHMENTS");
|
|
39240
39258
|
}
|
|
@@ -39380,12 +39398,30 @@ var ServerDraftSchema = external_exports.looseObject({
|
|
|
39380
39398
|
subject: external_exports.string().optional(),
|
|
39381
39399
|
body: external_exports.string().optional(),
|
|
39382
39400
|
replyToId: external_exports.number().nullable().optional(),
|
|
39383
|
-
recipients: external_exports.array(ApiRecipientSchema).optional()
|
|
39401
|
+
recipients: external_exports.array(ApiRecipientSchema).optional(),
|
|
39402
|
+
// Read for the LIFECYCLE answer (see tools/lifecycle.ts): which folder OFW
|
|
39403
|
+
// itself says this id lives in right now. `existsOnServer` alone cannot
|
|
39404
|
+
// distinguish "still a draft" from "was sent" — a sent draft still exists.
|
|
39405
|
+
// `id` accepts BOTH spellings deliberately. This schema is parsed in
|
|
39406
|
+
// `mode: 'strict'` because it backs the destructive-draft guard, so a
|
|
39407
|
+
// present-but-mistyped field THROWS — and OFW is already inconsistent about
|
|
39408
|
+
// this exact field: the folders listing (`FoldersSchema` in sync.ts) types it
|
|
39409
|
+
// `z.string()`, while message detail has been observed returning a number.
|
|
39410
|
+
// Pinning one spelling here would turn a harmless representation change into
|
|
39411
|
+
// a hard failure of ofw_save_draft / ofw_delete_draft, which is the opposite
|
|
39412
|
+
// of what a strict boundary is for: it exists to stop us acting on a response
|
|
39413
|
+
// we cannot interpret, not to reject one we can. `folderId` is normalized to
|
|
39414
|
+
// a string below, so both spellings compare correctly downstream.
|
|
39415
|
+
folder: external_exports.looseObject({
|
|
39416
|
+
id: external_exports.union([external_exports.string(), external_exports.number()]).optional(),
|
|
39417
|
+
name: external_exports.string().optional()
|
|
39418
|
+
}).nullable().optional(),
|
|
39419
|
+
date: external_exports.looseObject({ dateTime: external_exports.string().optional() }).nullable().optional()
|
|
39384
39420
|
});
|
|
39385
39421
|
function isNotFound(e) {
|
|
39386
39422
|
return e instanceof Error && /OFW API error: 404\b/.test(e.message);
|
|
39387
39423
|
}
|
|
39388
|
-
async function
|
|
39424
|
+
async function fetchMessageSnapshot(client2, id) {
|
|
39389
39425
|
let raw;
|
|
39390
39426
|
try {
|
|
39391
39427
|
raw = await client2.request("GET", `/pub/v3/messages/${id}`);
|
|
@@ -39402,12 +39438,20 @@ async function fetchServerDraft(client2, id) {
|
|
|
39402
39438
|
mode: "strict"
|
|
39403
39439
|
});
|
|
39404
39440
|
return {
|
|
39405
|
-
|
|
39406
|
-
|
|
39407
|
-
|
|
39408
|
-
|
|
39441
|
+
content: {
|
|
39442
|
+
subject: detail.subject ?? "",
|
|
39443
|
+
body: detail.body ?? "",
|
|
39444
|
+
replyToId: detail.replyToId ?? null,
|
|
39445
|
+
recipients: mapRecipients(detail.recipients)
|
|
39446
|
+
},
|
|
39447
|
+
folderId: detail.folder?.id === void 0 ? null : String(detail.folder.id),
|
|
39448
|
+
folderName: detail.folder?.name ?? null,
|
|
39449
|
+
dateTime: detail.date?.dateTime ?? null
|
|
39409
39450
|
};
|
|
39410
39451
|
}
|
|
39452
|
+
async function fetchServerDraft(client2, id) {
|
|
39453
|
+
return (await fetchMessageSnapshot(client2, id))?.content ?? null;
|
|
39454
|
+
}
|
|
39411
39455
|
var SUBSTANTIVE_FIELDS = ["subject", "body", "recipients"];
|
|
39412
39456
|
function substantiveChanges(changed) {
|
|
39413
39457
|
return changed.filter((f) => SUBSTANTIVE_FIELDS.includes(f));
|
|
@@ -39491,6 +39535,1037 @@ function staleDraftPayload(input) {
|
|
|
39491
39535
|
};
|
|
39492
39536
|
}
|
|
39493
39537
|
|
|
39538
|
+
// src/tools/lifecycle.ts
|
|
39539
|
+
var FOLDER_TYPE = {
|
|
39540
|
+
inbox: "INBOX",
|
|
39541
|
+
sent: "SENT_MESSAGES",
|
|
39542
|
+
drafts: "DRAFTS"
|
|
39543
|
+
};
|
|
39544
|
+
var FOLDER_ID_META_KEY = {
|
|
39545
|
+
inbox: "inbox_folder_id",
|
|
39546
|
+
sent: "sent_folder_id",
|
|
39547
|
+
drafts: "drafts_folder_id"
|
|
39548
|
+
};
|
|
39549
|
+
var FOLDERS = ["inbox", "sent", "drafts"];
|
|
39550
|
+
async function readFolderIdMap(store) {
|
|
39551
|
+
return {
|
|
39552
|
+
inbox: await store.getMeta(FOLDER_ID_META_KEY.inbox),
|
|
39553
|
+
sent: await store.getMeta(FOLDER_ID_META_KEY.sent),
|
|
39554
|
+
drafts: await store.getMeta(FOLDER_ID_META_KEY.drafts)
|
|
39555
|
+
};
|
|
39556
|
+
}
|
|
39557
|
+
async function persistFolderIds(store, systemFolders) {
|
|
39558
|
+
for (const folder of FOLDERS) {
|
|
39559
|
+
const entry = systemFolders.find((f) => f.folderType === FOLDER_TYPE[folder]);
|
|
39560
|
+
if (entry !== void 0) await store.setMeta(FOLDER_ID_META_KEY[folder], entry.id);
|
|
39561
|
+
}
|
|
39562
|
+
}
|
|
39563
|
+
async function ensureFolderIdMap(client2, store) {
|
|
39564
|
+
const cached2 = await readFolderIdMap(store);
|
|
39565
|
+
if (cached2.inbox !== null && cached2.sent !== null && cached2.drafts !== null) {
|
|
39566
|
+
return { map: cached2, requests: 0 };
|
|
39567
|
+
}
|
|
39568
|
+
try {
|
|
39569
|
+
const ids = await resolveFolderIds(client2, store);
|
|
39570
|
+
return { map: { inbox: ids.inbox, sent: ids.sent, drafts: ids.drafts }, requests: 1 };
|
|
39571
|
+
} catch {
|
|
39572
|
+
return { map: cached2, requests: 1 };
|
|
39573
|
+
}
|
|
39574
|
+
}
|
|
39575
|
+
function classifyState(snapshot, map2) {
|
|
39576
|
+
if (snapshot === null) return "deleted";
|
|
39577
|
+
const { folderId } = snapshot;
|
|
39578
|
+
if (folderId === null) return "unknown";
|
|
39579
|
+
if (map2.drafts !== null && folderId === map2.drafts) return "draft";
|
|
39580
|
+
if (map2.sent !== null && folderId === map2.sent) return "sent";
|
|
39581
|
+
if (map2.inbox !== null && folderId === map2.inbox) return "received";
|
|
39582
|
+
return "unknown";
|
|
39583
|
+
}
|
|
39584
|
+
function probeWouldStamp(cachedDraft, cachedMessage) {
|
|
39585
|
+
if (cachedDraft !== null) return false;
|
|
39586
|
+
if (cachedMessage === null) return true;
|
|
39587
|
+
if (cachedMessage.folder === "sent") return false;
|
|
39588
|
+
return !deriveRead(cachedMessage);
|
|
39589
|
+
}
|
|
39590
|
+
var SKIP_NOTE = 'Verifying this id requires fetching its detail from OurFamilyWizard, which would mark an unread inbox message as READ and stamp a co-parent-visible "First Viewed" time on the record. Ids already cached as drafts, as sent, or as already-read inbox messages are probed freely because none of those can stamp anything. Run ofw_sync_messages (it walks list pages, not bodies) or pass allowMarkRead:true.';
|
|
39591
|
+
function stateNote(state, cachedAsDraft, folderName) {
|
|
39592
|
+
if (state === "deleted") {
|
|
39593
|
+
return cachedAsDraft ? "This draft is in the local cache but NO LONGER EXISTS on OurFamilyWizard \u2014 it was sent or deleted elsewhere. Do not describe it as still unsent." : "Not found on OurFamilyWizard.";
|
|
39594
|
+
}
|
|
39595
|
+
if (state === "sent") {
|
|
39596
|
+
return cachedAsDraft ? "This id is cached as a DRAFT but OurFamilyWizard now has it in Sent \u2014 it was SENT (see sentAt). It is no longer a draft; saying it is still unsent would be false." : "This id is a sent message on OurFamilyWizard.";
|
|
39597
|
+
}
|
|
39598
|
+
if (state === "received") {
|
|
39599
|
+
return cachedAsDraft ? "This id is cached as a draft but OurFamilyWizard has it in the Inbox. Run ofw_sync_messages to reconcile." : "This id is an inbox message on OurFamilyWizard.";
|
|
39600
|
+
}
|
|
39601
|
+
if (state === "unknown") {
|
|
39602
|
+
return `OurFamilyWizard did not report a folder this tool can map${folderName === null ? "" : ` (it reported "${folderName}")`}, so what this id has become is NOT established. Treat it as unverified rather than assuming it is unchanged.`;
|
|
39603
|
+
}
|
|
39604
|
+
return void 0;
|
|
39605
|
+
}
|
|
39606
|
+
async function probeIds(client2, store, ids, opts) {
|
|
39607
|
+
const draftsById = new Map((await store.getDrafts(ids)).map((d) => [d.id, d]));
|
|
39608
|
+
const messagesById = new Map((await store.getMessages(ids)).map((m) => [m.id, m]));
|
|
39609
|
+
const prepared = ids.map((id) => {
|
|
39610
|
+
const cachedDraft = draftsById.get(id) ?? null;
|
|
39611
|
+
const cachedMessage = cachedDraft === null ? messagesById.get(id) ?? null : null;
|
|
39612
|
+
return {
|
|
39613
|
+
id,
|
|
39614
|
+
cachedDraft,
|
|
39615
|
+
cachedMessage,
|
|
39616
|
+
skip: !opts.allowMarkRead && probeWouldStamp(cachedDraft, cachedMessage)
|
|
39617
|
+
};
|
|
39618
|
+
});
|
|
39619
|
+
let requests = 0;
|
|
39620
|
+
let map2 = { inbox: null, sent: null, drafts: null };
|
|
39621
|
+
if (prepared.some((p) => !p.skip)) {
|
|
39622
|
+
const resolved = await ensureFolderIdMap(client2, store);
|
|
39623
|
+
map2 = resolved.map;
|
|
39624
|
+
requests += resolved.requests;
|
|
39625
|
+
}
|
|
39626
|
+
const keyById = new Map(
|
|
39627
|
+
(await store.getDraftLineageByIds(ids)).map((l) => [l.id, l.draftKey])
|
|
39628
|
+
);
|
|
39629
|
+
const items = [];
|
|
39630
|
+
for (const p of prepared) {
|
|
39631
|
+
if (p.skip) {
|
|
39632
|
+
items.push({ id: p.id, skipped: true, reason: "WOULD_MARK_READ", note: SKIP_NOTE });
|
|
39633
|
+
continue;
|
|
39634
|
+
}
|
|
39635
|
+
const probe = await probeOne(client2, p, map2, keyById.get(p.id) ?? null);
|
|
39636
|
+
requests += probe.requests;
|
|
39637
|
+
items.push(probe.item);
|
|
39638
|
+
}
|
|
39639
|
+
return { items, requests };
|
|
39640
|
+
}
|
|
39641
|
+
async function probeOne(client2, prepared, map2, draftKey) {
|
|
39642
|
+
const { id, cachedDraft } = prepared;
|
|
39643
|
+
let snapshot;
|
|
39644
|
+
try {
|
|
39645
|
+
snapshot = await fetchMessageSnapshot(client2, id);
|
|
39646
|
+
} catch (e) {
|
|
39647
|
+
return {
|
|
39648
|
+
requests: 1,
|
|
39649
|
+
item: {
|
|
39650
|
+
id,
|
|
39651
|
+
error: "FRESHNESS_CHECK_FAILED",
|
|
39652
|
+
message: e.message,
|
|
39653
|
+
inSync: null,
|
|
39654
|
+
note: "The freshness check itself failed, so nothing is confirmed either way."
|
|
39655
|
+
}
|
|
39656
|
+
};
|
|
39657
|
+
}
|
|
39658
|
+
const state = classifyState(snapshot, map2);
|
|
39659
|
+
const cacheRevision = cachedDraft === null ? null : draftRevision(cachedDraft);
|
|
39660
|
+
const serverRevision = snapshot === null ? null : draftRevision(snapshot.content);
|
|
39661
|
+
const viewedAt = snapshot?.content.recipients.find((r) => r.viewedAt !== null)?.viewedAt ?? null;
|
|
39662
|
+
let inSync;
|
|
39663
|
+
if (cachedDraft === null) inSync = null;
|
|
39664
|
+
else if (snapshot === null) inSync = false;
|
|
39665
|
+
else if (cacheRevision !== serverRevision) inSync = false;
|
|
39666
|
+
else if (state === "draft") inSync = true;
|
|
39667
|
+
else if (state === "unknown") inSync = null;
|
|
39668
|
+
else inSync = false;
|
|
39669
|
+
const notes = [];
|
|
39670
|
+
const stateN = stateNote(state, cachedDraft !== null, snapshot?.folderName ?? null);
|
|
39671
|
+
if (stateN !== void 0) notes.push(stateN);
|
|
39672
|
+
if (snapshot !== null && cachedDraft === null) {
|
|
39673
|
+
notes.push("Not in the drafts cache, so there is no cached copy to compare its content against (inSync is null, not false).");
|
|
39674
|
+
} else if (snapshot !== null && cacheRevision !== serverRevision) {
|
|
39675
|
+
notes.push("Content differs from the cache \u2014 it was edited on OurFamilyWizard since the last sync. Run ofw_sync_messages before reading or writing it.");
|
|
39676
|
+
}
|
|
39677
|
+
return {
|
|
39678
|
+
requests: 1,
|
|
39679
|
+
item: {
|
|
39680
|
+
id,
|
|
39681
|
+
state,
|
|
39682
|
+
folder: snapshot?.folderName ?? null,
|
|
39683
|
+
sentAt: state === "sent" ? snapshot?.dateTime ?? null : null,
|
|
39684
|
+
viewedAt,
|
|
39685
|
+
existsOnServer: snapshot !== null,
|
|
39686
|
+
cacheRevision,
|
|
39687
|
+
serverRevision,
|
|
39688
|
+
inSync,
|
|
39689
|
+
...draftKey !== null ? { draftKey } : {},
|
|
39690
|
+
...notes.length > 0 ? { note: notes.join(" ") } : {}
|
|
39691
|
+
}
|
|
39692
|
+
};
|
|
39693
|
+
}
|
|
39694
|
+
async function resolveDraftKey(store, draftKey) {
|
|
39695
|
+
const chain = await store.getDraftLineage(draftKey);
|
|
39696
|
+
if (chain.length === 0) return null;
|
|
39697
|
+
return { currentId: chain[chain.length - 1].id, ids: chain.map((r) => r.id) };
|
|
39698
|
+
}
|
|
39699
|
+
function newDraftKey() {
|
|
39700
|
+
return `dk_${crypto.randomUUID()}`;
|
|
39701
|
+
}
|
|
39702
|
+
|
|
39703
|
+
// src/extract/inflate.ts
|
|
39704
|
+
var MAX_DECOMPRESSED_BYTES = 32 * 1024 * 1024;
|
|
39705
|
+
var DecompressionLimitError = class extends Error {
|
|
39706
|
+
constructor(label, limit) {
|
|
39707
|
+
super(`${label} expands past the ${limit}-byte decompression cap`);
|
|
39708
|
+
this.name = "DecompressionLimitError";
|
|
39709
|
+
}
|
|
39710
|
+
};
|
|
39711
|
+
async function inflateBounded(data, format, limit, label) {
|
|
39712
|
+
const stream = new Blob([data]).stream().pipeThrough(new DecompressionStream(format));
|
|
39713
|
+
const reader = stream.getReader();
|
|
39714
|
+
const chunks = [];
|
|
39715
|
+
let total = 0;
|
|
39716
|
+
for (; ; ) {
|
|
39717
|
+
const { done, value } = await reader.read();
|
|
39718
|
+
if (done) break;
|
|
39719
|
+
total += value.length;
|
|
39720
|
+
if (total > limit) {
|
|
39721
|
+
await reader.cancel();
|
|
39722
|
+
throw new DecompressionLimitError(label, limit);
|
|
39723
|
+
}
|
|
39724
|
+
chunks.push(value);
|
|
39725
|
+
}
|
|
39726
|
+
return Buffer.concat(chunks);
|
|
39727
|
+
}
|
|
39728
|
+
|
|
39729
|
+
// src/extract/zip.ts
|
|
39730
|
+
var EOCD_SIG = 101010256;
|
|
39731
|
+
var CENTRAL_SIG = 33639248;
|
|
39732
|
+
var LOCAL_SIG = 67324752;
|
|
39733
|
+
var ZIP64_SENTINEL = 4294967295;
|
|
39734
|
+
var ZIP_MAX_UNCOMPRESSED_BYTES = MAX_DECOMPRESSED_BYTES;
|
|
39735
|
+
function findEocd(bytes) {
|
|
39736
|
+
const earliest = Math.max(0, bytes.length - (22 + 65535));
|
|
39737
|
+
for (let i = bytes.length - 22; i >= earliest; i--) {
|
|
39738
|
+
if (bytes.readUInt32LE(i) === EOCD_SIG) return i;
|
|
39739
|
+
}
|
|
39740
|
+
throw new Error("not a ZIP archive (no end-of-central-directory record)");
|
|
39741
|
+
}
|
|
39742
|
+
async function readZip(bytes, opts = {}) {
|
|
39743
|
+
const limit = opts.maxUncompressedBytes ?? ZIP_MAX_UNCOMPRESSED_BYTES;
|
|
39744
|
+
const eocd = findEocd(bytes);
|
|
39745
|
+
const count = bytes.readUInt16LE(eocd + 10);
|
|
39746
|
+
const cdOffset = bytes.readUInt32LE(eocd + 16);
|
|
39747
|
+
if (cdOffset === ZIP64_SENTINEL || count === 65535) {
|
|
39748
|
+
throw new Error("ZIP64 archives are not supported");
|
|
39749
|
+
}
|
|
39750
|
+
const entries = /* @__PURE__ */ new Map();
|
|
39751
|
+
let p = cdOffset;
|
|
39752
|
+
for (let i = 0; i < count; i++) {
|
|
39753
|
+
if (bytes.readUInt32LE(p) !== CENTRAL_SIG) {
|
|
39754
|
+
throw new Error(`corrupt ZIP central directory at offset ${p}`);
|
|
39755
|
+
}
|
|
39756
|
+
const nameLen = bytes.readUInt16LE(p + 28);
|
|
39757
|
+
const extraLen = bytes.readUInt16LE(p + 30);
|
|
39758
|
+
const commentLen = bytes.readUInt16LE(p + 32);
|
|
39759
|
+
const name = bytes.toString("utf8", p + 46, p + 46 + nameLen);
|
|
39760
|
+
entries.set(name, {
|
|
39761
|
+
name,
|
|
39762
|
+
method: bytes.readUInt16LE(p + 10),
|
|
39763
|
+
compressedSize: bytes.readUInt32LE(p + 20),
|
|
39764
|
+
uncompressedSize: bytes.readUInt32LE(p + 24),
|
|
39765
|
+
localOffset: bytes.readUInt32LE(p + 42)
|
|
39766
|
+
});
|
|
39767
|
+
p += 46 + nameLen + extraLen + commentLen;
|
|
39768
|
+
}
|
|
39769
|
+
const cache = /* @__PURE__ */ new Map();
|
|
39770
|
+
async function read(name) {
|
|
39771
|
+
const cached2 = cache.get(name);
|
|
39772
|
+
if (cached2) return cached2;
|
|
39773
|
+
const entry = entries.get(name);
|
|
39774
|
+
if (!entry) return null;
|
|
39775
|
+
if (entry.uncompressedSize > limit) {
|
|
39776
|
+
throw new Error(
|
|
39777
|
+
`ZIP member ${name} is too large to extract (${entry.uncompressedSize} bytes)`
|
|
39778
|
+
);
|
|
39779
|
+
}
|
|
39780
|
+
const lo = entry.localOffset;
|
|
39781
|
+
if (bytes.readUInt32LE(lo) !== LOCAL_SIG) {
|
|
39782
|
+
throw new Error(`corrupt ZIP local header for ${name}`);
|
|
39783
|
+
}
|
|
39784
|
+
const start = lo + 30 + bytes.readUInt16LE(lo + 26) + bytes.readUInt16LE(lo + 28);
|
|
39785
|
+
const raw = bytes.subarray(start, start + entry.compressedSize);
|
|
39786
|
+
let out;
|
|
39787
|
+
if (entry.method === 0) out = Buffer.from(raw);
|
|
39788
|
+
else if (entry.method === 8) out = await inflateBounded(raw, "deflate-raw", limit, `ZIP member ${name}`);
|
|
39789
|
+
else throw new Error(`unsupported ZIP compression method ${entry.method} for ${name}`);
|
|
39790
|
+
cache.set(name, out);
|
|
39791
|
+
return out;
|
|
39792
|
+
}
|
|
39793
|
+
return {
|
|
39794
|
+
names: () => [...entries.keys()],
|
|
39795
|
+
has: (name) => entries.has(name),
|
|
39796
|
+
read,
|
|
39797
|
+
async readText(name) {
|
|
39798
|
+
const buf = await read(name);
|
|
39799
|
+
if (!buf) return null;
|
|
39800
|
+
const text = buf.toString("utf8");
|
|
39801
|
+
return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
|
|
39802
|
+
}
|
|
39803
|
+
};
|
|
39804
|
+
}
|
|
39805
|
+
|
|
39806
|
+
// src/extract/xml.ts
|
|
39807
|
+
var NAMED_ENTITIES = {
|
|
39808
|
+
amp: "&",
|
|
39809
|
+
lt: "<",
|
|
39810
|
+
gt: ">",
|
|
39811
|
+
quot: '"',
|
|
39812
|
+
apos: "'"
|
|
39813
|
+
};
|
|
39814
|
+
function decodeXmlEntities(text) {
|
|
39815
|
+
if (!text.includes("&")) return text;
|
|
39816
|
+
return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (whole, body) => {
|
|
39817
|
+
if (body[0] === "#") {
|
|
39818
|
+
const code = body[1] === "x" || body[1] === "X" ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
|
|
39819
|
+
return String.fromCodePoint(code);
|
|
39820
|
+
}
|
|
39821
|
+
return NAMED_ENTITIES[body] ?? whole;
|
|
39822
|
+
});
|
|
39823
|
+
}
|
|
39824
|
+
function* elements(xml, tag) {
|
|
39825
|
+
const re = new RegExp(`<${tag}((?:\\s[^>]*?)?)(?:\\s*/>|>([\\s\\S]*?)</${tag}>)`, "g");
|
|
39826
|
+
for (let m = re.exec(xml); m !== null; m = re.exec(xml)) {
|
|
39827
|
+
yield { attrs: m[1], inner: m[2] ?? "" };
|
|
39828
|
+
}
|
|
39829
|
+
}
|
|
39830
|
+
function attr(attrs, name) {
|
|
39831
|
+
const m = new RegExp(`(?:^|\\s)${name}\\s*=\\s*("([^"]*)"|'([^']*)')`).exec(attrs);
|
|
39832
|
+
if (!m) return null;
|
|
39833
|
+
return decodeXmlEntities(m[2] ?? m[3]);
|
|
39834
|
+
}
|
|
39835
|
+
function textOf(xml, tag) {
|
|
39836
|
+
let out = "";
|
|
39837
|
+
for (const el of elements(xml, tag)) out += decodeXmlEntities(el.inner);
|
|
39838
|
+
return out;
|
|
39839
|
+
}
|
|
39840
|
+
|
|
39841
|
+
// src/extract/ooxml.ts
|
|
39842
|
+
function resolvePartPath(baseDir, target) {
|
|
39843
|
+
if (target.startsWith("/")) return target.slice(1);
|
|
39844
|
+
const segments = (baseDir + target).split("/");
|
|
39845
|
+
const out = [];
|
|
39846
|
+
for (const segment of segments) {
|
|
39847
|
+
if (segment === "." || segment === "") continue;
|
|
39848
|
+
if (segment === "..") out.pop();
|
|
39849
|
+
else out.push(segment);
|
|
39850
|
+
}
|
|
39851
|
+
return out.join("/");
|
|
39852
|
+
}
|
|
39853
|
+
function dirOf(partPath) {
|
|
39854
|
+
const i = partPath.lastIndexOf("/");
|
|
39855
|
+
return i === -1 ? "" : partPath.slice(0, i + 1);
|
|
39856
|
+
}
|
|
39857
|
+
async function readRels(zip, partPath) {
|
|
39858
|
+
const dir = dirOf(partPath);
|
|
39859
|
+
const base = partPath.slice(dir.length);
|
|
39860
|
+
const xml = await zip.readText(`${dir}_rels/${base}.rels`);
|
|
39861
|
+
if (!xml) return [];
|
|
39862
|
+
const rels = [];
|
|
39863
|
+
for (const el of elements(xml, "Relationship")) {
|
|
39864
|
+
const id = attr(el.attrs, "Id");
|
|
39865
|
+
const target = attr(el.attrs, "Target");
|
|
39866
|
+
if (!id || !target) continue;
|
|
39867
|
+
rels.push({ id, target: resolvePartPath(dir, target), type: attr(el.attrs, "Type") ?? "" });
|
|
39868
|
+
}
|
|
39869
|
+
return rels;
|
|
39870
|
+
}
|
|
39871
|
+
function findPart(zip, conventional, fileName) {
|
|
39872
|
+
if (zip.has(conventional)) return conventional;
|
|
39873
|
+
return zip.names().find((n) => n.endsWith(`/${fileName}`) || n === fileName) ?? null;
|
|
39874
|
+
}
|
|
39875
|
+
|
|
39876
|
+
// src/extract/spreadsheet.ts
|
|
39877
|
+
var DEFAULT_MAX_CELLS = 2e5;
|
|
39878
|
+
var BUILTIN_DATE_FORMATS = /* @__PURE__ */ new Set([14, 15, 16, 17, 18, 19, 20, 21, 22, 45, 46, 47]);
|
|
39879
|
+
var SERIAL_EPOCH_OFFSET = 25569;
|
|
39880
|
+
function excelSerialToIso(serial, date1904) {
|
|
39881
|
+
const base = date1904 ? serial + 1462 : serial;
|
|
39882
|
+
const corrected = Math.floor(base) < 60 ? base + 1 : base;
|
|
39883
|
+
const date5 = new Date(Math.round((corrected - SERIAL_EPOCH_OFFSET) * 864e5));
|
|
39884
|
+
if (Number.isNaN(date5.getTime())) return null;
|
|
39885
|
+
const iso = date5.toISOString();
|
|
39886
|
+
if (base < 1) return iso.slice(11, 19);
|
|
39887
|
+
return iso.slice(11, 19) === "00:00:00" ? iso.slice(0, 10) : iso.slice(0, 19);
|
|
39888
|
+
}
|
|
39889
|
+
function isDateFormat(numFmtId, formatCode) {
|
|
39890
|
+
if (BUILTIN_DATE_FORMATS.has(numFmtId)) return true;
|
|
39891
|
+
if (!formatCode) return false;
|
|
39892
|
+
const bare = formatCode.replace(/"[^"]*"/g, "").replace(/\[[^\]]*\]/g, "").replace(/\\./g, "");
|
|
39893
|
+
return /[ymdhs]/i.test(bare);
|
|
39894
|
+
}
|
|
39895
|
+
function columnIndex(ref) {
|
|
39896
|
+
const m = /^([A-Z]+)/.exec(ref);
|
|
39897
|
+
if (!m) return null;
|
|
39898
|
+
let index = 0;
|
|
39899
|
+
for (const ch of m[1]) index = index * 26 + (ch.charCodeAt(0) - 64);
|
|
39900
|
+
return index - 1;
|
|
39901
|
+
}
|
|
39902
|
+
function csvField(value) {
|
|
39903
|
+
return /[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
|
|
39904
|
+
}
|
|
39905
|
+
function toCsv(rows, cols) {
|
|
39906
|
+
return rows.map((row) => Array.from({ length: cols }, (_, i) => csvField(row[i] ?? "")).join(",")).join("\n");
|
|
39907
|
+
}
|
|
39908
|
+
async function readStyles(zip, dir) {
|
|
39909
|
+
const xml = await zip.readText(`${dir}styles.xml`);
|
|
39910
|
+
if (!xml) return { dateStyles: [] };
|
|
39911
|
+
const custom2 = /* @__PURE__ */ new Map();
|
|
39912
|
+
for (const el of elements(xml, "numFmt")) {
|
|
39913
|
+
const id = Number(attr(el.attrs, "numFmtId"));
|
|
39914
|
+
const code = attr(el.attrs, "formatCode");
|
|
39915
|
+
if (Number.isFinite(id) && code !== null) custom2.set(id, code);
|
|
39916
|
+
}
|
|
39917
|
+
const dateStyles = [];
|
|
39918
|
+
for (const block of elements(xml, "cellXfs")) {
|
|
39919
|
+
for (const xf of elements(block.inner, "xf")) {
|
|
39920
|
+
const id = Number(attr(xf.attrs, "numFmtId") ?? "0");
|
|
39921
|
+
dateStyles.push(isDateFormat(id, custom2.get(id)));
|
|
39922
|
+
}
|
|
39923
|
+
}
|
|
39924
|
+
return { dateStyles };
|
|
39925
|
+
}
|
|
39926
|
+
async function readSharedStrings(zip, dir) {
|
|
39927
|
+
const xml = await zip.readText(`${dir}sharedStrings.xml`);
|
|
39928
|
+
if (!xml) return [];
|
|
39929
|
+
return [...elements(xml, "si")].map((si) => textOf(si.inner, "t"));
|
|
39930
|
+
}
|
|
39931
|
+
function cellValue(attrs, inner, ctx) {
|
|
39932
|
+
const type = attr(attrs, "t") ?? "n";
|
|
39933
|
+
if (type === "inlineStr") return textOf(inner, "t");
|
|
39934
|
+
const raw = textOf(inner, "v");
|
|
39935
|
+
switch (type) {
|
|
39936
|
+
case "s": {
|
|
39937
|
+
const index = Number(raw);
|
|
39938
|
+
return ctx.shared[index] ?? "";
|
|
39939
|
+
}
|
|
39940
|
+
case "b":
|
|
39941
|
+
return raw === "1" ? "TRUE" : "FALSE";
|
|
39942
|
+
case "str":
|
|
39943
|
+
case "e":
|
|
39944
|
+
return raw;
|
|
39945
|
+
default: {
|
|
39946
|
+
const styleIndex = Number(attr(attrs, "s") ?? "-1");
|
|
39947
|
+
const numeric = Number(raw);
|
|
39948
|
+
if (ctx.styles.dateStyles[styleIndex] && raw !== "" && Number.isFinite(numeric)) {
|
|
39949
|
+
return excelSerialToIso(numeric, ctx.date1904) ?? raw;
|
|
39950
|
+
}
|
|
39951
|
+
return raw;
|
|
39952
|
+
}
|
|
39953
|
+
}
|
|
39954
|
+
}
|
|
39955
|
+
function parseSheet(xml, name, ctx, maxCells) {
|
|
39956
|
+
const rows = [];
|
|
39957
|
+
let cols = 0;
|
|
39958
|
+
let cells = 0;
|
|
39959
|
+
let truncated = false;
|
|
39960
|
+
for (const row of elements(xml, "row")) {
|
|
39961
|
+
if (cells >= maxCells) {
|
|
39962
|
+
truncated = true;
|
|
39963
|
+
break;
|
|
39964
|
+
}
|
|
39965
|
+
const values = [];
|
|
39966
|
+
for (const cell of elements(row.inner, "c")) {
|
|
39967
|
+
const ref = attr(cell.attrs, "r");
|
|
39968
|
+
const index = ref === null ? null : columnIndex(ref);
|
|
39969
|
+
if (index === null) continue;
|
|
39970
|
+
values[index] = cellValue(cell.attrs, cell.inner, ctx);
|
|
39971
|
+
cells++;
|
|
39972
|
+
if (index + 1 > cols) cols = index + 1;
|
|
39973
|
+
}
|
|
39974
|
+
rows.push(values);
|
|
39975
|
+
}
|
|
39976
|
+
return { name, rows: rows.length, cols, csv: toCsv(rows, cols), ...truncated ? { truncated } : {} };
|
|
39977
|
+
}
|
|
39978
|
+
async function extractXlsx(bytes, opts = {}) {
|
|
39979
|
+
const zip = await readZip(bytes);
|
|
39980
|
+
const workbookPath = findPart(zip, "xl/workbook.xml", "workbook.xml");
|
|
39981
|
+
if (!workbookPath) throw new Error("no workbook part found in the .xlsx archive");
|
|
39982
|
+
const dir = dirOf(workbookPath);
|
|
39983
|
+
const workbookXml = await zip.readText(workbookPath) ?? "";
|
|
39984
|
+
const rels = await readRels(zip, workbookPath);
|
|
39985
|
+
const targetById = new Map(rels.map((r) => [r.id, r.target]));
|
|
39986
|
+
const ctx = {
|
|
39987
|
+
shared: await readSharedStrings(zip, dir),
|
|
39988
|
+
styles: await readStyles(zip, dir),
|
|
39989
|
+
date1904: /<workbookPr[^>]*date1904="(1|true)"/i.test(workbookXml)
|
|
39990
|
+
};
|
|
39991
|
+
const maxCells = opts.maxCells ?? DEFAULT_MAX_CELLS;
|
|
39992
|
+
const sheets = [];
|
|
39993
|
+
const omitted = [];
|
|
39994
|
+
let index = 0;
|
|
39995
|
+
for (const el of elements(workbookXml, "sheet")) {
|
|
39996
|
+
const position = index++;
|
|
39997
|
+
const name = attr(el.attrs, "name") ?? `Sheet${position + 1}`;
|
|
39998
|
+
if (opts.select && !opts.select(position, name)) {
|
|
39999
|
+
omitted.push(name);
|
|
40000
|
+
continue;
|
|
40001
|
+
}
|
|
40002
|
+
const relId = attr(el.attrs, "r:id") ?? attr(el.attrs, "id");
|
|
40003
|
+
const path = (relId && targetById.get(relId)) ?? resolvePartPath(dir, `worksheets/sheet${position + 1}.xml`);
|
|
40004
|
+
const xml = await zip.readText(path);
|
|
40005
|
+
if (xml === null) {
|
|
40006
|
+
omitted.push(`${name} (sheet part not found in the workbook)`);
|
|
40007
|
+
continue;
|
|
40008
|
+
}
|
|
40009
|
+
sheets.push(parseSheet(xml, name, ctx, maxCells));
|
|
40010
|
+
}
|
|
40011
|
+
const truncated = sheets.some((s) => s.truncated);
|
|
40012
|
+
return {
|
|
40013
|
+
kind: "spreadsheet",
|
|
40014
|
+
sheets,
|
|
40015
|
+
...omitted.length ? { omitted } : {},
|
|
40016
|
+
...truncated ? { truncated } : {}
|
|
40017
|
+
};
|
|
40018
|
+
}
|
|
40019
|
+
function parseDelimitedRows(text, delimiter) {
|
|
40020
|
+
const rows = [];
|
|
40021
|
+
let row = [];
|
|
40022
|
+
let field = "";
|
|
40023
|
+
let quoted = false;
|
|
40024
|
+
let dirty = false;
|
|
40025
|
+
for (let i = 0; i < text.length; i++) {
|
|
40026
|
+
const ch = text[i];
|
|
40027
|
+
if (quoted) {
|
|
40028
|
+
if (ch !== '"') {
|
|
40029
|
+
field += ch;
|
|
40030
|
+
continue;
|
|
40031
|
+
}
|
|
40032
|
+
if (text[i + 1] === '"') {
|
|
40033
|
+
field += '"';
|
|
40034
|
+
i++;
|
|
40035
|
+
continue;
|
|
40036
|
+
}
|
|
40037
|
+
quoted = false;
|
|
40038
|
+
continue;
|
|
40039
|
+
}
|
|
40040
|
+
if (ch === '"') {
|
|
40041
|
+
quoted = true;
|
|
40042
|
+
dirty = true;
|
|
40043
|
+
continue;
|
|
40044
|
+
}
|
|
40045
|
+
if (ch === delimiter) {
|
|
40046
|
+
row.push(field);
|
|
40047
|
+
field = "";
|
|
40048
|
+
dirty = true;
|
|
40049
|
+
continue;
|
|
40050
|
+
}
|
|
40051
|
+
if (ch === "\r") continue;
|
|
40052
|
+
if (ch === "\n") {
|
|
40053
|
+
row.push(field);
|
|
40054
|
+
rows.push(row);
|
|
40055
|
+
row = [];
|
|
40056
|
+
field = "";
|
|
40057
|
+
dirty = false;
|
|
40058
|
+
continue;
|
|
40059
|
+
}
|
|
40060
|
+
field += ch;
|
|
40061
|
+
dirty = true;
|
|
40062
|
+
}
|
|
40063
|
+
if (dirty || field !== "") {
|
|
40064
|
+
row.push(field);
|
|
40065
|
+
rows.push(row);
|
|
40066
|
+
}
|
|
40067
|
+
return rows;
|
|
40068
|
+
}
|
|
40069
|
+
function extractDelimited(text, name, delimiter) {
|
|
40070
|
+
const rows = parseDelimitedRows(text, delimiter);
|
|
40071
|
+
const cols = rows.reduce((max, r) => Math.max(max, r.length), 0);
|
|
40072
|
+
return {
|
|
40073
|
+
kind: "spreadsheet",
|
|
40074
|
+
sheets: [{ name, rows: rows.length, cols, csv: toCsv(rows, cols) }]
|
|
40075
|
+
};
|
|
40076
|
+
}
|
|
40077
|
+
|
|
40078
|
+
// src/extract/document.ts
|
|
40079
|
+
var RUN_CONTENT = /<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>|<w:(tab|br|cr)\s*\/>/g;
|
|
40080
|
+
function paragraphText(inner) {
|
|
40081
|
+
let text = "";
|
|
40082
|
+
for (let m = RUN_CONTENT.exec(inner); m !== null; m = RUN_CONTENT.exec(inner)) {
|
|
40083
|
+
if (m[1] !== void 0) text += decodeXmlEntities(m[1]);
|
|
40084
|
+
else text += m[2] === "tab" ? " " : "\n";
|
|
40085
|
+
}
|
|
40086
|
+
RUN_CONTENT.lastIndex = 0;
|
|
40087
|
+
return text;
|
|
40088
|
+
}
|
|
40089
|
+
function styledParagraph(inner) {
|
|
40090
|
+
const text = paragraphText(inner);
|
|
40091
|
+
if (text === "") return "";
|
|
40092
|
+
const style = /<w:pStyle\s[^>]*w:val="([^"]*)"/.exec(inner)?.[1] ?? "";
|
|
40093
|
+
const heading = /^Heading(\d)$/.exec(style);
|
|
40094
|
+
if (heading) return `${"#".repeat(Math.min(Number(heading[1]), 6))} ${text}`;
|
|
40095
|
+
if (style === "Title" || style === "Subtitle") return `# ${text}`;
|
|
40096
|
+
if (style === "ListParagraph") return `- ${text}`;
|
|
40097
|
+
return text;
|
|
40098
|
+
}
|
|
40099
|
+
function tableText(inner) {
|
|
40100
|
+
const rows = [];
|
|
40101
|
+
for (const tr of elements(inner, "w:tr")) {
|
|
40102
|
+
const cells = [];
|
|
40103
|
+
for (const tc of elements(tr.inner, "w:tc")) {
|
|
40104
|
+
const parts = [];
|
|
40105
|
+
for (const p of elements(tc.inner, "w:p")) {
|
|
40106
|
+
const text = paragraphText(p.inner);
|
|
40107
|
+
if (text !== "") parts.push(text);
|
|
40108
|
+
}
|
|
40109
|
+
cells.push(parts.join(" "));
|
|
40110
|
+
}
|
|
40111
|
+
rows.push(`| ${cells.join(" | ")} |`);
|
|
40112
|
+
}
|
|
40113
|
+
return rows.join("\n");
|
|
40114
|
+
}
|
|
40115
|
+
var BLOCK = /<w:tbl(?:\s[^>]*?)?>[\s\S]*?<\/w:tbl>|<w:p(?:\s[^>]*?)?(?:\s*\/>|>([\s\S]*?)<\/w:p>)/g;
|
|
40116
|
+
async function extractDocx(bytes) {
|
|
40117
|
+
const zip = await readZip(bytes);
|
|
40118
|
+
const path = findPart(zip, "word/document.xml", "document.xml");
|
|
40119
|
+
if (!path) throw new Error("no document part found in the .docx archive");
|
|
40120
|
+
const xml = await zip.readText(path) ?? "";
|
|
40121
|
+
const blocks = [];
|
|
40122
|
+
for (let m = BLOCK.exec(xml); m !== null; m = BLOCK.exec(xml)) {
|
|
40123
|
+
const text = m[0].startsWith("<w:tbl") ? tableText(m[0]) : styledParagraph(m[1] ?? "");
|
|
40124
|
+
if (text !== "") blocks.push(text);
|
|
40125
|
+
}
|
|
40126
|
+
BLOCK.lastIndex = 0;
|
|
40127
|
+
return { kind: "document", text: blocks.join("\n\n") };
|
|
40128
|
+
}
|
|
40129
|
+
|
|
40130
|
+
// src/extract/presentation.ts
|
|
40131
|
+
var SLIDE_PATH = /^ppt\/slides\/slide(\d+)\.xml$/;
|
|
40132
|
+
function slideText(xml) {
|
|
40133
|
+
const lines = [];
|
|
40134
|
+
for (const p of elements(xml, "a:p")) {
|
|
40135
|
+
let line = "";
|
|
40136
|
+
for (const t of elements(p.inner, "a:t")) line += decodeXmlEntities(t.inner);
|
|
40137
|
+
if (line !== "") lines.push(line);
|
|
40138
|
+
}
|
|
40139
|
+
return lines.join("\n");
|
|
40140
|
+
}
|
|
40141
|
+
async function extractPptx(bytes, opts = {}) {
|
|
40142
|
+
const zip = await readZip(bytes);
|
|
40143
|
+
const paths = zip.names().map((name) => ({ name, n: Number(SLIDE_PATH.exec(name)?.[1]) })).filter((e) => Number.isFinite(e.n)).sort((a, b) => a.n - b.n);
|
|
40144
|
+
if (paths.length === 0) throw new Error("no slides found in the .pptx archive");
|
|
40145
|
+
const slides = [];
|
|
40146
|
+
const omitted = [];
|
|
40147
|
+
for (let i = 0; i < paths.length; i++) {
|
|
40148
|
+
const { name } = paths[i];
|
|
40149
|
+
const number4 = i + 1;
|
|
40150
|
+
if (opts.select && !opts.select(i, `slide ${number4}`)) {
|
|
40151
|
+
omitted.push(`slide ${number4}`);
|
|
40152
|
+
continue;
|
|
40153
|
+
}
|
|
40154
|
+
const text = slideText(await zip.readText(name) ?? "");
|
|
40155
|
+
const notesRel = (await readRels(zip, name)).find((r) => r.type.endsWith("/notesSlide"));
|
|
40156
|
+
const notesXml = notesRel ? await zip.readText(notesRel.target) : null;
|
|
40157
|
+
const notes = notesXml === null ? "" : slideText(notesXml);
|
|
40158
|
+
slides.push({ number: number4, text, ...notes ? { notes } : {} });
|
|
40159
|
+
}
|
|
40160
|
+
return {
|
|
40161
|
+
kind: "presentation",
|
|
40162
|
+
slides,
|
|
40163
|
+
...omitted.length ? { omitted } : {}
|
|
40164
|
+
};
|
|
40165
|
+
}
|
|
40166
|
+
|
|
40167
|
+
// src/extract/pdf.ts
|
|
40168
|
+
var OBJ_HEADER = /(\d+)\s+\d+\s+obj\b/g;
|
|
40169
|
+
function parseObjects(text) {
|
|
40170
|
+
const objects = /* @__PURE__ */ new Map();
|
|
40171
|
+
for (let m = OBJ_HEADER.exec(text); m !== null; m = OBJ_HEADER.exec(text)) {
|
|
40172
|
+
const start = m.index + m[0].length;
|
|
40173
|
+
const end = text.indexOf("endobj", start);
|
|
40174
|
+
objects.set(Number(m[1]), {
|
|
40175
|
+
num: Number(m[1]),
|
|
40176
|
+
body: text.slice(start, end === -1 ? void 0 : end),
|
|
40177
|
+
start
|
|
40178
|
+
});
|
|
40179
|
+
}
|
|
40180
|
+
OBJ_HEADER.lastIndex = 0;
|
|
40181
|
+
return objects;
|
|
40182
|
+
}
|
|
40183
|
+
function refsIn(fragment) {
|
|
40184
|
+
return [...fragment.matchAll(/(\d+)\s+\d+\s+R\b/g)].map((m) => Number(m[1]));
|
|
40185
|
+
}
|
|
40186
|
+
function orderedPages(objects) {
|
|
40187
|
+
const isPage = (o) => /\/Type\s*\/Page[^s]/.test(o.body);
|
|
40188
|
+
const inFileOrder = [...objects.values()].filter(isPage).sort((a, b) => a.start - b.start);
|
|
40189
|
+
const catalog = [...objects.values()].find((o) => /\/Type\s*\/Catalog/.test(o.body));
|
|
40190
|
+
const rootRef = catalog ? refsIn(/\/Pages\s+[^/>]*/.exec(catalog.body)?.[0] ?? "")[0] : void 0;
|
|
40191
|
+
if (rootRef === void 0) return inFileOrder;
|
|
40192
|
+
const ordered = [];
|
|
40193
|
+
const seen = /* @__PURE__ */ new Set();
|
|
40194
|
+
const walk = (num) => {
|
|
40195
|
+
if (seen.has(num)) return;
|
|
40196
|
+
seen.add(num);
|
|
40197
|
+
const obj = objects.get(num);
|
|
40198
|
+
if (!obj) return;
|
|
40199
|
+
if (isPage(obj)) {
|
|
40200
|
+
ordered.push(obj);
|
|
40201
|
+
return;
|
|
40202
|
+
}
|
|
40203
|
+
const kids = /\/Kids\s*\[([^\]]*)\]/.exec(obj.body)?.[1];
|
|
40204
|
+
if (kids) for (const kid of refsIn(kids)) walk(kid);
|
|
40205
|
+
};
|
|
40206
|
+
walk(rootRef);
|
|
40207
|
+
return ordered.length > 0 ? ordered : inFileOrder;
|
|
40208
|
+
}
|
|
40209
|
+
function streamBytes(bytes, obj) {
|
|
40210
|
+
const marker = /stream\r?\n/.exec(obj.body);
|
|
40211
|
+
if (!marker) return null;
|
|
40212
|
+
const from = obj.start + marker.index + marker[0].length;
|
|
40213
|
+
const declared = /\/Length\s+(\d+)(?!\s+\d+\s+R)/.exec(obj.body);
|
|
40214
|
+
if (declared) return bytes.subarray(from, from + Number(declared[1]));
|
|
40215
|
+
const end = bytes.indexOf("endstream", from, "latin1");
|
|
40216
|
+
return bytes.subarray(from, end === -1 ? void 0 : end);
|
|
40217
|
+
}
|
|
40218
|
+
async function decodeStream(bytes, obj) {
|
|
40219
|
+
const raw = streamBytes(bytes, obj);
|
|
40220
|
+
if (!raw) return null;
|
|
40221
|
+
const filter = /\/Filter\s*(\/\w+|\[[^\]]*\])/.exec(obj.body)?.[1] ?? "";
|
|
40222
|
+
if (filter === "") return raw;
|
|
40223
|
+
if (!filter.includes("FlateDecode")) return null;
|
|
40224
|
+
try {
|
|
40225
|
+
return await inflateBounded(raw, "deflate", MAX_DECOMPRESSED_BYTES, "PDF stream");
|
|
40226
|
+
} catch (err) {
|
|
40227
|
+
if (err instanceof DecompressionLimitError) throw err;
|
|
40228
|
+
return null;
|
|
40229
|
+
}
|
|
40230
|
+
}
|
|
40231
|
+
function readLiteral(text, i) {
|
|
40232
|
+
let value = "";
|
|
40233
|
+
let depth = 1;
|
|
40234
|
+
let p = i + 1;
|
|
40235
|
+
for (; p < text.length; p++) {
|
|
40236
|
+
const ch = text[p];
|
|
40237
|
+
if (ch === "\\") {
|
|
40238
|
+
const esc2 = text[++p];
|
|
40239
|
+
const simple = { n: "\n", r: "\r", t: " ", b: "\b", f: "\f" };
|
|
40240
|
+
if (simple[esc2]) {
|
|
40241
|
+
value += simple[esc2];
|
|
40242
|
+
continue;
|
|
40243
|
+
}
|
|
40244
|
+
const octal = /^[0-7]{1,3}/.exec(text.slice(p, p + 3))?.[0];
|
|
40245
|
+
if (octal) {
|
|
40246
|
+
value += String.fromCharCode(parseInt(octal, 8));
|
|
40247
|
+
p += octal.length - 1;
|
|
40248
|
+
continue;
|
|
40249
|
+
}
|
|
40250
|
+
if (esc2 === "\n") continue;
|
|
40251
|
+
value += esc2;
|
|
40252
|
+
continue;
|
|
40253
|
+
}
|
|
40254
|
+
if (ch === "(") {
|
|
40255
|
+
depth++;
|
|
40256
|
+
value += ch;
|
|
40257
|
+
continue;
|
|
40258
|
+
}
|
|
40259
|
+
if (ch === ")") {
|
|
40260
|
+
depth--;
|
|
40261
|
+
if (depth === 0) break;
|
|
40262
|
+
value += ch;
|
|
40263
|
+
continue;
|
|
40264
|
+
}
|
|
40265
|
+
value += ch;
|
|
40266
|
+
}
|
|
40267
|
+
return { value, next: p };
|
|
40268
|
+
}
|
|
40269
|
+
function decodeHexString(hex3) {
|
|
40270
|
+
const clean = hex3.replace(/[^0-9a-fA-F]/g, "");
|
|
40271
|
+
const padded = clean.length % 2 === 1 ? `${clean}0` : clean;
|
|
40272
|
+
const buf = Buffer.from(padded, "hex");
|
|
40273
|
+
if (buf.length >= 2 && buf.length % 2 === 0 && buf[0] === 254 && buf[1] === 255) {
|
|
40274
|
+
return buf.subarray(2).swap16().toString("utf16le");
|
|
40275
|
+
}
|
|
40276
|
+
if (buf.length % 2 === 0 && buf.length > 0 && buf.every((b, i) => i % 2 === 1 || b === 0)) {
|
|
40277
|
+
return buf.swap16().toString("utf16le");
|
|
40278
|
+
}
|
|
40279
|
+
return buf.toString("latin1");
|
|
40280
|
+
}
|
|
40281
|
+
function textFromContentStream(content) {
|
|
40282
|
+
let out = "";
|
|
40283
|
+
let pending = "";
|
|
40284
|
+
let arrayDepth = 0;
|
|
40285
|
+
for (let i = 0; i < content.length; i++) {
|
|
40286
|
+
const ch = content[i];
|
|
40287
|
+
if (ch === "(") {
|
|
40288
|
+
const { value, next } = readLiteral(content, i);
|
|
40289
|
+
pending += value;
|
|
40290
|
+
i = next;
|
|
40291
|
+
continue;
|
|
40292
|
+
}
|
|
40293
|
+
if (ch === "<" && content[i + 1] === "<") {
|
|
40294
|
+
i++;
|
|
40295
|
+
continue;
|
|
40296
|
+
}
|
|
40297
|
+
if (ch === "<") {
|
|
40298
|
+
const end = content.indexOf(">", i);
|
|
40299
|
+
if (end === -1) break;
|
|
40300
|
+
pending += decodeHexString(content.slice(i + 1, end));
|
|
40301
|
+
i = end;
|
|
40302
|
+
continue;
|
|
40303
|
+
}
|
|
40304
|
+
if (ch === "[") {
|
|
40305
|
+
arrayDepth++;
|
|
40306
|
+
continue;
|
|
40307
|
+
}
|
|
40308
|
+
if (ch === "]") {
|
|
40309
|
+
arrayDepth = 0;
|
|
40310
|
+
continue;
|
|
40311
|
+
}
|
|
40312
|
+
if (arrayDepth > 0 && (ch === "-" || ch >= "0" && ch <= "9")) {
|
|
40313
|
+
const num = /^-?\d+(\.\d+)?/.exec(content.slice(i));
|
|
40314
|
+
if (!num) continue;
|
|
40315
|
+
if (Number(num[0]) <= -100) pending += " ";
|
|
40316
|
+
i += num[0].length - 1;
|
|
40317
|
+
continue;
|
|
40318
|
+
}
|
|
40319
|
+
if (/[A-Za-z'"*]/.test(ch)) {
|
|
40320
|
+
const op = /^[A-Za-z*]+|^['"]/.exec(content.slice(i))?.[0] ?? ch;
|
|
40321
|
+
i += op.length - 1;
|
|
40322
|
+
if (op === "Tj" || op === "TJ") {
|
|
40323
|
+
out += pending;
|
|
40324
|
+
pending = "";
|
|
40325
|
+
continue;
|
|
40326
|
+
}
|
|
40327
|
+
if (op === "'" || op === '"') {
|
|
40328
|
+
out += `
|
|
40329
|
+
${pending}`;
|
|
40330
|
+
pending = "";
|
|
40331
|
+
continue;
|
|
40332
|
+
}
|
|
40333
|
+
if (op === "Td" || op === "TD" || op === "T*" || op === "ET") {
|
|
40334
|
+
out += "\n";
|
|
40335
|
+
continue;
|
|
40336
|
+
}
|
|
40337
|
+
}
|
|
40338
|
+
}
|
|
40339
|
+
return out;
|
|
40340
|
+
}
|
|
40341
|
+
function tidy(text) {
|
|
40342
|
+
return text.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
40343
|
+
}
|
|
40344
|
+
async function extractPdf(bytes, opts = {}) {
|
|
40345
|
+
const text = bytes.toString("latin1");
|
|
40346
|
+
if (/\/Encrypt\b/.test(text)) {
|
|
40347
|
+
throw new Error("the PDF is encrypted; its text cannot be extracted");
|
|
40348
|
+
}
|
|
40349
|
+
const objects = parseObjects(text);
|
|
40350
|
+
const pageObjects = orderedPages(objects);
|
|
40351
|
+
if (pageObjects.length === 0) throw new Error("no pages found in the PDF");
|
|
40352
|
+
const pages = [];
|
|
40353
|
+
const omitted = [];
|
|
40354
|
+
for (let i = 0; i < pageObjects.length; i++) {
|
|
40355
|
+
const number4 = i + 1;
|
|
40356
|
+
if (opts.select && !opts.select(i, `page ${number4}`)) {
|
|
40357
|
+
omitted.push(`page ${number4}`);
|
|
40358
|
+
continue;
|
|
40359
|
+
}
|
|
40360
|
+
const contentsFragment = /\/Contents\s*(\d+\s+\d+\s+R|\[[^\]]*\])/.exec(pageObjects[i].body)?.[1] ?? "";
|
|
40361
|
+
let raw = "";
|
|
40362
|
+
for (const ref of refsIn(contentsFragment)) {
|
|
40363
|
+
const streamObj = objects.get(ref);
|
|
40364
|
+
if (!streamObj) continue;
|
|
40365
|
+
const decoded = await decodeStream(bytes, streamObj);
|
|
40366
|
+
if (decoded) raw += `${decoded.toString("latin1")}
|
|
40367
|
+
`;
|
|
40368
|
+
}
|
|
40369
|
+
pages.push({ number: number4, text: tidy(textFromContentStream(raw)) });
|
|
40370
|
+
}
|
|
40371
|
+
const textLayer = pages.some((p) => p.text !== "");
|
|
40372
|
+
return {
|
|
40373
|
+
kind: "pdf",
|
|
40374
|
+
pages,
|
|
40375
|
+
textLayer,
|
|
40376
|
+
...textLayer ? {} : {
|
|
40377
|
+
note: "This PDF has no extractable text layer \u2014 it is most likely a scan or an image-only export. Reading it requires OCR, which this server does not perform."
|
|
40378
|
+
},
|
|
40379
|
+
...omitted.length ? { omitted } : {}
|
|
40380
|
+
};
|
|
40381
|
+
}
|
|
40382
|
+
|
|
40383
|
+
// src/extract/index.ts
|
|
40384
|
+
var MIME_KINDS = {
|
|
40385
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
|
|
40386
|
+
"application/vnd.ms-excel.sheet.macroenabled.12": "xlsx",
|
|
40387
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
|
|
40388
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
|
|
40389
|
+
"application/pdf": "pdf",
|
|
40390
|
+
"text/csv": "csv",
|
|
40391
|
+
"text/tab-separated-values": "tsv",
|
|
40392
|
+
"application/json": "text",
|
|
40393
|
+
"application/xml": "text",
|
|
40394
|
+
"application/xhtml+xml": "text",
|
|
40395
|
+
"application/javascript": "text",
|
|
40396
|
+
"application/x-yaml": "text"
|
|
40397
|
+
};
|
|
40398
|
+
var EXT_KINDS = {
|
|
40399
|
+
".xlsx": "xlsx",
|
|
40400
|
+
".xlsm": "xlsx",
|
|
40401
|
+
".docx": "docx",
|
|
40402
|
+
".pptx": "pptx",
|
|
40403
|
+
".pdf": "pdf",
|
|
40404
|
+
".csv": "csv",
|
|
40405
|
+
".tsv": "tsv",
|
|
40406
|
+
".tab": "tsv",
|
|
40407
|
+
".txt": "text",
|
|
40408
|
+
".md": "text",
|
|
40409
|
+
".json": "text",
|
|
40410
|
+
".xml": "text",
|
|
40411
|
+
".html": "text",
|
|
40412
|
+
".htm": "text",
|
|
40413
|
+
".log": "text",
|
|
40414
|
+
".yaml": "text",
|
|
40415
|
+
".yml": "text",
|
|
40416
|
+
".ics": "text",
|
|
40417
|
+
".vcf": "text",
|
|
40418
|
+
".srt": "text"
|
|
40419
|
+
};
|
|
40420
|
+
function extractKindFor(mimeType, fileName) {
|
|
40421
|
+
const byMime = MIME_KINDS[mimeType];
|
|
40422
|
+
if (byMime) return byMime;
|
|
40423
|
+
const dot = fileName.lastIndexOf(".");
|
|
40424
|
+
const byExt = dot === -1 ? void 0 : EXT_KINDS[fileName.slice(dot).toLowerCase()];
|
|
40425
|
+
if (byExt) return byExt;
|
|
40426
|
+
return mimeType.startsWith("text/") ? "text" : null;
|
|
40427
|
+
}
|
|
40428
|
+
function parsePartSpec(spec) {
|
|
40429
|
+
const ranges = [];
|
|
40430
|
+
const names = /* @__PURE__ */ new Set();
|
|
40431
|
+
for (const token of spec.split(",").map((t) => t.trim()).filter(Boolean)) {
|
|
40432
|
+
const range = /^(\d+)\s*-\s*(\d+)$/.exec(token);
|
|
40433
|
+
if (range) {
|
|
40434
|
+
ranges.push([Number(range[1]), Number(range[2])]);
|
|
40435
|
+
continue;
|
|
40436
|
+
}
|
|
40437
|
+
if (/^\d+$/.test(token)) ranges.push([Number(token), Number(token)]);
|
|
40438
|
+
names.add(token.toLowerCase());
|
|
40439
|
+
}
|
|
40440
|
+
if (ranges.length === 0 && names.size === 0) return () => true;
|
|
40441
|
+
return (index, name) => ranges.some(([from, to]) => index + 1 >= from && index + 1 <= to) || names.has(name.toLowerCase());
|
|
40442
|
+
}
|
|
40443
|
+
function clip(text, budget) {
|
|
40444
|
+
const cut = text.slice(0, budget);
|
|
40445
|
+
const lastBreak = cut.lastIndexOf("\n");
|
|
40446
|
+
return lastBreak > 0 ? cut.slice(0, lastBreak) : cut;
|
|
40447
|
+
}
|
|
40448
|
+
function omissionNote(label) {
|
|
40449
|
+
return `${label} (omitted: response character budget)`;
|
|
40450
|
+
}
|
|
40451
|
+
function applyCharBudget(extracted, maxChars) {
|
|
40452
|
+
switch (extracted.kind) {
|
|
40453
|
+
case "text":
|
|
40454
|
+
case "document": {
|
|
40455
|
+
if (extracted.text.length <= maxChars) return extracted;
|
|
40456
|
+
return { ...extracted, text: clip(extracted.text, maxChars), truncated: true };
|
|
40457
|
+
}
|
|
40458
|
+
case "spreadsheet": {
|
|
40459
|
+
const sheets = [];
|
|
40460
|
+
const omitted = [...extracted.omitted ?? []];
|
|
40461
|
+
let budget = maxChars;
|
|
40462
|
+
let truncated = extracted.truncated ?? false;
|
|
40463
|
+
for (const sheet of extracted.sheets) {
|
|
40464
|
+
if (budget <= 0) {
|
|
40465
|
+
omitted.push(omissionNote(sheet.name));
|
|
40466
|
+
truncated = true;
|
|
40467
|
+
continue;
|
|
40468
|
+
}
|
|
40469
|
+
if (sheet.csv.length <= budget) {
|
|
40470
|
+
sheets.push(sheet);
|
|
40471
|
+
budget -= sheet.csv.length;
|
|
40472
|
+
continue;
|
|
40473
|
+
}
|
|
40474
|
+
const csv = clip(sheet.csv, budget);
|
|
40475
|
+
sheets.push({ ...sheet, csv, rows: csv.split("\n").length, truncated: true });
|
|
40476
|
+
budget = 0;
|
|
40477
|
+
truncated = true;
|
|
40478
|
+
}
|
|
40479
|
+
return {
|
|
40480
|
+
...extracted,
|
|
40481
|
+
sheets,
|
|
40482
|
+
truncated,
|
|
40483
|
+
...omitted.length ? { omitted } : {}
|
|
40484
|
+
};
|
|
40485
|
+
}
|
|
40486
|
+
case "presentation": {
|
|
40487
|
+
const slides = [];
|
|
40488
|
+
const omitted = [...extracted.omitted ?? []];
|
|
40489
|
+
let budget = maxChars;
|
|
40490
|
+
let truncated = extracted.truncated ?? false;
|
|
40491
|
+
for (const slide of extracted.slides) {
|
|
40492
|
+
const size = slide.text.length + (slide.notes?.length ?? 0);
|
|
40493
|
+
if (budget <= 0) {
|
|
40494
|
+
omitted.push(omissionNote(`slide ${slide.number}`));
|
|
40495
|
+
truncated = true;
|
|
40496
|
+
continue;
|
|
40497
|
+
}
|
|
40498
|
+
if (size <= budget) {
|
|
40499
|
+
slides.push(slide);
|
|
40500
|
+
budget -= size;
|
|
40501
|
+
continue;
|
|
40502
|
+
}
|
|
40503
|
+
slides.push({ ...slide, text: clip(slide.text, budget), notes: void 0 });
|
|
40504
|
+
budget = 0;
|
|
40505
|
+
truncated = true;
|
|
40506
|
+
}
|
|
40507
|
+
return { ...extracted, slides, truncated, ...omitted.length ? { omitted } : {} };
|
|
40508
|
+
}
|
|
40509
|
+
case "pdf": {
|
|
40510
|
+
const pages = [];
|
|
40511
|
+
const omitted = [...extracted.omitted ?? []];
|
|
40512
|
+
let budget = maxChars;
|
|
40513
|
+
let truncated = extracted.truncated ?? false;
|
|
40514
|
+
for (const page of extracted.pages) {
|
|
40515
|
+
if (budget <= 0) {
|
|
40516
|
+
omitted.push(omissionNote(`page ${page.number}`));
|
|
40517
|
+
truncated = true;
|
|
40518
|
+
continue;
|
|
40519
|
+
}
|
|
40520
|
+
if (page.text.length <= budget) {
|
|
40521
|
+
pages.push(page);
|
|
40522
|
+
budget -= page.text.length;
|
|
40523
|
+
continue;
|
|
40524
|
+
}
|
|
40525
|
+
pages.push({ ...page, text: clip(page.text, budget) });
|
|
40526
|
+
budget = 0;
|
|
40527
|
+
truncated = true;
|
|
40528
|
+
}
|
|
40529
|
+
return { ...extracted, pages, truncated, ...omitted.length ? { omitted } : {} };
|
|
40530
|
+
}
|
|
40531
|
+
}
|
|
40532
|
+
}
|
|
40533
|
+
var DEFAULT_MAX_CHARS = 5e4;
|
|
40534
|
+
function decodeText(bytes) {
|
|
40535
|
+
const text = bytes.toString("utf8");
|
|
40536
|
+
return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
|
|
40537
|
+
}
|
|
40538
|
+
async function extractAttachment(bytes, mimeType, fileName, opts = {}) {
|
|
40539
|
+
const kind = extractKindFor(mimeType, fileName);
|
|
40540
|
+
if (kind === null) return null;
|
|
40541
|
+
const select = opts.parts === void 0 ? void 0 : parsePartSpec(opts.parts);
|
|
40542
|
+
let extracted;
|
|
40543
|
+
switch (kind) {
|
|
40544
|
+
case "xlsx":
|
|
40545
|
+
extracted = await extractXlsx(bytes, { select });
|
|
40546
|
+
break;
|
|
40547
|
+
case "csv":
|
|
40548
|
+
extracted = extractDelimited(decodeText(bytes), fileName, ",");
|
|
40549
|
+
break;
|
|
40550
|
+
case "tsv":
|
|
40551
|
+
extracted = extractDelimited(decodeText(bytes), fileName, " ");
|
|
40552
|
+
break;
|
|
40553
|
+
case "docx":
|
|
40554
|
+
extracted = await extractDocx(bytes);
|
|
40555
|
+
break;
|
|
40556
|
+
case "pptx":
|
|
40557
|
+
extracted = await extractPptx(bytes, { select });
|
|
40558
|
+
break;
|
|
40559
|
+
case "pdf":
|
|
40560
|
+
extracted = await extractPdf(bytes, { select });
|
|
40561
|
+
break;
|
|
40562
|
+
case "text":
|
|
40563
|
+
extracted = { kind: "text", text: decodeText(bytes) };
|
|
40564
|
+
break;
|
|
40565
|
+
}
|
|
40566
|
+
return applyCharBudget(extracted, opts.maxChars ?? DEFAULT_MAX_CHARS);
|
|
40567
|
+
}
|
|
40568
|
+
|
|
39494
40569
|
// src/tools/attachments.ts
|
|
39495
40570
|
import { readFileSync, statSync, mkdirSync, writeFileSync } from "node:fs";
|
|
39496
40571
|
import { basename, dirname as dirname2, extname } from "node:path";
|
|
@@ -39578,6 +40653,65 @@ var NodeAttachmentIO = class {
|
|
|
39578
40653
|
}
|
|
39579
40654
|
};
|
|
39580
40655
|
|
|
40656
|
+
// src/tools/delivery.ts
|
|
40657
|
+
async function tryExtract(bytes, mimeType, fileName, opts) {
|
|
40658
|
+
try {
|
|
40659
|
+
const extracted = await extractAttachment(bytes, mimeType, fileName, {
|
|
40660
|
+
maxChars: opts.maxChars,
|
|
40661
|
+
parts: opts.parts
|
|
40662
|
+
});
|
|
40663
|
+
if (!extracted) {
|
|
40664
|
+
return { reason: `no text extractor for ${mimeType} (${fileName})` };
|
|
40665
|
+
}
|
|
40666
|
+
return { extracted, truncated: extracted.truncated ?? false };
|
|
40667
|
+
} catch (err) {
|
|
40668
|
+
return { reason: `extraction failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
40669
|
+
}
|
|
40670
|
+
}
|
|
40671
|
+
async function buildInlineDelivery(input) {
|
|
40672
|
+
const { fileId, fileName, mimeType, bytes, forcedInline, options } = input;
|
|
40673
|
+
const meta3 = {
|
|
40674
|
+
fileId,
|
|
40675
|
+
fileName,
|
|
40676
|
+
mimeType,
|
|
40677
|
+
sizeBytes: bytes.length,
|
|
40678
|
+
mode: "inline"
|
|
40679
|
+
};
|
|
40680
|
+
if (forcedInline) meta3.forcedInline = true;
|
|
40681
|
+
const block = () => ({ type: "text", text: JSON.stringify(meta3, null, 2) });
|
|
40682
|
+
if (isHostRenderableImage(mimeType)) {
|
|
40683
|
+
meta3.deliveredVia = "image";
|
|
40684
|
+
return { content: [block(), { type: "image", data: bytes.toString("base64"), mimeType }] };
|
|
40685
|
+
}
|
|
40686
|
+
const attempts = [];
|
|
40687
|
+
if (options.extract === false) {
|
|
40688
|
+
attempts.push("extraction skipped (extract:false)");
|
|
40689
|
+
} else {
|
|
40690
|
+
const outcome = await tryExtract(bytes, mimeType, fileName, options);
|
|
40691
|
+
if (outcome.extracted) {
|
|
40692
|
+
meta3.deliveredVia = "extracted";
|
|
40693
|
+
meta3.extracted = outcome.extracted;
|
|
40694
|
+
meta3.truncated = outcome.truncated;
|
|
40695
|
+
meta3.note = "Content extracted from the file. Pass extract:false to get the raw bytes instead.";
|
|
40696
|
+
return { content: [block()] };
|
|
40697
|
+
}
|
|
40698
|
+
attempts.push(outcome.reason ?? "extraction produced no content");
|
|
40699
|
+
}
|
|
40700
|
+
meta3.deliveredVia = "blob";
|
|
40701
|
+
meta3.deliveryAttempts = attempts;
|
|
40702
|
+
meta3.note = "Returned as raw bytes. Some hosts cannot render an embedded resource of this type; if it came back unreadable, the file has no text extractor here (see deliveryAttempts).";
|
|
40703
|
+
return {
|
|
40704
|
+
content: [block(), {
|
|
40705
|
+
type: "resource",
|
|
40706
|
+
resource: {
|
|
40707
|
+
uri: `ofw://attachment/${fileId}/${encodeURIComponent(fileName)}`,
|
|
40708
|
+
mimeType,
|
|
40709
|
+
blob: bytes.toString("base64")
|
|
40710
|
+
}
|
|
40711
|
+
}]
|
|
40712
|
+
};
|
|
40713
|
+
}
|
|
40714
|
+
|
|
39581
40715
|
// src/tools/messages.ts
|
|
39582
40716
|
import { basename as basename2, join as join5 } from "node:path";
|
|
39583
40717
|
var DateSchema = external_exports.looseObject({ dateTime: external_exports.string() });
|
|
@@ -39608,7 +40742,10 @@ var MessageDetailSchema = external_exports.looseObject({
|
|
|
39608
40742
|
// The detail payload carries its own owning folder ({id, name}). We read the
|
|
39609
40743
|
// id to label a live-fetched message sent-vs-inbox instead of blindly
|
|
39610
40744
|
// defaulting to inbox — see the folder derivation in ofw_get_message.
|
|
39611
|
-
|
|
40745
|
+
// Same union as ServerDraftSchema's, for the same reason — OFW types this id
|
|
40746
|
+
// as a string on the folders listing and a number on message detail. Lenient
|
|
40747
|
+
// here, so a mismatch only warns, but it would warn on EVERY live fetch.
|
|
40748
|
+
folder: external_exports.looseObject({ id: external_exports.union([external_exports.string(), external_exports.number()]) }).optional()
|
|
39612
40749
|
});
|
|
39613
40750
|
var DetailFilesSchema = external_exports.looseObject({ files: external_exports.array(external_exports.number()).optional() });
|
|
39614
40751
|
var FolderCountsSchema = external_exports.looseObject({
|
|
@@ -39620,11 +40757,6 @@ var FolderCountsSchema = external_exports.looseObject({
|
|
|
39620
40757
|
count: external_exports.number().optional()
|
|
39621
40758
|
})).optional()
|
|
39622
40759
|
});
|
|
39623
|
-
var FOLDER_TYPE = {
|
|
39624
|
-
inbox: "INBOX",
|
|
39625
|
-
sent: "SENT_MESSAGES",
|
|
39626
|
-
drafts: "DRAFTS"
|
|
39627
|
-
};
|
|
39628
40760
|
var MAX_FRESHNESS_IDS = 25;
|
|
39629
40761
|
var UploadedFileSchema = external_exports.looseObject({
|
|
39630
40762
|
fileId: external_exports.number(),
|
|
@@ -39647,6 +40779,51 @@ async function draftsFreshness(cache) {
|
|
|
39647
40779
|
const cacheStatus = completed === "fresh" && freshness.staleness === "fresh" ? "fresh" : "unverified";
|
|
39648
40780
|
return { freshness, serverConfirmed: cacheStatus === "fresh", cacheStatus };
|
|
39649
40781
|
}
|
|
40782
|
+
var AUTO_REFRESH_DESC = 'If the result comes back EMPTY from a cache that is not verified-fresh, sync the backing folders first and answer from the refreshed cache instead of refusing. Defaults to the OFW_AUTO_REFRESH env var (false unless set), in which case the call refuses with result:"UNVERIFIED_EMPTY" and names the remedy. Costs OFW requests when it fires.';
|
|
40783
|
+
async function guardedCacheRead(o) {
|
|
40784
|
+
let value = await o.read();
|
|
40785
|
+
let refreshed = false;
|
|
40786
|
+
const unverifiable = (v) => o.isEmpty(v) && v.freshness.staleness !== "fresh";
|
|
40787
|
+
if (unverifiable(value) && o.autoRefresh) {
|
|
40788
|
+
await syncAll(o.client, {
|
|
40789
|
+
folders: o.folders,
|
|
40790
|
+
// Same ceiling ofw_sync_messages applies: an automatic refresh must never
|
|
40791
|
+
// stamp unread inbox messages as a side effect of a list read.
|
|
40792
|
+
fetchUnreadBodies: getAllowMarkRead() && getFetchUnreadBodies(),
|
|
40793
|
+
maxRequests: getSyncMaxRequests()
|
|
40794
|
+
}, o.cache);
|
|
40795
|
+
refreshed = true;
|
|
40796
|
+
value = await o.read();
|
|
40797
|
+
}
|
|
40798
|
+
return { value, refreshed, unverifiedEmpty: unverifiable(value) };
|
|
40799
|
+
}
|
|
40800
|
+
function unverifiedEmptyResponse(input) {
|
|
40801
|
+
const { freshness } = input;
|
|
40802
|
+
const age = freshness.ageSeconds === null ? "it has never been checked against OurFamilyWizard" : `it was last verified ${freshness.ageSeconds < 60 ? `${freshness.ageSeconds} sec` : `${Math.round(freshness.ageSeconds / 60)} min`} ago`;
|
|
40803
|
+
const refreshClause = input.refreshed ? " An automatic refresh ran on this call and did NOT make the result verifiable (the sync paused or skipped this folder), so the refusal stands." : "";
|
|
40804
|
+
return jsonErrorResponse({
|
|
40805
|
+
result: "UNVERIFIED_EMPTY",
|
|
40806
|
+
reason: `No ${input.what} were found, but the backing cache is "${freshness.staleness}" \u2014 ${age}. Refusing to report absence from unverified data: an empty result from a stale cache is indistinguishable from a verified "nothing there", and repeating it as one asserts a false negative about a legal record.${refreshClause}`,
|
|
40807
|
+
remedy: input.remedy,
|
|
40808
|
+
complete: false,
|
|
40809
|
+
freshness,
|
|
40810
|
+
...input.extra
|
|
40811
|
+
});
|
|
40812
|
+
}
|
|
40813
|
+
function markReadVerdict(cached2, requested) {
|
|
40814
|
+
const ceiling = getAllowMarkRead();
|
|
40815
|
+
if (ceiling && (requested ?? true)) return null;
|
|
40816
|
+
const wouldStamp = cached2 === null || cached2.folder === "inbox" && !deriveRead(cached2);
|
|
40817
|
+
if (!wouldStamp) return null;
|
|
40818
|
+
const because = ceiling ? "you passed allowMarkRead:false" : "this server runs with OFW_ALLOW_MARK_READ=false";
|
|
40819
|
+
return jsonErrorResponse({
|
|
40820
|
+
error: "MARK_READ_BLOCKED",
|
|
40821
|
+
messageId: cached2?.id ?? null,
|
|
40822
|
+
reason: cached2 === null ? "This id is not in the cache, so whether reading it would mark it read is unknowable without making the request that would." : "This is an unread inbox message; fetching its body would mark it read on OurFamilyWizard.",
|
|
40823
|
+
note: `Refused because ${because}. Reading a message for the first time stamps a "First Viewed" timestamp that your co-parent can see and that forms part of the record \u2014 it cannot be undone. To read it anyway, call again with allowMarkRead:true${ceiling ? "" : " (which this deployment does not permit \u2014 clear OFW_ALLOW_MARK_READ to re-enable)"}.`,
|
|
40824
|
+
...cached2 === null ? { hint: "Run ofw_sync_messages first: it walks list pages, not bodies, so it can tell you what this id is without stamping anything." } : { subject: cached2.subject, fromUser: cached2.fromUser, sentAt: cached2.sentAt }
|
|
40825
|
+
});
|
|
40826
|
+
}
|
|
39650
40827
|
function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
39651
40828
|
const writeMode = getWriteMode();
|
|
39652
40829
|
const allowSend = writeMode === "all";
|
|
@@ -39660,15 +40837,16 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
39660
40837
|
return jsonResponse({ folders: data, freshness });
|
|
39661
40838
|
});
|
|
39662
40839
|
server.registerTool("ofw_list_messages", {
|
|
39663
|
-
description:
|
|
39664
|
-
annotations: { readOnlyHint:
|
|
40840
|
+
description: 'List messages from the local OurFamilyWizard cache. Supports filtering by folder, date range, and a substring query on subject+body. Pagination is offset-based but if you know what you want (a date range, a topic), prefer the filters over walking pages \u2014 the cache may have 1000+ messages. Returns an explicit `complete` boolean describing the RESULT SET: true means "this is every message on OurFamilyWizard matching these filters as of freshness.asOf" \u2014 check it before asserting a count. An empty result from a cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY") rather than reported as an absence; pass autoRefresh:true to sync and answer instead.',
|
|
40841
|
+
annotations: { readOnlyHint: false },
|
|
39665
40842
|
inputSchema: {
|
|
39666
40843
|
folderId: external_exports.string().describe('Folder name: "inbox", "sent", or "both" (default "both")').optional(),
|
|
39667
40844
|
page: external_exports.number().int().min(1).describe("Page number (default 1)").optional(),
|
|
39668
40845
|
size: external_exports.number().int().min(1).describe("Messages per page (default 50)").optional(),
|
|
39669
40846
|
since: external_exports.string().describe("ISO date or datetime \u2014 only messages with sent_at >= since (inclusive)").optional(),
|
|
39670
40847
|
until: external_exports.string().describe("ISO date or datetime \u2014 only messages with sent_at < until (exclusive)").optional(),
|
|
39671
|
-
q: external_exports.string().describe("Substring match on subject AND body (case-insensitive). Use to find messages on a specific topic.").optional()
|
|
40848
|
+
q: external_exports.string().describe("Substring match on subject AND body (case-insensitive). Use to find messages on a specific topic.").optional(),
|
|
40849
|
+
autoRefresh: external_exports.boolean().describe(AUTO_REFRESH_DESC).optional()
|
|
39672
40850
|
}
|
|
39673
40851
|
}, async (args) => {
|
|
39674
40852
|
const page = args.page ?? 1;
|
|
@@ -39679,36 +40857,66 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
39679
40857
|
else if (folderArg === "sent") folder = "sent";
|
|
39680
40858
|
else if (folderArg === "both") folder = void 0;
|
|
39681
40859
|
else {
|
|
39682
|
-
return
|
|
39683
|
-
|
|
39684
|
-
|
|
39685
|
-
|
|
39686
|
-
|
|
39687
|
-
|
|
39688
|
-
note: 'folderId must be "inbox", "sent", or "both". Numeric OFW folder IDs are not supported by the cache. No lookup was performed \u2014 this empty result says nothing about what is in the cache.'
|
|
40860
|
+
return jsonErrorResponse({
|
|
40861
|
+
result: "INVALID_FOLDER",
|
|
40862
|
+
reason: `folderId must be "inbox", "sent", or "both" (got ${JSON.stringify(folderArg)}). Numeric OFW folder IDs are not supported by the cache.`,
|
|
40863
|
+
remedy: "Re-call with folderId omitted (searches both) or set to one of the three accepted names.",
|
|
40864
|
+
complete: false,
|
|
40865
|
+
note: 'No lookup was performed. This says NOTHING about what is in the cache \u2014 do not read it as "no messages".'
|
|
39689
40866
|
});
|
|
39690
40867
|
}
|
|
39691
40868
|
const cache = cacheProvider();
|
|
40869
|
+
const folders = folder === void 0 ? ["inbox", "sent"] : [folder];
|
|
39692
40870
|
const filter = { folder, since: args.since, until: args.until, q: args.q };
|
|
39693
|
-
const
|
|
39694
|
-
|
|
39695
|
-
|
|
39696
|
-
|
|
39697
|
-
|
|
40871
|
+
const { value, refreshed, unverifiedEmpty } = await guardedCacheRead({
|
|
40872
|
+
client: client2,
|
|
40873
|
+
cache,
|
|
40874
|
+
folders,
|
|
40875
|
+
autoRefresh: args.autoRefresh ?? getAutoRefreshStaleReads(),
|
|
40876
|
+
isEmpty: (v) => v.total === 0,
|
|
40877
|
+
read: async () => {
|
|
40878
|
+
const total2 = await cache.countMessages(filter);
|
|
40879
|
+
const messages2 = (await cache.listMessages({ ...filter, page, size })).map((m) => withReadState(m));
|
|
40880
|
+
const freshness2 = await buildFreshness(cache, { source: "cache", folders });
|
|
40881
|
+
return { messages: messages2, total: total2, freshness: freshness2 };
|
|
40882
|
+
}
|
|
39698
40883
|
});
|
|
39699
|
-
|
|
40884
|
+
if (unverifiedEmpty) {
|
|
40885
|
+
return unverifiedEmptyResponse({
|
|
40886
|
+
what: "messages matching these filters",
|
|
40887
|
+
freshness: value.freshness,
|
|
40888
|
+
refreshed,
|
|
40889
|
+
remedy: `Call ofw_sync_messages(folders:${JSON.stringify(folders)}) and retry, or re-call this tool with autoRefresh:true. ofw_check_freshness is the cheap live alternative when you only need to confirm a specific message.`,
|
|
40890
|
+
extra: { page, size, filters: { folderId: folderArg, since: args.since, until: args.until, q: args.q } }
|
|
40891
|
+
});
|
|
40892
|
+
}
|
|
40893
|
+
const { messages, total, freshness } = value;
|
|
40894
|
+
const fullSlice = page === 1 && messages.length === total;
|
|
40895
|
+
const complete = fullSlice && freshness.staleness === "fresh" && freshness.historyComplete;
|
|
40896
|
+
const payload = { messages, total, page, size, complete, freshness };
|
|
40897
|
+
if (!complete) {
|
|
40898
|
+
payload.completeNote = [
|
|
40899
|
+
!fullSlice ? `this page holds ${messages.length} of ${total} matching cached messages` : null,
|
|
40900
|
+
freshness.staleness !== "fresh" ? `the cache is "${freshness.staleness}", so newer messages may exist on OurFamilyWizard` : null,
|
|
40901
|
+
!freshness.historyComplete ? "older history is still being backfilled, so the cache does not yet hold every message" : null
|
|
40902
|
+
].filter((r) => r !== null).join("; ").concat(". Do not state a total or an absence from this result without resolving that first.");
|
|
40903
|
+
}
|
|
39700
40904
|
if (total === 0) {
|
|
39701
|
-
payload.note =
|
|
40905
|
+
payload.note = 'No messages match these filters, and the cache IS verified-fresh for these folders \u2014 so this is a real "nothing matched", not a stale-cache artefact. If you expected results, relax the filters.';
|
|
39702
40906
|
} else if (page * size < total) {
|
|
39703
40907
|
payload.note = `Showing ${(page - 1) * size + 1}\u2013${(page - 1) * size + messages.length} of ${total}. Increase 'page' to see more, or narrow with since/until/q.`;
|
|
39704
40908
|
}
|
|
40909
|
+
if (refreshed) {
|
|
40910
|
+
payload.autoRefreshed = true;
|
|
40911
|
+
}
|
|
39705
40912
|
return jsonResponse(payload);
|
|
39706
40913
|
});
|
|
39707
40914
|
server.registerTool("ofw_get_message", {
|
|
39708
|
-
description: 'Get a single OurFamilyWizard message OR draft by ID. Reads from local cache when available; otherwise fetches from OFW
|
|
40915
|
+
description: 'Get a single OurFamilyWizard message OR draft by ID. Reads from local cache when available; otherwise fetches from OFW \u2014 and for an UNREAD INBOX message that fetch marks it read and stamps a "First Viewed" time the co-parent can see, which is part of the record and cannot be undone. Pass allowMarkRead:false to refuse such a fetch instead (cached bodies, sent messages and already-read messages are unaffected, because none of them stamp anything). For ids that match a draft (in the drafts cache), the response carries folder="drafts" and the body/subject/recipients reflect the drafts cache (which ofw_sync_messages keeps fresh) \u2014 drafts have no `fromUser`, and `sentAt`/`fetchedBodyAt` mirror the draft\'s `modifiedAt`. For inbox/sent messages, folder is "inbox" or "sent" as before.',
|
|
39709
40916
|
annotations: { readOnlyHint: false },
|
|
39710
40917
|
inputSchema: {
|
|
39711
|
-
messageId: external_exports.string().describe("Message ID (also accepts draft IDs \u2014 drafts are routed via the drafts cache)")
|
|
40918
|
+
messageId: external_exports.string().describe("Message ID (also accepts draft IDs \u2014 drafts are routed via the drafts cache)"),
|
|
40919
|
+
allowMarkRead: external_exports.boolean().describe("Default true (the long-standing behaviour). Set false to refuse a fetch that would mark an unread INBOX message as READ on OurFamilyWizard \u2014 an irreversible, co-parent-visible change to the record. Reads that cannot stamp anything (a cached body, a sent message, an already-read message) still succeed. The server-wide OFW_ALLOW_MARK_READ=false is a ceiling this argument cannot raise.").optional()
|
|
39712
40920
|
}
|
|
39713
40921
|
}, async (args) => {
|
|
39714
40922
|
const id = Number(args.messageId);
|
|
@@ -39735,6 +40943,11 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
39735
40943
|
// Concurrency token — pass as expectedRevision to ofw_save_draft /
|
|
39736
40944
|
// ofw_delete_draft to assert you are editing THIS version.
|
|
39737
40945
|
revision: draftRevision(draftRow),
|
|
40946
|
+
// Stable logical identity. Survives the create-then-delete id churn of
|
|
40947
|
+
// editing AND the transition to sent — pass it to ofw_status to ask
|
|
40948
|
+
// "what happened to the thing I was working on?". Null when this draft
|
|
40949
|
+
// was never written through this tool (e.g. authored in the web app).
|
|
40950
|
+
draftKey: (await cache.getDraftLineageById(draftRow.id))?.draftKey ?? null,
|
|
39738
40951
|
cacheStatus,
|
|
39739
40952
|
// False = this draft's existence and unsent status are remembered from
|
|
39740
40953
|
// a cache, not confirmed on OFW. Call ofw_check_freshness before
|
|
@@ -39781,6 +40994,8 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
39781
40994
|
const freshness2 = await buildFreshness(cache, { source: "cache", folders: [row2.folder] });
|
|
39782
40995
|
return jsonResponse({ ...withReadState(row2), attachments: attachments2, freshness: freshness2 });
|
|
39783
40996
|
}
|
|
40997
|
+
const markReadCheck = markReadVerdict(cached2, args.allowMarkRead);
|
|
40998
|
+
if (markReadCheck !== null) return markReadCheck;
|
|
39784
40999
|
const detail = parseLenient(
|
|
39785
41000
|
MessageDetailSchema,
|
|
39786
41001
|
await client2.request("GET", `/pub/v3/messages/${encodeURIComponent(args.messageId)}`),
|
|
@@ -39888,6 +41103,7 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
39888
41103
|
}, SentDetailSchema, "ofw_send_message");
|
|
39889
41104
|
let persisted = null;
|
|
39890
41105
|
let verifyNote = null;
|
|
41106
|
+
let sentDraftKey = null;
|
|
39891
41107
|
if (newId !== null) {
|
|
39892
41108
|
verifyNote = verifyWriteLanded("message", { subject, body }, detail);
|
|
39893
41109
|
persisted = {
|
|
@@ -39904,6 +41120,16 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
39904
41120
|
listData: detail
|
|
39905
41121
|
};
|
|
39906
41122
|
await cache.upsertMessage(persisted);
|
|
41123
|
+
if (draftRef !== void 0) {
|
|
41124
|
+
const prior = await cache.getDraftLineageById(draftRef);
|
|
41125
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
41126
|
+
const key = prior?.draftKey ?? newDraftKey();
|
|
41127
|
+
if (prior === null) {
|
|
41128
|
+
await cache.recordDraftLineage({ id: draftRef, draftKey: key, previousId: null, recordedAt: now });
|
|
41129
|
+
}
|
|
41130
|
+
await cache.recordDraftLineage({ id: newId, draftKey: key, previousId: draftRef, recordedAt: now });
|
|
41131
|
+
sentDraftKey = key;
|
|
41132
|
+
}
|
|
39907
41133
|
for (const fileId of myFileIDs) {
|
|
39908
41134
|
const existing = await cache.getAttachment(fileId);
|
|
39909
41135
|
await cache.upsertAttachmentForMessage({
|
|
@@ -39925,7 +41151,7 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
39925
41151
|
await deleteOFWMessages(client2, [draftRef]);
|
|
39926
41152
|
await cache.deleteDraft(draftRef);
|
|
39927
41153
|
}
|
|
39928
|
-
const responseObj = persisted
|
|
41154
|
+
const responseObj = persisted === null ? raw : { ...persisted, ...sentDraftKey !== null ? { draftKey: sentDraftKey, previousId: draftRef } : {} };
|
|
39929
41155
|
const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Message sent successfully.";
|
|
39930
41156
|
const notes = [rewriteNote, verifyNote, unconfirmedNote].filter((n) => n !== null).join("\n\n");
|
|
39931
41157
|
return textResponse(notes ? `${notes}
|
|
@@ -39990,35 +41216,65 @@ ${JSON.stringify(
|
|
|
39990
41216
|
};
|
|
39991
41217
|
}
|
|
39992
41218
|
server.registerTool("ofw_list_drafts", {
|
|
39993
|
-
description:
|
|
39994
|
-
annotations: { readOnlyHint:
|
|
41219
|
+
description: 'List draft messages from the local OurFamilyWizard cache. Returns an explicit `complete` boolean describing the RESULT SET: true means "these are ALL the drafts on OurFamilyWizard as of freshness.asOf" \u2014 check it before saying "you have N drafts". Each draft carries its `draftKey` (stable across the create-then-delete churn of editing) when one is known. An empty result from a cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY"); pass autoRefresh:true to sync and answer instead. For a live, one-call answer prefer ofw_status(includeDraftInventory:true).',
|
|
41220
|
+
annotations: { readOnlyHint: false },
|
|
39995
41221
|
inputSchema: {
|
|
39996
41222
|
page: external_exports.number().int().min(1).describe("Page number (default 1)").optional(),
|
|
39997
|
-
size: external_exports.number().int().min(1).describe("Drafts per page (default 50)").optional()
|
|
41223
|
+
size: external_exports.number().int().min(1).describe("Drafts per page (default 50)").optional(),
|
|
41224
|
+
autoRefresh: external_exports.boolean().describe(AUTO_REFRESH_DESC).optional()
|
|
39998
41225
|
}
|
|
39999
41226
|
}, async (args) => {
|
|
40000
41227
|
const page = args.page ?? 1;
|
|
40001
41228
|
const size = args.size ?? 50;
|
|
40002
41229
|
const cache = cacheProvider();
|
|
40003
|
-
const {
|
|
40004
|
-
|
|
40005
|
-
|
|
40006
|
-
|
|
40007
|
-
|
|
40008
|
-
|
|
40009
|
-
|
|
40010
|
-
|
|
40011
|
-
|
|
40012
|
-
|
|
40013
|
-
|
|
40014
|
-
|
|
40015
|
-
|
|
40016
|
-
|
|
41230
|
+
const { value, refreshed, unverifiedEmpty } = await guardedCacheRead({
|
|
41231
|
+
client: client2,
|
|
41232
|
+
cache,
|
|
41233
|
+
folders: ["drafts"],
|
|
41234
|
+
autoRefresh: args.autoRefresh ?? getAutoRefreshStaleReads(),
|
|
41235
|
+
isEmpty: (v) => v.total === 0,
|
|
41236
|
+
read: async () => {
|
|
41237
|
+
const { freshness: freshness2, serverConfirmed: serverConfirmed2, cacheStatus } = await draftsFreshness(cache);
|
|
41238
|
+
const rows = await cache.listDrafts({ page, size });
|
|
41239
|
+
const total2 = await cache.countDrafts();
|
|
41240
|
+
const keyById = new Map(
|
|
41241
|
+
(await cache.getDraftLineageByIds(rows.map((d) => d.id))).map((l) => [l.id, l.draftKey])
|
|
41242
|
+
);
|
|
41243
|
+
const drafts2 = rows.map((d) => ({
|
|
41244
|
+
...d,
|
|
41245
|
+
revision: draftRevision(d),
|
|
41246
|
+
draftKey: keyById.get(d.id) ?? null,
|
|
41247
|
+
cacheStatus,
|
|
41248
|
+
serverConfirmed: serverConfirmed2,
|
|
41249
|
+
asOf: freshness2.asOf
|
|
41250
|
+
}));
|
|
41251
|
+
return { drafts: drafts2, total: total2, freshness: freshness2, serverConfirmed: serverConfirmed2 };
|
|
41252
|
+
}
|
|
41253
|
+
});
|
|
41254
|
+
if (unverifiedEmpty) {
|
|
41255
|
+
return unverifiedEmptyResponse({
|
|
41256
|
+
what: "drafts",
|
|
41257
|
+
freshness: value.freshness,
|
|
41258
|
+
refreshed,
|
|
41259
|
+
remedy: 'Call ofw_sync_messages(folders:["drafts"]) and retry, re-call with autoRefresh:true, or use ofw_status(includeDraftInventory:true) for a single live answer.',
|
|
41260
|
+
extra: { page, size }
|
|
40017
41261
|
});
|
|
40018
41262
|
}
|
|
40019
|
-
const
|
|
41263
|
+
const { drafts, total, freshness, serverConfirmed } = value;
|
|
41264
|
+
const fullSlice = page === 1 && drafts.length === total;
|
|
41265
|
+
const complete = serverConfirmed && fullSlice;
|
|
41266
|
+
const payload = { drafts, total, page, size, complete, freshness };
|
|
41267
|
+
if (!complete) {
|
|
41268
|
+
payload.completeNote = [
|
|
41269
|
+
!fullSlice ? `this page holds ${drafts.length} of ${total} cached drafts` : null,
|
|
41270
|
+
!serverConfirmed ? "the drafts cache has not been confirmed against OurFamilyWizard inside the freshness window" : null
|
|
41271
|
+
].filter((r) => r !== null).join("; ").concat(". Do NOT state a draft count from this result \u2014 call ofw_status(includeDraftInventory:true) for a live, complete one.");
|
|
41272
|
+
}
|
|
40020
41273
|
if (!serverConfirmed) {
|
|
40021
|
-
payload.note = 'serverConfirmed:false \u2014 these drafts are remembered from the local cache, NOT confirmed to still exist unsent on OurFamilyWizard right now, and their bodies may be behind the server. Do not state that a draft "is still sitting unsent" on this basis; drafts edited or
|
|
41274
|
+
payload.note = 'serverConfirmed:false \u2014 these drafts are remembered from the local cache, NOT confirmed to still exist unsent on OurFamilyWizard right now, and their bodies may be behind the server. Do not state that a draft "is still sitting unsent" on this basis; drafts edited, deleted or SENT in the OFW web app bump no timestamp, so the cache cannot detect it on its own. Call ofw_status / ofw_check_freshness (cheap, live) or ofw_sync_messages first. Writes are guarded regardless \u2014 ofw_save_draft and ofw_delete_draft re-check the server and refuse a stale overwrite.';
|
|
41275
|
+
}
|
|
41276
|
+
if (refreshed) {
|
|
41277
|
+
payload.autoRefreshed = true;
|
|
40022
41278
|
}
|
|
40023
41279
|
return jsonResponse(payload);
|
|
40024
41280
|
});
|
|
@@ -40078,6 +41334,7 @@ ${JSON.stringify(
|
|
|
40078
41334
|
let replaceNote = null;
|
|
40079
41335
|
let verifyNote = null;
|
|
40080
41336
|
let newRevision = null;
|
|
41337
|
+
let draftKey = null;
|
|
40081
41338
|
const warnings = [];
|
|
40082
41339
|
if (newId !== null) {
|
|
40083
41340
|
verifyNote = verifyWriteLanded("draft", { subject: args.subject, body: args.body }, detail);
|
|
@@ -40094,10 +41351,34 @@ ${JSON.stringify(
|
|
|
40094
41351
|
};
|
|
40095
41352
|
await cache.upsertDraft(persisted);
|
|
40096
41353
|
newRevision = draftRevision(persisted);
|
|
41354
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
41355
|
+
if (args.messageId !== void 0) {
|
|
41356
|
+
const prior = await cache.getDraftLineageById(args.messageId);
|
|
41357
|
+
if (prior !== null) {
|
|
41358
|
+
draftKey = prior.draftKey;
|
|
41359
|
+
} else {
|
|
41360
|
+
draftKey = newDraftKey();
|
|
41361
|
+
await cache.recordDraftLineage({
|
|
41362
|
+
id: args.messageId,
|
|
41363
|
+
draftKey,
|
|
41364
|
+
previousId: null,
|
|
41365
|
+
recordedAt: now
|
|
41366
|
+
});
|
|
41367
|
+
}
|
|
41368
|
+
} else {
|
|
41369
|
+
draftKey = newDraftKey();
|
|
41370
|
+
}
|
|
41371
|
+
await cache.recordDraftLineage({
|
|
41372
|
+
id: newId,
|
|
41373
|
+
draftKey,
|
|
41374
|
+
previousId: args.messageId ?? null,
|
|
41375
|
+
recordedAt: now
|
|
41376
|
+
});
|
|
40097
41377
|
if (resolvedReplyTo !== null && effectiveReplyTo !== resolvedReplyTo) {
|
|
40098
41378
|
const rewrittenFrom = requestedReplyTo !== resolvedReplyTo ? ` (rewritten from ${requestedReplyTo})` : "";
|
|
41379
|
+
const outcome = effectiveReplyTo === null ? "OurFamilyWizard did not thread this draft (its inReplyTo/showContext will be empty). The subject and body were saved; only the reply linkage was dropped." : `OurFamilyWizard re-targeted the reply to message ${effectiveReplyTo} instead. The draft IS threaded \u2014 to that message, not the one requested \u2014 and the inReplyTo in this response reflects where it actually landed.`;
|
|
40099
41380
|
warnings.push(
|
|
40100
|
-
`replyToId was requested as ${resolvedReplyTo}${rewrittenFrom} but the saved draft came back with replyToId ${effectiveReplyTo === null ? "null" : effectiveReplyTo} \u2014
|
|
41381
|
+
`replyToId was requested as ${resolvedReplyTo}${rewrittenFrom} but the saved draft came back with replyToId ${effectiveReplyTo === null ? "null" : effectiveReplyTo} \u2014 ${outcome} If threading matters, verify on ourfamilywizard.com.`
|
|
40101
41382
|
);
|
|
40102
41383
|
}
|
|
40103
41384
|
if (args.recipientIds !== void 0 && Array.isArray(detail.recipients)) {
|
|
@@ -40132,6 +41413,11 @@ ${JSON.stringify(
|
|
|
40132
41413
|
...persisted,
|
|
40133
41414
|
inReplyTo: persisted.replyToId,
|
|
40134
41415
|
revision: newRevision,
|
|
41416
|
+
// The id above is volatile — it changes on every edit. `draftKey` is
|
|
41417
|
+
// not: pass it to ofw_status to resolve the chain's CURRENT id, or to
|
|
41418
|
+
// find out that the draft was sent and when.
|
|
41419
|
+
draftKey,
|
|
41420
|
+
previousId: args.messageId ?? null,
|
|
40135
41421
|
cacheStatus: "fresh",
|
|
40136
41422
|
serverConfirmed: true,
|
|
40137
41423
|
...warnings.length > 0 ? { warnings } : {}
|
|
@@ -40169,25 +41455,46 @@ ${text}` : text);
|
|
|
40169
41455
|
${text}` : text);
|
|
40170
41456
|
});
|
|
40171
41457
|
server.registerTool("ofw_get_unread_sent", {
|
|
40172
|
-
description:
|
|
40173
|
-
annotations: { readOnlyHint:
|
|
41458
|
+
description: 'List sent messages that have not been read by one or more recipients. Reads from local cache. Returns `complete` describing whether every sent message was scanned. An empty SENT cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY") rather than reported as "nothing sent"; pass autoRefresh:true to sync and answer instead.',
|
|
41459
|
+
annotations: { readOnlyHint: false },
|
|
40174
41460
|
inputSchema: {
|
|
40175
41461
|
page: external_exports.number().int().min(1).describe("Page (default 1)").optional(),
|
|
40176
|
-
size: external_exports.number().int().min(1).describe("Per page (default 50)").optional()
|
|
41462
|
+
size: external_exports.number().int().min(1).describe("Per page (default 50)").optional(),
|
|
41463
|
+
autoRefresh: external_exports.boolean().describe(AUTO_REFRESH_DESC).optional()
|
|
40177
41464
|
}
|
|
40178
41465
|
}, async (args) => {
|
|
40179
41466
|
const page = args.page ?? 1;
|
|
40180
41467
|
const size = args.size ?? 50;
|
|
40181
41468
|
const cache = cacheProvider();
|
|
40182
|
-
const
|
|
40183
|
-
|
|
40184
|
-
|
|
40185
|
-
|
|
40186
|
-
|
|
40187
|
-
|
|
40188
|
-
|
|
41469
|
+
const { value, refreshed, unverifiedEmpty } = await guardedCacheRead({
|
|
41470
|
+
client: client2,
|
|
41471
|
+
cache,
|
|
41472
|
+
folders: ["sent"],
|
|
41473
|
+
autoRefresh: args.autoRefresh ?? getAutoRefreshStaleReads(),
|
|
41474
|
+
// The guard is about the CACHE being empty, not the verdict. "You have
|
|
41475
|
+
// no sent messages" is an absence claim a stale cache cannot support;
|
|
41476
|
+
// "all of them are read" is a verdict over messages we did see, and it is
|
|
41477
|
+
// labelled by `freshness` and `complete` as before.
|
|
41478
|
+
isEmpty: (v) => v.total === 0,
|
|
41479
|
+
read: async () => {
|
|
41480
|
+
const sent2 = await cache.listMessages({ folder: "sent", page, size });
|
|
41481
|
+
const total2 = await cache.countMessages({ folder: "sent" });
|
|
41482
|
+
const freshness2 = await buildFreshness(cache, { source: "cache", folders: ["sent"] });
|
|
41483
|
+
return { sent: sent2, total: total2, freshness: freshness2 };
|
|
41484
|
+
}
|
|
41485
|
+
});
|
|
41486
|
+
if (unverifiedEmpty) {
|
|
41487
|
+
return unverifiedEmptyResponse({
|
|
41488
|
+
what: "sent messages in the local cache",
|
|
41489
|
+
freshness: value.freshness,
|
|
41490
|
+
refreshed,
|
|
41491
|
+
remedy: 'Call ofw_sync_messages(folders:["sent"]) and retry, or re-call with autoRefresh:true.',
|
|
41492
|
+
extra: { page, size }
|
|
40189
41493
|
});
|
|
40190
41494
|
}
|
|
41495
|
+
const { sent, total, freshness } = value;
|
|
41496
|
+
const fullSlice = page === 1 && sent.length === total;
|
|
41497
|
+
const complete = fullSlice && freshness.staleness === "fresh" && freshness.historyComplete;
|
|
40191
41498
|
const unread = [];
|
|
40192
41499
|
for (const msg of sent) {
|
|
40193
41500
|
const unreadBy = msg.recipients.filter((r) => r.viewedAt === null).map((r) => r.name);
|
|
@@ -40195,14 +41502,17 @@ ${text}` : text);
|
|
|
40195
41502
|
unread.push({ id: msg.id, subject: msg.subject, sentAt: msg.sentAt, unreadBy });
|
|
40196
41503
|
}
|
|
40197
41504
|
}
|
|
41505
|
+
const payload = { unread, scanned: sent.length, total, complete, freshness };
|
|
41506
|
+
if (!complete) {
|
|
41507
|
+
payload.completeNote = `This verdict covers the ${sent.length} of ${total} cached sent messages on this page${freshness.staleness === "fresh" ? "" : `, from a cache that is "${freshness.staleness}"`}. It is not a statement about every message you have sent.`;
|
|
41508
|
+
}
|
|
40198
41509
|
if (unread.length === 0) {
|
|
40199
|
-
|
|
40200
|
-
|
|
40201
|
-
|
|
40202
|
-
|
|
40203
|
-
});
|
|
41510
|
+
payload.message = "Every sent message scanned had been read as of the timestamp in `freshness.asOf`. A recipient may have read \u2014 or not read \u2014 a message since without the cache hearing about it.";
|
|
41511
|
+
}
|
|
41512
|
+
if (refreshed) {
|
|
41513
|
+
payload.autoRefreshed = true;
|
|
40204
41514
|
}
|
|
40205
|
-
return jsonResponse(
|
|
41515
|
+
return jsonResponse(payload);
|
|
40206
41516
|
});
|
|
40207
41517
|
if (allowDrafts) server.registerTool("ofw_upload_attachment", {
|
|
40208
41518
|
description: `Upload a local file to OurFamilyWizard's "My Files" so it can be attached to a message. Returns the fileId \u2014 pass that to ofw_send_message or ofw_save_draft in myFileIDs to attach it. The file is uploaded as PRIVATE (visible only to you) by default; pass shareClass:"SHARED" to share with co-parents directly via the My Files area.`,
|
|
@@ -40246,13 +41556,16 @@ ${text}` : text);
|
|
|
40246
41556
|
});
|
|
40247
41557
|
});
|
|
40248
41558
|
server.registerTool("ofw_download_attachment", {
|
|
40249
|
-
description:
|
|
41559
|
+
description: "Download an OFW message attachment by fileId and return content you can actually read. Inline delivery walks a ladder and returns the first rung that works: (1) host-renderable images (PNG/JPEG/GIF/WEBP) come back as ImageContent; (2) .xlsx/.csv/.tsv, .pdf, .docx, .pptx and text files come back as EXTRACTED CONTENT \u2014 per-sheet CSV, per-page/slide text, document text \u2014 in the response JSON under `extracted`; (3) anything else comes back as an EmbeddedResource blob of the raw bytes. The meta block names the rung as `deliveredVia` and, when it falls through to bytes, lists what was tried in `deliveryAttempts`. Reported mime types are always normalized to a bare media type (no charset/name parameters). In disk mode the bytes are saved to ~/Downloads/ofw-mcp/ and the response carries the absolute path; pass extract:true to ALSO get the extracted content in that response. The default for `inline` can be flipped server-side via the OFW_INLINE_ATTACHMENTS env var. On a hosted deployment with no filesystem, disk mode is unavailable, so inline is forced (forcedInline:true) rather than failing \u2014 a saveTo path never costs you the content. fileId comes from attachments[].fileId on ofw_get_message. Override disk destination with OFW_ATTACHMENTS_DIR or saveTo. Re-downloading to the same path is a no-op (disk mode only).",
|
|
40250
41560
|
annotations: { readOnlyHint: false },
|
|
40251
41561
|
inputSchema: {
|
|
40252
41562
|
fileId: external_exports.number().describe("Attachment file id (from ofw_get_message \u2192 attachments[].fileId)"),
|
|
40253
|
-
inline: external_exports.boolean().describe("If true, return
|
|
41563
|
+
inline: external_exports.boolean().describe("If true, return content inline as MCP content blocks and skip the disk write. If false, write to disk and return the path \u2014 except on a hosted deployment with no filesystem, where inline is forced (forcedInline:true) so the content is still returned. If omitted, falls back to the OFW_INLINE_ATTACHMENTS env var (default: false = disk).").optional(),
|
|
40254
41564
|
saveTo: external_exports.string().describe("Absolute path or directory to write to. If a directory, the OFW filename is used. Default: ~/Downloads/ofw-mcp/<fileId>-<filename>. Ignored when inline is in effect.").optional(),
|
|
40255
|
-
force: external_exports.boolean().describe("Re-download even if already on disk. Default false. Ignored when inline:true (inline always fetches fresh bytes, or reuses an on-disk copy if present).").optional()
|
|
41565
|
+
force: external_exports.boolean().describe("Re-download even if already on disk. Default false. Ignored when inline:true (inline always fetches fresh bytes, or reuses an on-disk copy if present).").optional(),
|
|
41566
|
+
extract: external_exports.boolean().describe("Whether to extract readable content from the file. Default: on for inline delivery of any non-image type, off in disk mode. Set false to get the raw bytes inline instead of extracted text (e.g. to hash or re-upload the file); set true in disk mode to get both the saved path and the extracted content.").optional(),
|
|
41567
|
+
maxChars: external_exports.number().int().min(500).max(5e5).describe("Ceiling on extracted characters (default 50000). Over it, content is clipped on a row/line boundary, `truncated` is set, and anything dropped whole is listed in `extracted.omitted`.").optional(),
|
|
41568
|
+
parts: external_exports.string().describe('Which sheets / slides / pages to extract, e.g. "1-3,5" (1-based positions) or a sheet name like "2026". A bare number matches either a position or a name. Omit for everything. Unselected parts are listed in `extracted.omitted`.').optional()
|
|
40256
41569
|
}
|
|
40257
41570
|
}, async (args) => {
|
|
40258
41571
|
const fileId = args.fileId;
|
|
@@ -40260,6 +41573,7 @@ ${text}` : text);
|
|
|
40260
41573
|
const requestedInline = args.inline ?? getDefaultInlineAttachments();
|
|
40261
41574
|
const inline = requestedInline || !attachmentIO.supportsDisk;
|
|
40262
41575
|
const forcedInline = inline && !requestedInline;
|
|
41576
|
+
const deliveryOptions = { extract: args.extract, maxChars: args.maxChars, parts: args.parts };
|
|
40263
41577
|
let cached2 = await cache.getAttachment(fileId);
|
|
40264
41578
|
if (!cached2) {
|
|
40265
41579
|
await fetchAttachmentMeta(client2, fileId, 0, cache);
|
|
@@ -40279,25 +41593,15 @@ ${text}` : text);
|
|
|
40279
41593
|
headerMime = response2.contentType ?? cached2.mimeType;
|
|
40280
41594
|
fileName2 = response2.suggestedFileName ?? cached2.fileName;
|
|
40281
41595
|
}
|
|
40282
|
-
const
|
|
40283
|
-
|
|
40284
|
-
const meta3 = {
|
|
41596
|
+
const mimeType2 = resolveDownloadMime(bytes, headerMime, fileName2);
|
|
41597
|
+
return await buildInlineDelivery({
|
|
40285
41598
|
fileId,
|
|
40286
41599
|
fileName: fileName2,
|
|
40287
|
-
mimeType,
|
|
40288
|
-
|
|
40289
|
-
|
|
40290
|
-
|
|
40291
|
-
|
|
40292
|
-
const metaBlock = { type: "text", text: JSON.stringify(meta3, null, 2) };
|
|
40293
|
-
if (isHostRenderableImage(mimeType)) {
|
|
40294
|
-
return { content: [metaBlock, { type: "image", data: base643, mimeType }] };
|
|
40295
|
-
}
|
|
40296
|
-
return { content: [metaBlock, { type: "resource", resource: {
|
|
40297
|
-
uri: `ofw://attachment/${fileId}/${encodeURIComponent(fileName2)}`,
|
|
40298
|
-
mimeType,
|
|
40299
|
-
blob: base643
|
|
40300
|
-
} }] };
|
|
41600
|
+
mimeType: mimeType2,
|
|
41601
|
+
bytes,
|
|
41602
|
+
forcedInline,
|
|
41603
|
+
options: deliveryOptions
|
|
41604
|
+
});
|
|
40301
41605
|
}
|
|
40302
41606
|
let dest;
|
|
40303
41607
|
const safeName = basename2(cached2.fileName);
|
|
@@ -40308,29 +41612,34 @@ ${text}` : text);
|
|
|
40308
41612
|
} else {
|
|
40309
41613
|
dest = join5(getAttachmentsDir(), `${fileId}-${safeName}`);
|
|
40310
41614
|
}
|
|
41615
|
+
const extractOnDisk = args.extract === true;
|
|
40311
41616
|
if (!args.force && cached2.downloadedPath === dest) {
|
|
40312
|
-
|
|
40313
|
-
|
|
40314
|
-
|
|
40315
|
-
|
|
40316
|
-
|
|
40317
|
-
|
|
40318
|
-
|
|
40319
|
-
|
|
40320
|
-
|
|
40321
|
-
|
|
40322
|
-
|
|
41617
|
+
const onDisk = extractOnDisk ? attachmentIO.readDownloaded(dest) : null;
|
|
41618
|
+
if (!extractOnDisk || onDisk) {
|
|
41619
|
+
const mimeType2 = resolveDownloadMime(onDisk ?? Buffer.alloc(0), cached2.mimeType, cached2.fileName);
|
|
41620
|
+
return jsonResponse({
|
|
41621
|
+
fileId,
|
|
41622
|
+
path: dest,
|
|
41623
|
+
mimeType: mimeType2,
|
|
41624
|
+
sizeBytes: cached2.sizeBytes,
|
|
41625
|
+
fileName: cached2.fileName,
|
|
41626
|
+
note: "already downloaded",
|
|
41627
|
+
...onDisk ? await tryExtract(onDisk, mimeType2, cached2.fileName, deliveryOptions) : {}
|
|
41628
|
+
});
|
|
41629
|
+
}
|
|
40323
41630
|
}
|
|
40324
41631
|
const response = await client2.requestBinary("GET", `/pub/v1/myfiles/${fileId}/data`);
|
|
40325
41632
|
attachmentIO.writeDownload(dest, response.body);
|
|
40326
41633
|
await cache.markAttachmentDownloaded(fileId, dest);
|
|
40327
41634
|
const fileName = response.suggestedFileName ?? cached2.fileName;
|
|
41635
|
+
const mimeType = resolveDownloadMime(response.body, response.contentType ?? cached2.mimeType, fileName);
|
|
40328
41636
|
return jsonResponse({
|
|
40329
41637
|
fileId,
|
|
40330
41638
|
path: dest,
|
|
40331
|
-
mimeType
|
|
41639
|
+
mimeType,
|
|
40332
41640
|
sizeBytes: response.body.length,
|
|
40333
|
-
fileName
|
|
41641
|
+
fileName,
|
|
41642
|
+
...extractOnDisk ? await tryExtract(response.body, mimeType, fileName, deliveryOptions) : {}
|
|
40334
41643
|
});
|
|
40335
41644
|
});
|
|
40336
41645
|
server.registerTool("ofw_sync_messages", {
|
|
@@ -40338,7 +41647,7 @@ ${text}` : text);
|
|
|
40338
41647
|
annotations: { readOnlyHint: false },
|
|
40339
41648
|
inputSchema: {
|
|
40340
41649
|
folders: external_exports.array(external_exports.enum(["inbox", "sent", "drafts"])).min(1).describe("Folders to sync (default: all three). Must be non-empty if given \u2014 an empty list would sync nothing while reporting success.").optional(),
|
|
40341
|
-
fetchUnreadBodies: external_exports.boolean().describe(
|
|
41650
|
+
fetchUnreadBodies: external_exports.boolean().describe('If true, also fetch bodies for unread inbox messages \u2014 which marks each one READ on OurFamilyWizard and stamps a co-parent-visible "First Viewed" time that cannot be undone. Defaults to the OFW_FETCH_UNREAD_BODIES env var (false unless set), and is forced off entirely when OFW_ALLOW_MARK_READ=false.').optional(),
|
|
40342
41651
|
deep: external_exports.boolean().describe("If true, walk every OFW page until empty regardless of cache state. Use to backfill gaps. Default false.").optional(),
|
|
40343
41652
|
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()
|
|
40344
41653
|
}
|
|
@@ -40346,7 +41655,10 @@ ${text}` : text);
|
|
|
40346
41655
|
const cache = cacheProvider();
|
|
40347
41656
|
const result = await syncAll(client2, {
|
|
40348
41657
|
folders: args.folders,
|
|
40349
|
-
|
|
41658
|
+
// Default from OFW_FETCH_UNREAD_BODIES (false unless set), and capped by
|
|
41659
|
+
// the OFW_ALLOW_MARK_READ ceiling — fetching those bodies is exactly what
|
|
41660
|
+
// stamps a First Viewed time on every unread message it touches.
|
|
41661
|
+
fetchUnreadBodies: getAllowMarkRead() && (args.fetchUnreadBodies ?? getFetchUnreadBodies()),
|
|
40350
41662
|
deep: args.deep,
|
|
40351
41663
|
maxRequests: args.maxRequests ?? getSyncMaxRequests()
|
|
40352
41664
|
}, cache);
|
|
@@ -40357,16 +41669,16 @@ ${text}` : text);
|
|
|
40357
41669
|
return jsonResponse({ ...result, freshness });
|
|
40358
41670
|
});
|
|
40359
41671
|
server.registerTool("ofw_check_freshness", {
|
|
40360
|
-
description: 'Cheaply confirm whether the local cache still matches OurFamilyWizard, WITHOUT running a full sync. Use this before asserting anything about current state \u2014 especially "draft X is still sitting unsent"
|
|
40361
|
-
annotations: { readOnlyHint:
|
|
41672
|
+
description: 'Cheaply confirm whether the local cache still matches OurFamilyWizard, WITHOUT running a full sync. Use this before asserting anything about current state \u2014 especially "draft X is still sitting unsent". Costs one OFW request for the folder check plus one per messageId. For each folder it returns the live server count next to the cached count. For each id it returns a LIVE lifecycle `state` \u2014 "draft" | "sent" | "received" | "deleted" | "unknown" \u2014 alongside `folder`, `sentAt`, `existsOnServer` and a content comparison. `state` is the field that answers "is this still a draft?": a draft that has been SENT still exists on the server, so existsOnServer:true never distinguished the two. A cached draft whose state is no longer "draft" reports inSync:false even when its text is byte-identical. Content is compared by revision hash, because OFW draft timestamps do NOT change when a draft is edited in the web app. Does not fetch bodies into the cache, does not touch attachments, and does not depend on sync state. For draftKeys, or a full live draft inventory, use ofw_status.',
|
|
41673
|
+
annotations: { readOnlyHint: false },
|
|
40362
41674
|
inputSchema: {
|
|
40363
41675
|
folders: external_exports.array(external_exports.enum(["inbox", "sent", "drafts"])).min(1).describe("Folders to compare cached vs live counts for. Defaults to all three when messageIds is not given. Must be non-empty if given.").optional(),
|
|
40364
|
-
messageIds: external_exports.array(external_exports.number()).describe(`Specific ids to verify against OFW (max ${MAX_FRESHNESS_IDS}).
|
|
40365
|
-
allowMarkRead: external_exports.boolean().describe(
|
|
41676
|
+
messageIds: external_exports.array(external_exports.number()).describe(`Specific ids to verify against OFW (max ${MAX_FRESHNESS_IDS}). Ids cached as drafts, as sent messages, or as already-read inbox messages are probed freely \u2014 none of those can stamp the record. Anything else is skipped \u2014 see allowMarkRead.`).optional(),
|
|
41677
|
+
allowMarkRead: external_exports.boolean().describe('Default false. Probing an id whose cached state cannot rule out an unread INBOX message requires fetching its detail, which marks it READ on OurFamilyWizard and stamps a co-parent-visible "First Viewed" time \u2014 irreversible. Such ids are skipped (reason:"WOULD_MARK_READ") unless you set this to true. The server-wide OFW_ALLOW_MARK_READ=false is a ceiling this cannot raise.').optional()
|
|
40366
41678
|
}
|
|
40367
41679
|
}, async (args) => {
|
|
40368
41680
|
const cache = cacheProvider();
|
|
40369
|
-
const allowMarkRead = args.allowMarkRead ?? false;
|
|
41681
|
+
const allowMarkRead = getAllowMarkRead() && (args.allowMarkRead ?? false);
|
|
40370
41682
|
const requestedIds = args.messageIds ?? [];
|
|
40371
41683
|
const ids = requestedIds.slice(0, MAX_FRESHNESS_IDS);
|
|
40372
41684
|
const wantFolders = args.folders ?? (requestedIds.length > 0 ? [] : ["inbox", "sent", "drafts"]);
|
|
@@ -40380,12 +41692,14 @@ ${text}` : text);
|
|
|
40380
41692
|
{ label: "ofw-mcp", context: "GET /pub/v1/messageFolders (ofw_check_freshness)" }
|
|
40381
41693
|
);
|
|
40382
41694
|
const sys = data.systemFolders ?? [];
|
|
41695
|
+
await persistFolderIds(cache, sys);
|
|
40383
41696
|
for (const folder of wantFolders) {
|
|
40384
41697
|
const entry = sys.find((x) => x.folderType === FOLDER_TYPE[folder]);
|
|
40385
41698
|
const serverCount = entry?.totalCount ?? entry?.messageCount ?? entry?.count ?? null;
|
|
40386
41699
|
const cachedCount = folder === "drafts" ? (await cache.listDraftIds()).length : await cache.countMessages({ folder });
|
|
40387
41700
|
const state = await cache.getSyncState(folder);
|
|
40388
|
-
const
|
|
41701
|
+
const neverSynced = state === null;
|
|
41702
|
+
const historyComplete = !neverSynced && state.resumePage === null;
|
|
40389
41703
|
const inSync = serverCount === null || !historyComplete ? null : serverCount === cachedCount;
|
|
40390
41704
|
folders.push({
|
|
40391
41705
|
folder,
|
|
@@ -40395,56 +41709,13 @@ ${text}` : text);
|
|
|
40395
41709
|
historyComplete,
|
|
40396
41710
|
lastVerifiedAt: await getFolderVerifiedAt(cache, folder),
|
|
40397
41711
|
inSync,
|
|
40398
|
-
...inSync === null ? { note: serverCount === null ? "OFW did not report a count for this folder, so cached-vs-server cannot be compared. Use the per-id check instead." : "Older history is still being backfilled, so a lower cachedCount is expected and does not indicate drift." } : {}
|
|
40399
|
-
});
|
|
40400
|
-
}
|
|
40401
|
-
}
|
|
40402
|
-
const items = [];
|
|
40403
|
-
for (const id of ids) {
|
|
40404
|
-
const cachedDraft = await cache.getDraft(id);
|
|
40405
|
-
if (cachedDraft === null && !allowMarkRead) {
|
|
40406
|
-
items.push({
|
|
40407
|
-
id,
|
|
40408
|
-
skipped: true,
|
|
40409
|
-
reason: "NOT_A_CACHED_DRAFT",
|
|
40410
|
-
note: "Not in the drafts cache. Verifying it requires fetching its detail from OFW, which would mark an unread inbox message as READ on OurFamilyWizard. Pass allowMarkRead:true if that is acceptable."
|
|
40411
|
-
});
|
|
40412
|
-
continue;
|
|
40413
|
-
}
|
|
40414
|
-
requestsUsed++;
|
|
40415
|
-
try {
|
|
40416
|
-
const server2 = await fetchServerDraft(client2, id);
|
|
40417
|
-
const cacheRevision = cachedDraft === null ? null : draftRevision(cachedDraft);
|
|
40418
|
-
if (server2 === null) {
|
|
40419
|
-
items.push({
|
|
40420
|
-
id,
|
|
40421
|
-
existsOnServer: false,
|
|
40422
|
-
inSync: false,
|
|
40423
|
-
cacheRevision,
|
|
40424
|
-
serverRevision: null,
|
|
40425
|
-
note: cachedDraft === null ? "Not found on OurFamilyWizard." : "This draft is in the local cache but NO LONGER EXISTS on OurFamilyWizard \u2014 it was sent or deleted elsewhere. Do not describe it as still unsent."
|
|
40426
|
-
});
|
|
40427
|
-
continue;
|
|
40428
|
-
}
|
|
40429
|
-
const serverRevision = draftRevision(server2);
|
|
40430
|
-
items.push({
|
|
40431
|
-
id,
|
|
40432
|
-
existsOnServer: true,
|
|
40433
|
-
cacheRevision,
|
|
40434
|
-
serverRevision,
|
|
40435
|
-
inSync: cacheRevision !== null && cacheRevision === serverRevision,
|
|
40436
|
-
...cacheRevision === null ? { note: "Exists on OurFamilyWizard but is not in the local cache." } : cacheRevision !== serverRevision ? { note: "Content differs from the cache \u2014 it was edited on OurFamilyWizard since the last sync. Run ofw_sync_messages before reading or writing it." } : {}
|
|
40437
|
-
});
|
|
40438
|
-
} catch (e) {
|
|
40439
|
-
items.push({
|
|
40440
|
-
id,
|
|
40441
|
-
error: "FRESHNESS_CHECK_FAILED",
|
|
40442
|
-
message: e.message,
|
|
40443
|
-
inSync: null,
|
|
40444
|
-
note: "The freshness check itself failed, so nothing is confirmed either way."
|
|
41712
|
+
...inSync === null ? { note: serverCount === null ? "OFW did not report a count for this folder, so cached-vs-server cannot be compared. Use the per-id check instead." : neverSynced ? "This folder has never been synced, so the cache holds nothing to compare. Run ofw_sync_messages." : "Older history is still being backfilled, so a lower cachedCount is expected and does not indicate drift." } : {}
|
|
40445
41713
|
});
|
|
40446
41714
|
}
|
|
40447
41715
|
}
|
|
41716
|
+
const probed = await probeIds(client2, cache, ids, { allowMarkRead });
|
|
41717
|
+
requestsUsed += probed.requests;
|
|
41718
|
+
const items = probed.items;
|
|
40448
41719
|
const payload = {
|
|
40449
41720
|
checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
40450
41721
|
requestsUsed,
|
|
@@ -40456,6 +41727,132 @@ ${text}` : text);
|
|
|
40456
41727
|
}
|
|
40457
41728
|
return jsonResponse(payload);
|
|
40458
41729
|
});
|
|
41730
|
+
server.registerTool("ofw_status", {
|
|
41731
|
+
description: 'ONE live call that answers "where does everything stand?". This is the call that should back any status summary about drafts or specific messages \u2014 never session memory, and never a cached read alone. With no arguments it returns the FULL current draft inventory, verified against OurFamilyWizard. Pass ids and/or draftKeys to get each one\'s live lifecycle `state` ("draft" | "sent" | "received" | "deleted" | "unknown") with `sentAt` and `viewedAt`. A draftKey is the stable identity ofw_save_draft returns: editing a draft mints a new OFW id every time (create-then-delete), so the key is the only way to ask "what happened to the thing I was working on?" \u2014 it resolves to the chain\'s current id and keeps resolving after the draft is SENT (state:"sent" with sentMessageId). The top-level `complete` is true ONLY when every part of this snapshot was verified live; if it is false, do not state a draft count or a lifecycle claim from this payload.',
|
|
41732
|
+
annotations: { readOnlyHint: false },
|
|
41733
|
+
inputSchema: {
|
|
41734
|
+
ids: external_exports.array(external_exports.number()).describe(`Message/draft ids to resolve to a live state (combined with draftKeys, max ${MAX_FRESHNESS_IDS} probes per call).`).optional(),
|
|
41735
|
+
draftKeys: external_exports.array(external_exports.string()).describe("Stable draft keys (from ofw_save_draft / ofw_list_drafts) to resolve to their CURRENT id and state.").optional(),
|
|
41736
|
+
includeDraftInventory: external_exports.boolean().describe("Return the full current draft list, verified against OurFamilyWizard first. Defaults to TRUE when neither ids nor draftKeys is given (so a bare ofw_status() is a complete status snapshot), otherwise false.").optional(),
|
|
41737
|
+
allowMarkRead: external_exports.boolean().describe("Default false. An id whose cached state cannot rule out an unread INBOX message can only be probed by fetching its detail, which marks it READ on OurFamilyWizard \u2014 irreversible and co-parent-visible. Those are skipped unless this is true. Cached drafts, sent messages and already-read messages are always probed. Capped by OFW_ALLOW_MARK_READ.").optional()
|
|
41738
|
+
}
|
|
41739
|
+
}, async (args) => {
|
|
41740
|
+
const cache = cacheProvider();
|
|
41741
|
+
const allowMarkRead = getAllowMarkRead() && (args.allowMarkRead ?? false);
|
|
41742
|
+
const requestedIds = args.ids ?? [];
|
|
41743
|
+
const requestedKeys = args.draftKeys ?? [];
|
|
41744
|
+
const wantInventory = args.includeDraftInventory ?? (requestedIds.length === 0 && requestedKeys.length === 0);
|
|
41745
|
+
const allTargets = [
|
|
41746
|
+
...requestedIds.map((id) => ({ kind: "id", id })),
|
|
41747
|
+
...requestedKeys.map((draftKey) => ({ kind: "draftKey", draftKey }))
|
|
41748
|
+
];
|
|
41749
|
+
const targets = allTargets.slice(0, MAX_FRESHNESS_IDS);
|
|
41750
|
+
const truncated = allTargets.length - targets.length;
|
|
41751
|
+
if (!wantInventory && targets.length === 0) {
|
|
41752
|
+
return jsonErrorResponse({
|
|
41753
|
+
result: "NOTHING_REQUESTED",
|
|
41754
|
+
reason: "ofw_status was called with includeDraftInventory:false and no ids or draftKeys, so nothing was checked.",
|
|
41755
|
+
remedy: "Call ofw_status() with no arguments for the full draft inventory, or pass ids / draftKeys.",
|
|
41756
|
+
complete: false
|
|
41757
|
+
});
|
|
41758
|
+
}
|
|
41759
|
+
let probeRequests = 0;
|
|
41760
|
+
const incomplete = [];
|
|
41761
|
+
let drafts;
|
|
41762
|
+
let inventoryComplete = true;
|
|
41763
|
+
let inventoryFreshness;
|
|
41764
|
+
if (wantInventory) {
|
|
41765
|
+
const sync = await syncAll(client2, {
|
|
41766
|
+
folders: ["drafts"],
|
|
41767
|
+
maxRequests: getSyncMaxRequests()
|
|
41768
|
+
}, cache);
|
|
41769
|
+
inventoryComplete = sync.refreshed.includes("drafts");
|
|
41770
|
+
const { freshness, cacheStatus, serverConfirmed } = await draftsFreshness(cache);
|
|
41771
|
+
inventoryFreshness = freshness;
|
|
41772
|
+
if (!serverConfirmed) inventoryComplete = false;
|
|
41773
|
+
const total = await cache.countDrafts();
|
|
41774
|
+
const rows = await cache.listDrafts({ page: 1, size: Math.max(total, 1) });
|
|
41775
|
+
const keyById = new Map(
|
|
41776
|
+
(await cache.getDraftLineageByIds(rows.map((d) => d.id))).map((l) => [l.id, l.draftKey])
|
|
41777
|
+
);
|
|
41778
|
+
drafts = rows.map((d) => ({
|
|
41779
|
+
id: d.id,
|
|
41780
|
+
draftKey: keyById.get(d.id) ?? null,
|
|
41781
|
+
subject: d.subject,
|
|
41782
|
+
revision: draftRevision(d),
|
|
41783
|
+
modifiedAt: d.modifiedAt,
|
|
41784
|
+
recipients: d.recipients,
|
|
41785
|
+
replyToId: d.replyToId,
|
|
41786
|
+
cacheStatus
|
|
41787
|
+
}));
|
|
41788
|
+
if (!inventoryComplete) {
|
|
41789
|
+
incomplete.push("the drafts folder was not fully verified against OurFamilyWizard on this call (the request budget paused the walk), so this inventory may be missing or misreporting drafts");
|
|
41790
|
+
}
|
|
41791
|
+
}
|
|
41792
|
+
const requested = [];
|
|
41793
|
+
if (targets.length > 0) {
|
|
41794
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
41795
|
+
for (const t of targets) {
|
|
41796
|
+
if (t.kind === "draftKey" && !resolved.has(t.draftKey)) {
|
|
41797
|
+
resolved.set(t.draftKey, await resolveDraftKey(cache, t.draftKey));
|
|
41798
|
+
}
|
|
41799
|
+
}
|
|
41800
|
+
const toProbe = /* @__PURE__ */ new Set();
|
|
41801
|
+
for (const t of targets) {
|
|
41802
|
+
if (t.kind === "id") toProbe.add(t.id);
|
|
41803
|
+
else {
|
|
41804
|
+
const chain = resolved.get(t.draftKey);
|
|
41805
|
+
if (chain !== null && chain !== void 0) toProbe.add(chain.currentId);
|
|
41806
|
+
}
|
|
41807
|
+
}
|
|
41808
|
+
const probed = await probeIds(client2, cache, [...toProbe], { allowMarkRead });
|
|
41809
|
+
probeRequests += probed.requests;
|
|
41810
|
+
const probes = new Map(probed.items.map((item) => [item.id, item]));
|
|
41811
|
+
for (const t of targets) {
|
|
41812
|
+
if (t.kind === "id") {
|
|
41813
|
+
requested.push(decorate(probes.get(t.id)));
|
|
41814
|
+
continue;
|
|
41815
|
+
}
|
|
41816
|
+
const chain = resolved.get(t.draftKey);
|
|
41817
|
+
if (chain === null || chain === void 0) {
|
|
41818
|
+
requested.push({
|
|
41819
|
+
draftKey: t.draftKey,
|
|
41820
|
+
state: "unknown",
|
|
41821
|
+
error: "UNKNOWN_DRAFT_KEY",
|
|
41822
|
+
note: "This draftKey has never been recorded in the local cache, so it cannot be resolved to a message id. Draft keys are minted by ofw_save_draft; a cache rebuilt or opened on another machine will not know an older key."
|
|
41823
|
+
});
|
|
41824
|
+
continue;
|
|
41825
|
+
}
|
|
41826
|
+
requested.push({
|
|
41827
|
+
draftKey: t.draftKey,
|
|
41828
|
+
currentId: chain.currentId,
|
|
41829
|
+
previousIds: chain.ids.slice(0, -1),
|
|
41830
|
+
...decorate(probes.get(chain.currentId))
|
|
41831
|
+
});
|
|
41832
|
+
}
|
|
41833
|
+
for (const entry of requested) {
|
|
41834
|
+
if (entry.skipped === true || entry.error !== void 0 || entry.state === "unknown") {
|
|
41835
|
+
incomplete.push(`id/key ${String(entry.draftKey ?? entry.id)} could not be resolved to a confirmed live state`);
|
|
41836
|
+
}
|
|
41837
|
+
}
|
|
41838
|
+
}
|
|
41839
|
+
if (truncated > 0) {
|
|
41840
|
+
incomplete.push(`${truncated} of ${allTargets.length} requested ids/draftKeys were not probed (per-call cap of ${MAX_FRESHNESS_IDS})`);
|
|
41841
|
+
}
|
|
41842
|
+
const complete = incomplete.length === 0;
|
|
41843
|
+
return jsonResponse({
|
|
41844
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
41845
|
+
probeRequests,
|
|
41846
|
+
...drafts !== void 0 ? { drafts, draftCount: drafts.length, draftInventoryComplete: inventoryComplete } : {},
|
|
41847
|
+
...requested.length > 0 ? { requested } : {},
|
|
41848
|
+
complete,
|
|
41849
|
+
...complete ? {} : { incompleteReasons: incomplete, note: "complete:false \u2014 this snapshot is NOT a verified statement of current state. Do not report a draft count or say whether something was sent from it; resolve the reasons above (usually by calling ofw_sync_messages, or re-calling with allowMarkRead:true) and ask again." },
|
|
41850
|
+
...inventoryFreshness !== void 0 ? { freshness: inventoryFreshness } : {}
|
|
41851
|
+
});
|
|
41852
|
+
});
|
|
41853
|
+
}
|
|
41854
|
+
function decorate(item) {
|
|
41855
|
+
return item.state === "sent" ? { ...item, sentMessageId: item.id } : { ...item };
|
|
40459
41856
|
}
|
|
40460
41857
|
async function deleteOFWMessages(client2, ids) {
|
|
40461
41858
|
const form = new FormData();
|
|
@@ -40719,6 +42116,14 @@ function draftFromDb(r) {
|
|
|
40719
42116
|
listData: JSON.parse(r.list_data_json)
|
|
40720
42117
|
};
|
|
40721
42118
|
}
|
|
42119
|
+
function lineageFromDb(r) {
|
|
42120
|
+
return {
|
|
42121
|
+
id: r.id,
|
|
42122
|
+
draftKey: r.draft_key,
|
|
42123
|
+
previousId: r.previous_id,
|
|
42124
|
+
recordedAt: r.recorded_at
|
|
42125
|
+
};
|
|
42126
|
+
}
|
|
40722
42127
|
function attachmentFromDb(r) {
|
|
40723
42128
|
return {
|
|
40724
42129
|
fileId: r.file_id,
|
|
@@ -40774,6 +42179,17 @@ var SCHEMA_STATEMENTS = [
|
|
|
40774
42179
|
key TEXT PRIMARY KEY,
|
|
40775
42180
|
value TEXT NOT NULL
|
|
40776
42181
|
)`,
|
|
42182
|
+
// v3: draft identity chain. One row per OFW id, all the ids of one logical
|
|
42183
|
+
// document sharing a `draft_key`. Survives ofw_save_draft's create-then-delete
|
|
42184
|
+
// replacement AND the transition to a sent message, so "what happened to the
|
|
42185
|
+
// draft I was editing?" is answerable without guessing which id is current.
|
|
42186
|
+
`CREATE TABLE IF NOT EXISTS draft_lineage (
|
|
42187
|
+
id INTEGER PRIMARY KEY,
|
|
42188
|
+
draft_key TEXT NOT NULL,
|
|
42189
|
+
previous_id INTEGER,
|
|
42190
|
+
recorded_at TEXT NOT NULL
|
|
42191
|
+
)`,
|
|
42192
|
+
`CREATE INDEX IF NOT EXISTS idx_draft_lineage_key ON draft_lineage(draft_key, recorded_at, id)`,
|
|
40777
42193
|
// v2: attachments table. Idempotent — IF NOT EXISTS.
|
|
40778
42194
|
`CREATE TABLE IF NOT EXISTS attachments (
|
|
40779
42195
|
file_id INTEGER PRIMARY KEY,
|
|
@@ -40792,7 +42208,7 @@ var MIGRATIONS = [
|
|
|
40792
42208
|
// Resumable deep-sync cursor. Absent/NULL → SyncState.resumePage null.
|
|
40793
42209
|
"ALTER TABLE sync_state ADD COLUMN resume_page INTEGER"
|
|
40794
42210
|
];
|
|
40795
|
-
var SCHEMA_VERSION = "
|
|
42211
|
+
var SCHEMA_VERSION = "3";
|
|
40796
42212
|
function buildMessageFilter(opts) {
|
|
40797
42213
|
const wheres = [];
|
|
40798
42214
|
const params = [];
|
|
@@ -40977,6 +42393,10 @@ var OFWCacheCore = class {
|
|
|
40977
42393
|
);
|
|
40978
42394
|
return rows.map(draftFromDb);
|
|
40979
42395
|
}
|
|
42396
|
+
countDrafts() {
|
|
42397
|
+
const r = this.db.get("SELECT COUNT(*) as n FROM drafts", []);
|
|
42398
|
+
return r?.n ?? 0;
|
|
42399
|
+
}
|
|
40980
42400
|
deleteDraft(id) {
|
|
40981
42401
|
this.db.run("DELETE FROM drafts WHERE id = ?", [id]);
|
|
40982
42402
|
}
|
|
@@ -40984,6 +42404,57 @@ var OFWCacheCore = class {
|
|
|
40984
42404
|
const rows = this.db.all("SELECT id FROM drafts", []);
|
|
40985
42405
|
return rows.map((r) => r.id);
|
|
40986
42406
|
}
|
|
42407
|
+
/**
|
|
42408
|
+
* Link an id into a draft's identity chain. Upserts on id: re-recording the
|
|
42409
|
+
* same id (e.g. a retried save) rewrites its link rather than duplicating it,
|
|
42410
|
+
* so `getDraftLineage` can never report one id twice.
|
|
42411
|
+
*/
|
|
42412
|
+
recordDraftLineage(row) {
|
|
42413
|
+
this.db.run(
|
|
42414
|
+
`INSERT INTO draft_lineage (id, draft_key, previous_id, recorded_at) VALUES (?, ?, ?, ?)
|
|
42415
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
42416
|
+
draft_key=excluded.draft_key,
|
|
42417
|
+
previous_id=excluded.previous_id,
|
|
42418
|
+
recorded_at=excluded.recorded_at`,
|
|
42419
|
+
[
|
|
42420
|
+
row.id,
|
|
42421
|
+
requireString("draft_lineage.draftKey", row.draftKey),
|
|
42422
|
+
nullish3(row.previousId),
|
|
42423
|
+
requireString("draft_lineage.recordedAt", row.recordedAt)
|
|
42424
|
+
]
|
|
42425
|
+
);
|
|
42426
|
+
}
|
|
42427
|
+
getDraftLineageById(id) {
|
|
42428
|
+
const r = this.db.get("SELECT * FROM draft_lineage WHERE id = ?", [id]);
|
|
42429
|
+
return r ? lineageFromDb(r) : null;
|
|
42430
|
+
}
|
|
42431
|
+
/**
|
|
42432
|
+
* Batch read — one query for a whole page of drafts. On the Durable Object
|
|
42433
|
+
* backend each cache call is a subrequest, so a per-draft lookup would spend
|
|
42434
|
+
* the caller's sync budget on bookkeeping.
|
|
42435
|
+
*/
|
|
42436
|
+
getDraftLineageByIds(ids) {
|
|
42437
|
+
if (ids.length === 0) return [];
|
|
42438
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
42439
|
+
const rows = this.db.all(
|
|
42440
|
+
`SELECT * FROM draft_lineage WHERE id IN (${placeholders})`,
|
|
42441
|
+
ids
|
|
42442
|
+
);
|
|
42443
|
+
return rows.map(lineageFromDb);
|
|
42444
|
+
}
|
|
42445
|
+
/**
|
|
42446
|
+
* Every link in one chain, OLDEST FIRST — so the last element is the chain's
|
|
42447
|
+
* current id. Ordered by recorded_at then id: two links written inside the
|
|
42448
|
+
* same millisecond tie-break on id, and OFW mints ids monotonically, so the
|
|
42449
|
+
* newer replacement always sorts last.
|
|
42450
|
+
*/
|
|
42451
|
+
getDraftLineage(draftKey) {
|
|
42452
|
+
const rows = this.db.all(
|
|
42453
|
+
"SELECT * FROM draft_lineage WHERE draft_key = ? ORDER BY recorded_at ASC, id ASC",
|
|
42454
|
+
[draftKey]
|
|
42455
|
+
);
|
|
42456
|
+
return rows.map(lineageFromDb);
|
|
42457
|
+
}
|
|
40987
42458
|
getSyncState(folder) {
|
|
40988
42459
|
const r = this.db.get("SELECT last_sync_at, newest_id, resume_page FROM sync_state WHERE folder = ?", [folder]);
|
|
40989
42460
|
if (!r) return null;
|
|
@@ -41119,12 +42590,27 @@ var LocalCacheStore = class {
|
|
|
41119
42590
|
async listDrafts(opts) {
|
|
41120
42591
|
return this.core.listDrafts(opts);
|
|
41121
42592
|
}
|
|
42593
|
+
async countDrafts() {
|
|
42594
|
+
return this.core.countDrafts();
|
|
42595
|
+
}
|
|
41122
42596
|
async deleteDraft(id) {
|
|
41123
42597
|
this.core.deleteDraft(id);
|
|
41124
42598
|
}
|
|
41125
42599
|
async listDraftIds() {
|
|
41126
42600
|
return this.core.listDraftIds();
|
|
41127
42601
|
}
|
|
42602
|
+
async recordDraftLineage(row) {
|
|
42603
|
+
this.core.recordDraftLineage(row);
|
|
42604
|
+
}
|
|
42605
|
+
async getDraftLineageById(id) {
|
|
42606
|
+
return this.core.getDraftLineageById(id);
|
|
42607
|
+
}
|
|
42608
|
+
async getDraftLineageByIds(ids) {
|
|
42609
|
+
return this.core.getDraftLineageByIds(ids);
|
|
42610
|
+
}
|
|
42611
|
+
async getDraftLineage(draftKey) {
|
|
42612
|
+
return this.core.getDraftLineage(draftKey);
|
|
42613
|
+
}
|
|
41128
42614
|
async getSyncState(folder) {
|
|
41129
42615
|
return this.core.getSyncState(folder);
|
|
41130
42616
|
}
|
|
@@ -41228,7 +42714,7 @@ var nodeCacheProvider = () => nodeCache ??= OFWCache.open(getCacheDbPath());
|
|
|
41228
42714
|
var nodeAttachmentIO = new NodeAttachmentIO();
|
|
41229
42715
|
await runMcp({
|
|
41230
42716
|
name: "ofw",
|
|
41231
|
-
version: "2.
|
|
42717
|
+
version: "2.9.0",
|
|
41232
42718
|
// x-release-please-version
|
|
41233
42719
|
deps: client,
|
|
41234
42720
|
tools: [
|