@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.
@@ -1234,6 +1234,51 @@ export class MailxService implements MailxApi {
1234
1234
  console.log(` [word-edit] opened ${filePath} via ${opener}`);
1235
1235
  }
1236
1236
 
1237
+ // ── Word COM autosave sidecar (win32 + Word only) ──
1238
+ // "Save on close should be one step" (Bob 2026-07-30) — better: make
1239
+ // save CONTINUOUS so closing Word is the only step and the save
1240
+ // prompt never appears. A hidden PowerShell loop attaches to the
1241
+ // running Word via COM every ~2 s and calls doc.Save() whenever the
1242
+ // document is dirty. Each save trips the fs.watch above → mammoth →
1243
+ // compose mirrors the edit live. When the document (or Word) closes,
1244
+ // the sidecar exits on its own; stop() kills it if compose closes
1245
+ // first. PowerShell is used as the COM bridge deliberately: Node has
1246
+ // no COM without a native build dep, and allowScripts skips install
1247
+ // scripts so a compiled addon would arrive broken from the registry.
1248
+ let autosaveChild: ReturnType<typeof spawn> | null = null;
1249
+ if (process.platform === "win32" && opener === "word") {
1250
+ try {
1251
+ const psPath = path.join(dir, `${fileBase}-autosave.ps1`);
1252
+ fs.writeFileSync(psPath, [
1253
+ "param([string]$DocPath)",
1254
+ "$deadline = (Get-Date).AddHours(6)",
1255
+ "$attached = $false",
1256
+ "while ((Get-Date) -lt $deadline) {",
1257
+ " Start-Sleep -Seconds 2",
1258
+ " $word = $null",
1259
+ " try { $word = [Runtime.InteropServices.Marshal]::GetActiveObject('Word.Application') }",
1260
+ " catch { if ($attached) { exit 0 } else { continue } }",
1261
+ " $doc = $null",
1262
+ " try { foreach ($d in $word.Documents) { if ($d.FullName -ieq $DocPath) { $doc = $d; break } } } catch { continue }",
1263
+ " if ($null -eq $doc) { if ($attached) { exit 0 } else { continue } }",
1264
+ " $attached = $true",
1265
+ " try { if (-not $doc.Saved) { $doc.Save() } } catch { }",
1266
+ "}",
1267
+ "exit 1",
1268
+ ].join("\r\n"), "utf-8");
1269
+ autosaveChild = spawn("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", psPath, filePath],
1270
+ { detached: false, stdio: "ignore", windowsHide: true });
1271
+ autosaveChild.on("error", () => { autosaveChild = null; });
1272
+ autosaveChild.on("exit", (code) => {
1273
+ console.log(` [word-edit] autosave sidecar exited (${code === 0 ? "document closed" : `code ${code}`})`);
1274
+ try { fs.unlinkSync(psPath); } catch { /* */ }
1275
+ });
1276
+ console.log(` [word-edit] autosave sidecar armed — closing Word is the only step`);
1277
+ } catch (e: any) {
1278
+ console.warn(` [word-edit] autosave sidecar failed to start (${e?.message || e}) — Ctrl+S still works`);
1279
+ }
1280
+ }
1281
+
1237
1282
  // Watch for save events. fs.watch on Windows fires multiple events
1238
1283
  // per save (rename + change for atomic replacement); debounce so the
1239
1284
  // UI only reloads once per save. Watch the directory rather than the
@@ -1291,6 +1336,7 @@ export class MailxService implements MailxApi {
1291
1336
  const stop = () => {
1292
1337
  try { watcher.close(); } catch { /* */ }
1293
1338
  if (debounce) clearTimeout(debounce);
1339
+ try { autosaveChild?.kill(); } catch { /* */ }
1294
1340
  };
1295
1341
  this.wordEdits.set(editId, { path: filePath, stop });
1296
1342
 
@@ -1742,16 +1788,82 @@ export class MailxService implements MailxApi {
1742
1788
  // counter. Without this, typing fast left N stale 90-folder IMAP
1743
1789
  // sweeps all churning to completion in the background.
1744
1790
  const myGen = ++this.serverSearchGen;
1745
- // Parse qualifiers once; SEARCH runs per folder.
1746
- const criteria: any = {};
1747
- const fromMatch = q.match(/from:(\S+)/i);
1748
- const toMatch = q.match(/to:(\S+)/i);
1749
- const subjectMatch = q.match(/subject:(.+?)(?:\s+\w+:|$)/i);
1750
- const bodyText = q.replace(/(?:from|to|subject):\S+/gi, "").trim();
1751
- if (fromMatch) criteria.from = fromMatch[1];
1752
- if (toMatch) criteria.to = toMatch[1];
1753
- if (subjectMatch) criteria.subject = subjectMatch[1].trim();
1754
- if (bodyText) criteria.body = bodyText;
1791
+ // Parse qualifiers once; SEARCH runs per folder. Same query
1792
+ // syntax as local search (Bob 2026-07-30 "IMAP search should be
1793
+ // the same as desktop"): quoted phrases, from:/to:/cc:/subject:,
1794
+ // is:unread etc., after:/before:/date:, uppercase NOT (works
1795
+ // anywhere, including first) and OR — all mapped onto IMAP's
1796
+ // native SEARCH keys (NOT/OR/UNSEEN/SINCE/… are core RFC 3501).
1797
+ // has:attachment has no IMAP key and is ignored server-side.
1798
+ const criteria: any = { not: [], or: [] };
1799
+ const bodyTerms: string[] = [];
1800
+ const unq = (s: string): string => s.replace(/^"|"$/g, "");
1801
+ const parseRelDate = (s: string): Date | null => {
1802
+ const lower = s.toLowerCase().trim();
1803
+ if (lower === "today") { const d = new Date(); d.setHours(0, 0, 0, 0); return d; }
1804
+ if (lower === "yesterday") { const d = new Date(); d.setHours(0, 0, 0, 0); return new Date(d.getTime() - 86400_000); }
1805
+ const rel = lower.match(/^(\d+)([dwmy])$/);
1806
+ if (rel) {
1807
+ const n = parseInt(rel[1]);
1808
+ const ms = rel[2] === "d" ? n * 86400_000 : rel[2] === "w" ? n * 7 * 86400_000
1809
+ : rel[2] === "m" ? n * 30 * 86400_000 : n * 365 * 86400_000;
1810
+ return new Date(Date.now() - ms);
1811
+ }
1812
+ const ts = Date.parse(s);
1813
+ return isNaN(ts) ? null : new Date(ts);
1814
+ };
1815
+ // One token → one criteria fragment (or null for date/unmappable,
1816
+ // which merge into the top level / get dropped).
1817
+ const tokenToFrag = (tok: string): any | null => {
1818
+ const m = tok.match(/^(from|to|cc|subject):(.+)$/i);
1819
+ if (m) return { [m[1].toLowerCase()]: unq(m[2]) };
1820
+ const is = tok.match(/^is:(.+)$/i);
1821
+ if (is) {
1822
+ const v = is[1].toLowerCase();
1823
+ if (v === "unread") return { unseen: true };
1824
+ if (v === "read" || v === "seen") return { seen: true };
1825
+ if (v === "flagged" || v === "starred") return { flagged: true };
1826
+ if (v === "answered") return { answered: true };
1827
+ if (v === "draft") return { draft: true };
1828
+ return null;
1829
+ }
1830
+ if (/^(has|folder|date|after|before):/i.test(tok)) return null;
1831
+ const t = unq(tok);
1832
+ return t ? { body: t } : null;
1833
+ };
1834
+ const tokens = q.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
1835
+ let negate = false;
1836
+ for (let ti = 0; ti < tokens.length; ti++) {
1837
+ const tok = tokens[ti];
1838
+ if (tok === "NOT") { negate = true; continue; }
1839
+ if (tok === "AND" || tok === "OR") { negate = false; continue; }
1840
+ // Dates apply at the top level (negated dates are rare and
1841
+ // ambiguous — treated as positive).
1842
+ const dm = tok.match(/^date:([><]?=?)(.+)$/i) || tok.match(/^(after|before):(.+)$/i);
1843
+ if (dm) {
1844
+ const isAfter = /^after:/i.test(tok) || dm[1] === ">" || dm[1] === ">=";
1845
+ const isBefore = /^before:/i.test(tok) || dm[1] === "<" || dm[1] === "<=";
1846
+ const d = parseRelDate(unq(dm[2]));
1847
+ if (d) {
1848
+ if (isAfter) criteria.since = d;
1849
+ else if (isBefore) criteria.before = d;
1850
+ else { criteria.since = d; criteria.before = new Date(d.getTime() + 86400_000); }
1851
+ }
1852
+ negate = false;
1853
+ continue;
1854
+ }
1855
+ const frag = tokenToFrag(tok);
1856
+ if (!frag) { negate = false; continue; }
1857
+ const orAdjacent = tokens[ti - 1] === "OR" || tokens[ti + 1] === "OR";
1858
+ if (negate) criteria.not.push(frag);
1859
+ else if (orAdjacent) criteria.or.push(frag);
1860
+ else if (frag.body) bodyTerms.push(frag.body);
1861
+ else Object.assign(criteria, frag);
1862
+ negate = false;
1863
+ }
1864
+ if (bodyTerms.length) criteria.body = bodyTerms.length === 1 ? bodyTerms[0] : bodyTerms;
1865
+ if (criteria.not.length === 0) delete criteria.not;
1866
+ if (criteria.or.length === 0) delete criteria.or;
1755
1867
 
1756
1868
  // Server search spans every selectable folder on every enabled
1757
1869
  // account — otherwise a message that got moved / was in Sent /
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-service",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store-web",
3
- "version": "0.1.62",
3
+ "version": "0.1.63",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",