ofw-mcp 2.7.0 → 2.8.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 +15 -2
- package/dist/bundle.js +1081 -58
- package/dist/config.js +42 -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 +7 -2
- package/dist/tools/delivery.js +99 -0
- package/dist/tools/draft-freshness.js +56 -4
- package/dist/tools/messages.js +191 -51
- package/package.json +3 -3
- package/server.json +14 -2
- package/skills/ofw/SKILL.md +2 -2
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.8.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)",
|
|
@@ -38449,13 +38449,13 @@ var package_default = {
|
|
|
38449
38449
|
zod: "^4.4.3"
|
|
38450
38450
|
},
|
|
38451
38451
|
devDependencies: {
|
|
38452
|
-
"@chrischall/mcp-connector": "^1.
|
|
38452
|
+
"@chrischall/mcp-connector": "^1.1.1",
|
|
38453
38453
|
"@cloudflare/vitest-pool-workers": "^0.18.4",
|
|
38454
38454
|
"@cloudflare/workers-oauth-provider": "^0.8.1",
|
|
38455
38455
|
"@cloudflare/workers-types": "^5.20260708.1",
|
|
38456
38456
|
"@types/node": "^26.0.0",
|
|
38457
38457
|
"@vitest/coverage-v8": "^4.1.7",
|
|
38458
|
-
agents: "^0.
|
|
38458
|
+
agents: "^0.19.0",
|
|
38459
38459
|
esbuild: "^0.28.0",
|
|
38460
38460
|
typescript: "^7.0.2",
|
|
38461
38461
|
vitest: "^4.1.7",
|
|
@@ -38972,7 +38972,7 @@ async function walkPages(client2, folder, folderId, opts, store) {
|
|
|
38972
38972
|
await fetchAttachmentMetaBudgeted(client2, item.id, detailFileIds, store, budget);
|
|
38973
38973
|
}
|
|
38974
38974
|
}
|
|
38975
|
-
await store.upsertMessages(toUpsert);
|
|
38975
|
+
if (toUpsert.length > 0) await store.upsertMessages(toUpsert);
|
|
38976
38976
|
if (pageBudgetHit) {
|
|
38977
38977
|
return { synced, unread, newestId, pagesFetched, done: false, nextPage: page };
|
|
38978
38978
|
}
|
|
@@ -39235,6 +39235,20 @@ function getCalendarWritesAllowed() {
|
|
|
39235
39235
|
if (mode === "all") return true;
|
|
39236
39236
|
return mode === "drafts" && parseBoolEnv("OFW_CALENDAR_WRITES");
|
|
39237
39237
|
}
|
|
39238
|
+
function getAllowMarkRead() {
|
|
39239
|
+
const raw = process.env.OFW_ALLOW_MARK_READ;
|
|
39240
|
+
if (typeof raw !== "string" || raw.trim().length === 0) return true;
|
|
39241
|
+
const value = raw.trim().toLowerCase();
|
|
39242
|
+
if (["1", "true", "yes", "on"].includes(value)) return true;
|
|
39243
|
+
if (["0", "false", "no", "off"].includes(value)) return false;
|
|
39244
|
+
console.error(
|
|
39245
|
+
`[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.`
|
|
39246
|
+
);
|
|
39247
|
+
return false;
|
|
39248
|
+
}
|
|
39249
|
+
function getFetchUnreadBodies() {
|
|
39250
|
+
return parseBoolEnv("OFW_FETCH_UNREAD_BODIES");
|
|
39251
|
+
}
|
|
39238
39252
|
function getDefaultInlineAttachments() {
|
|
39239
39253
|
return parseBoolEnv("OFW_INLINE_ATTACHMENTS");
|
|
39240
39254
|
}
|
|
@@ -39408,6 +39422,10 @@ async function fetchServerDraft(client2, id) {
|
|
|
39408
39422
|
recipients: mapRecipients(detail.recipients)
|
|
39409
39423
|
};
|
|
39410
39424
|
}
|
|
39425
|
+
var SUBSTANTIVE_FIELDS = ["subject", "body", "recipients"];
|
|
39426
|
+
function substantiveChanges(changed) {
|
|
39427
|
+
return changed.filter((f) => SUBSTANTIVE_FIELDS.includes(f));
|
|
39428
|
+
}
|
|
39411
39429
|
function diffFields(a, b) {
|
|
39412
39430
|
const changed = [];
|
|
39413
39431
|
if (a.subject !== b.subject) changed.push("subject");
|
|
@@ -39431,6 +39449,17 @@ function checkDraftFreshness(input) {
|
|
|
39431
39449
|
if (expectedRevision === actual) {
|
|
39432
39450
|
return { verdict: "FRESH", reason: "expectedRevision matches the live server draft.", changedFields: [] };
|
|
39433
39451
|
}
|
|
39452
|
+
if (cached2 !== null && draftRevision(cached2) === expectedRevision) {
|
|
39453
|
+
const changedFields2 = diffFields(server, cached2);
|
|
39454
|
+
if (substantiveChanges(changedFields2).length === 0) {
|
|
39455
|
+
return {
|
|
39456
|
+
verdict: "FRESH",
|
|
39457
|
+
reason: `Only connector-authored metadata (${changedFields2.join(", ")}) changed since you read the draft; its subject, body and recipients are unchanged, so this is not a conflict.`,
|
|
39458
|
+
changedFields: changedFields2,
|
|
39459
|
+
metadataOnly: true
|
|
39460
|
+
};
|
|
39461
|
+
}
|
|
39462
|
+
}
|
|
39434
39463
|
return {
|
|
39435
39464
|
verdict: "STALE",
|
|
39436
39465
|
reason: `expectedRevision ${expectedRevision} does not match the live server draft (${actual}) \u2014 it changed after you read it.`,
|
|
@@ -39448,6 +39477,14 @@ function checkDraftFreshness(input) {
|
|
|
39448
39477
|
if (changedFields.length === 0) {
|
|
39449
39478
|
return { verdict: "FRESH", reason: "The cached draft matches the live server draft.", changedFields: [] };
|
|
39450
39479
|
}
|
|
39480
|
+
if (substantiveChanges(changedFields).length === 0) {
|
|
39481
|
+
return {
|
|
39482
|
+
verdict: "FRESH",
|
|
39483
|
+
reason: `Only connector-authored metadata (${changedFields.join(", ")}) differs from the cached copy; subject, body and recipients match, so this is not a conflict.`,
|
|
39484
|
+
changedFields,
|
|
39485
|
+
metadataOnly: true
|
|
39486
|
+
};
|
|
39487
|
+
}
|
|
39451
39488
|
return {
|
|
39452
39489
|
verdict: "STALE",
|
|
39453
39490
|
reason: `The draft on OurFamilyWizard differs from the cached copy (${changedFields.join(", ")}) \u2014 it was edited outside this tool.`,
|
|
@@ -39468,6 +39505,872 @@ function staleDraftPayload(input) {
|
|
|
39468
39505
|
};
|
|
39469
39506
|
}
|
|
39470
39507
|
|
|
39508
|
+
// src/extract/inflate.ts
|
|
39509
|
+
var MAX_DECOMPRESSED_BYTES = 32 * 1024 * 1024;
|
|
39510
|
+
var DecompressionLimitError = class extends Error {
|
|
39511
|
+
constructor(label, limit) {
|
|
39512
|
+
super(`${label} expands past the ${limit}-byte decompression cap`);
|
|
39513
|
+
this.name = "DecompressionLimitError";
|
|
39514
|
+
}
|
|
39515
|
+
};
|
|
39516
|
+
async function inflateBounded(data, format, limit, label) {
|
|
39517
|
+
const stream = new Blob([data]).stream().pipeThrough(new DecompressionStream(format));
|
|
39518
|
+
const reader = stream.getReader();
|
|
39519
|
+
const chunks = [];
|
|
39520
|
+
let total = 0;
|
|
39521
|
+
for (; ; ) {
|
|
39522
|
+
const { done, value } = await reader.read();
|
|
39523
|
+
if (done) break;
|
|
39524
|
+
total += value.length;
|
|
39525
|
+
if (total > limit) {
|
|
39526
|
+
await reader.cancel();
|
|
39527
|
+
throw new DecompressionLimitError(label, limit);
|
|
39528
|
+
}
|
|
39529
|
+
chunks.push(value);
|
|
39530
|
+
}
|
|
39531
|
+
return Buffer.concat(chunks);
|
|
39532
|
+
}
|
|
39533
|
+
|
|
39534
|
+
// src/extract/zip.ts
|
|
39535
|
+
var EOCD_SIG = 101010256;
|
|
39536
|
+
var CENTRAL_SIG = 33639248;
|
|
39537
|
+
var LOCAL_SIG = 67324752;
|
|
39538
|
+
var ZIP64_SENTINEL = 4294967295;
|
|
39539
|
+
var ZIP_MAX_UNCOMPRESSED_BYTES = MAX_DECOMPRESSED_BYTES;
|
|
39540
|
+
function findEocd(bytes) {
|
|
39541
|
+
const earliest = Math.max(0, bytes.length - (22 + 65535));
|
|
39542
|
+
for (let i = bytes.length - 22; i >= earliest; i--) {
|
|
39543
|
+
if (bytes.readUInt32LE(i) === EOCD_SIG) return i;
|
|
39544
|
+
}
|
|
39545
|
+
throw new Error("not a ZIP archive (no end-of-central-directory record)");
|
|
39546
|
+
}
|
|
39547
|
+
async function readZip(bytes, opts = {}) {
|
|
39548
|
+
const limit = opts.maxUncompressedBytes ?? ZIP_MAX_UNCOMPRESSED_BYTES;
|
|
39549
|
+
const eocd = findEocd(bytes);
|
|
39550
|
+
const count = bytes.readUInt16LE(eocd + 10);
|
|
39551
|
+
const cdOffset = bytes.readUInt32LE(eocd + 16);
|
|
39552
|
+
if (cdOffset === ZIP64_SENTINEL || count === 65535) {
|
|
39553
|
+
throw new Error("ZIP64 archives are not supported");
|
|
39554
|
+
}
|
|
39555
|
+
const entries = /* @__PURE__ */ new Map();
|
|
39556
|
+
let p = cdOffset;
|
|
39557
|
+
for (let i = 0; i < count; i++) {
|
|
39558
|
+
if (bytes.readUInt32LE(p) !== CENTRAL_SIG) {
|
|
39559
|
+
throw new Error(`corrupt ZIP central directory at offset ${p}`);
|
|
39560
|
+
}
|
|
39561
|
+
const nameLen = bytes.readUInt16LE(p + 28);
|
|
39562
|
+
const extraLen = bytes.readUInt16LE(p + 30);
|
|
39563
|
+
const commentLen = bytes.readUInt16LE(p + 32);
|
|
39564
|
+
const name = bytes.toString("utf8", p + 46, p + 46 + nameLen);
|
|
39565
|
+
entries.set(name, {
|
|
39566
|
+
name,
|
|
39567
|
+
method: bytes.readUInt16LE(p + 10),
|
|
39568
|
+
compressedSize: bytes.readUInt32LE(p + 20),
|
|
39569
|
+
uncompressedSize: bytes.readUInt32LE(p + 24),
|
|
39570
|
+
localOffset: bytes.readUInt32LE(p + 42)
|
|
39571
|
+
});
|
|
39572
|
+
p += 46 + nameLen + extraLen + commentLen;
|
|
39573
|
+
}
|
|
39574
|
+
const cache = /* @__PURE__ */ new Map();
|
|
39575
|
+
async function read(name) {
|
|
39576
|
+
const cached2 = cache.get(name);
|
|
39577
|
+
if (cached2) return cached2;
|
|
39578
|
+
const entry = entries.get(name);
|
|
39579
|
+
if (!entry) return null;
|
|
39580
|
+
if (entry.uncompressedSize > limit) {
|
|
39581
|
+
throw new Error(
|
|
39582
|
+
`ZIP member ${name} is too large to extract (${entry.uncompressedSize} bytes)`
|
|
39583
|
+
);
|
|
39584
|
+
}
|
|
39585
|
+
const lo = entry.localOffset;
|
|
39586
|
+
if (bytes.readUInt32LE(lo) !== LOCAL_SIG) {
|
|
39587
|
+
throw new Error(`corrupt ZIP local header for ${name}`);
|
|
39588
|
+
}
|
|
39589
|
+
const start = lo + 30 + bytes.readUInt16LE(lo + 26) + bytes.readUInt16LE(lo + 28);
|
|
39590
|
+
const raw = bytes.subarray(start, start + entry.compressedSize);
|
|
39591
|
+
let out;
|
|
39592
|
+
if (entry.method === 0) out = Buffer.from(raw);
|
|
39593
|
+
else if (entry.method === 8) out = await inflateBounded(raw, "deflate-raw", limit, `ZIP member ${name}`);
|
|
39594
|
+
else throw new Error(`unsupported ZIP compression method ${entry.method} for ${name}`);
|
|
39595
|
+
cache.set(name, out);
|
|
39596
|
+
return out;
|
|
39597
|
+
}
|
|
39598
|
+
return {
|
|
39599
|
+
names: () => [...entries.keys()],
|
|
39600
|
+
has: (name) => entries.has(name),
|
|
39601
|
+
read,
|
|
39602
|
+
async readText(name) {
|
|
39603
|
+
const buf = await read(name);
|
|
39604
|
+
if (!buf) return null;
|
|
39605
|
+
const text = buf.toString("utf8");
|
|
39606
|
+
return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
|
|
39607
|
+
}
|
|
39608
|
+
};
|
|
39609
|
+
}
|
|
39610
|
+
|
|
39611
|
+
// src/extract/xml.ts
|
|
39612
|
+
var NAMED_ENTITIES = {
|
|
39613
|
+
amp: "&",
|
|
39614
|
+
lt: "<",
|
|
39615
|
+
gt: ">",
|
|
39616
|
+
quot: '"',
|
|
39617
|
+
apos: "'"
|
|
39618
|
+
};
|
|
39619
|
+
function decodeXmlEntities(text) {
|
|
39620
|
+
if (!text.includes("&")) return text;
|
|
39621
|
+
return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (whole, body) => {
|
|
39622
|
+
if (body[0] === "#") {
|
|
39623
|
+
const code = body[1] === "x" || body[1] === "X" ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
|
|
39624
|
+
return String.fromCodePoint(code);
|
|
39625
|
+
}
|
|
39626
|
+
return NAMED_ENTITIES[body] ?? whole;
|
|
39627
|
+
});
|
|
39628
|
+
}
|
|
39629
|
+
function* elements(xml, tag) {
|
|
39630
|
+
const re = new RegExp(`<${tag}((?:\\s[^>]*?)?)(?:\\s*/>|>([\\s\\S]*?)</${tag}>)`, "g");
|
|
39631
|
+
for (let m = re.exec(xml); m !== null; m = re.exec(xml)) {
|
|
39632
|
+
yield { attrs: m[1], inner: m[2] ?? "" };
|
|
39633
|
+
}
|
|
39634
|
+
}
|
|
39635
|
+
function attr(attrs, name) {
|
|
39636
|
+
const m = new RegExp(`(?:^|\\s)${name}\\s*=\\s*("([^"]*)"|'([^']*)')`).exec(attrs);
|
|
39637
|
+
if (!m) return null;
|
|
39638
|
+
return decodeXmlEntities(m[2] ?? m[3]);
|
|
39639
|
+
}
|
|
39640
|
+
function textOf(xml, tag) {
|
|
39641
|
+
let out = "";
|
|
39642
|
+
for (const el of elements(xml, tag)) out += decodeXmlEntities(el.inner);
|
|
39643
|
+
return out;
|
|
39644
|
+
}
|
|
39645
|
+
|
|
39646
|
+
// src/extract/ooxml.ts
|
|
39647
|
+
function resolvePartPath(baseDir, target) {
|
|
39648
|
+
if (target.startsWith("/")) return target.slice(1);
|
|
39649
|
+
const segments = (baseDir + target).split("/");
|
|
39650
|
+
const out = [];
|
|
39651
|
+
for (const segment of segments) {
|
|
39652
|
+
if (segment === "." || segment === "") continue;
|
|
39653
|
+
if (segment === "..") out.pop();
|
|
39654
|
+
else out.push(segment);
|
|
39655
|
+
}
|
|
39656
|
+
return out.join("/");
|
|
39657
|
+
}
|
|
39658
|
+
function dirOf(partPath) {
|
|
39659
|
+
const i = partPath.lastIndexOf("/");
|
|
39660
|
+
return i === -1 ? "" : partPath.slice(0, i + 1);
|
|
39661
|
+
}
|
|
39662
|
+
async function readRels(zip, partPath) {
|
|
39663
|
+
const dir = dirOf(partPath);
|
|
39664
|
+
const base = partPath.slice(dir.length);
|
|
39665
|
+
const xml = await zip.readText(`${dir}_rels/${base}.rels`);
|
|
39666
|
+
if (!xml) return [];
|
|
39667
|
+
const rels = [];
|
|
39668
|
+
for (const el of elements(xml, "Relationship")) {
|
|
39669
|
+
const id = attr(el.attrs, "Id");
|
|
39670
|
+
const target = attr(el.attrs, "Target");
|
|
39671
|
+
if (!id || !target) continue;
|
|
39672
|
+
rels.push({ id, target: resolvePartPath(dir, target), type: attr(el.attrs, "Type") ?? "" });
|
|
39673
|
+
}
|
|
39674
|
+
return rels;
|
|
39675
|
+
}
|
|
39676
|
+
function findPart(zip, conventional, fileName) {
|
|
39677
|
+
if (zip.has(conventional)) return conventional;
|
|
39678
|
+
return zip.names().find((n) => n.endsWith(`/${fileName}`) || n === fileName) ?? null;
|
|
39679
|
+
}
|
|
39680
|
+
|
|
39681
|
+
// src/extract/spreadsheet.ts
|
|
39682
|
+
var DEFAULT_MAX_CELLS = 2e5;
|
|
39683
|
+
var BUILTIN_DATE_FORMATS = /* @__PURE__ */ new Set([14, 15, 16, 17, 18, 19, 20, 21, 22, 45, 46, 47]);
|
|
39684
|
+
var SERIAL_EPOCH_OFFSET = 25569;
|
|
39685
|
+
function excelSerialToIso(serial, date1904) {
|
|
39686
|
+
const base = date1904 ? serial + 1462 : serial;
|
|
39687
|
+
const corrected = Math.floor(base) < 60 ? base + 1 : base;
|
|
39688
|
+
const date5 = new Date(Math.round((corrected - SERIAL_EPOCH_OFFSET) * 864e5));
|
|
39689
|
+
if (Number.isNaN(date5.getTime())) return null;
|
|
39690
|
+
const iso = date5.toISOString();
|
|
39691
|
+
if (base < 1) return iso.slice(11, 19);
|
|
39692
|
+
return iso.slice(11, 19) === "00:00:00" ? iso.slice(0, 10) : iso.slice(0, 19);
|
|
39693
|
+
}
|
|
39694
|
+
function isDateFormat(numFmtId, formatCode) {
|
|
39695
|
+
if (BUILTIN_DATE_FORMATS.has(numFmtId)) return true;
|
|
39696
|
+
if (!formatCode) return false;
|
|
39697
|
+
const bare = formatCode.replace(/"[^"]*"/g, "").replace(/\[[^\]]*\]/g, "").replace(/\\./g, "");
|
|
39698
|
+
return /[ymdhs]/i.test(bare);
|
|
39699
|
+
}
|
|
39700
|
+
function columnIndex(ref) {
|
|
39701
|
+
const m = /^([A-Z]+)/.exec(ref);
|
|
39702
|
+
if (!m) return null;
|
|
39703
|
+
let index = 0;
|
|
39704
|
+
for (const ch of m[1]) index = index * 26 + (ch.charCodeAt(0) - 64);
|
|
39705
|
+
return index - 1;
|
|
39706
|
+
}
|
|
39707
|
+
function csvField(value) {
|
|
39708
|
+
return /[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
|
|
39709
|
+
}
|
|
39710
|
+
function toCsv(rows, cols) {
|
|
39711
|
+
return rows.map((row) => Array.from({ length: cols }, (_, i) => csvField(row[i] ?? "")).join(",")).join("\n");
|
|
39712
|
+
}
|
|
39713
|
+
async function readStyles(zip, dir) {
|
|
39714
|
+
const xml = await zip.readText(`${dir}styles.xml`);
|
|
39715
|
+
if (!xml) return { dateStyles: [] };
|
|
39716
|
+
const custom2 = /* @__PURE__ */ new Map();
|
|
39717
|
+
for (const el of elements(xml, "numFmt")) {
|
|
39718
|
+
const id = Number(attr(el.attrs, "numFmtId"));
|
|
39719
|
+
const code = attr(el.attrs, "formatCode");
|
|
39720
|
+
if (Number.isFinite(id) && code !== null) custom2.set(id, code);
|
|
39721
|
+
}
|
|
39722
|
+
const dateStyles = [];
|
|
39723
|
+
for (const block of elements(xml, "cellXfs")) {
|
|
39724
|
+
for (const xf of elements(block.inner, "xf")) {
|
|
39725
|
+
const id = Number(attr(xf.attrs, "numFmtId") ?? "0");
|
|
39726
|
+
dateStyles.push(isDateFormat(id, custom2.get(id)));
|
|
39727
|
+
}
|
|
39728
|
+
}
|
|
39729
|
+
return { dateStyles };
|
|
39730
|
+
}
|
|
39731
|
+
async function readSharedStrings(zip, dir) {
|
|
39732
|
+
const xml = await zip.readText(`${dir}sharedStrings.xml`);
|
|
39733
|
+
if (!xml) return [];
|
|
39734
|
+
return [...elements(xml, "si")].map((si) => textOf(si.inner, "t"));
|
|
39735
|
+
}
|
|
39736
|
+
function cellValue(attrs, inner, ctx) {
|
|
39737
|
+
const type = attr(attrs, "t") ?? "n";
|
|
39738
|
+
if (type === "inlineStr") return textOf(inner, "t");
|
|
39739
|
+
const raw = textOf(inner, "v");
|
|
39740
|
+
switch (type) {
|
|
39741
|
+
case "s": {
|
|
39742
|
+
const index = Number(raw);
|
|
39743
|
+
return ctx.shared[index] ?? "";
|
|
39744
|
+
}
|
|
39745
|
+
case "b":
|
|
39746
|
+
return raw === "1" ? "TRUE" : "FALSE";
|
|
39747
|
+
case "str":
|
|
39748
|
+
case "e":
|
|
39749
|
+
return raw;
|
|
39750
|
+
default: {
|
|
39751
|
+
const styleIndex = Number(attr(attrs, "s") ?? "-1");
|
|
39752
|
+
const numeric = Number(raw);
|
|
39753
|
+
if (ctx.styles.dateStyles[styleIndex] && raw !== "" && Number.isFinite(numeric)) {
|
|
39754
|
+
return excelSerialToIso(numeric, ctx.date1904) ?? raw;
|
|
39755
|
+
}
|
|
39756
|
+
return raw;
|
|
39757
|
+
}
|
|
39758
|
+
}
|
|
39759
|
+
}
|
|
39760
|
+
function parseSheet(xml, name, ctx, maxCells) {
|
|
39761
|
+
const rows = [];
|
|
39762
|
+
let cols = 0;
|
|
39763
|
+
let cells = 0;
|
|
39764
|
+
let truncated = false;
|
|
39765
|
+
for (const row of elements(xml, "row")) {
|
|
39766
|
+
if (cells >= maxCells) {
|
|
39767
|
+
truncated = true;
|
|
39768
|
+
break;
|
|
39769
|
+
}
|
|
39770
|
+
const values = [];
|
|
39771
|
+
for (const cell of elements(row.inner, "c")) {
|
|
39772
|
+
const ref = attr(cell.attrs, "r");
|
|
39773
|
+
const index = ref === null ? null : columnIndex(ref);
|
|
39774
|
+
if (index === null) continue;
|
|
39775
|
+
values[index] = cellValue(cell.attrs, cell.inner, ctx);
|
|
39776
|
+
cells++;
|
|
39777
|
+
if (index + 1 > cols) cols = index + 1;
|
|
39778
|
+
}
|
|
39779
|
+
rows.push(values);
|
|
39780
|
+
}
|
|
39781
|
+
return { name, rows: rows.length, cols, csv: toCsv(rows, cols), ...truncated ? { truncated } : {} };
|
|
39782
|
+
}
|
|
39783
|
+
async function extractXlsx(bytes, opts = {}) {
|
|
39784
|
+
const zip = await readZip(bytes);
|
|
39785
|
+
const workbookPath = findPart(zip, "xl/workbook.xml", "workbook.xml");
|
|
39786
|
+
if (!workbookPath) throw new Error("no workbook part found in the .xlsx archive");
|
|
39787
|
+
const dir = dirOf(workbookPath);
|
|
39788
|
+
const workbookXml = await zip.readText(workbookPath) ?? "";
|
|
39789
|
+
const rels = await readRels(zip, workbookPath);
|
|
39790
|
+
const targetById = new Map(rels.map((r) => [r.id, r.target]));
|
|
39791
|
+
const ctx = {
|
|
39792
|
+
shared: await readSharedStrings(zip, dir),
|
|
39793
|
+
styles: await readStyles(zip, dir),
|
|
39794
|
+
date1904: /<workbookPr[^>]*date1904="(1|true)"/i.test(workbookXml)
|
|
39795
|
+
};
|
|
39796
|
+
const maxCells = opts.maxCells ?? DEFAULT_MAX_CELLS;
|
|
39797
|
+
const sheets = [];
|
|
39798
|
+
const omitted = [];
|
|
39799
|
+
let index = 0;
|
|
39800
|
+
for (const el of elements(workbookXml, "sheet")) {
|
|
39801
|
+
const position = index++;
|
|
39802
|
+
const name = attr(el.attrs, "name") ?? `Sheet${position + 1}`;
|
|
39803
|
+
if (opts.select && !opts.select(position, name)) {
|
|
39804
|
+
omitted.push(name);
|
|
39805
|
+
continue;
|
|
39806
|
+
}
|
|
39807
|
+
const relId = attr(el.attrs, "r:id") ?? attr(el.attrs, "id");
|
|
39808
|
+
const path = (relId && targetById.get(relId)) ?? resolvePartPath(dir, `worksheets/sheet${position + 1}.xml`);
|
|
39809
|
+
const xml = await zip.readText(path);
|
|
39810
|
+
if (xml === null) {
|
|
39811
|
+
omitted.push(`${name} (sheet part not found in the workbook)`);
|
|
39812
|
+
continue;
|
|
39813
|
+
}
|
|
39814
|
+
sheets.push(parseSheet(xml, name, ctx, maxCells));
|
|
39815
|
+
}
|
|
39816
|
+
const truncated = sheets.some((s) => s.truncated);
|
|
39817
|
+
return {
|
|
39818
|
+
kind: "spreadsheet",
|
|
39819
|
+
sheets,
|
|
39820
|
+
...omitted.length ? { omitted } : {},
|
|
39821
|
+
...truncated ? { truncated } : {}
|
|
39822
|
+
};
|
|
39823
|
+
}
|
|
39824
|
+
function parseDelimitedRows(text, delimiter) {
|
|
39825
|
+
const rows = [];
|
|
39826
|
+
let row = [];
|
|
39827
|
+
let field = "";
|
|
39828
|
+
let quoted = false;
|
|
39829
|
+
let dirty = false;
|
|
39830
|
+
for (let i = 0; i < text.length; i++) {
|
|
39831
|
+
const ch = text[i];
|
|
39832
|
+
if (quoted) {
|
|
39833
|
+
if (ch !== '"') {
|
|
39834
|
+
field += ch;
|
|
39835
|
+
continue;
|
|
39836
|
+
}
|
|
39837
|
+
if (text[i + 1] === '"') {
|
|
39838
|
+
field += '"';
|
|
39839
|
+
i++;
|
|
39840
|
+
continue;
|
|
39841
|
+
}
|
|
39842
|
+
quoted = false;
|
|
39843
|
+
continue;
|
|
39844
|
+
}
|
|
39845
|
+
if (ch === '"') {
|
|
39846
|
+
quoted = true;
|
|
39847
|
+
dirty = true;
|
|
39848
|
+
continue;
|
|
39849
|
+
}
|
|
39850
|
+
if (ch === delimiter) {
|
|
39851
|
+
row.push(field);
|
|
39852
|
+
field = "";
|
|
39853
|
+
dirty = true;
|
|
39854
|
+
continue;
|
|
39855
|
+
}
|
|
39856
|
+
if (ch === "\r") continue;
|
|
39857
|
+
if (ch === "\n") {
|
|
39858
|
+
row.push(field);
|
|
39859
|
+
rows.push(row);
|
|
39860
|
+
row = [];
|
|
39861
|
+
field = "";
|
|
39862
|
+
dirty = false;
|
|
39863
|
+
continue;
|
|
39864
|
+
}
|
|
39865
|
+
field += ch;
|
|
39866
|
+
dirty = true;
|
|
39867
|
+
}
|
|
39868
|
+
if (dirty || field !== "") {
|
|
39869
|
+
row.push(field);
|
|
39870
|
+
rows.push(row);
|
|
39871
|
+
}
|
|
39872
|
+
return rows;
|
|
39873
|
+
}
|
|
39874
|
+
function extractDelimited(text, name, delimiter) {
|
|
39875
|
+
const rows = parseDelimitedRows(text, delimiter);
|
|
39876
|
+
const cols = rows.reduce((max, r) => Math.max(max, r.length), 0);
|
|
39877
|
+
return {
|
|
39878
|
+
kind: "spreadsheet",
|
|
39879
|
+
sheets: [{ name, rows: rows.length, cols, csv: toCsv(rows, cols) }]
|
|
39880
|
+
};
|
|
39881
|
+
}
|
|
39882
|
+
|
|
39883
|
+
// src/extract/document.ts
|
|
39884
|
+
var RUN_CONTENT = /<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>|<w:(tab|br|cr)\s*\/>/g;
|
|
39885
|
+
function paragraphText(inner) {
|
|
39886
|
+
let text = "";
|
|
39887
|
+
for (let m = RUN_CONTENT.exec(inner); m !== null; m = RUN_CONTENT.exec(inner)) {
|
|
39888
|
+
if (m[1] !== void 0) text += decodeXmlEntities(m[1]);
|
|
39889
|
+
else text += m[2] === "tab" ? " " : "\n";
|
|
39890
|
+
}
|
|
39891
|
+
RUN_CONTENT.lastIndex = 0;
|
|
39892
|
+
return text;
|
|
39893
|
+
}
|
|
39894
|
+
function styledParagraph(inner) {
|
|
39895
|
+
const text = paragraphText(inner);
|
|
39896
|
+
if (text === "") return "";
|
|
39897
|
+
const style = /<w:pStyle\s[^>]*w:val="([^"]*)"/.exec(inner)?.[1] ?? "";
|
|
39898
|
+
const heading = /^Heading(\d)$/.exec(style);
|
|
39899
|
+
if (heading) return `${"#".repeat(Math.min(Number(heading[1]), 6))} ${text}`;
|
|
39900
|
+
if (style === "Title" || style === "Subtitle") return `# ${text}`;
|
|
39901
|
+
if (style === "ListParagraph") return `- ${text}`;
|
|
39902
|
+
return text;
|
|
39903
|
+
}
|
|
39904
|
+
function tableText(inner) {
|
|
39905
|
+
const rows = [];
|
|
39906
|
+
for (const tr of elements(inner, "w:tr")) {
|
|
39907
|
+
const cells = [];
|
|
39908
|
+
for (const tc of elements(tr.inner, "w:tc")) {
|
|
39909
|
+
const parts = [];
|
|
39910
|
+
for (const p of elements(tc.inner, "w:p")) {
|
|
39911
|
+
const text = paragraphText(p.inner);
|
|
39912
|
+
if (text !== "") parts.push(text);
|
|
39913
|
+
}
|
|
39914
|
+
cells.push(parts.join(" "));
|
|
39915
|
+
}
|
|
39916
|
+
rows.push(`| ${cells.join(" | ")} |`);
|
|
39917
|
+
}
|
|
39918
|
+
return rows.join("\n");
|
|
39919
|
+
}
|
|
39920
|
+
var BLOCK = /<w:tbl(?:\s[^>]*?)?>[\s\S]*?<\/w:tbl>|<w:p(?:\s[^>]*?)?(?:\s*\/>|>([\s\S]*?)<\/w:p>)/g;
|
|
39921
|
+
async function extractDocx(bytes) {
|
|
39922
|
+
const zip = await readZip(bytes);
|
|
39923
|
+
const path = findPart(zip, "word/document.xml", "document.xml");
|
|
39924
|
+
if (!path) throw new Error("no document part found in the .docx archive");
|
|
39925
|
+
const xml = await zip.readText(path) ?? "";
|
|
39926
|
+
const blocks = [];
|
|
39927
|
+
for (let m = BLOCK.exec(xml); m !== null; m = BLOCK.exec(xml)) {
|
|
39928
|
+
const text = m[0].startsWith("<w:tbl") ? tableText(m[0]) : styledParagraph(m[1] ?? "");
|
|
39929
|
+
if (text !== "") blocks.push(text);
|
|
39930
|
+
}
|
|
39931
|
+
BLOCK.lastIndex = 0;
|
|
39932
|
+
return { kind: "document", text: blocks.join("\n\n") };
|
|
39933
|
+
}
|
|
39934
|
+
|
|
39935
|
+
// src/extract/presentation.ts
|
|
39936
|
+
var SLIDE_PATH = /^ppt\/slides\/slide(\d+)\.xml$/;
|
|
39937
|
+
function slideText(xml) {
|
|
39938
|
+
const lines = [];
|
|
39939
|
+
for (const p of elements(xml, "a:p")) {
|
|
39940
|
+
let line = "";
|
|
39941
|
+
for (const t of elements(p.inner, "a:t")) line += decodeXmlEntities(t.inner);
|
|
39942
|
+
if (line !== "") lines.push(line);
|
|
39943
|
+
}
|
|
39944
|
+
return lines.join("\n");
|
|
39945
|
+
}
|
|
39946
|
+
async function extractPptx(bytes, opts = {}) {
|
|
39947
|
+
const zip = await readZip(bytes);
|
|
39948
|
+
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);
|
|
39949
|
+
if (paths.length === 0) throw new Error("no slides found in the .pptx archive");
|
|
39950
|
+
const slides = [];
|
|
39951
|
+
const omitted = [];
|
|
39952
|
+
for (let i = 0; i < paths.length; i++) {
|
|
39953
|
+
const { name } = paths[i];
|
|
39954
|
+
const number4 = i + 1;
|
|
39955
|
+
if (opts.select && !opts.select(i, `slide ${number4}`)) {
|
|
39956
|
+
omitted.push(`slide ${number4}`);
|
|
39957
|
+
continue;
|
|
39958
|
+
}
|
|
39959
|
+
const text = slideText(await zip.readText(name) ?? "");
|
|
39960
|
+
const notesRel = (await readRels(zip, name)).find((r) => r.type.endsWith("/notesSlide"));
|
|
39961
|
+
const notesXml = notesRel ? await zip.readText(notesRel.target) : null;
|
|
39962
|
+
const notes = notesXml === null ? "" : slideText(notesXml);
|
|
39963
|
+
slides.push({ number: number4, text, ...notes ? { notes } : {} });
|
|
39964
|
+
}
|
|
39965
|
+
return {
|
|
39966
|
+
kind: "presentation",
|
|
39967
|
+
slides,
|
|
39968
|
+
...omitted.length ? { omitted } : {}
|
|
39969
|
+
};
|
|
39970
|
+
}
|
|
39971
|
+
|
|
39972
|
+
// src/extract/pdf.ts
|
|
39973
|
+
var OBJ_HEADER = /(\d+)\s+\d+\s+obj\b/g;
|
|
39974
|
+
function parseObjects(text) {
|
|
39975
|
+
const objects = /* @__PURE__ */ new Map();
|
|
39976
|
+
for (let m = OBJ_HEADER.exec(text); m !== null; m = OBJ_HEADER.exec(text)) {
|
|
39977
|
+
const start = m.index + m[0].length;
|
|
39978
|
+
const end = text.indexOf("endobj", start);
|
|
39979
|
+
objects.set(Number(m[1]), {
|
|
39980
|
+
num: Number(m[1]),
|
|
39981
|
+
body: text.slice(start, end === -1 ? void 0 : end),
|
|
39982
|
+
start
|
|
39983
|
+
});
|
|
39984
|
+
}
|
|
39985
|
+
OBJ_HEADER.lastIndex = 0;
|
|
39986
|
+
return objects;
|
|
39987
|
+
}
|
|
39988
|
+
function refsIn(fragment) {
|
|
39989
|
+
return [...fragment.matchAll(/(\d+)\s+\d+\s+R\b/g)].map((m) => Number(m[1]));
|
|
39990
|
+
}
|
|
39991
|
+
function orderedPages(objects) {
|
|
39992
|
+
const isPage = (o) => /\/Type\s*\/Page[^s]/.test(o.body);
|
|
39993
|
+
const inFileOrder = [...objects.values()].filter(isPage).sort((a, b) => a.start - b.start);
|
|
39994
|
+
const catalog = [...objects.values()].find((o) => /\/Type\s*\/Catalog/.test(o.body));
|
|
39995
|
+
const rootRef = catalog ? refsIn(/\/Pages\s+[^/>]*/.exec(catalog.body)?.[0] ?? "")[0] : void 0;
|
|
39996
|
+
if (rootRef === void 0) return inFileOrder;
|
|
39997
|
+
const ordered = [];
|
|
39998
|
+
const seen = /* @__PURE__ */ new Set();
|
|
39999
|
+
const walk = (num) => {
|
|
40000
|
+
if (seen.has(num)) return;
|
|
40001
|
+
seen.add(num);
|
|
40002
|
+
const obj = objects.get(num);
|
|
40003
|
+
if (!obj) return;
|
|
40004
|
+
if (isPage(obj)) {
|
|
40005
|
+
ordered.push(obj);
|
|
40006
|
+
return;
|
|
40007
|
+
}
|
|
40008
|
+
const kids = /\/Kids\s*\[([^\]]*)\]/.exec(obj.body)?.[1];
|
|
40009
|
+
if (kids) for (const kid of refsIn(kids)) walk(kid);
|
|
40010
|
+
};
|
|
40011
|
+
walk(rootRef);
|
|
40012
|
+
return ordered.length > 0 ? ordered : inFileOrder;
|
|
40013
|
+
}
|
|
40014
|
+
function streamBytes(bytes, obj) {
|
|
40015
|
+
const marker = /stream\r?\n/.exec(obj.body);
|
|
40016
|
+
if (!marker) return null;
|
|
40017
|
+
const from = obj.start + marker.index + marker[0].length;
|
|
40018
|
+
const declared = /\/Length\s+(\d+)(?!\s+\d+\s+R)/.exec(obj.body);
|
|
40019
|
+
if (declared) return bytes.subarray(from, from + Number(declared[1]));
|
|
40020
|
+
const end = bytes.indexOf("endstream", from, "latin1");
|
|
40021
|
+
return bytes.subarray(from, end === -1 ? void 0 : end);
|
|
40022
|
+
}
|
|
40023
|
+
async function decodeStream(bytes, obj) {
|
|
40024
|
+
const raw = streamBytes(bytes, obj);
|
|
40025
|
+
if (!raw) return null;
|
|
40026
|
+
const filter = /\/Filter\s*(\/\w+|\[[^\]]*\])/.exec(obj.body)?.[1] ?? "";
|
|
40027
|
+
if (filter === "") return raw;
|
|
40028
|
+
if (!filter.includes("FlateDecode")) return null;
|
|
40029
|
+
try {
|
|
40030
|
+
return await inflateBounded(raw, "deflate", MAX_DECOMPRESSED_BYTES, "PDF stream");
|
|
40031
|
+
} catch (err) {
|
|
40032
|
+
if (err instanceof DecompressionLimitError) throw err;
|
|
40033
|
+
return null;
|
|
40034
|
+
}
|
|
40035
|
+
}
|
|
40036
|
+
function readLiteral(text, i) {
|
|
40037
|
+
let value = "";
|
|
40038
|
+
let depth = 1;
|
|
40039
|
+
let p = i + 1;
|
|
40040
|
+
for (; p < text.length; p++) {
|
|
40041
|
+
const ch = text[p];
|
|
40042
|
+
if (ch === "\\") {
|
|
40043
|
+
const esc2 = text[++p];
|
|
40044
|
+
const simple = { n: "\n", r: "\r", t: " ", b: "\b", f: "\f" };
|
|
40045
|
+
if (simple[esc2]) {
|
|
40046
|
+
value += simple[esc2];
|
|
40047
|
+
continue;
|
|
40048
|
+
}
|
|
40049
|
+
const octal = /^[0-7]{1,3}/.exec(text.slice(p, p + 3))?.[0];
|
|
40050
|
+
if (octal) {
|
|
40051
|
+
value += String.fromCharCode(parseInt(octal, 8));
|
|
40052
|
+
p += octal.length - 1;
|
|
40053
|
+
continue;
|
|
40054
|
+
}
|
|
40055
|
+
if (esc2 === "\n") continue;
|
|
40056
|
+
value += esc2;
|
|
40057
|
+
continue;
|
|
40058
|
+
}
|
|
40059
|
+
if (ch === "(") {
|
|
40060
|
+
depth++;
|
|
40061
|
+
value += ch;
|
|
40062
|
+
continue;
|
|
40063
|
+
}
|
|
40064
|
+
if (ch === ")") {
|
|
40065
|
+
depth--;
|
|
40066
|
+
if (depth === 0) break;
|
|
40067
|
+
value += ch;
|
|
40068
|
+
continue;
|
|
40069
|
+
}
|
|
40070
|
+
value += ch;
|
|
40071
|
+
}
|
|
40072
|
+
return { value, next: p };
|
|
40073
|
+
}
|
|
40074
|
+
function decodeHexString(hex3) {
|
|
40075
|
+
const clean = hex3.replace(/[^0-9a-fA-F]/g, "");
|
|
40076
|
+
const padded = clean.length % 2 === 1 ? `${clean}0` : clean;
|
|
40077
|
+
const buf = Buffer.from(padded, "hex");
|
|
40078
|
+
if (buf.length >= 2 && buf.length % 2 === 0 && buf[0] === 254 && buf[1] === 255) {
|
|
40079
|
+
return buf.subarray(2).swap16().toString("utf16le");
|
|
40080
|
+
}
|
|
40081
|
+
if (buf.length % 2 === 0 && buf.length > 0 && buf.every((b, i) => i % 2 === 1 || b === 0)) {
|
|
40082
|
+
return buf.swap16().toString("utf16le");
|
|
40083
|
+
}
|
|
40084
|
+
return buf.toString("latin1");
|
|
40085
|
+
}
|
|
40086
|
+
function textFromContentStream(content) {
|
|
40087
|
+
let out = "";
|
|
40088
|
+
let pending = "";
|
|
40089
|
+
let arrayDepth = 0;
|
|
40090
|
+
for (let i = 0; i < content.length; i++) {
|
|
40091
|
+
const ch = content[i];
|
|
40092
|
+
if (ch === "(") {
|
|
40093
|
+
const { value, next } = readLiteral(content, i);
|
|
40094
|
+
pending += value;
|
|
40095
|
+
i = next;
|
|
40096
|
+
continue;
|
|
40097
|
+
}
|
|
40098
|
+
if (ch === "<" && content[i + 1] === "<") {
|
|
40099
|
+
i++;
|
|
40100
|
+
continue;
|
|
40101
|
+
}
|
|
40102
|
+
if (ch === "<") {
|
|
40103
|
+
const end = content.indexOf(">", i);
|
|
40104
|
+
if (end === -1) break;
|
|
40105
|
+
pending += decodeHexString(content.slice(i + 1, end));
|
|
40106
|
+
i = end;
|
|
40107
|
+
continue;
|
|
40108
|
+
}
|
|
40109
|
+
if (ch === "[") {
|
|
40110
|
+
arrayDepth++;
|
|
40111
|
+
continue;
|
|
40112
|
+
}
|
|
40113
|
+
if (ch === "]") {
|
|
40114
|
+
arrayDepth = 0;
|
|
40115
|
+
continue;
|
|
40116
|
+
}
|
|
40117
|
+
if (arrayDepth > 0 && (ch === "-" || ch >= "0" && ch <= "9")) {
|
|
40118
|
+
const num = /^-?\d+(\.\d+)?/.exec(content.slice(i));
|
|
40119
|
+
if (!num) continue;
|
|
40120
|
+
if (Number(num[0]) <= -100) pending += " ";
|
|
40121
|
+
i += num[0].length - 1;
|
|
40122
|
+
continue;
|
|
40123
|
+
}
|
|
40124
|
+
if (/[A-Za-z'"*]/.test(ch)) {
|
|
40125
|
+
const op = /^[A-Za-z*]+|^['"]/.exec(content.slice(i))?.[0] ?? ch;
|
|
40126
|
+
i += op.length - 1;
|
|
40127
|
+
if (op === "Tj" || op === "TJ") {
|
|
40128
|
+
out += pending;
|
|
40129
|
+
pending = "";
|
|
40130
|
+
continue;
|
|
40131
|
+
}
|
|
40132
|
+
if (op === "'" || op === '"') {
|
|
40133
|
+
out += `
|
|
40134
|
+
${pending}`;
|
|
40135
|
+
pending = "";
|
|
40136
|
+
continue;
|
|
40137
|
+
}
|
|
40138
|
+
if (op === "Td" || op === "TD" || op === "T*" || op === "ET") {
|
|
40139
|
+
out += "\n";
|
|
40140
|
+
continue;
|
|
40141
|
+
}
|
|
40142
|
+
}
|
|
40143
|
+
}
|
|
40144
|
+
return out;
|
|
40145
|
+
}
|
|
40146
|
+
function tidy(text) {
|
|
40147
|
+
return text.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
40148
|
+
}
|
|
40149
|
+
async function extractPdf(bytes, opts = {}) {
|
|
40150
|
+
const text = bytes.toString("latin1");
|
|
40151
|
+
if (/\/Encrypt\b/.test(text)) {
|
|
40152
|
+
throw new Error("the PDF is encrypted; its text cannot be extracted");
|
|
40153
|
+
}
|
|
40154
|
+
const objects = parseObjects(text);
|
|
40155
|
+
const pageObjects = orderedPages(objects);
|
|
40156
|
+
if (pageObjects.length === 0) throw new Error("no pages found in the PDF");
|
|
40157
|
+
const pages = [];
|
|
40158
|
+
const omitted = [];
|
|
40159
|
+
for (let i = 0; i < pageObjects.length; i++) {
|
|
40160
|
+
const number4 = i + 1;
|
|
40161
|
+
if (opts.select && !opts.select(i, `page ${number4}`)) {
|
|
40162
|
+
omitted.push(`page ${number4}`);
|
|
40163
|
+
continue;
|
|
40164
|
+
}
|
|
40165
|
+
const contentsFragment = /\/Contents\s*(\d+\s+\d+\s+R|\[[^\]]*\])/.exec(pageObjects[i].body)?.[1] ?? "";
|
|
40166
|
+
let raw = "";
|
|
40167
|
+
for (const ref of refsIn(contentsFragment)) {
|
|
40168
|
+
const streamObj = objects.get(ref);
|
|
40169
|
+
if (!streamObj) continue;
|
|
40170
|
+
const decoded = await decodeStream(bytes, streamObj);
|
|
40171
|
+
if (decoded) raw += `${decoded.toString("latin1")}
|
|
40172
|
+
`;
|
|
40173
|
+
}
|
|
40174
|
+
pages.push({ number: number4, text: tidy(textFromContentStream(raw)) });
|
|
40175
|
+
}
|
|
40176
|
+
const textLayer = pages.some((p) => p.text !== "");
|
|
40177
|
+
return {
|
|
40178
|
+
kind: "pdf",
|
|
40179
|
+
pages,
|
|
40180
|
+
textLayer,
|
|
40181
|
+
...textLayer ? {} : {
|
|
40182
|
+
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."
|
|
40183
|
+
},
|
|
40184
|
+
...omitted.length ? { omitted } : {}
|
|
40185
|
+
};
|
|
40186
|
+
}
|
|
40187
|
+
|
|
40188
|
+
// src/extract/index.ts
|
|
40189
|
+
var MIME_KINDS = {
|
|
40190
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
|
|
40191
|
+
"application/vnd.ms-excel.sheet.macroenabled.12": "xlsx",
|
|
40192
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
|
|
40193
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
|
|
40194
|
+
"application/pdf": "pdf",
|
|
40195
|
+
"text/csv": "csv",
|
|
40196
|
+
"text/tab-separated-values": "tsv",
|
|
40197
|
+
"application/json": "text",
|
|
40198
|
+
"application/xml": "text",
|
|
40199
|
+
"application/xhtml+xml": "text",
|
|
40200
|
+
"application/javascript": "text",
|
|
40201
|
+
"application/x-yaml": "text"
|
|
40202
|
+
};
|
|
40203
|
+
var EXT_KINDS = {
|
|
40204
|
+
".xlsx": "xlsx",
|
|
40205
|
+
".xlsm": "xlsx",
|
|
40206
|
+
".docx": "docx",
|
|
40207
|
+
".pptx": "pptx",
|
|
40208
|
+
".pdf": "pdf",
|
|
40209
|
+
".csv": "csv",
|
|
40210
|
+
".tsv": "tsv",
|
|
40211
|
+
".tab": "tsv",
|
|
40212
|
+
".txt": "text",
|
|
40213
|
+
".md": "text",
|
|
40214
|
+
".json": "text",
|
|
40215
|
+
".xml": "text",
|
|
40216
|
+
".html": "text",
|
|
40217
|
+
".htm": "text",
|
|
40218
|
+
".log": "text",
|
|
40219
|
+
".yaml": "text",
|
|
40220
|
+
".yml": "text",
|
|
40221
|
+
".ics": "text",
|
|
40222
|
+
".vcf": "text",
|
|
40223
|
+
".srt": "text"
|
|
40224
|
+
};
|
|
40225
|
+
function extractKindFor(mimeType, fileName) {
|
|
40226
|
+
const byMime = MIME_KINDS[mimeType];
|
|
40227
|
+
if (byMime) return byMime;
|
|
40228
|
+
const dot = fileName.lastIndexOf(".");
|
|
40229
|
+
const byExt = dot === -1 ? void 0 : EXT_KINDS[fileName.slice(dot).toLowerCase()];
|
|
40230
|
+
if (byExt) return byExt;
|
|
40231
|
+
return mimeType.startsWith("text/") ? "text" : null;
|
|
40232
|
+
}
|
|
40233
|
+
function parsePartSpec(spec) {
|
|
40234
|
+
const ranges = [];
|
|
40235
|
+
const names = /* @__PURE__ */ new Set();
|
|
40236
|
+
for (const token of spec.split(",").map((t) => t.trim()).filter(Boolean)) {
|
|
40237
|
+
const range = /^(\d+)\s*-\s*(\d+)$/.exec(token);
|
|
40238
|
+
if (range) {
|
|
40239
|
+
ranges.push([Number(range[1]), Number(range[2])]);
|
|
40240
|
+
continue;
|
|
40241
|
+
}
|
|
40242
|
+
if (/^\d+$/.test(token)) ranges.push([Number(token), Number(token)]);
|
|
40243
|
+
names.add(token.toLowerCase());
|
|
40244
|
+
}
|
|
40245
|
+
if (ranges.length === 0 && names.size === 0) return () => true;
|
|
40246
|
+
return (index, name) => ranges.some(([from, to]) => index + 1 >= from && index + 1 <= to) || names.has(name.toLowerCase());
|
|
40247
|
+
}
|
|
40248
|
+
function clip(text, budget) {
|
|
40249
|
+
const cut = text.slice(0, budget);
|
|
40250
|
+
const lastBreak = cut.lastIndexOf("\n");
|
|
40251
|
+
return lastBreak > 0 ? cut.slice(0, lastBreak) : cut;
|
|
40252
|
+
}
|
|
40253
|
+
function omissionNote(label) {
|
|
40254
|
+
return `${label} (omitted: response character budget)`;
|
|
40255
|
+
}
|
|
40256
|
+
function applyCharBudget(extracted, maxChars) {
|
|
40257
|
+
switch (extracted.kind) {
|
|
40258
|
+
case "text":
|
|
40259
|
+
case "document": {
|
|
40260
|
+
if (extracted.text.length <= maxChars) return extracted;
|
|
40261
|
+
return { ...extracted, text: clip(extracted.text, maxChars), truncated: true };
|
|
40262
|
+
}
|
|
40263
|
+
case "spreadsheet": {
|
|
40264
|
+
const sheets = [];
|
|
40265
|
+
const omitted = [...extracted.omitted ?? []];
|
|
40266
|
+
let budget = maxChars;
|
|
40267
|
+
let truncated = extracted.truncated ?? false;
|
|
40268
|
+
for (const sheet of extracted.sheets) {
|
|
40269
|
+
if (budget <= 0) {
|
|
40270
|
+
omitted.push(omissionNote(sheet.name));
|
|
40271
|
+
truncated = true;
|
|
40272
|
+
continue;
|
|
40273
|
+
}
|
|
40274
|
+
if (sheet.csv.length <= budget) {
|
|
40275
|
+
sheets.push(sheet);
|
|
40276
|
+
budget -= sheet.csv.length;
|
|
40277
|
+
continue;
|
|
40278
|
+
}
|
|
40279
|
+
const csv = clip(sheet.csv, budget);
|
|
40280
|
+
sheets.push({ ...sheet, csv, rows: csv.split("\n").length, truncated: true });
|
|
40281
|
+
budget = 0;
|
|
40282
|
+
truncated = true;
|
|
40283
|
+
}
|
|
40284
|
+
return {
|
|
40285
|
+
...extracted,
|
|
40286
|
+
sheets,
|
|
40287
|
+
truncated,
|
|
40288
|
+
...omitted.length ? { omitted } : {}
|
|
40289
|
+
};
|
|
40290
|
+
}
|
|
40291
|
+
case "presentation": {
|
|
40292
|
+
const slides = [];
|
|
40293
|
+
const omitted = [...extracted.omitted ?? []];
|
|
40294
|
+
let budget = maxChars;
|
|
40295
|
+
let truncated = extracted.truncated ?? false;
|
|
40296
|
+
for (const slide of extracted.slides) {
|
|
40297
|
+
const size = slide.text.length + (slide.notes?.length ?? 0);
|
|
40298
|
+
if (budget <= 0) {
|
|
40299
|
+
omitted.push(omissionNote(`slide ${slide.number}`));
|
|
40300
|
+
truncated = true;
|
|
40301
|
+
continue;
|
|
40302
|
+
}
|
|
40303
|
+
if (size <= budget) {
|
|
40304
|
+
slides.push(slide);
|
|
40305
|
+
budget -= size;
|
|
40306
|
+
continue;
|
|
40307
|
+
}
|
|
40308
|
+
slides.push({ ...slide, text: clip(slide.text, budget), notes: void 0 });
|
|
40309
|
+
budget = 0;
|
|
40310
|
+
truncated = true;
|
|
40311
|
+
}
|
|
40312
|
+
return { ...extracted, slides, truncated, ...omitted.length ? { omitted } : {} };
|
|
40313
|
+
}
|
|
40314
|
+
case "pdf": {
|
|
40315
|
+
const pages = [];
|
|
40316
|
+
const omitted = [...extracted.omitted ?? []];
|
|
40317
|
+
let budget = maxChars;
|
|
40318
|
+
let truncated = extracted.truncated ?? false;
|
|
40319
|
+
for (const page of extracted.pages) {
|
|
40320
|
+
if (budget <= 0) {
|
|
40321
|
+
omitted.push(omissionNote(`page ${page.number}`));
|
|
40322
|
+
truncated = true;
|
|
40323
|
+
continue;
|
|
40324
|
+
}
|
|
40325
|
+
if (page.text.length <= budget) {
|
|
40326
|
+
pages.push(page);
|
|
40327
|
+
budget -= page.text.length;
|
|
40328
|
+
continue;
|
|
40329
|
+
}
|
|
40330
|
+
pages.push({ ...page, text: clip(page.text, budget) });
|
|
40331
|
+
budget = 0;
|
|
40332
|
+
truncated = true;
|
|
40333
|
+
}
|
|
40334
|
+
return { ...extracted, pages, truncated, ...omitted.length ? { omitted } : {} };
|
|
40335
|
+
}
|
|
40336
|
+
}
|
|
40337
|
+
}
|
|
40338
|
+
var DEFAULT_MAX_CHARS = 5e4;
|
|
40339
|
+
function decodeText(bytes) {
|
|
40340
|
+
const text = bytes.toString("utf8");
|
|
40341
|
+
return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
|
|
40342
|
+
}
|
|
40343
|
+
async function extractAttachment(bytes, mimeType, fileName, opts = {}) {
|
|
40344
|
+
const kind = extractKindFor(mimeType, fileName);
|
|
40345
|
+
if (kind === null) return null;
|
|
40346
|
+
const select = opts.parts === void 0 ? void 0 : parsePartSpec(opts.parts);
|
|
40347
|
+
let extracted;
|
|
40348
|
+
switch (kind) {
|
|
40349
|
+
case "xlsx":
|
|
40350
|
+
extracted = await extractXlsx(bytes, { select });
|
|
40351
|
+
break;
|
|
40352
|
+
case "csv":
|
|
40353
|
+
extracted = extractDelimited(decodeText(bytes), fileName, ",");
|
|
40354
|
+
break;
|
|
40355
|
+
case "tsv":
|
|
40356
|
+
extracted = extractDelimited(decodeText(bytes), fileName, " ");
|
|
40357
|
+
break;
|
|
40358
|
+
case "docx":
|
|
40359
|
+
extracted = await extractDocx(bytes);
|
|
40360
|
+
break;
|
|
40361
|
+
case "pptx":
|
|
40362
|
+
extracted = await extractPptx(bytes, { select });
|
|
40363
|
+
break;
|
|
40364
|
+
case "pdf":
|
|
40365
|
+
extracted = await extractPdf(bytes, { select });
|
|
40366
|
+
break;
|
|
40367
|
+
case "text":
|
|
40368
|
+
extracted = { kind: "text", text: decodeText(bytes) };
|
|
40369
|
+
break;
|
|
40370
|
+
}
|
|
40371
|
+
return applyCharBudget(extracted, opts.maxChars ?? DEFAULT_MAX_CHARS);
|
|
40372
|
+
}
|
|
40373
|
+
|
|
39471
40374
|
// src/tools/attachments.ts
|
|
39472
40375
|
import { readFileSync, statSync, mkdirSync, writeFileSync } from "node:fs";
|
|
39473
40376
|
import { basename, dirname as dirname2, extname } from "node:path";
|
|
@@ -39555,6 +40458,65 @@ var NodeAttachmentIO = class {
|
|
|
39555
40458
|
}
|
|
39556
40459
|
};
|
|
39557
40460
|
|
|
40461
|
+
// src/tools/delivery.ts
|
|
40462
|
+
async function tryExtract(bytes, mimeType, fileName, opts) {
|
|
40463
|
+
try {
|
|
40464
|
+
const extracted = await extractAttachment(bytes, mimeType, fileName, {
|
|
40465
|
+
maxChars: opts.maxChars,
|
|
40466
|
+
parts: opts.parts
|
|
40467
|
+
});
|
|
40468
|
+
if (!extracted) {
|
|
40469
|
+
return { reason: `no text extractor for ${mimeType} (${fileName})` };
|
|
40470
|
+
}
|
|
40471
|
+
return { extracted, truncated: extracted.truncated ?? false };
|
|
40472
|
+
} catch (err) {
|
|
40473
|
+
return { reason: `extraction failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
40474
|
+
}
|
|
40475
|
+
}
|
|
40476
|
+
async function buildInlineDelivery(input) {
|
|
40477
|
+
const { fileId, fileName, mimeType, bytes, forcedInline, options } = input;
|
|
40478
|
+
const meta3 = {
|
|
40479
|
+
fileId,
|
|
40480
|
+
fileName,
|
|
40481
|
+
mimeType,
|
|
40482
|
+
sizeBytes: bytes.length,
|
|
40483
|
+
mode: "inline"
|
|
40484
|
+
};
|
|
40485
|
+
if (forcedInline) meta3.forcedInline = true;
|
|
40486
|
+
const block = () => ({ type: "text", text: JSON.stringify(meta3, null, 2) });
|
|
40487
|
+
if (isHostRenderableImage(mimeType)) {
|
|
40488
|
+
meta3.deliveredVia = "image";
|
|
40489
|
+
return { content: [block(), { type: "image", data: bytes.toString("base64"), mimeType }] };
|
|
40490
|
+
}
|
|
40491
|
+
const attempts = [];
|
|
40492
|
+
if (options.extract === false) {
|
|
40493
|
+
attempts.push("extraction skipped (extract:false)");
|
|
40494
|
+
} else {
|
|
40495
|
+
const outcome = await tryExtract(bytes, mimeType, fileName, options);
|
|
40496
|
+
if (outcome.extracted) {
|
|
40497
|
+
meta3.deliveredVia = "extracted";
|
|
40498
|
+
meta3.extracted = outcome.extracted;
|
|
40499
|
+
meta3.truncated = outcome.truncated;
|
|
40500
|
+
meta3.note = "Content extracted from the file. Pass extract:false to get the raw bytes instead.";
|
|
40501
|
+
return { content: [block()] };
|
|
40502
|
+
}
|
|
40503
|
+
attempts.push(outcome.reason ?? "extraction produced no content");
|
|
40504
|
+
}
|
|
40505
|
+
meta3.deliveredVia = "blob";
|
|
40506
|
+
meta3.deliveryAttempts = attempts;
|
|
40507
|
+
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).";
|
|
40508
|
+
return {
|
|
40509
|
+
content: [block(), {
|
|
40510
|
+
type: "resource",
|
|
40511
|
+
resource: {
|
|
40512
|
+
uri: `ofw://attachment/${fileId}/${encodeURIComponent(fileName)}`,
|
|
40513
|
+
mimeType,
|
|
40514
|
+
blob: bytes.toString("base64")
|
|
40515
|
+
}
|
|
40516
|
+
}]
|
|
40517
|
+
};
|
|
40518
|
+
}
|
|
40519
|
+
|
|
39558
40520
|
// src/tools/messages.ts
|
|
39559
40521
|
import { basename as basename2, join as join5 } from "node:path";
|
|
39560
40522
|
var DateSchema = external_exports.looseObject({ dateTime: external_exports.string() });
|
|
@@ -39570,7 +40532,9 @@ var SavedDraftDetailSchema = external_exports.looseObject({
|
|
|
39570
40532
|
body: external_exports.string().optional(),
|
|
39571
40533
|
date: DateSchema.optional(),
|
|
39572
40534
|
replyToId: external_exports.number().nullable().optional(),
|
|
39573
|
-
recipients: external_exports.array(ApiRecipientSchema).optional()
|
|
40535
|
+
recipients: external_exports.array(ApiRecipientSchema).optional(),
|
|
40536
|
+
// Read to audit whether requested myFileIDs actually attached (Defect 3).
|
|
40537
|
+
files: external_exports.array(external_exports.number()).optional()
|
|
39574
40538
|
});
|
|
39575
40539
|
var MessageDetailSchema = external_exports.looseObject({
|
|
39576
40540
|
id: external_exports.number(),
|
|
@@ -39622,6 +40586,20 @@ async function draftsFreshness(cache) {
|
|
|
39622
40586
|
const cacheStatus = completed === "fresh" && freshness.staleness === "fresh" ? "fresh" : "unverified";
|
|
39623
40587
|
return { freshness, serverConfirmed: cacheStatus === "fresh", cacheStatus };
|
|
39624
40588
|
}
|
|
40589
|
+
function markReadVerdict(cached2, requested) {
|
|
40590
|
+
const ceiling = getAllowMarkRead();
|
|
40591
|
+
if (ceiling && (requested ?? true)) return null;
|
|
40592
|
+
const wouldStamp = cached2 === null || cached2.folder === "inbox" && !deriveRead(cached2);
|
|
40593
|
+
if (!wouldStamp) return null;
|
|
40594
|
+
const because = ceiling ? "you passed allowMarkRead:false" : "this server runs with OFW_ALLOW_MARK_READ=false";
|
|
40595
|
+
return jsonErrorResponse({
|
|
40596
|
+
error: "MARK_READ_BLOCKED",
|
|
40597
|
+
messageId: cached2?.id ?? null,
|
|
40598
|
+
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.",
|
|
40599
|
+
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)"}.`,
|
|
40600
|
+
...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 }
|
|
40601
|
+
});
|
|
40602
|
+
}
|
|
39625
40603
|
function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
39626
40604
|
const writeMode = getWriteMode();
|
|
39627
40605
|
const allowSend = writeMode === "all";
|
|
@@ -39680,10 +40658,11 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
39680
40658
|
return jsonResponse(payload);
|
|
39681
40659
|
});
|
|
39682
40660
|
server.registerTool("ofw_get_message", {
|
|
39683
|
-
description: 'Get a single OurFamilyWizard message OR draft by ID. Reads from local cache when available; otherwise fetches from OFW
|
|
40661
|
+
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.',
|
|
39684
40662
|
annotations: { readOnlyHint: false },
|
|
39685
40663
|
inputSchema: {
|
|
39686
|
-
messageId: external_exports.string().describe("Message ID (also accepts draft IDs \u2014 drafts are routed via the drafts cache)")
|
|
40664
|
+
messageId: external_exports.string().describe("Message ID (also accepts draft IDs \u2014 drafts are routed via the drafts cache)"),
|
|
40665
|
+
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()
|
|
39687
40666
|
}
|
|
39688
40667
|
}, async (args) => {
|
|
39689
40668
|
const id = Number(args.messageId);
|
|
@@ -39756,6 +40735,8 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
39756
40735
|
const freshness2 = await buildFreshness(cache, { source: "cache", folders: [row2.folder] });
|
|
39757
40736
|
return jsonResponse({ ...withReadState(row2), attachments: attachments2, freshness: freshness2 });
|
|
39758
40737
|
}
|
|
40738
|
+
const markReadCheck = markReadVerdict(cached2, args.allowMarkRead);
|
|
40739
|
+
if (markReadCheck !== null) return markReadCheck;
|
|
39759
40740
|
const detail = parseLenient(
|
|
39760
40741
|
MessageDetailSchema,
|
|
39761
40742
|
await client2.request("GET", `/pub/v3/messages/${encodeURIComponent(args.messageId)}`),
|
|
@@ -39935,7 +40916,10 @@ ${text}` : text);
|
|
|
39935
40916
|
};
|
|
39936
40917
|
}
|
|
39937
40918
|
const verdict = checkDraftFreshness({ server: server2, cached: cached2, expectedRevision });
|
|
39938
|
-
if (verdict.verdict === "FRESH")
|
|
40919
|
+
if (verdict.verdict === "FRESH") {
|
|
40920
|
+
const note = verdict.metadataOnly ? `NOTE: draft ${draftId} was treated as current for this ${action}. Since you read it, OurFamilyWizard normalized connector-authored metadata (${verdict.changedFields.join(", ")}); the subject, body and recipients are unchanged, so this is not a conflict.` : null;
|
|
40921
|
+
return { ok: true, note };
|
|
40922
|
+
}
|
|
39939
40923
|
if (force) {
|
|
39940
40924
|
console.error(`[ofw-mcp] WARNING: force:true overrode a ${verdict.verdict} verdict on draft ${draftId} (${action}). ${verdict.reason}`);
|
|
39941
40925
|
const echoed = server2 === null ? "The draft no longer existed on OurFamilyWizard." : `The server version that was overwritten is preserved below under "overwrittenServerDraft".`;
|
|
@@ -39995,7 +40979,7 @@ ${JSON.stringify(
|
|
|
39995
40979
|
return jsonResponse(payload);
|
|
39996
40980
|
});
|
|
39997
40981
|
if (allowDrafts) server.registerTool("ofw_save_draft", {
|
|
39998
|
-
description: "Save a message as a draft in OurFamilyWizard. Recipients are optional. Pass messageId to replace an existing draft \u2014 note that under the hood this creates a NEW draft and deletes the old one (OFW's update-in-place endpoint silently no-ops while echoing the posted body, so we don't use it); the response.id will be the NEW id, not the messageId you passed, and the change is documented in a transparency NOTE in the response. If replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included in response). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After saving, the tool re-fetches the draft from OFW to populate the local cache from authoritative server state. SAFETY: because replacing DESTROYS the old draft rather than merging, passing messageId first re-reads that draft from OFW and REFUSES the write if
|
|
40982
|
+
description: "Save a message as a draft in OurFamilyWizard. Recipients are optional. Pass messageId to replace an existing draft \u2014 note that under the hood this creates a NEW draft and deletes the old one (OFW's update-in-place endpoint silently no-ops while echoing the posted body, so we don't use it); the response.id will be the NEW id, not the messageId you passed, and the change is documented in a transparency NOTE in the response that also lists which fields (subject/body/recipients/replyToId/attachments) were carried over. If replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included in response). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After saving, the tool re-fetches the draft from OFW to populate the local cache from authoritative server state, and the returned `revision` reflects that authoritative state (so it will match on your next edit). FIELD PRESERVATION: the response echoes the effective threading (replyToId/inReplyTo) and, whenever OFW did not carry over a requested replyToId, recipient or attachment, a `warnings[]` entry naming what was dropped \u2014 never a silent null. SAFETY: because replacing DESTROYS the old draft rather than merging, passing messageId first re-reads that draft from OFW and REFUSES the write if its subject/body/recipients changed since you read it (drafts edited in the OFW web app do not bump any timestamp, so the local cache can be silently behind). A pure replyToId normalization by OFW is NOT treated as a conflict. The refusal returns the current server body under serverBody \u2014 merge your edit into it and retry with expectedRevision.",
|
|
39999
40983
|
annotations: { readOnlyHint: false },
|
|
40000
40984
|
inputSchema: {
|
|
40001
40985
|
subject: external_exports.string().describe("Message subject"),
|
|
@@ -40050,32 +41034,68 @@ ${JSON.stringify(
|
|
|
40050
41034
|
let replaceNote = null;
|
|
40051
41035
|
let verifyNote = null;
|
|
40052
41036
|
let newRevision = null;
|
|
41037
|
+
const warnings = [];
|
|
40053
41038
|
if (newId !== null) {
|
|
40054
41039
|
verifyNote = verifyWriteLanded("draft", { subject: args.subject, body: args.body }, detail);
|
|
41040
|
+
const effectiveReplyTo = detail.replyToId ?? null;
|
|
41041
|
+
const storedRecipients = mapRecipients(detail.recipients);
|
|
40055
41042
|
persisted = {
|
|
40056
41043
|
id: newId,
|
|
40057
41044
|
subject: detail.subject ?? args.subject,
|
|
40058
41045
|
body: detail.body ?? "",
|
|
40059
|
-
recipients:
|
|
40060
|
-
replyToId:
|
|
41046
|
+
recipients: storedRecipients,
|
|
41047
|
+
replyToId: effectiveReplyTo,
|
|
40061
41048
|
modifiedAt: detail.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
40062
41049
|
listData: detail
|
|
40063
41050
|
};
|
|
40064
41051
|
await cache.upsertDraft(persisted);
|
|
40065
41052
|
newRevision = draftRevision(persisted);
|
|
41053
|
+
if (resolvedReplyTo !== null && effectiveReplyTo !== resolvedReplyTo) {
|
|
41054
|
+
const rewrittenFrom = requestedReplyTo !== resolvedReplyTo ? ` (rewritten from ${requestedReplyTo})` : "";
|
|
41055
|
+
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.`;
|
|
41056
|
+
warnings.push(
|
|
41057
|
+
`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.`
|
|
41058
|
+
);
|
|
41059
|
+
}
|
|
41060
|
+
if (args.recipientIds !== void 0 && Array.isArray(detail.recipients)) {
|
|
41061
|
+
const requested = [...new Set(args.recipientIds)].sort((a, b) => a - b);
|
|
41062
|
+
const stored = [...new Set(storedRecipients.map((r) => r.userId))].sort((a, b) => a - b);
|
|
41063
|
+
if (requested.join(",") !== stored.join(",")) {
|
|
41064
|
+
warnings.push(
|
|
41065
|
+
`recipientIds were requested as [${requested.join(", ")}] but the saved draft has [${stored.join(", ")}]. Verify the recipients on ourfamilywizard.com.`
|
|
41066
|
+
);
|
|
41067
|
+
}
|
|
41068
|
+
}
|
|
41069
|
+
if (myFileIDs.length > 0 && Array.isArray(detail.files)) {
|
|
41070
|
+
const storedFiles = new Set(detail.files);
|
|
41071
|
+
const missing = myFileIDs.filter((id) => !storedFiles.has(id));
|
|
41072
|
+
if (missing.length > 0) {
|
|
41073
|
+
warnings.push(
|
|
41074
|
+
`Attachment fileId(s) ${missing.join(", ")} were requested in myFileIDs but are not attached to the saved draft. Re-upload or re-attach if needed.`
|
|
41075
|
+
);
|
|
41076
|
+
}
|
|
41077
|
+
}
|
|
40066
41078
|
if (args.messageId !== void 0 && args.messageId !== newId) {
|
|
40067
41079
|
try {
|
|
40068
41080
|
await deleteOFWMessages(client2, [args.messageId]);
|
|
40069
41081
|
await cache.deleteDraft(args.messageId);
|
|
40070
|
-
replaceNote = `NOTE: ofw_save_draft replaced draft ${args.messageId} via create-then-delete. The new draft id is ${newId}; the old draft has been deleted. (OFW's update-in-place endpoint silently no-ops on subsequent updates, so we never use it. If you cached the old id anywhere, replace it with the new one.)`;
|
|
41082
|
+
replaceNote = `NOTE: ofw_save_draft replaced draft ${args.messageId} via create-then-delete. The new draft id is ${newId}; the old draft has been deleted. (OFW's update-in-place endpoint silently no-ops on subsequent updates, so we never use it. If you cached the old id anywhere, replace it with the new one.) Fields carried over to the new draft: subject, body, recipients (${persisted.recipients.length}), replyToId (${persisted.replyToId === null ? "none" : persisted.replyToId}), attachments (${myFileIDs.length}).${warnings.length > 0 ? " See warnings above for any field OurFamilyWizard did not carry over." : ""}`;
|
|
40071
41083
|
} catch (e) {
|
|
40072
41084
|
replaceNote = `WARNING: New draft ${newId} was created successfully, but the old draft ${args.messageId} could NOT be deleted: ${e.message}. BOTH drafts now exist on OurFamilyWizard and nothing was lost. Verify ${newId} reads correctly, then remove ${args.messageId} with ofw_delete_draft.`;
|
|
40073
41085
|
}
|
|
40074
41086
|
}
|
|
40075
41087
|
}
|
|
40076
|
-
const responseObj = persisted !== null ? {
|
|
41088
|
+
const responseObj = persisted !== null ? {
|
|
41089
|
+
...persisted,
|
|
41090
|
+
inReplyTo: persisted.replyToId,
|
|
41091
|
+
revision: newRevision,
|
|
41092
|
+
cacheStatus: "fresh",
|
|
41093
|
+
serverConfirmed: true,
|
|
41094
|
+
...warnings.length > 0 ? { warnings } : {}
|
|
41095
|
+
} : raw;
|
|
40077
41096
|
const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Draft saved.";
|
|
40078
|
-
const
|
|
41097
|
+
const warnNote = warnings.length > 0 ? `WARNING: ${warnings.join("\n\n")}` : null;
|
|
41098
|
+
const notes = [forceNote, rewriteNote, verifyNote, warnNote, replaceNote].filter((n) => n !== null).join("\n\n");
|
|
40079
41099
|
return textResponse(notes ? `${notes}
|
|
40080
41100
|
|
|
40081
41101
|
${text}` : text);
|
|
@@ -40183,13 +41203,16 @@ ${text}` : text);
|
|
|
40183
41203
|
});
|
|
40184
41204
|
});
|
|
40185
41205
|
server.registerTool("ofw_download_attachment", {
|
|
40186
|
-
description:
|
|
41206
|
+
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).",
|
|
40187
41207
|
annotations: { readOnlyHint: false },
|
|
40188
41208
|
inputSchema: {
|
|
40189
41209
|
fileId: external_exports.number().describe("Attachment file id (from ofw_get_message \u2192 attachments[].fileId)"),
|
|
40190
|
-
inline: external_exports.boolean().describe("If true, return
|
|
41210
|
+
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(),
|
|
40191
41211
|
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(),
|
|
40192
|
-
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()
|
|
41212
|
+
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(),
|
|
41213
|
+
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(),
|
|
41214
|
+
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(),
|
|
41215
|
+
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()
|
|
40193
41216
|
}
|
|
40194
41217
|
}, async (args) => {
|
|
40195
41218
|
const fileId = args.fileId;
|
|
@@ -40197,6 +41220,7 @@ ${text}` : text);
|
|
|
40197
41220
|
const requestedInline = args.inline ?? getDefaultInlineAttachments();
|
|
40198
41221
|
const inline = requestedInline || !attachmentIO.supportsDisk;
|
|
40199
41222
|
const forcedInline = inline && !requestedInline;
|
|
41223
|
+
const deliveryOptions = { extract: args.extract, maxChars: args.maxChars, parts: args.parts };
|
|
40200
41224
|
let cached2 = await cache.getAttachment(fileId);
|
|
40201
41225
|
if (!cached2) {
|
|
40202
41226
|
await fetchAttachmentMeta(client2, fileId, 0, cache);
|
|
@@ -40216,25 +41240,15 @@ ${text}` : text);
|
|
|
40216
41240
|
headerMime = response2.contentType ?? cached2.mimeType;
|
|
40217
41241
|
fileName2 = response2.suggestedFileName ?? cached2.fileName;
|
|
40218
41242
|
}
|
|
40219
|
-
const
|
|
40220
|
-
|
|
40221
|
-
const meta3 = {
|
|
41243
|
+
const mimeType2 = resolveDownloadMime(bytes, headerMime, fileName2);
|
|
41244
|
+
return await buildInlineDelivery({
|
|
40222
41245
|
fileId,
|
|
40223
41246
|
fileName: fileName2,
|
|
40224
|
-
mimeType,
|
|
40225
|
-
|
|
40226
|
-
|
|
40227
|
-
|
|
40228
|
-
|
|
40229
|
-
const metaBlock = { type: "text", text: JSON.stringify(meta3, null, 2) };
|
|
40230
|
-
if (isHostRenderableImage(mimeType)) {
|
|
40231
|
-
return { content: [metaBlock, { type: "image", data: base643, mimeType }] };
|
|
40232
|
-
}
|
|
40233
|
-
return { content: [metaBlock, { type: "resource", resource: {
|
|
40234
|
-
uri: `ofw://attachment/${fileId}/${encodeURIComponent(fileName2)}`,
|
|
40235
|
-
mimeType,
|
|
40236
|
-
blob: base643
|
|
40237
|
-
} }] };
|
|
41247
|
+
mimeType: mimeType2,
|
|
41248
|
+
bytes,
|
|
41249
|
+
forcedInline,
|
|
41250
|
+
options: deliveryOptions
|
|
41251
|
+
});
|
|
40238
41252
|
}
|
|
40239
41253
|
let dest;
|
|
40240
41254
|
const safeName = basename2(cached2.fileName);
|
|
@@ -40245,29 +41259,34 @@ ${text}` : text);
|
|
|
40245
41259
|
} else {
|
|
40246
41260
|
dest = join5(getAttachmentsDir(), `${fileId}-${safeName}`);
|
|
40247
41261
|
}
|
|
41262
|
+
const extractOnDisk = args.extract === true;
|
|
40248
41263
|
if (!args.force && cached2.downloadedPath === dest) {
|
|
40249
|
-
|
|
40250
|
-
|
|
40251
|
-
|
|
40252
|
-
|
|
40253
|
-
|
|
40254
|
-
|
|
40255
|
-
|
|
40256
|
-
|
|
40257
|
-
|
|
40258
|
-
|
|
40259
|
-
|
|
41264
|
+
const onDisk = extractOnDisk ? attachmentIO.readDownloaded(dest) : null;
|
|
41265
|
+
if (!extractOnDisk || onDisk) {
|
|
41266
|
+
const mimeType2 = resolveDownloadMime(onDisk ?? Buffer.alloc(0), cached2.mimeType, cached2.fileName);
|
|
41267
|
+
return jsonResponse({
|
|
41268
|
+
fileId,
|
|
41269
|
+
path: dest,
|
|
41270
|
+
mimeType: mimeType2,
|
|
41271
|
+
sizeBytes: cached2.sizeBytes,
|
|
41272
|
+
fileName: cached2.fileName,
|
|
41273
|
+
note: "already downloaded",
|
|
41274
|
+
...onDisk ? await tryExtract(onDisk, mimeType2, cached2.fileName, deliveryOptions) : {}
|
|
41275
|
+
});
|
|
41276
|
+
}
|
|
40260
41277
|
}
|
|
40261
41278
|
const response = await client2.requestBinary("GET", `/pub/v1/myfiles/${fileId}/data`);
|
|
40262
41279
|
attachmentIO.writeDownload(dest, response.body);
|
|
40263
41280
|
await cache.markAttachmentDownloaded(fileId, dest);
|
|
40264
41281
|
const fileName = response.suggestedFileName ?? cached2.fileName;
|
|
41282
|
+
const mimeType = resolveDownloadMime(response.body, response.contentType ?? cached2.mimeType, fileName);
|
|
40265
41283
|
return jsonResponse({
|
|
40266
41284
|
fileId,
|
|
40267
41285
|
path: dest,
|
|
40268
|
-
mimeType
|
|
41286
|
+
mimeType,
|
|
40269
41287
|
sizeBytes: response.body.length,
|
|
40270
|
-
fileName
|
|
41288
|
+
fileName,
|
|
41289
|
+
...extractOnDisk ? await tryExtract(response.body, mimeType, fileName, deliveryOptions) : {}
|
|
40271
41290
|
});
|
|
40272
41291
|
});
|
|
40273
41292
|
server.registerTool("ofw_sync_messages", {
|
|
@@ -40275,7 +41294,7 @@ ${text}` : text);
|
|
|
40275
41294
|
annotations: { readOnlyHint: false },
|
|
40276
41295
|
inputSchema: {
|
|
40277
41296
|
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(),
|
|
40278
|
-
fetchUnreadBodies: external_exports.boolean().describe(
|
|
41297
|
+
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(),
|
|
40279
41298
|
deep: external_exports.boolean().describe("If true, walk every OFW page until empty regardless of cache state. Use to backfill gaps. Default false.").optional(),
|
|
40280
41299
|
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()
|
|
40281
41300
|
}
|
|
@@ -40283,7 +41302,10 @@ ${text}` : text);
|
|
|
40283
41302
|
const cache = cacheProvider();
|
|
40284
41303
|
const result = await syncAll(client2, {
|
|
40285
41304
|
folders: args.folders,
|
|
40286
|
-
|
|
41305
|
+
// Default from OFW_FETCH_UNREAD_BODIES (false unless set), and capped by
|
|
41306
|
+
// the OFW_ALLOW_MARK_READ ceiling — fetching those bodies is exactly what
|
|
41307
|
+
// stamps a First Viewed time on every unread message it touches.
|
|
41308
|
+
fetchUnreadBodies: getAllowMarkRead() && (args.fetchUnreadBodies ?? getFetchUnreadBodies()),
|
|
40287
41309
|
deep: args.deep,
|
|
40288
41310
|
maxRequests: args.maxRequests ?? getSyncMaxRequests()
|
|
40289
41311
|
}, cache);
|
|
@@ -40303,7 +41325,7 @@ ${text}` : text);
|
|
|
40303
41325
|
}
|
|
40304
41326
|
}, async (args) => {
|
|
40305
41327
|
const cache = cacheProvider();
|
|
40306
|
-
const allowMarkRead = args.allowMarkRead ?? false;
|
|
41328
|
+
const allowMarkRead = getAllowMarkRead() && (args.allowMarkRead ?? false);
|
|
40307
41329
|
const requestedIds = args.messageIds ?? [];
|
|
40308
41330
|
const ids = requestedIds.slice(0, MAX_FRESHNESS_IDS);
|
|
40309
41331
|
const wantFolders = args.folders ?? (requestedIds.length > 0 ? [] : ["inbox", "sent", "drafts"]);
|
|
@@ -40322,7 +41344,8 @@ ${text}` : text);
|
|
|
40322
41344
|
const serverCount = entry?.totalCount ?? entry?.messageCount ?? entry?.count ?? null;
|
|
40323
41345
|
const cachedCount = folder === "drafts" ? (await cache.listDraftIds()).length : await cache.countMessages({ folder });
|
|
40324
41346
|
const state = await cache.getSyncState(folder);
|
|
40325
|
-
const
|
|
41347
|
+
const neverSynced = state === null;
|
|
41348
|
+
const historyComplete = !neverSynced && state.resumePage === null;
|
|
40326
41349
|
const inSync = serverCount === null || !historyComplete ? null : serverCount === cachedCount;
|
|
40327
41350
|
folders.push({
|
|
40328
41351
|
folder,
|
|
@@ -40332,7 +41355,7 @@ ${text}` : text);
|
|
|
40332
41355
|
historyComplete,
|
|
40333
41356
|
lastVerifiedAt: await getFolderVerifiedAt(cache, folder),
|
|
40334
41357
|
inSync,
|
|
40335
|
-
...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." } : {}
|
|
41358
|
+
...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." } : {}
|
|
40336
41359
|
});
|
|
40337
41360
|
}
|
|
40338
41361
|
}
|
|
@@ -41165,7 +42188,7 @@ var nodeCacheProvider = () => nodeCache ??= OFWCache.open(getCacheDbPath());
|
|
|
41165
42188
|
var nodeAttachmentIO = new NodeAttachmentIO();
|
|
41166
42189
|
await runMcp({
|
|
41167
42190
|
name: "ofw",
|
|
41168
|
-
version: "2.
|
|
42191
|
+
version: "2.8.0",
|
|
41169
42192
|
// x-release-please-version
|
|
41170
42193
|
deps: client,
|
|
41171
42194
|
tools: [
|