@bobfrankston/rmfmail 1.2.316 → 1.2.318

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.
@@ -16,12 +16,12 @@
16
16
 
17
17
  import {
18
18
  getPrimaryAccount,
19
- getCalendarEvents, getCalendars, createCalendarEvent, refreshCalendarNow,
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
@@ -78,7 +95,19 @@ interface CalInfo {
78
95
  name: string;
79
96
  color: string;
80
97
  primary: boolean;
98
+ /** Google's accessRole; owner and writer can take a new event. */
99
+ accessRole?: string;
100
+ }
101
+
102
+ /** Calendars a new event can be saved to, personal first. */
103
+ function writableCalendars(list: CalInfo[]): CalInfo[] {
104
+ return list
105
+ .filter(c => c.accessRole === "owner" || c.accessRole === "writer")
106
+ .sort((a, b) => (a.primary ? 0 : 1) - (b.primary ? 0 : 1) || a.name.localeCompare(b.name));
81
107
  }
108
+ /** The calendar the last event was saved to — a per-machine convenience,
109
+ * so a run of entries on a shared calendar does not mean re-picking it. */
110
+ const LAST_CALENDAR_PREF = "mailx.calendar.lastCreateCalendar";
82
111
 
83
112
  /** Selected Google calendars, by id. The primary calendar is also keyed
84
113
  * under the literal "primary" so legacy/stale rows (whose `calendar_id`
@@ -176,7 +205,7 @@ async function renderCalendarList(): Promise<void> {
176
205
  // Personal first, then alphabetical.
177
206
  const sorted = [...list].sort((a, b) =>
178
207
  (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)}">
208
+ 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
209
  <input type="checkbox" class="cal-side-cal-check" data-cal-id="${escapeHtml(c.id)}" ${hiddenCalendars.has(c.id) ? "" : "checked"}>
181
210
  ${calIconHtml(c)}
182
211
  <span class="cal-side-cal-name">${escapeHtml(c.name)}</span>
@@ -436,7 +465,9 @@ function renderEvents(events: CalEvent[]): void {
436
465
  <span class="cal-side-event-title cal-side-event-holiday-title" title="${escapeHtml(e.title)}">${calIconHtml(info)} ${escapeHtml(e.title)}</span>
437
466
  </div>`;
438
467
  } else {
439
- const titleAttr = link ? 'title="Click to open in Google Calendar"' : "";
468
+ const titleAttr = link
469
+ ? 'title="Click to open in Google Calendar to edit it. Right-click for more."'
470
+ : 'title="Saved in rmfmail, syncing to Google — it can be opened there once it has synced. Right-click to delete."';
440
471
  html += `<div class="cal-side-event" data-id="${e.id}"${recurAttr} data-link="${escapeHtml(link)}" ${titleAttr}>
441
472
  ${calIconHtml(info)}
442
473
  <span class="cal-side-event-time">${escapeHtml(formatTime(e))}</span>
@@ -482,16 +513,38 @@ function renderEvents(events: CalEvent[]): void {
482
513
  el.addEventListener("contextmenu", (e) => {
483
514
  e.preventDefault();
484
515
  const link = el.dataset.link;
485
- const items = [];
516
+ const uuid = el.dataset.id || "";
517
+ const title = el.querySelector(".cal-side-event-title")?.textContent?.trim() || "this event";
518
+ const items: MenuItem[] = [];
486
519
  if (link) items.push({
487
- label: "View in browser",
520
+ label: `Edit "${title}" in Google Calendar`,
488
521
  action: () => openInBrowser(link),
489
522
  });
490
523
  items.push({
491
524
  label: "Open Google Calendar",
492
525
  action: () => openInBrowser("https://calendar.google.com/"),
493
526
  });
494
- if (items.length > 0) showContextMenu(e.clientX, e.clientY, items, { editTarget: e.target });
527
+ if (uuid) {
528
+ items.push({ label: "", action: () => {}, separator: true });
529
+ items.push({
530
+ label: `Delete "${title}"`,
531
+ tooltip: "Removes it from your calendar in rmfmail now and from Google when it syncs.",
532
+ action: async () => {
533
+ if (!await confirmAt(e.clientX, e.clientY, `Delete "${title}"`)) return;
534
+ try {
535
+ await deleteCalendarEvent(uuid);
536
+ const st = document.getElementById("status-sync");
537
+ if (st) st.textContent = `Deleted event: ${title}`;
538
+ await refresh();
539
+ } catch (err: any) {
540
+ const st = document.getElementById("status-sync");
541
+ if (st) st.textContent = `Couldn't delete "${title}": ${err?.message || err}`;
542
+ console.error("[calendar] delete failed:", err);
543
+ }
544
+ },
545
+ });
546
+ }
547
+ showContextMenu(e.clientX, e.clientY, items, { editTarget: e.target });
495
548
  });
496
549
  });
497
550
  }
@@ -595,6 +648,7 @@ async function renderTasks(prefetched?: any[]): Promise<void> {
595
648
  }
596
649
 
597
650
  async function refresh(): Promise<void> {
651
+ followToday();
598
652
  renderHead();
599
653
  const from = new Date(viewYear, viewMonth, viewDay);
600
654
  // A throw here (IPC reject, daemon not ready) must NOT leave the
@@ -630,12 +684,15 @@ async function refresh(): Promise<void> {
630
684
  /** Open the natural-language new-event modal. The user types a free-form
631
685
  * description ("Lunch with John tomorrow at noon at Joe's"); we send it
632
686
  * 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. */
687
+ * (title/start/end/location/notes), and SAVE them — a local row at once,
688
+ * pushed to Google by the store-sync queue exactly as the sidebar's
689
+ * task editor does. Until 2026-09-10 this opened Google Calendar's
690
+ * create page in the browser instead, and the reader had to save it
691
+ * there, in whatever Google account the browser was signed into (Bob:
692
+ * "add an event puts me into Google Calendar. It should just save the
693
+ * event because I can easily edit it later"). If AI is disabled or the
694
+ * parse fails, the dialog says so and offers "Create as text": the raw
695
+ * text becomes the title of an all-day event today, to be edited. */
639
696
  function openNewEventDialog(): void {
640
697
  const backdrop = document.createElement("div");
641
698
  backdrop.className = "mailx-modal-backdrop";
@@ -650,7 +707,7 @@ function openNewEventDialog(): void {
650
707
 
651
708
  const hint = document.createElement("div");
652
709
  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.";
710
+ 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
711
 
655
712
  const ta = document.createElement("textarea");
656
713
  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;";
@@ -659,6 +716,36 @@ function openNewEventDialog(): void {
659
716
  const status = document.createElement("div");
660
717
  status.style.cssText = "min-height:1.2em;font-size:0.85em;color:var(--color-text-muted);";
661
718
 
719
+ // Which calendar. Offered only when Google says more than one can take
720
+ // an event (Bob 2026-09-10: "I want to start making more use of shared
721
+ // calendars"); with one there is nothing to choose and the row is not
722
+ // shown. The list is the one the sidebar already enumerated; fetched
723
+ // here only if the sidebar has not loaded it yet.
724
+ const calRow = document.createElement("label");
725
+ calRow.style.cssText = "display:flex;align-items:center;gap:8px;font-size:0.9em;";
726
+ calRow.title = "The calendar this event is saved to. Only calendars you can write to are listed; to add or share a calendar, do that in Google Calendar and press Refresh.";
727
+ const calSelect = document.createElement("select");
728
+ calSelect.style.cssText = "flex:1;font:inherit;padding:4px;";
729
+ calRow.append("Calendar", calSelect);
730
+ calRow.hidden = true;
731
+ const fillCalendars = (list: CalInfo[]) => {
732
+ const writable = writableCalendars(list);
733
+ calSelect.innerHTML = "";
734
+ let last = "";
735
+ try { last = localStorage.getItem(LAST_CALENDAR_PREF) || ""; } catch { /* per-viewer convenience only */ }
736
+ for (const c of writable) {
737
+ const opt = document.createElement("option");
738
+ opt.value = c.id;
739
+ opt.textContent = c.primary ? `${c.name} (personal)` : c.name;
740
+ if (c.id === last || (!last && c.primary)) opt.selected = true;
741
+ calSelect.append(opt);
742
+ }
743
+ calRow.hidden = writable.length < 2;
744
+ };
745
+ if (calendarList.length) fillCalendars(calendarList);
746
+ else getCalendars().then(list => { calendarList = list as CalInfo[]; fillCalendars(calendarList); })
747
+ .catch((e: any) => console.warn("[calendar] calendar list for the picker failed:", e?.message || e));
748
+
662
749
  const btnRow = document.createElement("div");
663
750
  btnRow.style.cssText = "display:flex;gap:8px;justify-content:flex-end;";
664
751
 
@@ -671,7 +758,7 @@ function openNewEventDialog(): void {
671
758
  createBtn.style.cssText = "padding:6px 14px;font-weight:500;";
672
759
 
673
760
  btnRow.append(cancelBtn, createBtn);
674
- panel.append(heading, hint, ta, status, btnRow);
761
+ panel.append(heading, hint, ta, calRow, status, btnRow);
675
762
  backdrop.append(panel);
676
763
  document.body.append(backdrop);
677
764
  setTimeout(() => ta.focus(), 0);
@@ -727,117 +814,84 @@ function openNewEventDialog(): void {
727
814
  }
728
815
  }
729
816
 
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;
817
+ // Save each extracted event (the text may describe several) as a
818
+ // local row; the store-sync queue pushes it to Google and binds the
819
+ // provider id when that lands. Local-first: the row is in the
820
+ // sidebar before Google has heard of it. The saved title is named
821
+ // in the status line, never "saved" alone.
736
822
  const targets = events.length ? events : [null];
737
- // The page opens in the browser's DEFAULT Google account, which need
738
- // not be the one mailx's calendar reads (Bob 2026-09-10: "I just used
739
- // the add event but don't see them on Google calendar"). authuser=
740
- // names the account, so the event lands where the sidebar looks.
741
- let authuser = "";
742
- try { authuser = String((await getPrimaryAccount("calendar"))?.email || ""); }
743
- catch (e: any) { console.warn("[calendar] primary account lookup failed, opening without authuser:", e?.message || e); }
823
+ const saved: string[] = [];
824
+ const calendarId = calSelect.value || "primary";
825
+ const calendarName = calSelect.selectedOptions[0]?.textContent || "";
826
+ try { localStorage.setItem(LAST_CALENDAR_PREF, calendarId); } catch { /* per-viewer convenience only */ }
744
827
  for (const ev of targets) {
745
- const url = buildGoogleEventUrl(ev, text, authuser);
746
- if (api?.openExternal) api.openExternal(url);
747
- else window.open(url, "_blank");
828
+ const row = { ...eventToLocalRow(ev, text), calendarId };
829
+ try {
830
+ await createCalendarEvent(row);
831
+ saved.push(row.title);
832
+ } catch (e: any) {
833
+ // Stop at the first failure and leave the dialog open with the
834
+ // reason: a silent partial save is worse than none.
835
+ status.textContent = `Couldn't save "${row.title}": ${e?.message || e}`;
836
+ createBtn.disabled = false;
837
+ cancelBtn.disabled = false;
838
+ return;
839
+ }
748
840
  }
749
- armPostCreateRefresh();
750
841
  close();
842
+ const st = document.getElementById("status-sync");
843
+ const where = calendarName && !calRow.hidden ? ` to ${calendarName}` : "";
844
+ if (st) st.textContent = saved.length === 1 ? `Saved event${where}: ${saved[0]}` : `Saved ${saved.length} events${where}: ${saved.join("; ")}`;
845
+ await refresh();
751
846
  });
752
847
  }
753
848
 
754
- /** After handing an event to Google Calendar in the browser, pull it back
755
- * as soon as it can exist: the next time this window regains focus (the
756
- * reader saved in the browser and came back), and on a short schedule in
757
- * case the browser sits on another monitor and focus never moves. Each
758
- * pull goes through refreshCalendarNow, which has its own 20 s floor and
759
- * emits calendarUpdated when rows changed — the sidebar re-renders from
760
- * that event, not from here. Disarms after the window; a second create
761
- * re-arms. (Bob 2026-09-10: "creating an event should trigger an
762
- * immediate update for the calendar view".) */
763
- const POST_CREATE_REFRESH_DELAYS_MS = [45_000, 120_000, 300_000];
764
- let postCreateTimers: number[] = [];
765
- let postCreateFocusHandler: (() => void) | null = null;
766
- function armPostCreateRefresh(): void {
767
- disarmPostCreateRefresh();
768
- const pull = async (why: string) => {
769
- try {
770
- const r = await refreshCalendarNow();
771
- if (!r?.ok) console.log(`[calendar] post-create refresh (${why}) not started: ${r?.reason || "unknown"}`);
772
- } catch (e: any) {
773
- console.warn(`[calendar] post-create refresh (${why}) failed:`, e?.message || e);
774
- }
775
- };
776
- postCreateFocusHandler = () => { void pull("window focus"); };
777
- window.addEventListener("focus", postCreateFocusHandler);
778
- postCreateTimers = POST_CREATE_REFRESH_DELAYS_MS.map((ms, i) => window.setTimeout(() => {
779
- void pull(`${ms / 1000} s after create`);
780
- if (i === POST_CREATE_REFRESH_DELAYS_MS.length - 1) disarmPostCreateRefresh();
781
- }, ms));
782
- }
783
- function disarmPostCreateRefresh(): void {
784
- for (const t of postCreateTimers) window.clearTimeout(t);
785
- postCreateTimers = [];
786
- if (postCreateFocusHandler) window.removeEventListener("focus", postCreateFocusHandler);
787
- postCreateFocusHandler = null;
849
+ /** The extracted event (wall-clock ISO strings in its own zone) as the
850
+ * row createCalendarEvent takes (epoch ms). A null event "Create as
851
+ * text" becomes an all-day event today titled with the raw text. */
852
+ function eventToLocalRow(event: any, fallbackText: string): { title: string; startMs: number; endMs: number; allDay: boolean; location?: string; notes?: string } {
853
+ const startOfToday = new Date(); startOfToday.setHours(0, 0, 0, 0);
854
+ if (!event?.startISO) {
855
+ return { title: (event?.title || fallbackText).slice(0, 200), startMs: startOfToday.getTime(), endMs: startOfToday.getTime() + 86400_000, allDay: true };
856
+ }
857
+ const allDay = !!event.allDay || !event.startISO.includes("T");
858
+ const startMs = allDay ? new Date(event.startISO.slice(0, 10) + "T00:00:00").getTime() : wallClockToMs(event.startISO, event.timeZone);
859
+ let endMs: number;
860
+ if (event.endISO) endMs = allDay ? new Date(event.endISO.slice(0, 10) + "T00:00:00").getTime() : wallClockToMs(event.endISO, event.timeZone);
861
+ else endMs = startMs + (allDay ? 86400_000 : 3600_000);
862
+ // An all-day end is exclusive; the extractor may hand back the same day
863
+ // for start and end, which would be a zero-length event.
864
+ if (allDay && endMs <= startMs) endMs = startMs + 86400_000;
865
+ return { title: event.title || fallbackText.slice(0, 200), startMs, endMs, allDay, location: event.location || undefined, notes: event.notes || undefined };
788
866
  }
789
867
 
790
- /** Compose Google Calendar's "render?action=TEMPLATE" URL with extracted
791
- * fields. When `event` is null we still produce a useful URL the raw
792
- * description goes into `text=` (becomes the title) and Google's UI lets
793
- * the user fill in dates manually. Times are emitted in compact form
794
- * without `Z` so Google interprets them in the user's calendar timezone. */
795
- function buildGoogleEventUrl(event: any, fallbackText: string, authuser = ""): string {
796
- const base = "https://calendar.google.com/calendar/render?action=TEMPLATE";
797
- const params: string[] = [];
798
- // Which Google account the page opens in the one mailx's calendar
799
- // reads, not whichever the browser signed in last (2026-09-10).
800
- if (authuser) params.push(`authuser=${encodeURIComponent(authuser)}`);
801
- const fmt = (iso: string): string => {
802
- // ISO "2026-05-06T12:00:00" "20260506T120000". Google accepts
803
- // local-naive times in this form.
804
- const m = iso.match(/(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?/);
805
- if (!m) return "";
806
- const [, y, mo, d, hh, mm, ss] = m;
807
- const date = `${y}${mo}${d}`;
808
- if (!hh) return date; // all-day form: dates=YYYYMMDD/YYYYMMDD
809
- return `${date}T${hh}${mm}${ss || "00"}`;
810
- };
811
-
812
- if (event?.title) params.push(`text=${encodeURIComponent(event.title)}`);
813
- else params.push(`text=${encodeURIComponent(fallbackText.slice(0, 200))}`);
814
-
815
- if (event?.startISO) {
816
- const startFmt = fmt(event.startISO);
817
- // For all-day events Google wants `dates=YYYYMMDD/YYYYMMDD` where
818
- // the end date is exclusive (next day). For timed events it's
819
- // `YYYYMMDDTHHMMSS/YYYYMMDDTHHMMSS`. Default end = +1h timed,
820
- // +1 day all-day.
821
- if (event.allDay) {
822
- const startDate = new Date(event.startISO + (event.startISO.includes("T") ? "" : "T00:00:00"));
823
- const endDate = new Date(startDate.getTime() + 86400_000);
824
- const endFmt = `${endDate.getFullYear()}${String(endDate.getMonth() + 1).padStart(2, "0")}${String(endDate.getDate()).padStart(2, "0")}`;
825
- params.push(`dates=${startFmt}/${endFmt}`);
826
- } else {
827
- const endFmt = event.endISO
828
- ? fmt(event.endISO)
829
- : fmt(new Date(new Date(event.startISO).getTime() + 3600_000).toISOString().slice(0, 19));
830
- params.push(`dates=${startFmt}/${endFmt}`);
868
+ /** Epoch ms for a wall-clock ISO string ("2026-09-12T09:00:00") read in
869
+ * `timeZone` an invite that says "9am Kuala Lumpur" stays 9am there.
870
+ * Without a zone the string is read as local time, which is what
871
+ * `new Date(iso)` does for an offset-less string. */
872
+ function wallClockToMs(iso: string, timeZone?: string): number {
873
+ const local = new Date(iso).getTime();
874
+ if (!timeZone) return local;
875
+ try {
876
+ // Offset of the zone at that instant: format the local guess back in
877
+ // the zone, and the difference is the correction. Two passes settle a
878
+ // guess that straddles a DST change.
879
+ let ms = local;
880
+ for (let i = 0; i < 2; i++) {
881
+ 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" })
882
+ .formatToParts(new Date(ms)).reduce((o, p) => (o[p.type] = p.value, o), {} as Record<string, string>);
883
+ const asIfLocal = new Date(`${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}`).getTime();
884
+ ms += local - asIfLocal;
831
885
  }
886
+ return ms;
887
+ } catch (e: any) {
888
+ console.warn(`[calendar] unknown time zone "${timeZone}", reading the time as local:`, e?.message || e);
889
+ return local;
832
890
  }
833
- if (event?.location) params.push(`location=${encodeURIComponent(event.location)}`);
834
- if (event?.notes) params.push(`details=${encodeURIComponent(event.notes)}`);
835
- // Times above are wall-clock in the event's own zone (an invite that says
836
- // "9am Kuala Lumpur" stays 9am); ctz tells Google which zone that is.
837
- if (event?.timeZone) params.push(`ctz=${encodeURIComponent(event.timeZone)}`);
838
- return `${base}&${params.join("&")}`;
839
891
  }
840
892
 
893
+
894
+
841
895
  /** Show the sidebar (called from the View menu toggle). Idempotent. */
842
896
  export async function showCalendarSidebar(): Promise<void> {
843
897
  const el = document.getElementById("calendar-sidebar");
@@ -877,9 +931,22 @@ export function initCalendarSidebar(): void {
877
931
  (el as any).__wired = true;
878
932
  el.addEventListener("click", fn);
879
933
  };
880
- wireOnce("cal-side-prev", () => { const d = new Date(viewYear, viewMonth, viewDay - 1); viewYear = d.getFullYear(); viewMonth = d.getMonth(); viewDay = d.getDate(); refresh(); });
881
- wireOnce("cal-side-next", () => { const d = new Date(viewYear, viewMonth, viewDay + 1); viewYear = d.getFullYear(); viewMonth = d.getMonth(); viewDay = d.getDate(); refresh(); });
882
- wireOnce("cal-side-today", () => { const t = new Date(); viewYear = t.getFullYear(); viewMonth = t.getMonth(); viewDay = t.getDate(); refresh(); });
934
+ wireOnce("cal-side-prev", () => { viewPinned = true; const d = new Date(viewYear, viewMonth, viewDay - 1); viewYear = d.getFullYear(); viewMonth = d.getMonth(); viewDay = d.getDate(); refresh(); });
935
+ wireOnce("cal-side-next", () => { viewPinned = true; const d = new Date(viewYear, viewMonth, viewDay + 1); viewYear = d.getFullYear(); viewMonth = d.getMonth(); viewDay = d.getDate(); refresh(); });
936
+ wireOnce("cal-side-today", () => { viewPinned = false; followToday(); refresh(); });
937
+ if (!(window as any).__mailxCalRolloverArmed) {
938
+ (window as any).__mailxCalRolloverArmed = true;
939
+ armMidnightRollover();
940
+ // Coming back to mailx from elsewhere — Google Calendar in the
941
+ // browser, most likely — pull what changed there. The daemon's 20 s
942
+ // floor turns a flapping window into at most one call per 20 s.
943
+ window.addEventListener("focus", () => {
944
+ if (!isCalendarSidebarOn()) return;
945
+ void refreshCalendarNow().then(r => {
946
+ if (!r?.ok && r?.reason && !/refreshed \d+ s ago/.test(r.reason)) console.log(`[calendar] focus refresh not started: ${r.reason}`);
947
+ }).catch((e: any) => console.warn("[calendar] focus refresh failed:", e?.message || e));
948
+ });
949
+ }
883
950
  wireOnce("cal-side-new", () => { openNewEventDialog(); });
884
951
  wireOnce("cal-side-new-task", async () => {
885
952
  const title = prompt("Task title:");
@@ -918,7 +985,16 @@ export function initCalendarSidebar(): void {
918
985
  try {
919
986
  // Also re-enumerate calendars — the user may have selected /
920
987
  // deselected one in Google since the sidebar opened.
921
- await Promise.all([renderCalendarList(), refresh()]);
988
+ // refreshCalendarNow is what makes this button honest: refresh()
989
+ // alone re-reads local rows, and the daemon's 5-minute throttle
990
+ // turned "Refresh events from Google" into a no-op most of the
991
+ // time (Bob 2026-09-10: "still showing me yesterday's calendar").
992
+ // Its emit re-renders the list when the pull changes anything.
993
+ const [, , pulled] = await Promise.all([renderCalendarList(), refresh(), refreshCalendarNow()]);
994
+ if (!pulled?.ok) {
995
+ const st = document.getElementById("status-sync");
996
+ if (st) st.textContent = `Calendar not pulled from Google: ${pulled?.reason || "unknown"}`;
997
+ }
922
998
  } finally {
923
999
  setTimeout(() => {
924
1000
  btn?.classList.remove("cal-side-refreshing");
package/client/index.html CHANGED
@@ -908,14 +908,14 @@
908
908
 
909
909
  <aside class="calendar-sidebar" id="calendar-sidebar" hidden aria-label="Calendar sidebar">
910
910
  <header class="cal-side-head">
911
- <button class="cal-side-nav" id="cal-side-prev" title="Previous">‹</button>
911
+ <button class="cal-side-nav" id="cal-side-prev" title="Previous day. The list then stays on that day until you press Today.">‹</button>
912
912
  <span class="cal-side-date" id="cal-side-date"></span>
913
- <button class="cal-side-nav" id="cal-side-today" title="Today">○</button>
914
- <button class="cal-side-nav" id="cal-side-next" title="Next">›</button>
913
+ <button class="cal-side-nav" id="cal-side-today" title="Back to today. The list follows the date on its own unless you have stepped to another day.">○</button>
914
+ <button class="cal-side-nav" id="cal-side-next" title="Next day. The list then stays on that day until you press Today.">›</button>
915
915
  </header>
916
916
  <div class="cal-side-actions">
917
- <button class="cal-side-new" id="cal-side-new" title="New event">+ New event</button>
918
- <button class="cal-side-new" id="cal-side-refresh-events" title="Refresh events from Google">↻</button>
917
+ <button class="cal-side-new" id="cal-side-new" title="New event: describe it in plain English and it is saved to your calendar. Click an event to edit it in Google Calendar; right-click to delete it.">+ New event</button>
918
+ <button class="cal-side-new" id="cal-side-refresh-events" title="Pull events and the calendar list from Google now. Otherwise they refresh every 5 minutes and whenever this window regains focus.">↻</button>
919
919
  <label class="cal-side-opt" title="Include expanded instances of recurring events">
920
920
  <input type="checkbox" id="cal-side-show-recurring" checked> Show recurring
921
921
  </label>
@@ -925,7 +925,7 @@
925
925
  </div>
926
926
  <!-- One checkbox per Google-selected calendar, generated at runtime
927
927
  by renderCalendarList(). Uncheck to hide that calendar in mailx. -->
928
- <div class="cal-side-calendars" id="cal-side-calendars"></div>
928
+ <div class="cal-side-calendars" id="cal-side-calendars" title="Your Google calendars. Untick one to hide its events here; add or remove calendars in Google Calendar itself."></div>
929
929
  <div class="cal-side-body" id="cal-side-body">
930
930
  <div class="cal-side-empty">Loading…</div>
931
931
  </div>
package/npmchanges.md CHANGED
@@ -1829,3 +1829,78 @@ self-spoof findings 35 before v1.2.314, 20 after it, 15 now. The 15 that
1829
1829
  remain are the sextortion, "failed to deliver", NDA-review and
1830
1830
  document-share phishes the check exists for. mailx-types 0.1.91.
1831
1831
 
1832
+ ## v1.2.316 — 2026-09-10
1833
+
1834
+ New event: spelling suggestions, the right Google account, and a prompt refresh
1835
+
1836
+ Three from Bob 2026-09-10 on the calendar sidebar's New event dialog.
1837
+
1838
+ "Spelling correct didn't work." The main window's right-click handler
1839
+ replaced the native WebView2 menu with mailx's Cut/Copy/Paste block on
1840
+ every surface — and Chromium's spelling suggestions and "Add to
1841
+ dictionary" exist only in the native menu, unreachable from JS. A word in
1842
+ the New event box was underlined with no menu that could fix it. Plain
1843
+ text controls (TEXTAREA, text-like INPUT) now keep the native menu, which
1844
+ already carries Cut/Copy/Paste/Select all for a text field; everything
1845
+ else is unchanged. (msger's compose items — Bold, Italic, View source —
1846
+ also appear on those fields via its IsEditable gate and forward to the
1847
+ compose frame, which is a no-op outside compose; cosmetic, noted.)
1848
+
1849
+ "I just used the add event but don't see them on Google calendar." The
1850
+ dialog opens Google Calendar's create page in the browser, which signs in
1851
+ as the browser's DEFAULT Google account, not necessarily the one mailx's
1852
+ calendar reads. The URL now carries authuser=<primary calendar account
1853
+ email> so the event lands where the sidebar looks.
1854
+
1855
+ "Creating an event should trigger an immediate update for the calendar
1856
+ view." The reader saves the event in the browser; mailx only learned of
1857
+ it at the next 5-minute throttled refresh. New refreshCalendarNow IPC
1858
+ (service, jsonrpc, mailxapi.js, api-client, MailxApi, Android stub that
1859
+ says ok:false) rewinds the throttle once and goes through
1860
+ getCalendarEvents, so the in-flight dedup and the calendarUpdated emit
1861
+ stay in one place; a 20 s floor of its own keeps it from reopening the
1862
+ quota hole the throttle closed. The sidebar arms it after a create: on
1863
+ the next window focus, and at 45 s / 2 min / 5 min in case the browser
1864
+ sits on another monitor. mailx-types 0.1.93, mailx-service 0.1.36,
1865
+ mailx-store-web 0.1.114.
1866
+
1867
+ ## v1.2.317 — 2026-09-10
1868
+
1869
+ Calendar sidebar: New event saves here, the list follows the date, Refresh pulls
1870
+
1871
+ Four from Bob 2026-09-10, all in the calendar sidebar.
1872
+
1873
+ "Add an event puts me into Google Calendar. It should just save the event
1874
+ because I can easily edit it later." The New event dialog now saves each
1875
+ extracted event as a local row through createCalendarEvent — the IPC and
1876
+ the store-sync push to Google existed since the two-way cache and nothing
1877
+ called them — and the sidebar shows it at once; Google gets it from the
1878
+ queue. The status line names what was saved. "Create as text" (AI parse
1879
+ failed) becomes an all-day event today titled with the text. Wall-clock
1880
+ times in a named zone are converted properly (wallClockToMs), so a "9am
1881
+ Kuala Lumpur" invite stays 9am there. buildGoogleEventUrl, the authuser
1882
+ work from v1.2.316 and the post-create refresh timers are gone with the
1883
+ page they served.
1884
+
1885
+ "It is still showing me yesterday's calendar." The view date was fixed
1886
+ when the module loaded and never moved. It now follows the calendar date
1887
+ unless the reader has stepped to another day with ‹ › (Today clears
1888
+ that), and re-renders at local midnight.
1889
+
1890
+ "Still showing…" had a second cause: the Refresh button said "from
1891
+ Google" and only re-read local rows — the daemon's 5-minute throttle made
1892
+ it a no-op most of the time. It now calls refreshCalendarNow (v1.2.316)
1893
+ and reports when nothing was pulled and why. The window's focus does the
1894
+ same, so an edit made in Google Calendar shows on coming back to rmfmail.
1895
+
1896
+ "How do I edit the calendar list? Perhaps more tooltip help would be
1897
+ useful?" Every control in the sidebar header now says what it does and
1898
+ what it does not: the calendar rows say that unticking hides a calendar
1899
+ here while the list itself is the calendars selected in Google Calendar;
1900
+ the ‹ › buttons say the list then stays on that day; New event says it
1901
+ saves and how to edit. Event rows: click opens the event in Google
1902
+ Calendar to edit; a just-saved row says it is syncing and cannot be
1903
+ opened there yet; right-click offers Edit "‹title›" in Google Calendar
1904
+ and Delete "‹title›" (local-first, queued to Google), with confirmAt at
1905
+ the pointer.
1906
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/rmfmail",
3
- "version": "1.2.316",
3
+ "version": "1.2.318",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -34,8 +34,8 @@
34
34
  "dependencies": {
35
35
  "@bobfrankston/iflow-direct": "^0.1.68",
36
36
  "@bobfrankston/mailx-host": "^0.1.15",
37
- "@bobfrankston/mailx-imap": "^0.1.180",
38
- "@bobfrankston/mailx-store-web": "^0.1.114",
37
+ "@bobfrankston/mailx-imap": "^0.1.181",
38
+ "@bobfrankston/mailx-store-web": "^0.1.115",
39
39
  "@bobfrankston/mailx-sync": "^0.1.29",
40
40
  "@bobfrankston/miscinfo": "^1.0.21",
41
41
  "@bobfrankston/msger": "^0.1.431",
@@ -119,8 +119,8 @@
119
119
  "dependencies": {
120
120
  "@bobfrankston/iflow-direct": "^0.1.68",
121
121
  "@bobfrankston/mailx-host": "^0.1.15",
122
- "@bobfrankston/mailx-imap": "^0.1.180",
123
- "@bobfrankston/mailx-store-web": "^0.1.114",
122
+ "@bobfrankston/mailx-imap": "^0.1.181",
123
+ "@bobfrankston/mailx-store-web": "^0.1.115",
124
124
  "@bobfrankston/mailx-sync": "^0.1.29",
125
125
  "@bobfrankston/miscinfo": "^1.0.21",
126
126
  "@bobfrankston/msger": "^0.1.431",
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.181",
3
+ "version": "0.1.182",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bobfrankston/mailx-imap",
9
- "version": "0.1.181",
9
+ "version": "0.1.182",
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
12
  "@bobfrankston/iflow-direct": "^0.1.27",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.181",
3
+ "version": "0.1.182",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -72,6 +72,11 @@ export interface GCalendarListEntry {
72
72
  * `reminders.useDefault === true`. Each entry is `{ method, minutes }`;
73
73
  * client carries only the minutes for popup reminders. */
74
74
  defaultReminderMinutes: number[];
75
+ /** Google's accessRole: "owner" | "writer" | "reader" | "freeBusyReader".
76
+ * Only the first two can take a new event — the New event dialog
77
+ * offers those (Bob 2026-09-10: "when creating an event I should be
78
+ * able to specify which calendar"). */
79
+ accessRole: string;
75
80
  }
76
81
  export declare function listCalendars(tokenProvider: TokenProvider): Promise<GCalendarListEntry[]>;
77
82
  export declare function createCalendarEvent(tokenProvider: TokenProvider, event: any, calendarId?: string): Promise<GCalEvent>;