@bobfrankston/rmfmail 1.1.45 → 1.1.49

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.
Files changed (43) hide show
  1. package/TODO.md +9 -0
  2. package/client/app.bundle.js +371 -80
  3. package/client/app.bundle.js.map +4 -4
  4. package/client/app.js +167 -32
  5. package/client/app.js.map +1 -1
  6. package/client/app.ts +153 -32
  7. package/client/components/calendar-sidebar.js +221 -88
  8. package/client/components/calendar-sidebar.js.map +1 -1
  9. package/client/components/calendar-sidebar.ts +224 -83
  10. package/client/compose/compose.bundle.js +14 -0
  11. package/client/compose/compose.bundle.js.map +2 -2
  12. package/client/compose/spellcheck.js +15 -0
  13. package/client/compose/spellcheck.js.map +1 -1
  14. package/client/compose/spellcheck.ts +14 -0
  15. package/client/help/search-help.js +75 -0
  16. package/client/help/search-help.js.map +1 -0
  17. package/client/help/search-help.ts +75 -0
  18. package/client/index.html +7 -7
  19. package/client/lib/api-client.js +5 -0
  20. package/client/lib/api-client.js.map +1 -1
  21. package/client/lib/api-client.ts +5 -0
  22. package/client/lib/mailxapi.js +1 -0
  23. package/client/styles/components.css +204 -6
  24. package/docs/search.md +5 -1
  25. package/package.json +1 -1
  26. package/packages/mailx-service/google-sync.d.ts +3 -0
  27. package/packages/mailx-service/google-sync.d.ts.map +1 -1
  28. package/packages/mailx-service/google-sync.js +1 -0
  29. package/packages/mailx-service/google-sync.js.map +1 -1
  30. package/packages/mailx-service/google-sync.ts +4 -0
  31. package/packages/mailx-service/index.d.ts +11 -0
  32. package/packages/mailx-service/index.d.ts.map +1 -1
  33. package/packages/mailx-service/index.js +99 -127
  34. package/packages/mailx-service/index.js.map +1 -1
  35. package/packages/mailx-service/index.ts +91 -122
  36. package/packages/mailx-service/jsonrpc.js +2 -0
  37. package/packages/mailx-service/jsonrpc.js.map +1 -1
  38. package/packages/mailx-service/jsonrpc.ts +2 -0
  39. package/packages/mailx-settings/index.d.ts.map +1 -1
  40. package/packages/mailx-settings/index.js +4 -1
  41. package/packages/mailx-settings/index.js.map +1 -1
  42. package/packages/mailx-settings/index.ts +4 -1
  43. /package/packages/mailx-imap/{node_modules.npmglobalize-stash-28848 → node_modules.npmglobalize-stash-43988}/.package-lock.json +0 -0
package/client/app.ts CHANGED
@@ -406,7 +406,7 @@ initFolderTree(folderTree, (accountId, folderId, folderName, specialUse) => {
406
406
  currentFolderId = folderId;
407
407
  // Drop search state on folder switch — input alone wasn't enough,
408
408
  // searchMode stayed true and the next loadMessages was rerouted.
409
- if (searchInput) searchInput.value = "";
409
+ if (searchInput) { searchInput.value = ""; updateSearchHighlight(); }
410
410
  clearSearchMode();
411
411
  markAsSeen();
412
412
  releaseFocus();
@@ -425,7 +425,7 @@ initFolderTree(folderTree, (accountId, folderId, folderName, specialUse) => {
425
425
  // stayed active, so "All Inboxes" appeared to be ignoring its own
426
426
  // contents (Bob 2026-05-09: "I just switched to all inboxes but the
427
427
  // search is still showing even though all the entries are there.").
428
- if (searchInput) searchInput.value = "";
428
+ if (searchInput) { searchInput.value = ""; updateSearchHighlight(); }
429
429
  clearSearchMode();
430
430
  releaseFocus();
431
431
  loadUnifiedInbox();
@@ -440,7 +440,7 @@ initFolderTree(folderTree, (accountId, folderId, folderName, specialUse) => {
440
440
  // setActiveTabView (that would be a no-op update, but keeping them separate
441
441
  // keeps "user navigated" distinct from "tab restored").
442
442
  function applyTabView(tab: ViewTab): void {
443
- if (searchInput) searchInput.value = "";
443
+ if (searchInput) { searchInput.value = ""; updateSearchHighlight(); }
444
444
  clearSearchMode();
445
445
  releaseFocus();
446
446
  const v = tab.view;
@@ -457,7 +457,7 @@ function applyTabView(tab: ViewTab): void {
457
457
  setTitle(`${APP_NAME} - ${tab.title}`);
458
458
  setNarrowFolderTitle(tab.title);
459
459
  } else {
460
- if (searchInput) searchInput.value = v.query;
460
+ if (searchInput) { searchInput.value = v.query; updateSearchHighlight(); }
461
461
  loadSearchResults(v.query, v.scope, v.accountId, v.folderId, v.includeTrash);
462
462
  setTitle(`${APP_NAME} - Search`);
463
463
  setNarrowFolderTitle(`Search: ${v.query}`);
@@ -1817,6 +1817,77 @@ function recordSearchHistory(query: string): void {
1817
1817
  }
1818
1818
  refreshSearchHistoryDatalist();
1819
1819
 
1820
+ // ── Live search-syntax highlighting ──
1821
+ // An overlay behind the search input colors recognized qualifiers
1822
+ // (from:/to:/subject:/date:/after:/before:/has:/is:/folder:), FTS boolean
1823
+ // operators (OR/NOT/AND), quoted phrases, and /regex/ literals — reparsed
1824
+ // on every keystroke so the user sees how mailx will interpret the query.
1825
+ // An invalid /regex/ only goes red after a brief settle delay, so it
1826
+ // doesn't flash red while the pattern is still being typed.
1827
+ const searchHl = document.getElementById("search-hl");
1828
+ const SEARCH_QUALIFIERS = new Set(["from", "to", "subject", "date", "after", "before", "has", "is", "folder"]);
1829
+ const SEARCH_REGEX_SETTLE_MS = 500;
1830
+ let searchRegexSettleTimer: ReturnType<typeof setTimeout> | null = null;
1831
+
1832
+ function escapeHl(s: string): string {
1833
+ return s.replace(/[&<>]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c] || c));
1834
+ }
1835
+
1836
+ function renderSearchHighlight(settled: boolean): void {
1837
+ if (!searchHl || !searchInput) return;
1838
+ const text = searchInput.value;
1839
+ // Tokenize keeping whitespace runs so the overlay lines up glyph-for-
1840
+ // glyph with the input's own text.
1841
+ const tokens = text.match(/\s+|"[^"]*"?|\S+/g) || [];
1842
+ let html = "";
1843
+ for (const tok of tokens) {
1844
+ if (/^\s+$/.test(tok)) { html += escapeHl(tok); continue; }
1845
+ // /regex/ literal — leading slash, body, optional closing slash.
1846
+ if (tok.startsWith("/") && tok.length > 1) {
1847
+ let bad = false;
1848
+ if (tok.endsWith("/")) {
1849
+ try { new RegExp(tok.slice(1, -1), "i"); } catch { bad = true; }
1850
+ }
1851
+ const cls = (bad && settled) ? "sh-regex-bad" : "sh-regex";
1852
+ html += `<span class="${cls}">${escapeHl(tok)}</span>`;
1853
+ continue;
1854
+ }
1855
+ // qualifier:value — color the keyword: prefix when recognized.
1856
+ const qm = tok.match(/^([a-z]+):(.*)$/i);
1857
+ if (qm && SEARCH_QUALIFIERS.has(qm[1].toLowerCase())) {
1858
+ html += `<span class="sh-key">${escapeHl(qm[1] + ":")}</span>${escapeHl(qm[2])}`;
1859
+ continue;
1860
+ }
1861
+ // FTS boolean operator.
1862
+ if (/^(OR|NOT|AND)$/.test(tok)) {
1863
+ html += `<span class="sh-op">${escapeHl(tok)}</span>`;
1864
+ continue;
1865
+ }
1866
+ // Quoted phrase.
1867
+ if (tok.startsWith("\"")) {
1868
+ html += `<span class="sh-phrase">${escapeHl(tok)}</span>`;
1869
+ continue;
1870
+ }
1871
+ html += escapeHl(tok);
1872
+ }
1873
+ searchHl.innerHTML = html;
1874
+ searchHl.scrollLeft = searchInput.scrollLeft;
1875
+ }
1876
+
1877
+ function updateSearchHighlight(): void {
1878
+ renderSearchHighlight(false);
1879
+ if (searchRegexSettleTimer) clearTimeout(searchRegexSettleTimer);
1880
+ searchRegexSettleTimer = setTimeout(() => renderSearchHighlight(true), SEARCH_REGEX_SETTLE_MS);
1881
+ }
1882
+
1883
+ // Keep the overlay scroll-aligned when the input scrolls horizontally,
1884
+ // and seed it once at startup.
1885
+ searchInput?.addEventListener("scroll", () => {
1886
+ if (searchHl) searchHl.scrollLeft = searchInput.scrollLeft;
1887
+ });
1888
+ searchInput?.addEventListener("change", () => updateSearchHighlight());
1889
+ updateSearchHighlight();
1890
+
1820
1891
  function doSearch(immediate = false): void {
1821
1892
  const query = searchInput.value.trim();
1822
1893
  if (query.length === 0) { reloadCurrentFolder(); return; }
@@ -1863,6 +1934,7 @@ let reloadDebounceTimer: ReturnType<typeof setTimeout> | null = null;
1863
1934
 
1864
1935
  searchInput?.addEventListener("input", () => {
1865
1936
  clearTimeout(searchTimeout);
1937
+ updateSearchHighlight();
1866
1938
  if (searchInput.value.trim() === "") {
1867
1939
  // Cleared — reset immediately, no debounce. Must exit search mode
1868
1940
  // first; otherwise reloadCurrentFolder() sees searchMode=true and
@@ -1883,6 +1955,7 @@ searchInput?.addEventListener("keydown", (e) => {
1883
1955
  }
1884
1956
  if (e.key === "Escape") {
1885
1957
  searchInput.value = "";
1958
+ updateSearchHighlight();
1886
1959
  clearSearchMode();
1887
1960
  // Clear any client-side filters
1888
1961
  const body = document.getElementById("ml-body");
@@ -3319,40 +3392,88 @@ optSnippet?.addEventListener("change", () => {
3319
3392
  });
3320
3393
 
3321
3394
  // ── Search help button (?) ──
3322
- // Opens a small modal showing docs/search.md so the user can see the
3323
- // query syntax for each mode (FTS5 vs IMAP SEARCH vs Gmail API). The
3324
- // markdown is fetched from the service via readConfigHelp("search"),
3325
- // rendered as plain text inside a <pre> — no full markdown engine, just
3326
- // preserves headings + tables visually. The doc itself is the source of
3327
- // truth (lives at app/docs/search.md, deployed to GDrive on every
3328
- // release like the other config help files).
3395
+ // Toggles a NON-modal panel with the search-syntax reference so it stays
3396
+ // visible while the user types into the search box build a query
3397
+ // against it, then dismiss. The panel is anchored directly below the
3398
+ // search bar (no dark backdrop, no centered modal).
3399
+ //
3400
+ // The help content is HTML compiled into the app (client/help/search-help.ts),
3401
+ // NOT a docs/*.md file. Feature help is application content; the docs/*.md
3402
+ // files are only a stand-in for proper settings UI and stay out of this path.
3403
+ // The module is dynamically imported so its markup isn't in the cold-start
3404
+ // bundle.
3329
3405
  document.getElementById("search-help")?.addEventListener("click", async () => {
3330
- const { readConfigHelp } = await import("./lib/api-client.js");
3331
- const r = await readConfigHelp("search").catch(() => ({ content: "" }));
3332
- const md = (r as any)?.content || "";
3333
- const backdrop = document.createElement("div");
3334
- backdrop.className = "mailx-modal-backdrop";
3335
- backdrop.style.cssText = "position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;";
3406
+ const btn = document.getElementById("search-help") as HTMLButtonElement | null;
3407
+ if (!btn) return;
3408
+
3409
+ // Toggle: a second click on ? closes the open panel.
3410
+ const existing = document.getElementById("search-help-panel");
3411
+ if (existing) { existing.remove(); btn.setAttribute("aria-expanded", "false"); return; }
3412
+
3413
+ const { SEARCH_HELP_HTML } = await import("./help/search-help.js");
3414
+
3415
+ const searchBar = document.querySelector("search.ml-search") as HTMLElement | null;
3416
+
3336
3417
  const panel = document.createElement("div");
3337
- panel.style.cssText = "background:var(--color-bg,#fff);color:var(--color-text,#000);border-radius:8px;box-shadow:0 8px 32px rgba(0,0,0,0.3);width:min(820px,92vw);max-height:85vh;display:flex;flex-direction:column;";
3418
+ panel.id = "search-help-panel";
3419
+ panel.className = "search-help-panel";
3338
3420
  panel.innerHTML = `
3339
- <div style="padding:14px 18px;border-bottom:1px solid var(--color-border,#ddd);display:flex;justify-content:space-between;align-items:center">
3340
- <span style="font-weight:600">Search syntax</span>
3341
- <button type="button" id="search-help-close" style="background:none;border:0;font-size:18px;cursor:pointer">&times;</button>
3421
+ <div class="search-help-head">
3422
+ <span>Search syntax</span>
3423
+ <button type="button" id="search-help-close" title="Close (Esc)" aria-label="Close">&times;</button>
3342
3424
  </div>
3343
- <pre style="margin:0;padding:14px 18px;overflow:auto;font:13px/1.5 var(--font-ui);white-space:pre-wrap;word-wrap:break-word">${
3344
- (md || "Help not available — check that docs/search.md is deployed.")
3345
- .replace(/[&<>]/g, (c: string) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c] || c))
3346
- }</pre>
3425
+ <div class="search-help-body">${SEARCH_HELP_HTML}</div>
3347
3426
  `;
3348
- backdrop.appendChild(panel);
3349
- document.body.appendChild(backdrop);
3350
- const close = (): void => backdrop.remove();
3427
+ document.body.appendChild(panel);
3428
+ btn.setAttribute("aria-expanded", "true");
3429
+
3430
+ // Anchor below the search bar, clamped to the viewport. Re-runs on
3431
+ // resize so the panel tracks the bar if the layout reflows.
3432
+ const position = (): void => {
3433
+ const anchor = searchBar || btn;
3434
+ const rect = anchor.getBoundingClientRect();
3435
+ const margin = 8;
3436
+ const top = rect.bottom + 2;
3437
+ const width = Math.min(640, Math.max(320, rect.width));
3438
+ let left = rect.left;
3439
+ if (left + width > window.innerWidth - margin) left = window.innerWidth - margin - width;
3440
+ if (left < margin) left = margin;
3441
+ panel.style.top = `${top}px`;
3442
+ panel.style.left = `${left}px`;
3443
+ panel.style.width = `${width}px`;
3444
+ panel.style.maxHeight = `${Math.max(160, window.innerHeight - top - margin)}px`;
3445
+ };
3446
+ position();
3447
+
3448
+ const close = (): void => {
3449
+ panel.remove();
3450
+ btn.setAttribute("aria-expanded", "false");
3451
+ window.removeEventListener("resize", position);
3452
+ document.removeEventListener("keydown", onKey, true);
3453
+ document.removeEventListener("mousedown", onOutside);
3454
+ };
3455
+ // Esc always closes the help panel while it's open — including when
3456
+ // focus is in the search box. Capture phase + stopPropagation so the
3457
+ // search input doesn't also clear/blur on the same keystroke.
3458
+ const onKey = (e: KeyboardEvent): void => {
3459
+ if (e.key === "Escape") {
3460
+ e.preventDefault();
3461
+ e.stopPropagation();
3462
+ close();
3463
+ }
3464
+ };
3465
+ // Click outside dismisses — except clicks on the search bar itself
3466
+ // (typing a query must not close the reference) or the ? button
3467
+ // (its own handler toggles).
3468
+ const onOutside = (e: MouseEvent): void => {
3469
+ const t = e.target as Node;
3470
+ if (panel.contains(t) || btn.contains(t) || searchBar?.contains(t)) return;
3471
+ close();
3472
+ };
3351
3473
  panel.querySelector("#search-help-close")?.addEventListener("click", close);
3352
- backdrop.addEventListener("click", e => { if (e.target === backdrop) close(); });
3353
- document.addEventListener("keydown", function escClose(e) {
3354
- if (e.key === "Escape") { close(); document.removeEventListener("keydown", escClose); }
3355
- });
3474
+ window.addEventListener("resize", position);
3475
+ document.addEventListener("keydown", onKey, true);
3476
+ document.addEventListener("mousedown", onOutside);
3356
3477
  });
3357
3478
 
3358
3479
  // ── JSONC config file editor ──