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.
@@ -32,6 +32,14 @@ function draftFromDb(r) {
32
32
  listData: JSON.parse(r.list_data_json),
33
33
  };
34
34
  }
35
+ function lineageFromDb(r) {
36
+ return {
37
+ id: r.id,
38
+ draftKey: r.draft_key,
39
+ previousId: r.previous_id,
40
+ recordedAt: r.recorded_at,
41
+ };
42
+ }
35
43
  function attachmentFromDb(r) {
36
44
  return {
37
45
  fileId: r.file_id,
@@ -93,6 +101,17 @@ export const SCHEMA_STATEMENTS = [
93
101
  key TEXT PRIMARY KEY,
94
102
  value TEXT NOT NULL
95
103
  )`,
104
+ // v3: draft identity chain. One row per OFW id, all the ids of one logical
105
+ // document sharing a `draft_key`. Survives ofw_save_draft's create-then-delete
106
+ // replacement AND the transition to a sent message, so "what happened to the
107
+ // draft I was editing?" is answerable without guessing which id is current.
108
+ `CREATE TABLE IF NOT EXISTS draft_lineage (
109
+ id INTEGER PRIMARY KEY,
110
+ draft_key TEXT NOT NULL,
111
+ previous_id INTEGER,
112
+ recorded_at TEXT NOT NULL
113
+ )`,
114
+ `CREATE INDEX IF NOT EXISTS idx_draft_lineage_key ON draft_lineage(draft_key, recorded_at, id)`,
96
115
  // v2: attachments table. Idempotent — IF NOT EXISTS.
97
116
  `CREATE TABLE IF NOT EXISTS attachments (
98
117
  file_id INTEGER PRIMARY KEY,
@@ -119,7 +138,7 @@ export const MIGRATIONS = [
119
138
  'ALTER TABLE sync_state ADD COLUMN resume_page INTEGER',
120
139
  ];
121
140
  /** The schema version stamped into the `meta` table on open. */
122
- export const SCHEMA_VERSION = '2';
141
+ export const SCHEMA_VERSION = '3';
123
142
  // Build the WHERE clause + bound params for message queries. listMessages and
124
143
  // countMessages share this so the filter semantics can't drift.
125
144
  function buildMessageFilter(opts) {
@@ -302,6 +321,11 @@ export class OFWCacheCore {
302
321
  const rows = this.db.all('SELECT * FROM drafts ORDER BY modified_at DESC, id DESC LIMIT ? OFFSET ?', [opts.size, offset]);
303
322
  return rows.map(draftFromDb);
304
323
  }
324
+ countDrafts() {
325
+ const r = this.db.get('SELECT COUNT(*) as n FROM drafts', []);
326
+ /* v8 ignore next -- SELECT COUNT(*) always returns exactly one row; the ?./?? are defensive */
327
+ return r?.n ?? 0;
328
+ }
305
329
  deleteDraft(id) {
306
330
  this.db.run('DELETE FROM drafts WHERE id = ?', [id]);
307
331
  }
@@ -309,6 +333,45 @@ export class OFWCacheCore {
309
333
  const rows = this.db.all('SELECT id FROM drafts', []);
310
334
  return rows.map((r) => r.id);
311
335
  }
336
+ /**
337
+ * Link an id into a draft's identity chain. Upserts on id: re-recording the
338
+ * same id (e.g. a retried save) rewrites its link rather than duplicating it,
339
+ * so `getDraftLineage` can never report one id twice.
340
+ */
341
+ recordDraftLineage(row) {
342
+ this.db.run(`INSERT INTO draft_lineage (id, draft_key, previous_id, recorded_at) VALUES (?, ?, ?, ?)
343
+ ON CONFLICT(id) DO UPDATE SET
344
+ draft_key=excluded.draft_key,
345
+ previous_id=excluded.previous_id,
346
+ recorded_at=excluded.recorded_at`, [row.id, requireString('draft_lineage.draftKey', row.draftKey), nullish(row.previousId),
347
+ requireString('draft_lineage.recordedAt', row.recordedAt)]);
348
+ }
349
+ getDraftLineageById(id) {
350
+ const r = this.db.get('SELECT * FROM draft_lineage WHERE id = ?', [id]);
351
+ return r ? lineageFromDb(r) : null;
352
+ }
353
+ /**
354
+ * Batch read — one query for a whole page of drafts. On the Durable Object
355
+ * backend each cache call is a subrequest, so a per-draft lookup would spend
356
+ * the caller's sync budget on bookkeeping.
357
+ */
358
+ getDraftLineageByIds(ids) {
359
+ if (ids.length === 0)
360
+ return [];
361
+ const placeholders = ids.map(() => '?').join(', ');
362
+ const rows = this.db.all(`SELECT * FROM draft_lineage WHERE id IN (${placeholders})`, ids);
363
+ return rows.map(lineageFromDb);
364
+ }
365
+ /**
366
+ * Every link in one chain, OLDEST FIRST — so the last element is the chain's
367
+ * current id. Ordered by recorded_at then id: two links written inside the
368
+ * same millisecond tie-break on id, and OFW mints ids monotonically, so the
369
+ * newer replacement always sorts last.
370
+ */
371
+ getDraftLineage(draftKey) {
372
+ const rows = this.db.all('SELECT * FROM draft_lineage WHERE draft_key = ? ORDER BY recorded_at ASC, id ASC', [draftKey]);
373
+ return rows.map(lineageFromDb);
374
+ }
312
375
  getSyncState(folder) {
313
376
  const r = this.db.get('SELECT last_sync_at, newest_id, resume_page FROM sync_state WHERE folder = ?', [folder]);
314
377
  if (!r)
@@ -444,12 +507,27 @@ export class LocalCacheStore {
444
507
  async listDrafts(opts) {
445
508
  return this.core.listDrafts(opts);
446
509
  }
510
+ async countDrafts() {
511
+ return this.core.countDrafts();
512
+ }
447
513
  async deleteDraft(id) {
448
514
  this.core.deleteDraft(id);
449
515
  }
450
516
  async listDraftIds() {
451
517
  return this.core.listDraftIds();
452
518
  }
519
+ async recordDraftLineage(row) {
520
+ this.core.recordDraftLineage(row);
521
+ }
522
+ async getDraftLineageById(id) {
523
+ return this.core.getDraftLineageById(id);
524
+ }
525
+ async getDraftLineageByIds(ids) {
526
+ return this.core.getDraftLineageByIds(ids);
527
+ }
528
+ async getDraftLineage(draftKey) {
529
+ return this.core.getDraftLineage(draftKey);
530
+ }
453
531
  async getSyncState(folder) {
454
532
  return this.core.getSyncState(folder);
455
533
  }
package/dist/config.js CHANGED
@@ -82,6 +82,66 @@ export function getCalendarWritesAllowed() {
82
82
  return true;
83
83
  return mode === 'drafts' && parseBoolEnv('OFW_CALENDAR_WRITES');
84
84
  }
85
+ /**
86
+ * Deployment-wide ceiling on reads that STAMP the record.
87
+ *
88
+ * Fetching a message body from OFW marks it read and stamps a "First Viewed"
89
+ * timestamp the co-parent can see. That is part of the court-visible record and
90
+ * it cannot be undone — and unlike a send, it happens as a side effect of an
91
+ * ordinary read, so nothing about the caller's intent signals it.
92
+ *
93
+ * Default TRUE: unset means exactly the behaviour that shipped before this flag
94
+ * existed. Set OFW_ALLOW_MARK_READ=false and it becomes a hard ceiling rather
95
+ * than a default — `ofw_get_message` refuses a fetch that would stamp,
96
+ * `ofw_check_freshness` ignores `allowMarkRead:true`, and `ofw_sync_messages`
97
+ * ignores `fetchUnreadBodies:true`. A per-call argument (or an instruction
98
+ * injected into one) cannot raise it, which is the same structural posture
99
+ * OFW_WRITE_MODE takes for writes.
100
+ *
101
+ * An unrecognized value fails CLOSED, with a warning: someone who wrote
102
+ * "flase" meant to disable this, and honouring the typo as the permissive
103
+ * default would keep stamping the record while looking configured.
104
+ */
105
+ export function getAllowMarkRead() {
106
+ const raw = process.env.OFW_ALLOW_MARK_READ;
107
+ if (typeof raw !== 'string' || raw.trim().length === 0)
108
+ return true;
109
+ const value = raw.trim().toLowerCase();
110
+ if (['1', 'true', 'yes', 'on'].includes(value))
111
+ return true;
112
+ if (['0', 'false', 'no', 'off'].includes(value))
113
+ return false;
114
+ // stdio transport: stderr only — stdout is reserved for JSON-RPC.
115
+ console.error(`[ofw-mcp] Unrecognized OFW_ALLOW_MARK_READ "${raw.trim()}" — failing closed to "false" (no tool may mark a message read on OFW). Valid values: true, false.`);
116
+ return false;
117
+ }
118
+ /**
119
+ * Default for ofw_sync_messages' `fetchUnreadBodies` arg. False (the shipped
120
+ * default) so an ordinary sync never stamps unread inbox messages; set
121
+ * OFW_FETCH_UNREAD_BODIES=true on a deployment where read receipts are routine
122
+ * and you would rather have the bodies cached. Capped by getAllowMarkRead().
123
+ */
124
+ export function getFetchUnreadBodies() {
125
+ return parseBoolEnv('OFW_FETCH_UNREAD_BODIES');
126
+ }
127
+ /**
128
+ * Default for the read tools' `autoRefresh` arg.
129
+ *
130
+ * When a cached read comes back EMPTY and the cache is not `fresh`, the tools
131
+ * refuse to report that emptiness (see UNVERIFIED_EMPTY in tools/messages.ts):
132
+ * an empty result from a 207-minute-old cache is shaped identically to a
133
+ * verified "nothing there", and answering "no, it wasn't sent" from one is how
134
+ * a false negative becomes a confident statement about a legal record.
135
+ *
136
+ * The refusal names its remedy, so the default (false) costs one extra call.
137
+ * Set OFW_AUTO_REFRESH=true and the tools instead sync the backing folders
138
+ * themselves and answer from the refreshed cache — same guarantee, no round
139
+ * trip. Never silently degrades: if the refresh does not make the read
140
+ * verifiable, the refusal still fires.
141
+ */
142
+ export function getAutoRefreshStaleReads() {
143
+ return parseBoolEnv('OFW_AUTO_REFRESH');
144
+ }
85
145
  // Default for ofw_download_attachment's `inline` arg when the caller doesn't
86
146
  // pass one. Set OFW_INLINE_ATTACHMENTS=true to have attachments returned as
87
147
  // MCP content blocks by default (skipping disk) — useful on sandboxed MCP
@@ -0,0 +1,83 @@
1
+ // Word (.docx) text extraction.
2
+ //
3
+ // The goal is a readable transcript, not a faithful re-render: headings keep
4
+ // their level as `#` prefixes, list paragraphs keep their bullet, and table
5
+ // rows come back pipe-delimited. Structure is what makes an extracted custody
6
+ // schedule answerable ("which row is Thanksgiving?"); a flat wall of runs is
7
+ // not much better than the blob we are replacing.
8
+ import { readZip } from './zip.js';
9
+ import { decodeXmlEntities, elements } from './xml.js';
10
+ import { findPart } from './ooxml.js';
11
+ // A run's text, a tab, or a break — matched in document order so an inline tab
12
+ // does not get hoisted out of position by a tags-then-text pass.
13
+ const RUN_CONTENT = /<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>|<w:(tab|br|cr)\s*\/>/g;
14
+ function paragraphText(inner) {
15
+ let text = '';
16
+ for (let m = RUN_CONTENT.exec(inner); m !== null; m = RUN_CONTENT.exec(inner)) {
17
+ if (m[1] !== undefined)
18
+ text += decodeXmlEntities(m[1]);
19
+ else
20
+ text += m[2] === 'tab' ? '\t' : '\n';
21
+ }
22
+ RUN_CONTENT.lastIndex = 0;
23
+ return text;
24
+ }
25
+ /** Prefix a paragraph according to its `<w:pStyle>`, if it has a known one. */
26
+ function styledParagraph(inner) {
27
+ const text = paragraphText(inner);
28
+ if (text === '')
29
+ return '';
30
+ const style = /<w:pStyle\s[^>]*w:val="([^"]*)"/.exec(inner)?.[1] ?? '';
31
+ const heading = /^Heading(\d)$/.exec(style);
32
+ if (heading)
33
+ return `${'#'.repeat(Math.min(Number(heading[1]), 6))} ${text}`;
34
+ if (style === 'Title' || style === 'Subtitle')
35
+ return `# ${text}`;
36
+ if (style === 'ListParagraph')
37
+ return `- ${text}`;
38
+ return text;
39
+ }
40
+ function tableText(inner) {
41
+ const rows = [];
42
+ for (const tr of elements(inner, 'w:tr')) {
43
+ const cells = [];
44
+ for (const tc of elements(tr.inner, 'w:tc')) {
45
+ // A cell holds paragraphs; keep it on one line so the row stays a row.
46
+ const parts = [];
47
+ for (const p of elements(tc.inner, 'w:p')) {
48
+ const text = paragraphText(p.inner);
49
+ if (text !== '')
50
+ parts.push(text);
51
+ }
52
+ cells.push(parts.join(' '));
53
+ }
54
+ rows.push(`| ${cells.join(' | ')} |`);
55
+ }
56
+ return rows.join('\n');
57
+ }
58
+ // Top-level block scan: a table swallows its own paragraphs (the match spans
59
+ // them), so the alternation yields tables and stray paragraphs in order without
60
+ // emitting a table's cell text twice.
61
+ // The paragraph arm mirrors `elements()`: a lazy attribute run that does not
62
+ // eat a self-closing slash, so `<w:p w14:paraId="…"/>` cannot swallow the
63
+ // following paragraph.
64
+ const BLOCK = /<w:tbl(?:\s[^>]*?)?>[\s\S]*?<\/w:tbl>|<w:p(?:\s[^>]*?)?(?:\s*\/>|>([\s\S]*?)<\/w:p>)/g;
65
+ /** Extract the text of a .docx document part. */
66
+ export async function extractDocx(bytes) {
67
+ const zip = await readZip(bytes);
68
+ const path = findPart(zip, 'word/document.xml', 'document.xml');
69
+ if (!path)
70
+ throw new Error('no document part found in the .docx archive');
71
+ /* v8 ignore next -- findPart only returns a name the archive contains, so readText cannot be null here */
72
+ const xml = (await zip.readText(path)) ?? '';
73
+ const blocks = [];
74
+ for (let m = BLOCK.exec(xml); m !== null; m = BLOCK.exec(xml)) {
75
+ // Group 1 is the paragraph's inner XML — undefined for a table match and
76
+ // for a self-closing (empty) paragraph.
77
+ const text = m[0].startsWith('<w:tbl') ? tableText(m[0]) : styledParagraph(m[1] ?? '');
78
+ if (text !== '')
79
+ blocks.push(text);
80
+ }
81
+ BLOCK.lastIndex = 0;
82
+ return { kind: 'document', text: blocks.join('\n\n') };
83
+ }
@@ -0,0 +1,222 @@
1
+ // The delivery ladder's extraction rung.
2
+ //
3
+ // `ofw_download_attachment` fetches bytes successfully and then has to hand the
4
+ // caller something it can actually read. A host renders four image types
5
+ // inline and rejects every other embedded resource outright ("Resources of
6
+ // type 'application/vnd.…sheet' are not currently supported"), so for a
7
+ // spreadsheet, PDF, Word or PowerPoint attachment the bytes are delivered and
8
+ // unreadable at the same time. Rendering is a display concern; content is a
9
+ // data concern, and this module is what keeps the second from being decided by
10
+ // the first: whatever the host can draw, the FILE's text comes back as text.
11
+ import { extractXlsx, extractDelimited } from './spreadsheet.js';
12
+ import { extractDocx } from './document.js';
13
+ import { extractPptx } from './presentation.js';
14
+ import { extractPdf } from './pdf.js';
15
+ const MIME_KINDS = {
16
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
17
+ 'application/vnd.ms-excel.sheet.macroenabled.12': 'xlsx',
18
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
19
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
20
+ 'application/pdf': 'pdf',
21
+ 'text/csv': 'csv',
22
+ 'text/tab-separated-values': 'tsv',
23
+ 'application/json': 'text',
24
+ 'application/xml': 'text',
25
+ 'application/xhtml+xml': 'text',
26
+ 'application/javascript': 'text',
27
+ 'application/x-yaml': 'text',
28
+ };
29
+ const EXT_KINDS = {
30
+ '.xlsx': 'xlsx', '.xlsm': 'xlsx',
31
+ '.docx': 'docx',
32
+ '.pptx': 'pptx',
33
+ '.pdf': 'pdf',
34
+ '.csv': 'csv',
35
+ '.tsv': 'tsv', '.tab': 'tsv',
36
+ '.txt': 'text', '.md': 'text', '.json': 'text', '.xml': 'text',
37
+ '.html': 'text', '.htm': 'text', '.log': 'text', '.yaml': 'text', '.yml': 'text',
38
+ '.ics': 'text', '.vcf': 'text', '.srt': 'text',
39
+ };
40
+ /**
41
+ * Which extractor handles this attachment, by MIME then by extension. The
42
+ * extension is a real fallback, not a formality: OFW serves binaries with a
43
+ * `charset` bolted on and sometimes with no useful type at all.
44
+ */
45
+ export function extractKindFor(mimeType, fileName) {
46
+ const byMime = MIME_KINDS[mimeType];
47
+ if (byMime)
48
+ return byMime;
49
+ const dot = fileName.lastIndexOf('.');
50
+ const byExt = dot === -1 ? undefined : EXT_KINDS[fileName.slice(dot).toLowerCase()];
51
+ if (byExt)
52
+ return byExt;
53
+ // Any other text/* type (text/plain, text/markdown, text/html, …) is text.
54
+ return mimeType.startsWith('text/') ? 'text' : null;
55
+ }
56
+ /**
57
+ * Build a part selector from a `"1-3,5"` / `"Summary,2026"` spec. Numbers are
58
+ * 1-based positions; anything else matches a sheet or slide/page label. An
59
+ * empty spec selects everything rather than nothing — a filter that silently
60
+ * excluded all content would look exactly like an empty file.
61
+ */
62
+ export function parsePartSpec(spec) {
63
+ const ranges = [];
64
+ const names = new Set();
65
+ for (const token of spec.split(',').map((t) => t.trim()).filter(Boolean)) {
66
+ const range = /^(\d+)\s*-\s*(\d+)$/.exec(token);
67
+ if (range) {
68
+ ranges.push([Number(range[1]), Number(range[2])]);
69
+ continue;
70
+ }
71
+ // A bare number is a position AND a possible name: spreadsheet tabs are
72
+ // routinely named for a year ("2026"), and reading that token as a position
73
+ // only would make the obvious `parts: "2026"` select nothing at all.
74
+ if (/^\d+$/.test(token))
75
+ ranges.push([Number(token), Number(token)]);
76
+ names.add(token.toLowerCase());
77
+ }
78
+ if (ranges.length === 0 && names.size === 0)
79
+ return () => true;
80
+ return (index, name) => ranges.some(([from, to]) => index + 1 >= from && index + 1 <= to)
81
+ || names.has(name.toLowerCase());
82
+ }
83
+ /** Cut `text` to `budget` characters on a line boundary where possible. */
84
+ function clip(text, budget) {
85
+ const cut = text.slice(0, budget);
86
+ const lastBreak = cut.lastIndexOf('\n');
87
+ return lastBreak > 0 ? cut.slice(0, lastBreak) : cut;
88
+ }
89
+ function omissionNote(label) {
90
+ return `${label} (omitted: response character budget)`;
91
+ }
92
+ /**
93
+ * Trim an extraction to `maxChars`, recording exactly what was dropped. A
94
+ * truncated payload that does not say so is worse than a short one: the caller
95
+ * reads a partial custody schedule as the whole schedule.
96
+ */
97
+ export function applyCharBudget(extracted, maxChars) {
98
+ switch (extracted.kind) {
99
+ case 'text':
100
+ case 'document': {
101
+ if (extracted.text.length <= maxChars)
102
+ return extracted;
103
+ return { ...extracted, text: clip(extracted.text, maxChars), truncated: true };
104
+ }
105
+ case 'spreadsheet': {
106
+ const sheets = [];
107
+ const omitted = [...(extracted.omitted ?? [])];
108
+ let budget = maxChars;
109
+ let truncated = extracted.truncated ?? false;
110
+ for (const sheet of extracted.sheets) {
111
+ if (budget <= 0) {
112
+ omitted.push(omissionNote(sheet.name));
113
+ truncated = true;
114
+ continue;
115
+ }
116
+ if (sheet.csv.length <= budget) {
117
+ sheets.push(sheet);
118
+ budget -= sheet.csv.length;
119
+ continue;
120
+ }
121
+ const csv = clip(sheet.csv, budget);
122
+ sheets.push({ ...sheet, csv, rows: csv.split('\n').length, truncated: true });
123
+ budget = 0;
124
+ truncated = true;
125
+ }
126
+ return {
127
+ ...extracted, sheets, truncated,
128
+ ...(omitted.length ? { omitted } : {}),
129
+ };
130
+ }
131
+ case 'presentation': {
132
+ const slides = [];
133
+ const omitted = [...(extracted.omitted ?? [])];
134
+ let budget = maxChars;
135
+ let truncated = extracted.truncated ?? false;
136
+ for (const slide of extracted.slides) {
137
+ const size = slide.text.length + (slide.notes?.length ?? 0);
138
+ if (budget <= 0) {
139
+ omitted.push(omissionNote(`slide ${slide.number}`));
140
+ truncated = true;
141
+ continue;
142
+ }
143
+ if (size <= budget) {
144
+ slides.push(slide);
145
+ budget -= size;
146
+ continue;
147
+ }
148
+ slides.push({ ...slide, text: clip(slide.text, budget), notes: undefined });
149
+ budget = 0;
150
+ truncated = true;
151
+ }
152
+ return { ...extracted, slides, truncated, ...(omitted.length ? { omitted } : {}) };
153
+ }
154
+ case 'pdf': {
155
+ const pages = [];
156
+ const omitted = [...(extracted.omitted ?? [])];
157
+ let budget = maxChars;
158
+ let truncated = extracted.truncated ?? false;
159
+ for (const page of extracted.pages) {
160
+ if (budget <= 0) {
161
+ omitted.push(omissionNote(`page ${page.number}`));
162
+ truncated = true;
163
+ continue;
164
+ }
165
+ if (page.text.length <= budget) {
166
+ pages.push(page);
167
+ budget -= page.text.length;
168
+ continue;
169
+ }
170
+ pages.push({ ...page, text: clip(page.text, budget) });
171
+ budget = 0;
172
+ truncated = true;
173
+ }
174
+ return { ...extracted, pages, truncated, ...(omitted.length ? { omitted } : {}) };
175
+ }
176
+ }
177
+ }
178
+ /** Default response budget: roughly 12k tokens of extracted text. */
179
+ export const DEFAULT_MAX_CHARS = 50_000;
180
+ function decodeText(bytes) {
181
+ const text = bytes.toString('utf8');
182
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
183
+ }
184
+ /**
185
+ * Extract readable content from an attachment, or return null when the format
186
+ * has no extractor (a .zip, a .heic, an unknown binary) and the caller should
187
+ * fall through to the next rung of the delivery ladder.
188
+ *
189
+ * Throws when a format that SHOULD be extractable cannot be read, so the
190
+ * failure can be reported by name rather than swallowed.
191
+ */
192
+ export async function extractAttachment(bytes, mimeType, fileName, opts = {}) {
193
+ const kind = extractKindFor(mimeType, fileName);
194
+ if (kind === null)
195
+ return null;
196
+ const select = opts.parts === undefined ? undefined : parsePartSpec(opts.parts);
197
+ let extracted;
198
+ switch (kind) {
199
+ case 'xlsx':
200
+ extracted = await extractXlsx(bytes, { select });
201
+ break;
202
+ case 'csv':
203
+ extracted = extractDelimited(decodeText(bytes), fileName, ',');
204
+ break;
205
+ case 'tsv':
206
+ extracted = extractDelimited(decodeText(bytes), fileName, '\t');
207
+ break;
208
+ case 'docx':
209
+ extracted = await extractDocx(bytes);
210
+ break;
211
+ case 'pptx':
212
+ extracted = await extractPptx(bytes, { select });
213
+ break;
214
+ case 'pdf':
215
+ extracted = await extractPdf(bytes, { select });
216
+ break;
217
+ case 'text':
218
+ extracted = { kind: 'text', text: decodeText(bytes) };
219
+ break;
220
+ }
221
+ return applyCharBudget(extracted, opts.maxChars ?? DEFAULT_MAX_CHARS);
222
+ }
@@ -0,0 +1,55 @@
1
+ // Bounded decompression, shared by the ZIP reader and the PDF stream decoder.
2
+ //
3
+ // Both formats let the FILE state how big a member expands to, and both are
4
+ // read from co-parent-supplied attachments. A declared size is therefore a
5
+ // hint, never a guarantee: a ZIP central directory can claim 1 KB in front of a
6
+ // member that expands to a gigabyte, and a PDF stream dictionary declares
7
+ // nothing about its inflated length at all. Checking the declared number before
8
+ // inflating rejects an HONEST oversized member cheaply — it does nothing about
9
+ // a lying one.
10
+ //
11
+ // So the real cap is enforced on the bytes as they actually arrive: read the
12
+ // decompressed stream chunk by chunk, and abort the moment the running total
13
+ // passes the limit. Peak memory is then bounded by the limit rather than by
14
+ // whatever the file felt like claiming.
15
+ /** 32 MiB. Sized to fit comfortably inside the Worker's memory budget. */
16
+ export const MAX_DECOMPRESSED_BYTES = 32 * 1024 * 1024;
17
+ /**
18
+ * Thrown when decompression is aborted for exceeding its cap. Distinct from a
19
+ * decode failure so callers can tell "this file is hostile or absurd" from
20
+ * "this stream is corrupt" — the first deserves to be reported, the second is
21
+ * routinely survivable.
22
+ */
23
+ export class DecompressionLimitError extends Error {
24
+ constructor(label, limit) {
25
+ super(`${label} expands past the ${limit}-byte decompression cap`);
26
+ this.name = 'DecompressionLimitError';
27
+ }
28
+ }
29
+ /**
30
+ * Inflate `data`, aborting if the OUTPUT exceeds `limit` bytes.
31
+ *
32
+ * `deflate-raw` is the ZIP member format; `deflate` is the zlib-wrapped form a
33
+ * PDF `/FlateDecode` stream uses. Both go through the WHATWG
34
+ * `DecompressionStream` so this runs unchanged on Node and workerd.
35
+ */
36
+ export async function inflateBounded(data, format, limit, label) {
37
+ const stream = new Blob([data]).stream()
38
+ .pipeThrough(new DecompressionStream(format));
39
+ const reader = stream.getReader();
40
+ const chunks = [];
41
+ let total = 0;
42
+ for (;;) {
43
+ const { done, value } = await reader.read();
44
+ if (done)
45
+ break;
46
+ total += value.length;
47
+ if (total > limit) {
48
+ // Stop pulling: the rest of the payload is never allocated.
49
+ await reader.cancel();
50
+ throw new DecompressionLimitError(label, limit);
51
+ }
52
+ chunks.push(value);
53
+ }
54
+ return Buffer.concat(chunks);
55
+ }
@@ -0,0 +1,58 @@
1
+ // Shared OOXML plumbing: part-path resolution and relationship lookup.
2
+ //
3
+ // Every OOXML format points from one part to another through a `_rels` file
4
+ // (`r:id="rId3"` → `Target="worksheets/sheet3.xml"`), and the target is
5
+ // relative to the REFERRING part's directory. Guessing the target from the id
6
+ // number instead works right up until a document has been edited enough for the
7
+ // numbering to drift, which is why this indirection is followed properly.
8
+ import { elements, attr } from './xml.js';
9
+ /** Resolve a relationship target against the referring part's directory. */
10
+ export function resolvePartPath(baseDir, target) {
11
+ // A leading "/" means package-absolute.
12
+ if (target.startsWith('/'))
13
+ return target.slice(1);
14
+ const segments = (baseDir + target).split('/');
15
+ const out = [];
16
+ for (const segment of segments) {
17
+ if (segment === '.' || segment === '')
18
+ continue;
19
+ if (segment === '..')
20
+ out.pop();
21
+ else
22
+ out.push(segment);
23
+ }
24
+ return out.join('/');
25
+ }
26
+ /** The directory of a part path, with a trailing slash (`""` at the root). */
27
+ export function dirOf(partPath) {
28
+ const i = partPath.lastIndexOf('/');
29
+ return i === -1 ? '' : partPath.slice(0, i + 1);
30
+ }
31
+ /** Read the `_rels` companion of `partPath`. Empty when the part has none. */
32
+ export async function readRels(zip, partPath) {
33
+ const dir = dirOf(partPath);
34
+ const base = partPath.slice(dir.length);
35
+ const xml = await zip.readText(`${dir}_rels/${base}.rels`);
36
+ if (!xml)
37
+ return [];
38
+ const rels = [];
39
+ for (const el of elements(xml, 'Relationship')) {
40
+ const id = attr(el.attrs, 'Id');
41
+ const target = attr(el.attrs, 'Target');
42
+ /* v8 ignore next -- a Relationship without Id/Target is malformed OOXML; skipping keeps one bad entry from failing the whole read */
43
+ if (!id || !target)
44
+ continue;
45
+ rels.push({ id, target: resolvePartPath(dir, target), type: attr(el.attrs, 'Type') ?? '' });
46
+ }
47
+ return rels;
48
+ }
49
+ /**
50
+ * Locate a top-level part: the conventional path when present, otherwise the
51
+ * first entry whose name ends with the same file name. Producers other than
52
+ * Microsoft's occasionally use a different directory.
53
+ */
54
+ export function findPart(zip, conventional, fileName) {
55
+ if (zip.has(conventional))
56
+ return conventional;
57
+ return zip.names().find((n) => n.endsWith(`/${fileName}`) || n === fileName) ?? null;
58
+ }