@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.
- package/.commitmsg +16 -29
- package/.llm/trusted-lists.md +6 -0
- package/client/app.bundle.js +159 -91
- package/client/app.bundle.js.map +2 -2
- package/client/components/calendar-sidebar.js +210 -132
- package/client/components/calendar-sidebar.js.map +1 -1
- package/client/components/calendar-sidebar.ts +193 -117
- package/client/index.html +6 -6
- package/npmchanges.md +75 -0
- package/package.json +5 -5
- package/packages/mailx-imap/package-lock.json +2 -2
- package/packages/mailx-imap/package.json +1 -1
- package/packages/mailx-service/google-sync.d.ts +5 -0
- package/packages/mailx-service/google-sync.d.ts.map +1 -1
- package/packages/mailx-service/google-sync.js +1 -0
- package/packages/mailx-service/google-sync.js.map +1 -1
- package/packages/mailx-service/google-sync.ts +6 -0
- package/packages/mailx-service/index.d.ts +4 -0
- package/packages/mailx-service/index.d.ts.map +1 -1
- package/packages/mailx-service/index.js +19 -8
- package/packages/mailx-service/index.js.map +1 -1
- package/packages/mailx-service/index.ts +23 -8
- package/packages/mailx-service/package.json +1 -1
- package/packages/mailx-settings/package.json +1 -1
- package/packages/mailx-store/package.json +1 -1
- package/packages/mailx-store-web/package.json +1 -1
- package/packages/mailx-types/mailx-api.d.ts +2 -0
- package/packages/mailx-types/mailx-api.d.ts.map +1 -1
- package/packages/mailx-types/mailx-api.ts +2 -1
- package/packages/mailx-types/package.json +1 -1
|
@@ -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
|
|
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
|
|
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: "
|
|
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 (
|
|
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
|
|
634
|
-
*
|
|
635
|
-
*
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
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
|
|
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
|
-
//
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
//
|
|
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
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
|
746
|
-
|
|
747
|
-
|
|
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
|
-
/**
|
|
755
|
-
*
|
|
756
|
-
*
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
const
|
|
764
|
-
let
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
/**
|
|
791
|
-
*
|
|
792
|
-
*
|
|
793
|
-
*
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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", () => {
|
|
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
|
-
|
|
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="
|
|
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="
|
|
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.
|
|
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.
|
|
38
|
-
"@bobfrankston/mailx-store-web": "^0.1.
|
|
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.
|
|
123
|
-
"@bobfrankston/mailx-store-web": "^0.1.
|
|
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.
|
|
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.
|
|
9
|
+
"version": "0.1.182",
|
|
10
10
|
"license": "ISC",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@bobfrankston/iflow-direct": "^0.1.27",
|
|
@@ -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>;
|