@bobfrankston/rmfmail 1.2.327 → 1.2.329
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/.commitmsg +5 -8
- package/.llm/config-cache-crash.md +11 -0
- package/client/app.bundle.js +2 -12
- package/client/app.bundle.js.map +2 -2
- package/client/components/calendar-sidebar.js +8 -12
- package/client/components/calendar-sidebar.js.map +1 -1
- package/client/components/calendar-sidebar.ts +7 -5
- package/client/compose/compose.bundle.js +6 -3
- package/client/compose/compose.bundle.js.map +2 -2
- package/client/compose/compose.js +13 -13
- package/client/compose/compose.js.map +1 -1
- package/client/compose/compose.ts +12 -13
- package/npmchanges.md +27 -0
- package/package.json +1 -1
- package/packages/mailx-service/index.d.ts.map +1 -1
- package/packages/mailx-service/index.js +66 -12
- package/packages/mailx-service/index.js.map +1 -1
- package/packages/mailx-service/index.ts +63 -10
- package/packages/mailx-service/package.json +1 -1
- package/packages/mailx-service/word-html.d.ts +18 -0
- package/packages/mailx-service/word-html.d.ts.map +1 -0
- package/packages/mailx-service/word-html.js +78 -0
- package/packages/mailx-service/word-html.js.map +1 -0
- package/packages/mailx-service/word-html.ts +78 -0
|
@@ -1351,6 +1351,10 @@ export class MailxService implements MailxApi {
|
|
|
1351
1351
|
live.add(`${base}.htm`);
|
|
1352
1352
|
live.add(`~$${base.slice(2)}.docx`);
|
|
1353
1353
|
live.add(`${base}-autosave.ps1`);
|
|
1354
|
+
// The sidecar's Word HTML mirror and the image folder Word writes
|
|
1355
|
+
// beside it (2026-09-13 Claude Code, Fable 5.1).
|
|
1356
|
+
live.add(`${base}-word.htm`);
|
|
1357
|
+
live.add(`${base}-word_files`);
|
|
1354
1358
|
}
|
|
1355
1359
|
let removed = 0, bytes = 0;
|
|
1356
1360
|
const cutoff = Date.now() - maxAgeMs;
|
|
@@ -1533,10 +1537,23 @@ export class MailxService implements MailxApi {
|
|
|
1533
1537
|
if (process.platform === "win32" && opener === "word") {
|
|
1534
1538
|
try {
|
|
1535
1539
|
const psPath = path.join(dir, `${fileBase}-autosave.ps1`);
|
|
1540
|
+
// 2026-09-13 20:10 EDT — Claude Code (Fable 5.1), at Bob's direction.
|
|
1541
|
+
// The sidecar now also mirrors the document as Word's own
|
|
1542
|
+
// filtered HTML via Range.ExportFragment (format 10 =
|
|
1543
|
+
// wdFormatFilteredHTML), which leaves the document's file and
|
|
1544
|
+
// format untouched. That export replaces mammoth as the way
|
|
1545
|
+
// edits come back (see word-html.ts for why). Export happens
|
|
1546
|
+
// BEFORE the save so the .htm is already fresh when the .docx
|
|
1547
|
+
// watcher fires; a manual Ctrl+S (document clean, file stamp
|
|
1548
|
+
// advanced) is mirrored on the next tick. The first attach
|
|
1549
|
+
// records the stamp without exporting so untouched content is
|
|
1550
|
+
// not re-rendered into compose before the user types anything.
|
|
1551
|
+
const wordHtmPath = path.join(dir, `${fileBase}-word.htm`);
|
|
1536
1552
|
fs.writeFileSync(psPath, [
|
|
1537
|
-
"param([string]$DocPath)",
|
|
1553
|
+
"param([string]$DocPath, [string]$HtmPath)",
|
|
1538
1554
|
"$deadline = (Get-Date).AddHours(6)",
|
|
1539
1555
|
"$attached = $false",
|
|
1556
|
+
"$lastStamp = $null",
|
|
1540
1557
|
"while ((Get-Date) -lt $deadline) {",
|
|
1541
1558
|
" Start-Sleep -Seconds 2",
|
|
1542
1559
|
" $word = $null",
|
|
@@ -1546,11 +1563,23 @@ export class MailxService implements MailxApi {
|
|
|
1546
1563
|
" try { foreach ($d in $word.Documents) { if ($d.FullName -ieq $DocPath) { $doc = $d; break } } } catch { continue }",
|
|
1547
1564
|
" if ($null -eq $doc) { if ($attached) { exit 0 } else { continue } }",
|
|
1548
1565
|
" $attached = $true",
|
|
1549
|
-
"
|
|
1566
|
+
" if ($null -eq $lastStamp) { try { $lastStamp = (Get-Item -LiteralPath $DocPath).LastWriteTimeUtc } catch { continue } }",
|
|
1567
|
+
" # A COM call can fail transiently while Word has a dialog up; the next",
|
|
1568
|
+
" # tick retries and the daemon logs any mirror that never arrives.",
|
|
1569
|
+
" try {",
|
|
1570
|
+
" if (-not $doc.Saved) {",
|
|
1571
|
+
" $doc.Content.ExportFragment($HtmPath, 10)",
|
|
1572
|
+
" $doc.Save()",
|
|
1573
|
+
" $lastStamp = (Get-Item -LiteralPath $DocPath).LastWriteTimeUtc",
|
|
1574
|
+
" } else {",
|
|
1575
|
+
" $stamp = (Get-Item -LiteralPath $DocPath).LastWriteTimeUtc",
|
|
1576
|
+
" if ($stamp -gt $lastStamp) { $doc.Content.ExportFragment($HtmPath, 10); $lastStamp = $stamp }",
|
|
1577
|
+
" }",
|
|
1578
|
+
" } catch { }",
|
|
1550
1579
|
"}",
|
|
1551
1580
|
"exit 1",
|
|
1552
1581
|
].join("\r\n"), "utf-8");
|
|
1553
|
-
autosaveChild = spawn("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", psPath, filePath],
|
|
1582
|
+
autosaveChild = spawn("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", psPath, filePath, wordHtmPath],
|
|
1554
1583
|
{ detached: false, stdio: "ignore", windowsHide: true });
|
|
1555
1584
|
autosaveChild.on("error", () => { autosaveChild = null; });
|
|
1556
1585
|
autosaveChild.on("exit", (code) => {
|
|
@@ -1592,13 +1621,20 @@ export class MailxService implements MailxApi {
|
|
|
1592
1621
|
// wordEditUpdated event. Mammoth is dynamically imported on first
|
|
1593
1622
|
// save so the cold-start cost is paid only when the user actually
|
|
1594
1623
|
// edits in Word — most sessions never hit it.
|
|
1595
|
-
|
|
1624
|
+
// One debounce timer PER FILE. The sidecar writes the -word.htm and
|
|
1625
|
+
// then the .docx within a few hundred ms; a single shared timer let
|
|
1626
|
+
// the .docx event cancel the .htm one and the mirror was lost.
|
|
1627
|
+
// (2026-09-13 Claude Code, Fable 5.1)
|
|
1628
|
+
const debounces = new Map<string, ReturnType<typeof setTimeout>>();
|
|
1596
1629
|
let lastSig = "";
|
|
1597
1630
|
// Word saves the .docx in place on Ctrl+S, but Save-As-Web-Page
|
|
1598
1631
|
// writes a SIBLING .htm/.html (same base name) — catch those too so
|
|
1599
1632
|
// a web-page save doesn't vanish into an unwatched file (Bob
|
|
1600
1633
|
// 2026-07-18, .html-fallback variant of the same report).
|
|
1601
|
-
|
|
1634
|
+
// `-word.htm` is the sidecar's Range.ExportFragment mirror (see the
|
|
1635
|
+
// sidecar script above and word-html.ts).
|
|
1636
|
+
const wordHtmName = `${fileBase}-word.htm`;
|
|
1637
|
+
const watchNames = new Set([`${fileBase}.docx`, `${fileBase}.htm`, `${fileBase}.html`, wordHtmName]);
|
|
1602
1638
|
// Word's owner-lock file: "~$" + the filename with its first TWO
|
|
1603
1639
|
// characters dropped. Word creates it while the document is open and
|
|
1604
1640
|
// DELETES it on close — a filesystem fact the watcher already sees.
|
|
@@ -1630,17 +1666,33 @@ export class MailxService implements MailxApi {
|
|
|
1630
1666
|
if (!name || !watchNames.has(name)) return;
|
|
1631
1667
|
const savedPath = path.join(dir, name);
|
|
1632
1668
|
const isDocx = name.endsWith(".docx");
|
|
1633
|
-
|
|
1634
|
-
|
|
1669
|
+
const isWordHtm = name === wordHtmName;
|
|
1670
|
+
const pending = debounces.get(name);
|
|
1671
|
+
if (pending) clearTimeout(pending);
|
|
1672
|
+
debounces.set(name, setTimeout(async () => {
|
|
1673
|
+
debounces.delete(name);
|
|
1635
1674
|
let stat: fs.Stats;
|
|
1636
1675
|
try { stat = fs.statSync(savedPath); } catch { return; }
|
|
1637
1676
|
const sig = `${savedPath}:${stat.size}:${stat.mtimeMs}`;
|
|
1638
1677
|
if (sig === lastSig) return;
|
|
1639
1678
|
lastSig = sig;
|
|
1679
|
+
// Once the sidecar has produced a Word HTML mirror, that file
|
|
1680
|
+
// is the source and every .docx save is ignored: the sidecar
|
|
1681
|
+
// exports before each autosave and mirrors a manual Ctrl+S on
|
|
1682
|
+
// its next tick, so the .docx never carries content the .htm
|
|
1683
|
+
// will not. mammoth stays only for sessions without the
|
|
1684
|
+
// sidecar (no Word, LibreOffice, COM attach failed).
|
|
1685
|
+
if (isDocx && fs.existsSync(path.join(dir, wordHtmName))) {
|
|
1686
|
+
console.log(` [word-edit] file changed: ${name} — Word HTML mirror is active, waiting for ${wordHtmName}`);
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1640
1689
|
console.log(` [word-edit] file changed: ${name} (${stat.size} bytes) — converting`);
|
|
1641
1690
|
try {
|
|
1642
1691
|
let inner: string;
|
|
1643
|
-
if (
|
|
1692
|
+
if (isWordHtm) {
|
|
1693
|
+
const { wordHtmlToFragment } = await import("./word-html.js");
|
|
1694
|
+
inner = wordHtmlToFragment(savedPath);
|
|
1695
|
+
} else if (isDocx) {
|
|
1644
1696
|
const buf = fs.readFileSync(savedPath);
|
|
1645
1697
|
const mammoth = (await import("mammoth")).default;
|
|
1646
1698
|
const result = await mammoth.convertToHtml({ buffer: buf });
|
|
@@ -1675,11 +1727,12 @@ export class MailxService implements MailxApi {
|
|
|
1675
1727
|
} catch (e: any) {
|
|
1676
1728
|
console.error(` [word-edit] read after save failed: ${e.message}`);
|
|
1677
1729
|
}
|
|
1678
|
-
}, 300);
|
|
1730
|
+
}, 300));
|
|
1679
1731
|
});
|
|
1680
1732
|
const stop = () => {
|
|
1681
1733
|
try { watcher.close(); } catch { /* */ }
|
|
1682
|
-
|
|
1734
|
+
for (const t of debounces.values()) clearTimeout(t);
|
|
1735
|
+
debounces.clear();
|
|
1683
1736
|
try { autosaveChild?.kill(); } catch { /* */ }
|
|
1684
1737
|
};
|
|
1685
1738
|
this.wordEdits.set(editId, { path: filePath, stop, opener, html });
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Word's own HTML export → a body fragment compose can take.
|
|
3
|
+
*
|
|
4
|
+
* 2026-09-13 20:10 EDT — Claude Code (Fable 5.1), at Bob's direction ("go
|
|
5
|
+
* ahead and build the export fragment route").
|
|
6
|
+
*
|
|
7
|
+
* The Edit-in-Word autosave sidecar calls Word's `Range.ExportFragment(path,
|
|
8
|
+
* wdFormatFilteredHTML)` over COM on every change, which writes Word's
|
|
9
|
+
* filtered-HTML rendering of the document WITHOUT changing the document's
|
|
10
|
+
* own file or format. That HTML keeps colours, fonts, alignment, spacing and
|
|
11
|
+
* paragraph borders as inline styles — the things mammoth's docx→HTML throws
|
|
12
|
+
* away (measured on a real reply 2026-09-13: mammoth returned only <p>, <a>
|
|
13
|
+
* and <br>). This module turns that export into a fragment: body content
|
|
14
|
+
* only, Word's class and comment cruft removed, sidecar image files inlined.
|
|
15
|
+
*/
|
|
16
|
+
/** Read a Word filtered-HTML export and return body-content HTML for compose. */
|
|
17
|
+
export declare function wordHtmlToFragment(htmPath: string): string;
|
|
18
|
+
//# sourceMappingURL=word-html.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"word-html.d.ts","sourceRoot":"","sources":["word-html.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AA8CH,iFAAiF;AACjF,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAgB1D"}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Word's own HTML export → a body fragment compose can take.
|
|
3
|
+
*
|
|
4
|
+
* 2026-09-13 20:10 EDT — Claude Code (Fable 5.1), at Bob's direction ("go
|
|
5
|
+
* ahead and build the export fragment route").
|
|
6
|
+
*
|
|
7
|
+
* The Edit-in-Word autosave sidecar calls Word's `Range.ExportFragment(path,
|
|
8
|
+
* wdFormatFilteredHTML)` over COM on every change, which writes Word's
|
|
9
|
+
* filtered-HTML rendering of the document WITHOUT changing the document's
|
|
10
|
+
* own file or format. That HTML keeps colours, fonts, alignment, spacing and
|
|
11
|
+
* paragraph borders as inline styles — the things mammoth's docx→HTML throws
|
|
12
|
+
* away (measured on a real reply 2026-09-13: mammoth returned only <p>, <a>
|
|
13
|
+
* and <br>). This module turns that export into a fragment: body content
|
|
14
|
+
* only, Word's class and comment cruft removed, sidecar image files inlined.
|
|
15
|
+
*/
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
/** Decode Word's export honouring its own charset meta (utf-8 in every
|
|
19
|
+
* export seen so far, but Word has written windows-1252 in the past). */
|
|
20
|
+
function decodeWordHtml(bytes) {
|
|
21
|
+
const probe = bytes.subarray(0, 2048).toString("latin1");
|
|
22
|
+
const m = /charset=["']?([\w-]+)/i.exec(probe);
|
|
23
|
+
const charset = (m?.[1] || "utf-8").toLowerCase();
|
|
24
|
+
try {
|
|
25
|
+
return new TextDecoder(charset).decode(bytes);
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
// An unknown label falls back to utf-8 rather than failing the
|
|
29
|
+
// mirror; the caller logs the fragment length either way.
|
|
30
|
+
console.warn(` [word-edit] unknown charset "${charset}" in Word export — decoding as utf-8 (${e?.message || e})`);
|
|
31
|
+
return bytes.toString("utf-8");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const IMAGE_MIME = {
|
|
35
|
+
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif",
|
|
36
|
+
".bmp": "image/bmp", ".webp": "image/webp", ".svg": "image/svg+xml", ".emf": "image/emf", ".wmf": "image/wmf",
|
|
37
|
+
};
|
|
38
|
+
/** Turn `<img src="x_files/image001.png">` into a data: URI. Word writes
|
|
39
|
+
* images next to the export in a `<name>_files` folder; compose already
|
|
40
|
+
* converts data: images to cid parts on send. A missing file is left as
|
|
41
|
+
* is and logged — the text still mirrors. */
|
|
42
|
+
function inlineImages(html, baseDir) {
|
|
43
|
+
return html.replace(/<img\b[^>]*\bsrc\s*=\s*["']?([^"'\s>]+)["']?[^>]*>/gi, (tag, src) => {
|
|
44
|
+
if (/^(data|https?|cid):/i.test(src))
|
|
45
|
+
return tag;
|
|
46
|
+
const file = path.resolve(baseDir, decodeURIComponent(src));
|
|
47
|
+
const mime = IMAGE_MIME[path.extname(file).toLowerCase()];
|
|
48
|
+
if (!mime)
|
|
49
|
+
return tag;
|
|
50
|
+
try {
|
|
51
|
+
const data = fs.readFileSync(file).toString("base64");
|
|
52
|
+
return tag.replace(src, `data:${mime};base64,${data}`);
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
console.warn(` [word-edit] image ${src} from the Word export could not be read: ${e?.message || e}`);
|
|
56
|
+
return tag;
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/** Read a Word filtered-HTML export and return body-content HTML for compose. */
|
|
61
|
+
export function wordHtmlToFragment(htmPath) {
|
|
62
|
+
const html = decodeWordHtml(fs.readFileSync(htmPath));
|
|
63
|
+
const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
|
|
64
|
+
let inner = bodyMatch ? bodyMatch[1] : html;
|
|
65
|
+
inner = inner
|
|
66
|
+
// Conditional comments around fake-list bullets, and any other comment.
|
|
67
|
+
.replace(/<!--[\s\S]*?-->/g, "")
|
|
68
|
+
// Word's paragraph/run classes; their styles live in the export's
|
|
69
|
+
// <style> block, which does not travel with the fragment.
|
|
70
|
+
.replace(/\s+class\s*=\s*["']?Mso[\w-]*["']?/gi, "")
|
|
71
|
+
// Office namespace tags (<o:p></o:p> paragraph marks) and their content-free shells.
|
|
72
|
+
.replace(/<\/?o:[^>]*>/gi, "")
|
|
73
|
+
// Word wraps the whole export in a section div; compose has no use for it.
|
|
74
|
+
.replace(/^\s*<div\s+class\s*=\s*["']?WordSection\d*["']?[^>]*>([\s\S]*)<\/div>\s*$/i, "$1");
|
|
75
|
+
inner = inlineImages(inner, path.dirname(htmPath));
|
|
76
|
+
return inner.trim();
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=word-html.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"word-html.js","sourceRoot":"","sources":["word-html.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B;0EAC0E;AAC1E,SAAS,cAAc,CAAC,KAAa;IACjC,MAAM,KAAK,GAAG,KAAK,CAAC,QAAQ,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;IACzD,MAAM,CAAC,GAAG,wBAAwB,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IAC/C,MAAM,OAAO,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,OAAO,CAAC,CAAC,WAAW,EAAE,CAAC;IAClD,IAAI,CAAC;QACD,OAAO,IAAI,WAAW,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAClD,CAAC;IAAC,OAAO,CAAM,EAAE,CAAC;QACd,+DAA+D;QAC/D,0DAA0D;QAC1D,OAAO,CAAC,IAAI,CAAC,kCAAkC,OAAO,yCAAyC,CAAC,EAAE,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC;QACnH,OAAO,KAAK,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;IACnC,CAAC;AACL,CAAC;AAED,MAAM,UAAU,GAA2B;IACvC,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,YAAY,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,WAAW;IACrF,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,eAAe,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,WAAW;CAChH,CAAC;AAEF;;;8CAG8C;AAC9C,SAAS,YAAY,CAAC,IAAY,EAAE,OAAe;IAC/C,OAAO,IAAI,CAAC,OAAO,CAAC,sDAAsD,EAAE,CAAC,GAAG,EAAE,GAAW,EAAE,EAAE;QAC7F,IAAI,sBAAsB,CAAC,IAAI,CAAC,GAAG,CAAC;YAAE,OAAO,GAAG,CAAC;QACjD,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,kBAAkB,CAAC,GAAG,CAAC,CAAC,CAAC;QAC5D,MAAM,IAAI,GAAG,UAAU,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC;QAC1D,IAAI,CAAC,IAAI;YAAE,OAAO,GAAG,CAAC;QACtB,IAAI,CAAC;YACD,MAAM,IAAI,GAAG,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;YACtD,OAAO,GAAG,CAAC,OAAO,CAAC,GAAG,EAAE,QAAQ,IAAI,WAAW,IAAI,EAAE,CAAC,CAAC;QAC3D,CAAC;QAAC,OAAO,CAAM,EAAE,CAAC;YACd,OAAO,CAAC,IAAI,CAAC,uBAAuB,GAAG,4CAA4C,CAAC,EAAE,OAAO,IAAI,CAAC,EAAE,CAAC,CAAC;YACtG,OAAO,GAAG,CAAC;QACf,CAAC;IACL,CAAC,CAAC,CAAC;AACP,CAAC;AAED,iFAAiF;AACjF,MAAM,UAAU,kBAAkB,CAAC,OAAe;IAC9C,MAAM,IAAI,GAAG,cAAc,CAAC,EAAE,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC,CAAC;IACtD,MAAM,SAAS,GAAG,IAAI,CAAC,KAAK,CAAC,gCAAgC,CAAC,CAAC;IAC/D,IAAI,KAAK,GAAG,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IAC5C,KAAK,GAAG,KAAK;QACT,wEAAwE;SACvE,OAAO,CAAC,kBAAkB,EAAE,EAAE,CAAC;QAChC,kEAAkE;QAClE,0DAA0D;SACzD,OAAO,CAAC,sCAAsC,EAAE,EAAE,CAAC;QACpD,qFAAqF;SACpF,OAAO,CAAC,gBAAgB,EAAE,EAAE,CAAC;QAC9B,2EAA2E;SAC1E,OAAO,CAAC,4EAA4E,EAAE,IAAI,CAAC,CAAC;IACjG,KAAK,GAAG,YAAY,CAAC,KAAK,EAAE,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC;IACnD,OAAO,KAAK,CAAC,IAAI,EAAE,CAAC;AACxB,CAAC"}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Word's own HTML export → a body fragment compose can take.
|
|
3
|
+
*
|
|
4
|
+
* 2026-09-13 20:10 EDT — Claude Code (Fable 5.1), at Bob's direction ("go
|
|
5
|
+
* ahead and build the export fragment route").
|
|
6
|
+
*
|
|
7
|
+
* The Edit-in-Word autosave sidecar calls Word's `Range.ExportFragment(path,
|
|
8
|
+
* wdFormatFilteredHTML)` over COM on every change, which writes Word's
|
|
9
|
+
* filtered-HTML rendering of the document WITHOUT changing the document's
|
|
10
|
+
* own file or format. That HTML keeps colours, fonts, alignment, spacing and
|
|
11
|
+
* paragraph borders as inline styles — the things mammoth's docx→HTML throws
|
|
12
|
+
* away (measured on a real reply 2026-09-13: mammoth returned only <p>, <a>
|
|
13
|
+
* and <br>). This module turns that export into a fragment: body content
|
|
14
|
+
* only, Word's class and comment cruft removed, sidecar image files inlined.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
|
|
20
|
+
/** Decode Word's export honouring its own charset meta (utf-8 in every
|
|
21
|
+
* export seen so far, but Word has written windows-1252 in the past). */
|
|
22
|
+
function decodeWordHtml(bytes: Buffer): string {
|
|
23
|
+
const probe = bytes.subarray(0, 2048).toString("latin1");
|
|
24
|
+
const m = /charset=["']?([\w-]+)/i.exec(probe);
|
|
25
|
+
const charset = (m?.[1] || "utf-8").toLowerCase();
|
|
26
|
+
try {
|
|
27
|
+
return new TextDecoder(charset).decode(bytes);
|
|
28
|
+
} catch (e: any) {
|
|
29
|
+
// An unknown label falls back to utf-8 rather than failing the
|
|
30
|
+
// mirror; the caller logs the fragment length either way.
|
|
31
|
+
console.warn(` [word-edit] unknown charset "${charset}" in Word export — decoding as utf-8 (${e?.message || e})`);
|
|
32
|
+
return bytes.toString("utf-8");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const IMAGE_MIME: Record<string, string> = {
|
|
37
|
+
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif",
|
|
38
|
+
".bmp": "image/bmp", ".webp": "image/webp", ".svg": "image/svg+xml", ".emf": "image/emf", ".wmf": "image/wmf",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Turn `<img src="x_files/image001.png">` into a data: URI. Word writes
|
|
42
|
+
* images next to the export in a `<name>_files` folder; compose already
|
|
43
|
+
* converts data: images to cid parts on send. A missing file is left as
|
|
44
|
+
* is and logged — the text still mirrors. */
|
|
45
|
+
function inlineImages(html: string, baseDir: string): string {
|
|
46
|
+
return html.replace(/<img\b[^>]*\bsrc\s*=\s*["']?([^"'\s>]+)["']?[^>]*>/gi, (tag, src: string) => {
|
|
47
|
+
if (/^(data|https?|cid):/i.test(src)) return tag;
|
|
48
|
+
const file = path.resolve(baseDir, decodeURIComponent(src));
|
|
49
|
+
const mime = IMAGE_MIME[path.extname(file).toLowerCase()];
|
|
50
|
+
if (!mime) return tag;
|
|
51
|
+
try {
|
|
52
|
+
const data = fs.readFileSync(file).toString("base64");
|
|
53
|
+
return tag.replace(src, `data:${mime};base64,${data}`);
|
|
54
|
+
} catch (e: any) {
|
|
55
|
+
console.warn(` [word-edit] image ${src} from the Word export could not be read: ${e?.message || e}`);
|
|
56
|
+
return tag;
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Read a Word filtered-HTML export and return body-content HTML for compose. */
|
|
62
|
+
export function wordHtmlToFragment(htmPath: string): string {
|
|
63
|
+
const html = decodeWordHtml(fs.readFileSync(htmPath));
|
|
64
|
+
const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
|
|
65
|
+
let inner = bodyMatch ? bodyMatch[1] : html;
|
|
66
|
+
inner = inner
|
|
67
|
+
// Conditional comments around fake-list bullets, and any other comment.
|
|
68
|
+
.replace(/<!--[\s\S]*?-->/g, "")
|
|
69
|
+
// Word's paragraph/run classes; their styles live in the export's
|
|
70
|
+
// <style> block, which does not travel with the fragment.
|
|
71
|
+
.replace(/\s+class\s*=\s*["']?Mso[\w-]*["']?/gi, "")
|
|
72
|
+
// Office namespace tags (<o:p></o:p> paragraph marks) and their content-free shells.
|
|
73
|
+
.replace(/<\/?o:[^>]*>/gi, "")
|
|
74
|
+
// Word wraps the whole export in a section div; compose has no use for it.
|
|
75
|
+
.replace(/^\s*<div\s+class\s*=\s*["']?WordSection\d*["']?[^>]*>([\s\S]*)<\/div>\s*$/i, "$1");
|
|
76
|
+
inner = inlineImages(inner, path.dirname(htmPath));
|
|
77
|
+
return inner.trim();
|
|
78
|
+
}
|