@bobfrankston/rmfmail 1.2.315 → 1.2.317

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 (44) hide show
  1. package/.commitmsg +35 -15
  2. package/.llm/trusted-lists.md +11 -0
  3. package/client/android-bootstrap.bundle.js +3 -0
  4. package/client/android-bootstrap.bundle.js.map +2 -2
  5. package/client/app.bundle.js +124 -51
  6. package/client/app.bundle.js.map +2 -2
  7. package/client/app.js +12 -0
  8. package/client/app.js.map +1 -1
  9. package/client/app.ts +11 -0
  10. package/client/components/calendar-sidebar.js +160 -77
  11. package/client/components/calendar-sidebar.js.map +1 -1
  12. package/client/components/calendar-sidebar.ts +147 -70
  13. package/client/compose/compose.bundle.js +4 -0
  14. package/client/compose/compose.bundle.js.map +2 -2
  15. package/client/index.html +6 -6
  16. package/client/lib/api-client.js +6 -0
  17. package/client/lib/api-client.js.map +1 -1
  18. package/client/lib/api-client.ts +6 -0
  19. package/client/lib/mailxapi.js +4 -1
  20. package/npmchanges.md +55 -0
  21. package/package.json +5 -5
  22. package/packages/mailx-imap/package-lock.json +2 -2
  23. package/packages/mailx-imap/package.json +1 -1
  24. package/packages/mailx-service/index.d.ts +14 -0
  25. package/packages/mailx-service/index.d.ts.map +1 -1
  26. package/packages/mailx-service/index.js +31 -0
  27. package/packages/mailx-service/index.js.map +1 -1
  28. package/packages/mailx-service/index.ts +29 -0
  29. package/packages/mailx-service/jsonrpc.js +2 -0
  30. package/packages/mailx-service/jsonrpc.js.map +1 -1
  31. package/packages/mailx-service/jsonrpc.ts +2 -0
  32. package/packages/mailx-service/package.json +1 -1
  33. package/packages/mailx-settings/package.json +1 -1
  34. package/packages/mailx-store/package.json +1 -1
  35. package/packages/mailx-store-web/package.json +1 -1
  36. package/packages/mailx-store-web/web-service.d.ts +4 -0
  37. package/packages/mailx-store-web/web-service.d.ts.map +1 -1
  38. package/packages/mailx-store-web/web-service.js +1 -0
  39. package/packages/mailx-store-web/web-service.js.map +1 -1
  40. package/packages/mailx-store-web/web-service.ts +1 -0
  41. package/packages/mailx-types/mailx-api.d.ts +5 -0
  42. package/packages/mailx-types/mailx-api.d.ts.map +1 -1
  43. package/packages/mailx-types/mailx-api.ts +2 -0
  44. package/packages/mailx-types/package.json +1 -1
@@ -16,12 +16,12 @@
16
16
 
17
17
  import {
18
18
  getPrimaryAccount,
19
- getCalendarEvents, getCalendars, createCalendarEvent,
19
+ getCalendarEvents, getCalendars, createCalendarEvent, deleteCalendarEvent, refreshCalendarNow,
20
20
  getTasks, createTask, updateTask, deleteTask,
21
21
  reauthGoogleScopes,
22
22
  getSettings, saveSettings,
23
23
  } from "../lib/api-client.js";
24
- import { showContextMenu, confirmAt } from "./context-menu.js";
24
+ import { showContextMenu, confirmAt, type MenuItem } from "./context-menu.js";
25
25
 
26
26
  const SIDEBAR_PREF = "mailx-calendar-sidebar-on";
27
27
  const SHOW_RECURRING_PREF = "mailx-cal-show-recurring";
@@ -60,6 +60,23 @@ const HOLIDAY_HORIZON_MS = 14 * 86400_000;
60
60
  let viewYear = new Date().getFullYear();
61
61
  let viewMonth = new Date().getMonth();
62
62
  let viewDay = new Date().getDate();
63
+ /** True once the reader steps to another day with ‹ ›; "Today" clears it.
64
+ * While false the view FOLLOWS the calendar date: the app runs for days,
65
+ * and the date above was fixed when this module loaded (Bob 2026-09-10:
66
+ * "it is still showing me yesterday's calendar"). */
67
+ let viewPinned = false;
68
+ function followToday(): void {
69
+ if (viewPinned) return;
70
+ const t = new Date();
71
+ viewYear = t.getFullYear(); viewMonth = t.getMonth(); viewDay = t.getDate();
72
+ }
73
+ /** Re-render at the next local midnight, then re-arm. A setInterval would
74
+ * drift; computing the next midnight each time lands on it exactly. */
75
+ function armMidnightRollover(): void {
76
+ const now = new Date();
77
+ const next = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 5);
78
+ window.setTimeout(() => { void refresh(); armMidnightRollover(); }, next.getTime() - now.getTime());
79
+ }
63
80
  let lastEvents: CalEvent[] = [];
64
81
  /** Overdue tasks (due before today, not completed) — pinned to the top of
65
82
  * the calendar list. Cached so renderEvents() can be re-run on a calendar
@@ -176,7 +193,7 @@ async function renderCalendarList(): Promise<void> {
176
193
  // Personal first, then alphabetical.
177
194
  const sorted = [...list].sort((a, b) =>
178
195
  (a.primary ? 0 : 1) - (b.primary ? 0 : 1) || a.name.localeCompare(b.name));
179
- host.innerHTML = sorted.map(c => `<label class="cal-side-cal-row" title="${escapeHtml(c.name)}">
196
+ host.innerHTML = sorted.map(c => `<label class="cal-side-cal-row" title="${escapeHtml(c.name)} — untick to hide its events in this list. Which calendars are listed is set in Google Calendar: the ones selected there appear; use Refresh after changing it.">
180
197
  <input type="checkbox" class="cal-side-cal-check" data-cal-id="${escapeHtml(c.id)}" ${hiddenCalendars.has(c.id) ? "" : "checked"}>
181
198
  ${calIconHtml(c)}
182
199
  <span class="cal-side-cal-name">${escapeHtml(c.name)}</span>
@@ -436,7 +453,9 @@ function renderEvents(events: CalEvent[]): void {
436
453
  <span class="cal-side-event-title cal-side-event-holiday-title" title="${escapeHtml(e.title)}">${calIconHtml(info)} ${escapeHtml(e.title)}</span>
437
454
  </div>`;
438
455
  } else {
439
- const titleAttr = link ? 'title="Click to open in Google Calendar"' : "";
456
+ const titleAttr = link
457
+ ? 'title="Click to open in Google Calendar to edit it. Right-click for more."'
458
+ : 'title="Saved in rmfmail, syncing to Google — it can be opened there once it has synced. Right-click to delete."';
440
459
  html += `<div class="cal-side-event" data-id="${e.id}"${recurAttr} data-link="${escapeHtml(link)}" ${titleAttr}>
441
460
  ${calIconHtml(info)}
442
461
  <span class="cal-side-event-time">${escapeHtml(formatTime(e))}</span>
@@ -482,16 +501,38 @@ function renderEvents(events: CalEvent[]): void {
482
501
  el.addEventListener("contextmenu", (e) => {
483
502
  e.preventDefault();
484
503
  const link = el.dataset.link;
485
- const items = [];
504
+ const uuid = el.dataset.id || "";
505
+ const title = el.querySelector(".cal-side-event-title")?.textContent?.trim() || "this event";
506
+ const items: MenuItem[] = [];
486
507
  if (link) items.push({
487
- label: "View in browser",
508
+ label: `Edit "${title}" in Google Calendar`,
488
509
  action: () => openInBrowser(link),
489
510
  });
490
511
  items.push({
491
512
  label: "Open Google Calendar",
492
513
  action: () => openInBrowser("https://calendar.google.com/"),
493
514
  });
494
- if (items.length > 0) showContextMenu(e.clientX, e.clientY, items, { editTarget: e.target });
515
+ if (uuid) {
516
+ items.push({ label: "", action: () => {}, separator: true });
517
+ items.push({
518
+ label: `Delete "${title}"`,
519
+ tooltip: "Removes it from your calendar in rmfmail now and from Google when it syncs.",
520
+ action: async () => {
521
+ if (!await confirmAt(e.clientX, e.clientY, `Delete "${title}"`)) return;
522
+ try {
523
+ await deleteCalendarEvent(uuid);
524
+ const st = document.getElementById("status-sync");
525
+ if (st) st.textContent = `Deleted event: ${title}`;
526
+ await refresh();
527
+ } catch (err: any) {
528
+ const st = document.getElementById("status-sync");
529
+ if (st) st.textContent = `Couldn't delete "${title}": ${err?.message || err}`;
530
+ console.error("[calendar] delete failed:", err);
531
+ }
532
+ },
533
+ });
534
+ }
535
+ showContextMenu(e.clientX, e.clientY, items, { editTarget: e.target });
495
536
  });
496
537
  });
497
538
  }
@@ -595,6 +636,7 @@ async function renderTasks(prefetched?: any[]): Promise<void> {
595
636
  }
596
637
 
597
638
  async function refresh(): Promise<void> {
639
+ followToday();
598
640
  renderHead();
599
641
  const from = new Date(viewYear, viewMonth, viewDay);
600
642
  // A throw here (IPC reject, daemon not ready) must NOT leave the
@@ -630,12 +672,15 @@ async function refresh(): Promise<void> {
630
672
  /** Open the natural-language new-event modal. The user types a free-form
631
673
  * description ("Lunch with John tomorrow at noon at Joe's"); we send it
632
674
  * to aiTransform with action=extractEvent, get back structured fields
633
- * (title/start/end/location/notes), and open Google Calendar's
634
- * create-event URL with those fields pre-filled. The user reviews / edits
635
- * / saves in Google's UI that path keeps mailx out of the
636
- * event-editor business and gets all the niceties (recurrence, guests,
637
- * conferencing) for free. If AI is disabled or extraction fails, the raw
638
- * text becomes the title and Google's own quick-add parser takes over. */
675
+ * (title/start/end/location/notes), and SAVE them — a local row at once,
676
+ * pushed to Google by the store-sync queue exactly as the sidebar's
677
+ * task editor does. Until 2026-09-10 this opened Google Calendar's
678
+ * create page in the browser instead, and the reader had to save it
679
+ * there, in whatever Google account the browser was signed into (Bob:
680
+ * "add an event puts me into Google Calendar. It should just save the
681
+ * event because I can easily edit it later"). If AI is disabled or the
682
+ * parse fails, the dialog says so and offers "Create as text": the raw
683
+ * text becomes the title of an all-day event today, to be edited. */
639
684
  function openNewEventDialog(): void {
640
685
  const backdrop = document.createElement("div");
641
686
  backdrop.className = "mailx-modal-backdrop";
@@ -650,7 +695,7 @@ function openNewEventDialog(): void {
650
695
 
651
696
  const hint = document.createElement("div");
652
697
  hint.style.cssText = "font-size:0.9em;color:var(--color-text-muted);";
653
- hint.textContent = "Describe the event in plain English — AI extracts the details and opens Google Calendar to confirm.";
698
+ hint.textContent = "Describe the event in plain English — AI extracts the details and saves it to your calendar. Edit it afterwards if it needs adjusting.";
654
699
 
655
700
  const ta = document.createElement("textarea");
656
701
  ta.style.cssText = "width:100%;min-height:120px;font:inherit;padding:8px;border-radius:4px;border:1px solid var(--color-border, #ccc);box-sizing:border-box;resize:vertical;";
@@ -727,70 +772,80 @@ function openNewEventDialog(): void {
727
772
  }
728
773
  }
729
774
 
730
- // Build Google Calendar's create-event URL(s) one per extracted
731
- // event (the text may describe several). Always opens in browser
732
- // for the user to confirm/edit/save. We never write to the local
733
- // store from this path the round-trip back via Google Calendar
734
- // sync keeps state consistent and avoids duplicate events.
735
- const api = (window as any).mailxapi;
775
+ // Save each extracted event (the text may describe several) as a
776
+ // local row; the store-sync queue pushes it to Google and binds the
777
+ // provider id when that lands. Local-first: the row is in the
778
+ // sidebar before Google has heard of it. The saved title is named
779
+ // in the status line, never "saved" alone.
736
780
  const targets = events.length ? events : [null];
781
+ const saved: string[] = [];
737
782
  for (const ev of targets) {
738
- const url = buildGoogleEventUrl(ev, text);
739
- if (api?.openExternal) api.openExternal(url);
740
- else window.open(url, "_blank");
783
+ const row = eventToLocalRow(ev, text);
784
+ try {
785
+ await createCalendarEvent(row);
786
+ saved.push(row.title);
787
+ } catch (e: any) {
788
+ // Stop at the first failure and leave the dialog open with the
789
+ // reason: a silent partial save is worse than none.
790
+ status.textContent = `Couldn't save "${row.title}": ${e?.message || e}`;
791
+ createBtn.disabled = false;
792
+ cancelBtn.disabled = false;
793
+ return;
794
+ }
741
795
  }
742
796
  close();
797
+ const st = document.getElementById("status-sync");
798
+ if (st) st.textContent = saved.length === 1 ? `Saved event: ${saved[0]}` : `Saved ${saved.length} events: ${saved.join("; ")}`;
799
+ await refresh();
743
800
  });
744
801
  }
745
802
 
746
- /** Compose Google Calendar's "render?action=TEMPLATE" URL with extracted
747
- * fields. When `event` is null we still produce a useful URL the raw
748
- * description goes into `text=` (becomes the title) and Google's UI lets
749
- * the user fill in dates manually. Times are emitted in compact form
750
- * without `Z` so Google interprets them in the user's calendar timezone. */
751
- function buildGoogleEventUrl(event: any, fallbackText: string): string {
752
- const base = "https://calendar.google.com/calendar/render?action=TEMPLATE";
753
- const params: string[] = [];
754
- const fmt = (iso: string): string => {
755
- // ISO "2026-05-06T12:00:00" "20260506T120000". Google accepts
756
- // local-naive times in this form.
757
- const m = iso.match(/(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?/);
758
- if (!m) return "";
759
- const [, y, mo, d, hh, mm, ss] = m;
760
- const date = `${y}${mo}${d}`;
761
- if (!hh) return date; // all-day form: dates=YYYYMMDD/YYYYMMDD
762
- return `${date}T${hh}${mm}${ss || "00"}`;
763
- };
803
+ /** The extracted event (wall-clock ISO strings in its own zone) as the
804
+ * row createCalendarEvent takes (epoch ms). A null event"Create as
805
+ * text" becomes an all-day event today titled with the raw text. */
806
+ function eventToLocalRow(event: any, fallbackText: string): { title: string; startMs: number; endMs: number; allDay: boolean; location?: string; notes?: string } {
807
+ const startOfToday = new Date(); startOfToday.setHours(0, 0, 0, 0);
808
+ if (!event?.startISO) {
809
+ return { title: (event?.title || fallbackText).slice(0, 200), startMs: startOfToday.getTime(), endMs: startOfToday.getTime() + 86400_000, allDay: true };
810
+ }
811
+ const allDay = !!event.allDay || !event.startISO.includes("T");
812
+ const startMs = allDay ? new Date(event.startISO.slice(0, 10) + "T00:00:00").getTime() : wallClockToMs(event.startISO, event.timeZone);
813
+ let endMs: number;
814
+ if (event.endISO) endMs = allDay ? new Date(event.endISO.slice(0, 10) + "T00:00:00").getTime() : wallClockToMs(event.endISO, event.timeZone);
815
+ else endMs = startMs + (allDay ? 86400_000 : 3600_000);
816
+ // An all-day end is exclusive; the extractor may hand back the same day
817
+ // for start and end, which would be a zero-length event.
818
+ if (allDay && endMs <= startMs) endMs = startMs + 86400_000;
819
+ return { title: event.title || fallbackText.slice(0, 200), startMs, endMs, allDay, location: event.location || undefined, notes: event.notes || undefined };
820
+ }
764
821
 
765
- if (event?.title) params.push(`text=${encodeURIComponent(event.title)}`);
766
- else params.push(`text=${encodeURIComponent(fallbackText.slice(0, 200))}`);
767
-
768
- if (event?.startISO) {
769
- const startFmt = fmt(event.startISO);
770
- // For all-day events Google wants `dates=YYYYMMDD/YYYYMMDD` where
771
- // the end date is exclusive (next day). For timed events it's
772
- // `YYYYMMDDTHHMMSS/YYYYMMDDTHHMMSS`. Default end = +1h timed,
773
- // +1 day all-day.
774
- if (event.allDay) {
775
- const startDate = new Date(event.startISO + (event.startISO.includes("T") ? "" : "T00:00:00"));
776
- const endDate = new Date(startDate.getTime() + 86400_000);
777
- const endFmt = `${endDate.getFullYear()}${String(endDate.getMonth() + 1).padStart(2, "0")}${String(endDate.getDate()).padStart(2, "0")}`;
778
- params.push(`dates=${startFmt}/${endFmt}`);
779
- } else {
780
- const endFmt = event.endISO
781
- ? fmt(event.endISO)
782
- : fmt(new Date(new Date(event.startISO).getTime() + 3600_000).toISOString().slice(0, 19));
783
- params.push(`dates=${startFmt}/${endFmt}`);
822
+ /** Epoch ms for a wall-clock ISO string ("2026-09-12T09:00:00") read in
823
+ * `timeZone` — an invite that says "9am Kuala Lumpur" stays 9am there.
824
+ * Without a zone the string is read as local time, which is what
825
+ * `new Date(iso)` does for an offset-less string. */
826
+ function wallClockToMs(iso: string, timeZone?: string): number {
827
+ const local = new Date(iso).getTime();
828
+ if (!timeZone) return local;
829
+ try {
830
+ // Offset of the zone at that instant: format the local guess back in
831
+ // the zone, and the difference is the correction. Two passes settle a
832
+ // guess that straddles a DST change.
833
+ let ms = local;
834
+ for (let i = 0; i < 2; i++) {
835
+ const parts = new Intl.DateTimeFormat("en-US", { timeZone, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" })
836
+ .formatToParts(new Date(ms)).reduce((o, p) => (o[p.type] = p.value, o), {} as Record<string, string>);
837
+ const asIfLocal = new Date(`${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}`).getTime();
838
+ ms += local - asIfLocal;
784
839
  }
840
+ return ms;
841
+ } catch (e: any) {
842
+ console.warn(`[calendar] unknown time zone "${timeZone}", reading the time as local:`, e?.message || e);
843
+ return local;
785
844
  }
786
- if (event?.location) params.push(`location=${encodeURIComponent(event.location)}`);
787
- if (event?.notes) params.push(`details=${encodeURIComponent(event.notes)}`);
788
- // Times above are wall-clock in the event's own zone (an invite that says
789
- // "9am Kuala Lumpur" stays 9am); ctz tells Google which zone that is.
790
- if (event?.timeZone) params.push(`ctz=${encodeURIComponent(event.timeZone)}`);
791
- return `${base}&${params.join("&")}`;
792
845
  }
793
846
 
847
+
848
+
794
849
  /** Show the sidebar (called from the View menu toggle). Idempotent. */
795
850
  export async function showCalendarSidebar(): Promise<void> {
796
851
  const el = document.getElementById("calendar-sidebar");
@@ -830,9 +885,22 @@ export function initCalendarSidebar(): void {
830
885
  (el as any).__wired = true;
831
886
  el.addEventListener("click", fn);
832
887
  };
833
- wireOnce("cal-side-prev", () => { const d = new Date(viewYear, viewMonth, viewDay - 1); viewYear = d.getFullYear(); viewMonth = d.getMonth(); viewDay = d.getDate(); refresh(); });
834
- wireOnce("cal-side-next", () => { const d = new Date(viewYear, viewMonth, viewDay + 1); viewYear = d.getFullYear(); viewMonth = d.getMonth(); viewDay = d.getDate(); refresh(); });
835
- wireOnce("cal-side-today", () => { const t = new Date(); viewYear = t.getFullYear(); viewMonth = t.getMonth(); viewDay = t.getDate(); refresh(); });
888
+ wireOnce("cal-side-prev", () => { viewPinned = true; const d = new Date(viewYear, viewMonth, viewDay - 1); viewYear = d.getFullYear(); viewMonth = d.getMonth(); viewDay = d.getDate(); refresh(); });
889
+ wireOnce("cal-side-next", () => { viewPinned = true; const d = new Date(viewYear, viewMonth, viewDay + 1); viewYear = d.getFullYear(); viewMonth = d.getMonth(); viewDay = d.getDate(); refresh(); });
890
+ wireOnce("cal-side-today", () => { viewPinned = false; followToday(); refresh(); });
891
+ if (!(window as any).__mailxCalRolloverArmed) {
892
+ (window as any).__mailxCalRolloverArmed = true;
893
+ armMidnightRollover();
894
+ // Coming back to mailx from elsewhere — Google Calendar in the
895
+ // browser, most likely — pull what changed there. The daemon's 20 s
896
+ // floor turns a flapping window into at most one call per 20 s.
897
+ window.addEventListener("focus", () => {
898
+ if (!isCalendarSidebarOn()) return;
899
+ void refreshCalendarNow().then(r => {
900
+ if (!r?.ok && r?.reason && !/refreshed \d+ s ago/.test(r.reason)) console.log(`[calendar] focus refresh not started: ${r.reason}`);
901
+ }).catch((e: any) => console.warn("[calendar] focus refresh failed:", e?.message || e));
902
+ });
903
+ }
836
904
  wireOnce("cal-side-new", () => { openNewEventDialog(); });
837
905
  wireOnce("cal-side-new-task", async () => {
838
906
  const title = prompt("Task title:");
@@ -871,7 +939,16 @@ export function initCalendarSidebar(): void {
871
939
  try {
872
940
  // Also re-enumerate calendars — the user may have selected /
873
941
  // deselected one in Google since the sidebar opened.
874
- await Promise.all([renderCalendarList(), refresh()]);
942
+ // refreshCalendarNow is what makes this button honest: refresh()
943
+ // alone re-reads local rows, and the daemon's 5-minute throttle
944
+ // turned "Refresh events from Google" into a no-op most of the
945
+ // time (Bob 2026-09-10: "still showing me yesterday's calendar").
946
+ // Its emit re-renders the list when the pull changes anything.
947
+ const [, , pulled] = await Promise.all([renderCalendarList(), refresh(), refreshCalendarNow()]);
948
+ if (!pulled?.ok) {
949
+ const st = document.getElementById("status-sync");
950
+ if (st) st.textContent = `Calendar not pulled from Google: ${pulled?.reason || "unknown"}`;
951
+ }
875
952
  } finally {
876
953
  setTimeout(() => {
877
954
  btn?.classList.remove("cal-side-refreshing");
@@ -1103,6 +1103,7 @@ __export(api_client_exports, {
1103
1103
  reauthGoogleScopes: () => reauthGoogleScopes,
1104
1104
  reauthenticate: () => reauthenticate,
1105
1105
  recordSpamReport: () => recordSpamReport,
1106
+ refreshCalendarNow: () => refreshCalendarNow,
1106
1107
  registerMailto: () => registerMailto,
1107
1108
  removeUserDictWord: () => removeUserDictWord,
1108
1109
  renameFolder: () => renameFolder,
@@ -1256,6 +1257,9 @@ function getPrimaryAccount(feature) {
1256
1257
  function getCalendarEvents(fromMs, toMs) {
1257
1258
  return ipc().getCalendarEvents?.(fromMs, toMs) ?? Promise.resolve([]);
1258
1259
  }
1260
+ function refreshCalendarNow() {
1261
+ return ipc().refreshCalendarNow?.() ?? Promise.resolve({ ok: false, reason: "not available" });
1262
+ }
1259
1263
  function getCalendars() {
1260
1264
  return ipc().getCalendars?.() ?? Promise.resolve([]);
1261
1265
  }