@bobfrankston/rmfmail 1.1.45 → 1.1.51

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 (47) hide show
  1. package/TODO.md +11 -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 +2 -3
  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 +14 -2
  29. package/packages/mailx-service/google-sync.js.map +1 -1
  30. package/packages/mailx-service/google-sync.ts +17 -2
  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-store/db.d.ts.map +1 -1
  44. package/packages/mailx-store/db.js +8 -3
  45. package/packages/mailx-store/db.js.map +1 -1
  46. package/packages/mailx-store/db.ts +8 -3
  47. /package/packages/mailx-imap/{node_modules.npmglobalize-stash-28848 → node_modules.npmglobalize-stash-40840}/.package-lock.json +0 -0
package/client/app.js CHANGED
@@ -413,8 +413,10 @@ initFolderTree(folderTree, (accountId, folderId, folderName, specialUse) => {
413
413
  currentFolderId = folderId;
414
414
  // Drop search state on folder switch — input alone wasn't enough,
415
415
  // searchMode stayed true and the next loadMessages was rerouted.
416
- if (searchInput)
416
+ if (searchInput) {
417
417
  searchInput.value = "";
418
+ updateSearchHighlight();
419
+ }
418
420
  clearSearchMode();
419
421
  markAsSeen();
420
422
  releaseFocus();
@@ -433,8 +435,10 @@ initFolderTree(folderTree, (accountId, folderId, folderName, specialUse) => {
433
435
  // stayed active, so "All Inboxes" appeared to be ignoring its own
434
436
  // contents (Bob 2026-05-09: "I just switched to all inboxes but the
435
437
  // search is still showing even though all the entries are there.").
436
- if (searchInput)
438
+ if (searchInput) {
437
439
  searchInput.value = "";
440
+ updateSearchHighlight();
441
+ }
438
442
  clearSearchMode();
439
443
  releaseFocus();
440
444
  loadUnifiedInbox();
@@ -448,8 +452,10 @@ initFolderTree(folderTree, (accountId, folderId, folderName, specialUse) => {
448
452
  // setActiveTabView (that would be a no-op update, but keeping them separate
449
453
  // keeps "user navigated" distinct from "tab restored").
450
454
  function applyTabView(tab) {
451
- if (searchInput)
455
+ if (searchInput) {
452
456
  searchInput.value = "";
457
+ updateSearchHighlight();
458
+ }
453
459
  clearSearchMode();
454
460
  releaseFocus();
455
461
  const v = tab.view;
@@ -468,8 +474,10 @@ function applyTabView(tab) {
468
474
  setNarrowFolderTitle(tab.title);
469
475
  }
470
476
  else {
471
- if (searchInput)
477
+ if (searchInput) {
472
478
  searchInput.value = v.query;
479
+ updateSearchHighlight();
480
+ }
473
481
  loadSearchResults(v.query, v.scope, v.accountId, v.folderId, v.includeTrash);
474
482
  setTitle(`${APP_NAME} - Search`);
475
483
  setNarrowFolderTitle(`Search: ${v.query}`);
@@ -1893,6 +1901,83 @@ function recordSearchHistory(query) {
1893
1901
  refreshSearchHistoryDatalist();
1894
1902
  }
1895
1903
  refreshSearchHistoryDatalist();
1904
+ // ── Live search-syntax highlighting ──
1905
+ // An overlay behind the search input colors recognized qualifiers
1906
+ // (from:/to:/subject:/date:/after:/before:/has:/is:/folder:), FTS boolean
1907
+ // operators (OR/NOT/AND), quoted phrases, and /regex/ literals — reparsed
1908
+ // on every keystroke so the user sees how mailx will interpret the query.
1909
+ // An invalid /regex/ only goes red after a brief settle delay, so it
1910
+ // doesn't flash red while the pattern is still being typed.
1911
+ const searchHl = document.getElementById("search-hl");
1912
+ const SEARCH_QUALIFIERS = new Set(["from", "to", "subject", "date", "after", "before", "has", "is", "folder"]);
1913
+ const SEARCH_REGEX_SETTLE_MS = 500;
1914
+ let searchRegexSettleTimer = null;
1915
+ function escapeHl(s) {
1916
+ return s.replace(/[&<>]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c] || c));
1917
+ }
1918
+ function renderSearchHighlight(settled) {
1919
+ if (!searchHl || !searchInput)
1920
+ return;
1921
+ const text = searchInput.value;
1922
+ // Tokenize keeping whitespace runs so the overlay lines up glyph-for-
1923
+ // glyph with the input's own text.
1924
+ const tokens = text.match(/\s+|"[^"]*"?|\S+/g) || [];
1925
+ let html = "";
1926
+ for (const tok of tokens) {
1927
+ if (/^\s+$/.test(tok)) {
1928
+ html += escapeHl(tok);
1929
+ continue;
1930
+ }
1931
+ // /regex/ literal — leading slash, body, optional closing slash.
1932
+ if (tok.startsWith("/") && tok.length > 1) {
1933
+ let bad = false;
1934
+ if (tok.endsWith("/")) {
1935
+ try {
1936
+ new RegExp(tok.slice(1, -1), "i");
1937
+ }
1938
+ catch {
1939
+ bad = true;
1940
+ }
1941
+ }
1942
+ const cls = (bad && settled) ? "sh-regex-bad" : "sh-regex";
1943
+ html += `<span class="${cls}">${escapeHl(tok)}</span>`;
1944
+ continue;
1945
+ }
1946
+ // qualifier:value — color the keyword: prefix when recognized.
1947
+ const qm = tok.match(/^([a-z]+):(.*)$/i);
1948
+ if (qm && SEARCH_QUALIFIERS.has(qm[1].toLowerCase())) {
1949
+ html += `<span class="sh-key">${escapeHl(qm[1] + ":")}</span>${escapeHl(qm[2])}`;
1950
+ continue;
1951
+ }
1952
+ // FTS boolean operator.
1953
+ if (/^(OR|NOT|AND)$/.test(tok)) {
1954
+ html += `<span class="sh-op">${escapeHl(tok)}</span>`;
1955
+ continue;
1956
+ }
1957
+ // Quoted phrase.
1958
+ if (tok.startsWith("\"")) {
1959
+ html += `<span class="sh-phrase">${escapeHl(tok)}</span>`;
1960
+ continue;
1961
+ }
1962
+ html += escapeHl(tok);
1963
+ }
1964
+ searchHl.innerHTML = html;
1965
+ searchHl.scrollLeft = searchInput.scrollLeft;
1966
+ }
1967
+ function updateSearchHighlight() {
1968
+ renderSearchHighlight(false);
1969
+ if (searchRegexSettleTimer)
1970
+ clearTimeout(searchRegexSettleTimer);
1971
+ searchRegexSettleTimer = setTimeout(() => renderSearchHighlight(true), SEARCH_REGEX_SETTLE_MS);
1972
+ }
1973
+ // Keep the overlay scroll-aligned when the input scrolls horizontally,
1974
+ // and seed it once at startup.
1975
+ searchInput?.addEventListener("scroll", () => {
1976
+ if (searchHl)
1977
+ searchHl.scrollLeft = searchInput.scrollLeft;
1978
+ });
1979
+ searchInput?.addEventListener("change", () => updateSearchHighlight());
1980
+ updateSearchHighlight();
1896
1981
  function doSearch(immediate = false) {
1897
1982
  const query = searchInput.value.trim();
1898
1983
  if (query.length === 0) {
@@ -1936,6 +2021,7 @@ let currentFolderId = 0;
1936
2021
  let reloadDebounceTimer = null;
1937
2022
  searchInput?.addEventListener("input", () => {
1938
2023
  clearTimeout(searchTimeout);
2024
+ updateSearchHighlight();
1939
2025
  if (searchInput.value.trim() === "") {
1940
2026
  // Cleared — reset immediately, no debounce. Must exit search mode
1941
2027
  // first; otherwise reloadCurrentFolder() sees searchMode=true and
@@ -1958,6 +2044,7 @@ searchInput?.addEventListener("keydown", (e) => {
1958
2044
  }
1959
2045
  if (e.key === "Escape") {
1960
2046
  searchInput.value = "";
2047
+ updateSearchHighlight();
1961
2048
  clearSearchMode();
1962
2049
  // Clear any client-side filters
1963
2050
  const body = document.getElementById("ml-body");
@@ -3504,42 +3591,90 @@ optSnippet?.addEventListener("change", () => {
3504
3591
  localStorage.setItem("mailx-snippet", String(optSnippet.checked));
3505
3592
  });
3506
3593
  // ── Search help button (?) ──
3507
- // Opens a small modal showing docs/search.md so the user can see the
3508
- // query syntax for each mode (FTS5 vs IMAP SEARCH vs Gmail API). The
3509
- // markdown is fetched from the service via readConfigHelp("search"),
3510
- // rendered as plain text inside a <pre> — no full markdown engine, just
3511
- // preserves headings + tables visually. The doc itself is the source of
3512
- // truth (lives at app/docs/search.md, deployed to GDrive on every
3513
- // release like the other config help files).
3594
+ // Toggles a NON-modal panel with the search-syntax reference so it stays
3595
+ // visible while the user types into the search box build a query
3596
+ // against it, then dismiss. The panel is anchored directly below the
3597
+ // search bar (no dark backdrop, no centered modal).
3598
+ //
3599
+ // The help content is HTML compiled into the app (client/help/search-help.ts),
3600
+ // NOT a docs/*.md file. Feature help is application content; the docs/*.md
3601
+ // files are only a stand-in for proper settings UI and stay out of this path.
3602
+ // The module is dynamically imported so its markup isn't in the cold-start
3603
+ // bundle.
3514
3604
  document.getElementById("search-help")?.addEventListener("click", async () => {
3515
- const { readConfigHelp } = await import("./lib/api-client.js");
3516
- const r = await readConfigHelp("search").catch(() => ({ content: "" }));
3517
- const md = r?.content || "";
3518
- const backdrop = document.createElement("div");
3519
- backdrop.className = "mailx-modal-backdrop";
3520
- 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;";
3605
+ const btn = document.getElementById("search-help");
3606
+ if (!btn)
3607
+ return;
3608
+ // Toggle: a second click on ? closes the open panel.
3609
+ const existing = document.getElementById("search-help-panel");
3610
+ if (existing) {
3611
+ existing.remove();
3612
+ btn.setAttribute("aria-expanded", "false");
3613
+ return;
3614
+ }
3615
+ const { SEARCH_HELP_HTML } = await import("./help/search-help.js");
3616
+ const searchBar = document.querySelector("search.ml-search");
3521
3617
  const panel = document.createElement("div");
3522
- 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;";
3618
+ panel.id = "search-help-panel";
3619
+ panel.className = "search-help-panel";
3523
3620
  panel.innerHTML = `
3524
- <div style="padding:14px 18px;border-bottom:1px solid var(--color-border,#ddd);display:flex;justify-content:space-between;align-items:center">
3525
- <span style="font-weight:600">Search syntax</span>
3526
- <button type="button" id="search-help-close" style="background:none;border:0;font-size:18px;cursor:pointer">&times;</button>
3621
+ <div class="search-help-head">
3622
+ <span>Search syntax</span>
3623
+ <button type="button" id="search-help-close" title="Close (Esc)" aria-label="Close">&times;</button>
3527
3624
  </div>
3528
- <pre style="margin:0;padding:14px 18px;overflow:auto;font:13px/1.5 var(--font-ui);white-space:pre-wrap;word-wrap:break-word">${(md || "Help not available — check that docs/search.md is deployed.")
3529
- .replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c] || c))}</pre>
3625
+ <div class="search-help-body">${SEARCH_HELP_HTML}</div>
3530
3626
  `;
3531
- backdrop.appendChild(panel);
3532
- document.body.appendChild(backdrop);
3533
- const close = () => backdrop.remove();
3534
- panel.querySelector("#search-help-close")?.addEventListener("click", close);
3535
- backdrop.addEventListener("click", e => { if (e.target === backdrop)
3536
- close(); });
3537
- document.addEventListener("keydown", function escClose(e) {
3627
+ document.body.appendChild(panel);
3628
+ btn.setAttribute("aria-expanded", "true");
3629
+ // Anchor below the search bar, clamped to the viewport. Re-runs on
3630
+ // resize so the panel tracks the bar if the layout reflows.
3631
+ const position = () => {
3632
+ const anchor = searchBar || btn;
3633
+ const rect = anchor.getBoundingClientRect();
3634
+ const margin = 8;
3635
+ const top = rect.bottom + 2;
3636
+ const width = Math.min(640, Math.max(320, rect.width));
3637
+ let left = rect.left;
3638
+ if (left + width > window.innerWidth - margin)
3639
+ left = window.innerWidth - margin - width;
3640
+ if (left < margin)
3641
+ left = margin;
3642
+ panel.style.top = `${top}px`;
3643
+ panel.style.left = `${left}px`;
3644
+ panel.style.width = `${width}px`;
3645
+ panel.style.maxHeight = `${Math.max(160, window.innerHeight - top - margin)}px`;
3646
+ };
3647
+ position();
3648
+ const close = () => {
3649
+ panel.remove();
3650
+ btn.setAttribute("aria-expanded", "false");
3651
+ window.removeEventListener("resize", position);
3652
+ document.removeEventListener("keydown", onKey, true);
3653
+ document.removeEventListener("mousedown", onOutside);
3654
+ };
3655
+ // Esc always closes the help panel while it's open — including when
3656
+ // focus is in the search box. Capture phase + stopPropagation so the
3657
+ // search input doesn't also clear/blur on the same keystroke.
3658
+ const onKey = (e) => {
3538
3659
  if (e.key === "Escape") {
3660
+ e.preventDefault();
3661
+ e.stopPropagation();
3539
3662
  close();
3540
- document.removeEventListener("keydown", escClose);
3541
3663
  }
3542
- });
3664
+ };
3665
+ // Click outside dismisses — except clicks on the search bar itself
3666
+ // (typing a query must not close the reference) or the ? button
3667
+ // (its own handler toggles).
3668
+ const onOutside = (e) => {
3669
+ const t = e.target;
3670
+ if (panel.contains(t) || btn.contains(t) || searchBar?.contains(t))
3671
+ return;
3672
+ close();
3673
+ };
3674
+ panel.querySelector("#search-help-close")?.addEventListener("click", close);
3675
+ window.addEventListener("resize", position);
3676
+ document.addEventListener("keydown", onKey, true);
3677
+ document.addEventListener("mousedown", onOutside);
3543
3678
  });
3544
3679
  // ── JSONC config file editor ──
3545
3680
  document.getElementById("btn-edit-jsonc")?.addEventListener("click", async () => {