@bobfrankston/rmfmail 1.2.197 → 1.2.199

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.
@@ -1231,6 +1231,53 @@ export class MailxService {
1231
1231
  else {
1232
1232
  console.log(` [word-edit] opened ${filePath} via ${opener}`);
1233
1233
  }
1234
+ // ── Word COM autosave sidecar (win32 + Word only) ──
1235
+ // "Save on close should be one step" (Bob 2026-07-30) — better: make
1236
+ // save CONTINUOUS so closing Word is the only step and the save
1237
+ // prompt never appears. A hidden PowerShell loop attaches to the
1238
+ // running Word via COM every ~2 s and calls doc.Save() whenever the
1239
+ // document is dirty. Each save trips the fs.watch above → mammoth →
1240
+ // compose mirrors the edit live. When the document (or Word) closes,
1241
+ // the sidecar exits on its own; stop() kills it if compose closes
1242
+ // first. PowerShell is used as the COM bridge deliberately: Node has
1243
+ // no COM without a native build dep, and allowScripts skips install
1244
+ // scripts so a compiled addon would arrive broken from the registry.
1245
+ let autosaveChild = null;
1246
+ if (process.platform === "win32" && opener === "word") {
1247
+ try {
1248
+ const psPath = path.join(dir, `${fileBase}-autosave.ps1`);
1249
+ fs.writeFileSync(psPath, [
1250
+ "param([string]$DocPath)",
1251
+ "$deadline = (Get-Date).AddHours(6)",
1252
+ "$attached = $false",
1253
+ "while ((Get-Date) -lt $deadline) {",
1254
+ " Start-Sleep -Seconds 2",
1255
+ " $word = $null",
1256
+ " try { $word = [Runtime.InteropServices.Marshal]::GetActiveObject('Word.Application') }",
1257
+ " catch { if ($attached) { exit 0 } else { continue } }",
1258
+ " $doc = $null",
1259
+ " try { foreach ($d in $word.Documents) { if ($d.FullName -ieq $DocPath) { $doc = $d; break } } } catch { continue }",
1260
+ " if ($null -eq $doc) { if ($attached) { exit 0 } else { continue } }",
1261
+ " $attached = $true",
1262
+ " try { if (-not $doc.Saved) { $doc.Save() } } catch { }",
1263
+ "}",
1264
+ "exit 1",
1265
+ ].join("\r\n"), "utf-8");
1266
+ autosaveChild = spawn("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", psPath, filePath], { detached: false, stdio: "ignore", windowsHide: true });
1267
+ autosaveChild.on("error", () => { autosaveChild = null; });
1268
+ autosaveChild.on("exit", (code) => {
1269
+ console.log(` [word-edit] autosave sidecar exited (${code === 0 ? "document closed" : `code ${code}`})`);
1270
+ try {
1271
+ fs.unlinkSync(psPath);
1272
+ }
1273
+ catch { /* */ }
1274
+ });
1275
+ console.log(` [word-edit] autosave sidecar armed — closing Word is the only step`);
1276
+ }
1277
+ catch (e) {
1278
+ console.warn(` [word-edit] autosave sidecar failed to start (${e?.message || e}) — Ctrl+S still works`);
1279
+ }
1280
+ }
1234
1281
  // Watch for save events. fs.watch on Windows fires multiple events
1235
1282
  // per save (rename + change for atomic replacement); debounce so the
1236
1283
  // UI only reloads once per save. Watch the directory rather than the
@@ -1305,6 +1352,10 @@ export class MailxService {
1305
1352
  catch { /* */ }
1306
1353
  if (debounce)
1307
1354
  clearTimeout(debounce);
1355
+ try {
1356
+ autosaveChild?.kill();
1357
+ }
1358
+ catch { /* */ }
1308
1359
  };
1309
1360
  this.wordEdits.set(editId, { path: filePath, stop });
1310
1361
  return { ok: opener !== "none", path: filePath, opener };
@@ -1767,20 +1818,118 @@ export class MailxService {
1767
1818
  // counter. Without this, typing fast left N stale 90-folder IMAP
1768
1819
  // sweeps all churning to completion in the background.
1769
1820
  const myGen = ++this.serverSearchGen;
1770
- // Parse qualifiers once; SEARCH runs per folder.
1771
- const criteria = {};
1772
- const fromMatch = q.match(/from:(\S+)/i);
1773
- const toMatch = q.match(/to:(\S+)/i);
1774
- const subjectMatch = q.match(/subject:(.+?)(?:\s+\w+:|$)/i);
1775
- const bodyText = q.replace(/(?:from|to|subject):\S+/gi, "").trim();
1776
- if (fromMatch)
1777
- criteria.from = fromMatch[1];
1778
- if (toMatch)
1779
- criteria.to = toMatch[1];
1780
- if (subjectMatch)
1781
- criteria.subject = subjectMatch[1].trim();
1782
- if (bodyText)
1783
- criteria.body = bodyText;
1821
+ // Parse qualifiers once; SEARCH runs per folder. Same query
1822
+ // syntax as local search (Bob 2026-07-30 "IMAP search should be
1823
+ // the same as desktop"): quoted phrases, from:/to:/cc:/subject:,
1824
+ // is:unread etc., after:/before:/date:, uppercase NOT (works
1825
+ // anywhere, including first) and OR — all mapped onto IMAP's
1826
+ // native SEARCH keys (NOT/OR/UNSEEN/SINCE/… are core RFC 3501).
1827
+ // has:attachment has no IMAP key and is ignored server-side.
1828
+ const criteria = { not: [], or: [] };
1829
+ const bodyTerms = [];
1830
+ const unq = (s) => s.replace(/^"|"$/g, "");
1831
+ const parseRelDate = (s) => {
1832
+ const lower = s.toLowerCase().trim();
1833
+ if (lower === "today") {
1834
+ const d = new Date();
1835
+ d.setHours(0, 0, 0, 0);
1836
+ return d;
1837
+ }
1838
+ if (lower === "yesterday") {
1839
+ const d = new Date();
1840
+ d.setHours(0, 0, 0, 0);
1841
+ return new Date(d.getTime() - 86400_000);
1842
+ }
1843
+ const rel = lower.match(/^(\d+)([dwmy])$/);
1844
+ if (rel) {
1845
+ const n = parseInt(rel[1]);
1846
+ const ms = rel[2] === "d" ? n * 86400_000 : rel[2] === "w" ? n * 7 * 86400_000
1847
+ : rel[2] === "m" ? n * 30 * 86400_000 : n * 365 * 86400_000;
1848
+ return new Date(Date.now() - ms);
1849
+ }
1850
+ const ts = Date.parse(s);
1851
+ return isNaN(ts) ? null : new Date(ts);
1852
+ };
1853
+ // One token → one criteria fragment (or null for date/unmappable,
1854
+ // which merge into the top level / get dropped).
1855
+ const tokenToFrag = (tok) => {
1856
+ const m = tok.match(/^(from|to|cc|subject):(.+)$/i);
1857
+ if (m)
1858
+ return { [m[1].toLowerCase()]: unq(m[2]) };
1859
+ const is = tok.match(/^is:(.+)$/i);
1860
+ if (is) {
1861
+ const v = is[1].toLowerCase();
1862
+ if (v === "unread")
1863
+ return { unseen: true };
1864
+ if (v === "read" || v === "seen")
1865
+ return { seen: true };
1866
+ if (v === "flagged" || v === "starred")
1867
+ return { flagged: true };
1868
+ if (v === "answered")
1869
+ return { answered: true };
1870
+ if (v === "draft")
1871
+ return { draft: true };
1872
+ return null;
1873
+ }
1874
+ if (/^(has|folder|date|after|before):/i.test(tok))
1875
+ return null;
1876
+ const t = unq(tok);
1877
+ return t ? { body: t } : null;
1878
+ };
1879
+ const tokens = q.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
1880
+ let negate = false;
1881
+ for (let ti = 0; ti < tokens.length; ti++) {
1882
+ const tok = tokens[ti];
1883
+ if (tok === "NOT") {
1884
+ negate = true;
1885
+ continue;
1886
+ }
1887
+ if (tok === "AND" || tok === "OR") {
1888
+ negate = false;
1889
+ continue;
1890
+ }
1891
+ // Dates apply at the top level (negated dates are rare and
1892
+ // ambiguous — treated as positive).
1893
+ const dm = tok.match(/^date:([><]?=?)(.+)$/i) || tok.match(/^(after|before):(.+)$/i);
1894
+ if (dm) {
1895
+ const isAfter = /^after:/i.test(tok) || dm[1] === ">" || dm[1] === ">=";
1896
+ const isBefore = /^before:/i.test(tok) || dm[1] === "<" || dm[1] === "<=";
1897
+ const d = parseRelDate(unq(dm[2]));
1898
+ if (d) {
1899
+ if (isAfter)
1900
+ criteria.since = d;
1901
+ else if (isBefore)
1902
+ criteria.before = d;
1903
+ else {
1904
+ criteria.since = d;
1905
+ criteria.before = new Date(d.getTime() + 86400_000);
1906
+ }
1907
+ }
1908
+ negate = false;
1909
+ continue;
1910
+ }
1911
+ const frag = tokenToFrag(tok);
1912
+ if (!frag) {
1913
+ negate = false;
1914
+ continue;
1915
+ }
1916
+ const orAdjacent = tokens[ti - 1] === "OR" || tokens[ti + 1] === "OR";
1917
+ if (negate)
1918
+ criteria.not.push(frag);
1919
+ else if (orAdjacent)
1920
+ criteria.or.push(frag);
1921
+ else if (frag.body)
1922
+ bodyTerms.push(frag.body);
1923
+ else
1924
+ Object.assign(criteria, frag);
1925
+ negate = false;
1926
+ }
1927
+ if (bodyTerms.length)
1928
+ criteria.body = bodyTerms.length === 1 ? bodyTerms[0] : bodyTerms;
1929
+ if (criteria.not.length === 0)
1930
+ delete criteria.not;
1931
+ if (criteria.or.length === 0)
1932
+ delete criteria.or;
1784
1933
  // Server search spans every selectable folder on every enabled
1785
1934
  // account — otherwise a message that got moved / was in Sent /
1786
1935
  // only exists in an archive folder silently fails to turn up.