@bobfrankston/rmfmail 1.2.325 → 1.2.327

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 CHANGED
@@ -1,19 +1,9 @@
1
- accounts.jsonc: crash-safe local cache + live reconcile instead of "restart to apply"
1
+ Edit in Word: only the user's own part goes to Word; signature + quoted message are re-attached verbatim
2
2
 
3
- A Kernel-Power 41 crash (2026-09-13 16:36) left ~/.rmfmail/accounts.jsonc
4
- zero-filled: cloudRead rewrote the local cache with a plain writeFileSync on
5
- EVERY cloud read (every 3 min via pollCloud), so the file was perpetually in a
6
- just-written window and NTFS committed the size but not the data. jsonc-parser
7
- returned undefined for the NULs without a single log line, the daemon booted
8
- with 0 accounts, and the cloud copy sat unused behind the "restart to apply"
9
- banner.
10
-
11
- - mailx-settings 0.1.92: atomicWriteText (tmp + fsync + rename) exported and
12
- used by every local-cache writer; writeTextIfChanged skips identical
13
- content; readJsonc reports jsonc-parser errors instead of returning nothing.
14
- - mailx-imap 0.1.185: pollCloud writes through atomicWriteText.
15
- - bin/mailx.ts: reconcileAccountsFromDisk — new enabled accounts are added
16
- live (addAccount + inbox check + sync + IDLE) on any accounts.jsonc change,
17
- including the boot-time cloud refresh; configChanged carries restartNeeded.
18
- - client: the Restart banner shows only when restartNeeded; otherwise a
19
- status-bar line names what changed.
3
+ html-to-docx flattens <blockquote> to plain paragraphs and mammoth brings back
4
+ only <p>/<a>/<br>, so a reply came home with the quoted message's formatting
5
+ gone and the bordered signature reduced to two lines. splitWordEditable()
6
+ (client/compose/compose.ts) cuts the body at the signature rule
7
+ (hr.mailx-sig-rule, legacy hr[style*=3cm]) or div.reply; the head goes to
8
+ Word, the tail is taken from the editor at apply time and appended unchanged.
9
+ Signature rule now carries class="mailx-sig-rule".
@@ -22,5 +22,25 @@ Session: Claude Fable 5.1, started 2026-09-13 ~17:00 EDT.
22
22
  4. Bump mailx-settings, mailx-imap; rebuild.cmd.
23
23
 
24
24
  ## Status
25
- - [x] edits - [x] tsc + bundles - [ ] rebuild.cmd (running) - [ ] Bob restarts app
25
+ - [x] edits - [x] tsc + bundles - [x] rebuild.cmd → rmfmail 1.2.325, settings 0.1.94, imap 0.1.186, APK built (17:10) - [ ] Bob restarts app
26
26
  - DONE.md entry written as v1.2.325; .commitmsg written; versions: settings 0.1.92, imap 0.1.185
27
+
28
+ ## Follow-on (same session, ~18:45): typing at a link edge extended the link (TinyMCE)
29
+ - Cause: 2026-09-10 step-out ran only after paste; Backspace/click/arrow left the caret in TinyMCE's
30
+ inline-boundary "inside, at end" position (blue highlight) → every keystroke grew the link.
31
+ - Fix: `keepTypingOutsideLinks` in client/compose/editor.ts — check before every printable keydown and
32
+ one tick after paste; moves the caret just after (or before) the anchor when only whitespace/U+FEFF
33
+ separates it from the edge. Quill's typed path NOT covered (not the configured editor).
34
+ - Ships as rmfmail 1.2.326 via rebuild.cmd (running). Bob must restart the daemon for the new bundle.
35
+
36
+ ## Follow-on (~19:40): Edit in Word lost the quoted message's formatting
37
+ - Cause: whole body → html-to-docx (flattens blockquote) → Word → mammoth (only p/a/br back).
38
+ - Fix (v1.2.327, publishing): `splitWordEditable()` in client/compose/compose.ts — Word gets only the
39
+ part above the signature rule (`hr.mailx-sig-rule`, legacy `hr[style*=3cm]`) or `div.reply`; the
40
+ tail is re-read from the editor at apply time and appended unchanged.
41
+ - OPEN (Bob's direction): replace mammoth on the return path with his `y:\dev\utils\whts` converter.
42
+ whts today = whole-document CLI (`converter(docPath)` writes files, uses globals/logger/drive
43
+ paths, emits CSS classes via styleToClass, pulls sql/itemgen/pdf deps). Needs a lean exported
44
+ `docxToHtml()` core with inline styles before rmfmail can import it. Not touched — separate project.
45
+ - Checked: bundled TinyMCE 8.8.2 has NO Word-HTML cleaner (that is PowerPaste, paid); only the
46
+ generic paste path and `mceInsertClipboardContent`.
@@ -5324,32 +5324,43 @@ async function createTinyMceEditor2(container2, opts = {}) {
5324
5324
  const body = native?.getBody?.();
5325
5325
  if (native && body) {
5326
5326
  wireMarkdownPaste(body, (html) => native.insertContent(html));
5327
- stepCaretOutOfPastedLink(native);
5327
+ keepTypingOutsideLinks(native);
5328
5328
  }
5329
5329
  } catch {
5330
5330
  }
5331
5331
  return ed;
5332
5332
  }
5333
- function stepCaretOutOfPastedLink(native) {
5334
- native.on("paste", () => {
5335
- setTimeout(() => {
5336
- try {
5337
- const rng = native.selection.getRng();
5338
- if (!rng || !rng.collapsed)
5339
- return;
5340
- const node = native.selection.getNode();
5341
- const a = node?.closest?.("a[href]");
5342
- if (!a)
5343
- return;
5344
- const rest = rng.cloneRange();
5345
- rest.setEnd(a, a.childNodes.length);
5346
- if (rest.toString().trim())
5347
- return;
5333
+ function keepTypingOutsideLinks(native) {
5334
+ const isBlank = (s) => !s.replace(/\uFEFF/g, "").trim();
5335
+ const stepOutOfLinkEdge = () => {
5336
+ try {
5337
+ const rng = native.selection.getRng();
5338
+ if (!rng || !rng.collapsed)
5339
+ return;
5340
+ const node = native.selection.getNode();
5341
+ const a = node?.closest?.("a[href]");
5342
+ if (!a)
5343
+ return;
5344
+ const toEnd = rng.cloneRange();
5345
+ toEnd.setEnd(a, a.childNodes.length);
5346
+ const fromStart = rng.cloneRange();
5347
+ fromStart.setStart(a, 0);
5348
+ if (isBlank(toEnd.toString())) {
5348
5349
  native.selection.setCursorLocation(a.parentNode, native.dom.nodeIndex(a) + 1);
5349
- } catch (e) {
5350
- console.warn("[editor] could not move the caret past the pasted link:", e?.message || e);
5350
+ } else if (isBlank(fromStart.toString())) {
5351
+ native.selection.setCursorLocation(a.parentNode, native.dom.nodeIndex(a));
5351
5352
  }
5352
- }, 0);
5353
+ } catch (e) {
5354
+ console.warn("[editor] could not move the caret off the link edge:", e?.message || e);
5355
+ }
5356
+ };
5357
+ native.on("paste", () => {
5358
+ setTimeout(stepOutOfLinkEdge, 0);
5359
+ });
5360
+ native.on("keydown", (e) => {
5361
+ if (e.ctrlKey || e.metaKey || e.altKey || e.key.length !== 1)
5362
+ return;
5363
+ stepOutOfLinkEdge();
5353
5364
  });
5354
5365
  }
5355
5366
 
@@ -6248,7 +6259,7 @@ function applyInit(init) {
6248
6259
  sigHtml = acct.signature;
6249
6260
  }
6250
6261
  if (sigHtml) {
6251
- const sigRule = '<hr style="width:3cm;margin:0.8em 0 0.5em 0;border:0;border-top:1px solid #999;">';
6262
+ const sigRule = '<hr class="mailx-sig-rule" style="width:3cm;margin:0.8em 0 0.5em 0;border:0;border-top:1px solid #999;">';
6252
6263
  const sigBlock = `<br>${sigRule}${sigHtml}`;
6253
6264
  bodyToRender = isReplyForward ? `<br>${sigBlock}<br>${bodyToRender}` : `${bodyToRender}${sigBlock}`;
6254
6265
  }
@@ -7039,6 +7050,18 @@ var extEditHintClose = null;
7039
7050
  }
7040
7051
  }
7041
7052
  var wordEditOpening = false;
7053
+ function splitWordEditable(html) {
7054
+ const doc = new DOMParser().parseFromString(html, "text/html");
7055
+ const nodes = Array.from(doc.body.childNodes);
7056
+ const cutAt = nodes.findIndex((n) => n instanceof HTMLElement && n.matches('hr.mailx-sig-rule, hr[style*="3cm"], div.reply'));
7057
+ if (cutAt < 0) return { head: html, tail: "" };
7058
+ const serialize = (part) => {
7059
+ const box = doc.createElement("div");
7060
+ for (const n of part) box.appendChild(n.cloneNode(true));
7061
+ return box.innerHTML;
7062
+ };
7063
+ return { head: serialize(nodes.slice(0, cutAt)), tail: serialize(nodes.slice(cutAt)) };
7064
+ }
7042
7065
  document.getElementById("btn-edit-in-word")?.addEventListener("click", async () => {
7043
7066
  const btn = document.getElementById("btn-edit-in-word");
7044
7067
  if (wordEditOpening) return;
@@ -7050,7 +7073,9 @@ document.getElementById("btn-edit-in-word")?.addEventListener("click", async ()
7050
7073
  if (!wordEditId) wordEditId = `compose-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
7051
7074
  showDraftStatus("Opening in Word\u2026 (this takes a few seconds)", false);
7052
7075
  try {
7053
- const result = await openInWord(wordEditId, editor.getHtml(), popoutInitId || "");
7076
+ const { head, tail } = splitWordEditable(editor.getHtml());
7077
+ logClientEvent("word-edit-split", { headChars: head.length, tailChars: tail.length, editId: wordEditId });
7078
+ const result = await openInWord(wordEditId, head, popoutInitId || "");
7054
7079
  if (!result.ok || result.opener === "none") {
7055
7080
  showDraftStatus("Couldn't launch an editor. Install Word, LibreOffice, or set a default for .html.", true);
7056
7081
  return;
@@ -7210,8 +7235,9 @@ onEvent((ev) => {
7210
7235
  }
7211
7236
  try {
7212
7237
  extEditHintClose?.();
7213
- logClientEvent("word-edit-applied", { chars: (ev.html || "").length, editId: ev.editId });
7214
- editor.setHtml(ev.html || "");
7238
+ const { tail } = splitWordEditable(editor.getHtml());
7239
+ logClientEvent("word-edit-applied", { chars: (ev.html || "").length, tailChars: tail.length, editId: ev.editId });
7240
+ editor.setHtml((ev.html || "") + tail);
7215
7241
  showDraftStatus("Reloaded edits from external editor.", false);
7216
7242
  scheduleDraftSave();
7217
7243
  } catch (e) {