@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
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* All storage goes through the service-side two-way cache (calendar_events
|
|
14
14
|
* and tasks tables); this file does not use localStorage for data.
|
|
15
15
|
*/
|
|
16
|
-
import {
|
|
16
|
+
import { getCalendarEvents, getCalendars, createCalendarEvent, deleteCalendarEvent, refreshCalendarNow, getTasks, createTask, updateTask, deleteTask, reauthGoogleScopes, getSettings, saveSettings, } from "../lib/api-client.js";
|
|
17
17
|
import { showContextMenu, confirmAt } from "./context-menu.js";
|
|
18
18
|
const SIDEBAR_PREF = "mailx-calendar-sidebar-on";
|
|
19
19
|
const SHOW_RECURRING_PREF = "mailx-cal-show-recurring";
|
|
@@ -32,11 +32,40 @@ const HOLIDAY_HORIZON_MS = 14 * 86400_000;
|
|
|
32
32
|
let viewYear = new Date().getFullYear();
|
|
33
33
|
let viewMonth = new Date().getMonth();
|
|
34
34
|
let viewDay = new Date().getDate();
|
|
35
|
+
/** True once the reader steps to another day with ‹ ›; "Today" clears it.
|
|
36
|
+
* While false the view FOLLOWS the calendar date: the app runs for days,
|
|
37
|
+
* and the date above was fixed when this module loaded (Bob 2026-09-10:
|
|
38
|
+
* "it is still showing me yesterday's calendar"). */
|
|
39
|
+
let viewPinned = false;
|
|
40
|
+
function followToday() {
|
|
41
|
+
if (viewPinned)
|
|
42
|
+
return;
|
|
43
|
+
const t = new Date();
|
|
44
|
+
viewYear = t.getFullYear();
|
|
45
|
+
viewMonth = t.getMonth();
|
|
46
|
+
viewDay = t.getDate();
|
|
47
|
+
}
|
|
48
|
+
/** Re-render at the next local midnight, then re-arm. A setInterval would
|
|
49
|
+
* drift; computing the next midnight each time lands on it exactly. */
|
|
50
|
+
function armMidnightRollover() {
|
|
51
|
+
const now = new Date();
|
|
52
|
+
const next = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 5);
|
|
53
|
+
window.setTimeout(() => { void refresh(); armMidnightRollover(); }, next.getTime() - now.getTime());
|
|
54
|
+
}
|
|
35
55
|
let lastEvents = [];
|
|
36
56
|
/** Overdue tasks (due before today, not completed) — pinned to the top of
|
|
37
57
|
* the calendar list. Cached so renderEvents() can be re-run on a calendar
|
|
38
58
|
* visibility toggle without re-fetching tasks. */
|
|
39
59
|
let lastOverdueTasks = [];
|
|
60
|
+
/** Calendars a new event can be saved to, personal first. */
|
|
61
|
+
function writableCalendars(list) {
|
|
62
|
+
return list
|
|
63
|
+
.filter(c => c.accessRole === "owner" || c.accessRole === "writer")
|
|
64
|
+
.sort((a, b) => (a.primary ? 0 : 1) - (b.primary ? 0 : 1) || a.name.localeCompare(b.name));
|
|
65
|
+
}
|
|
66
|
+
/** The calendar the last event was saved to — a per-machine convenience,
|
|
67
|
+
* so a run of entries on a shared calendar does not mean re-picking it. */
|
|
68
|
+
const LAST_CALENDAR_PREF = "mailx.calendar.lastCreateCalendar";
|
|
40
69
|
/** Selected Google calendars, by id. The primary calendar is also keyed
|
|
41
70
|
* under the literal "primary" so legacy/stale rows (whose `calendar_id`
|
|
42
71
|
* predates per-calendar tagging) still resolve. */
|
|
@@ -144,7 +173,7 @@ async function renderCalendarList() {
|
|
|
144
173
|
else {
|
|
145
174
|
// Personal first, then alphabetical.
|
|
146
175
|
const sorted = [...list].sort((a, b) => (a.primary ? 0 : 1) - (b.primary ? 0 : 1) || a.name.localeCompare(b.name));
|
|
147
|
-
host.innerHTML = sorted.map(c => `<label class="cal-side-cal-row" title="${escapeHtml(c.name)}">
|
|
176
|
+
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.">
|
|
148
177
|
<input type="checkbox" class="cal-side-cal-check" data-cal-id="${escapeHtml(c.id)}" ${hiddenCalendars.has(c.id) ? "" : "checked"}>
|
|
149
178
|
${calIconHtml(c)}
|
|
150
179
|
<span class="cal-side-cal-name">${escapeHtml(c.name)}</span>
|
|
@@ -417,7 +446,9 @@ function renderEvents(events) {
|
|
|
417
446
|
</div>`;
|
|
418
447
|
}
|
|
419
448
|
else {
|
|
420
|
-
const titleAttr = link
|
|
449
|
+
const titleAttr = link
|
|
450
|
+
? 'title="Click to open in Google Calendar to edit it. Right-click for more."'
|
|
451
|
+
: 'title="Saved in rmfmail, syncing to Google — it can be opened there once it has synced. Right-click to delete."';
|
|
421
452
|
html += `<div class="cal-side-event" data-id="${e.id}"${recurAttr} data-link="${escapeHtml(link)}" ${titleAttr}>
|
|
422
453
|
${calIconHtml(info)}
|
|
423
454
|
<span class="cal-side-event-time">${escapeHtml(formatTime(e))}</span>
|
|
@@ -469,18 +500,43 @@ function renderEvents(events) {
|
|
|
469
500
|
el.addEventListener("contextmenu", (e) => {
|
|
470
501
|
e.preventDefault();
|
|
471
502
|
const link = el.dataset.link;
|
|
503
|
+
const uuid = el.dataset.id || "";
|
|
504
|
+
const title = el.querySelector(".cal-side-event-title")?.textContent?.trim() || "this event";
|
|
472
505
|
const items = [];
|
|
473
506
|
if (link)
|
|
474
507
|
items.push({
|
|
475
|
-
label: "
|
|
508
|
+
label: `Edit "${title}" in Google Calendar`,
|
|
476
509
|
action: () => openInBrowser(link),
|
|
477
510
|
});
|
|
478
511
|
items.push({
|
|
479
512
|
label: "Open Google Calendar",
|
|
480
513
|
action: () => openInBrowser("https://calendar.google.com/"),
|
|
481
514
|
});
|
|
482
|
-
if (
|
|
483
|
-
|
|
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}"`))
|
|
522
|
+
return;
|
|
523
|
+
try {
|
|
524
|
+
await deleteCalendarEvent(uuid);
|
|
525
|
+
const st = document.getElementById("status-sync");
|
|
526
|
+
if (st)
|
|
527
|
+
st.textContent = `Deleted event: ${title}`;
|
|
528
|
+
await refresh();
|
|
529
|
+
}
|
|
530
|
+
catch (err) {
|
|
531
|
+
const st = document.getElementById("status-sync");
|
|
532
|
+
if (st)
|
|
533
|
+
st.textContent = `Couldn't delete "${title}": ${err?.message || err}`;
|
|
534
|
+
console.error("[calendar] delete failed:", err);
|
|
535
|
+
}
|
|
536
|
+
},
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
showContextMenu(e.clientX, e.clientY, items, { editTarget: e.target });
|
|
484
540
|
});
|
|
485
541
|
});
|
|
486
542
|
}
|
|
@@ -595,6 +651,7 @@ async function renderTasks(prefetched) {
|
|
|
595
651
|
});
|
|
596
652
|
}
|
|
597
653
|
async function refresh() {
|
|
654
|
+
followToday();
|
|
598
655
|
renderHead();
|
|
599
656
|
const from = new Date(viewYear, viewMonth, viewDay);
|
|
600
657
|
// A throw here (IPC reject, daemon not ready) must NOT leave the
|
|
@@ -632,12 +689,15 @@ async function refresh() {
|
|
|
632
689
|
/** Open the natural-language new-event modal. The user types a free-form
|
|
633
690
|
* description ("Lunch with John tomorrow at noon at Joe's"); we send it
|
|
634
691
|
* to aiTransform with action=extractEvent, get back structured fields
|
|
635
|
-
* (title/start/end/location/notes), and
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
692
|
+
* (title/start/end/location/notes), and SAVE them — a local row at once,
|
|
693
|
+
* pushed to Google by the store-sync queue — exactly as the sidebar's
|
|
694
|
+
* task editor does. Until 2026-09-10 this opened Google Calendar's
|
|
695
|
+
* create page in the browser instead, and the reader had to save it
|
|
696
|
+
* there, in whatever Google account the browser was signed into (Bob:
|
|
697
|
+
* "add an event puts me into Google Calendar. It should just save the
|
|
698
|
+
* event because I can easily edit it later"). If AI is disabled or the
|
|
699
|
+
* parse fails, the dialog says so and offers "Create as text": the raw
|
|
700
|
+
* text becomes the title of an all-day event today, to be edited. */
|
|
641
701
|
function openNewEventDialog() {
|
|
642
702
|
const backdrop = document.createElement("div");
|
|
643
703
|
backdrop.className = "mailx-modal-backdrop";
|
|
@@ -649,12 +709,47 @@ function openNewEventDialog() {
|
|
|
649
709
|
heading.textContent = "New event";
|
|
650
710
|
const hint = document.createElement("div");
|
|
651
711
|
hint.style.cssText = "font-size:0.9em;color:var(--color-text-muted);";
|
|
652
|
-
hint.textContent = "Describe the event in plain English — AI extracts the details and
|
|
712
|
+
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.";
|
|
653
713
|
const ta = document.createElement("textarea");
|
|
654
714
|
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;";
|
|
655
715
|
ta.placeholder = "Lunch with John tomorrow at noon at Joe's Pizza";
|
|
656
716
|
const status = document.createElement("div");
|
|
657
717
|
status.style.cssText = "min-height:1.2em;font-size:0.85em;color:var(--color-text-muted);";
|
|
718
|
+
// Which calendar. Offered only when Google says more than one can take
|
|
719
|
+
// an event (Bob 2026-09-10: "I want to start making more use of shared
|
|
720
|
+
// calendars"); with one there is nothing to choose and the row is not
|
|
721
|
+
// shown. The list is the one the sidebar already enumerated; fetched
|
|
722
|
+
// here only if the sidebar has not loaded it yet.
|
|
723
|
+
const calRow = document.createElement("label");
|
|
724
|
+
calRow.style.cssText = "display:flex;align-items:center;gap:8px;font-size:0.9em;";
|
|
725
|
+
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.";
|
|
726
|
+
const calSelect = document.createElement("select");
|
|
727
|
+
calSelect.style.cssText = "flex:1;font:inherit;padding:4px;";
|
|
728
|
+
calRow.append("Calendar", calSelect);
|
|
729
|
+
calRow.hidden = true;
|
|
730
|
+
const fillCalendars = (list) => {
|
|
731
|
+
const writable = writableCalendars(list);
|
|
732
|
+
calSelect.innerHTML = "";
|
|
733
|
+
let last = "";
|
|
734
|
+
try {
|
|
735
|
+
last = localStorage.getItem(LAST_CALENDAR_PREF) || "";
|
|
736
|
+
}
|
|
737
|
+
catch { /* per-viewer convenience only */ }
|
|
738
|
+
for (const c of writable) {
|
|
739
|
+
const opt = document.createElement("option");
|
|
740
|
+
opt.value = c.id;
|
|
741
|
+
opt.textContent = c.primary ? `${c.name} (personal)` : c.name;
|
|
742
|
+
if (c.id === last || (!last && c.primary))
|
|
743
|
+
opt.selected = true;
|
|
744
|
+
calSelect.append(opt);
|
|
745
|
+
}
|
|
746
|
+
calRow.hidden = writable.length < 2;
|
|
747
|
+
};
|
|
748
|
+
if (calendarList.length)
|
|
749
|
+
fillCalendars(calendarList);
|
|
750
|
+
else
|
|
751
|
+
getCalendars().then(list => { calendarList = list; fillCalendars(calendarList); })
|
|
752
|
+
.catch((e) => console.warn("[calendar] calendar list for the picker failed:", e?.message || e));
|
|
658
753
|
const btnRow = document.createElement("div");
|
|
659
754
|
btnRow.style.cssText = "display:flex;gap:8px;justify-content:flex-end;";
|
|
660
755
|
const cancelBtn = document.createElement("button");
|
|
@@ -664,7 +759,7 @@ function openNewEventDialog() {
|
|
|
664
759
|
createBtn.textContent = "Create";
|
|
665
760
|
createBtn.style.cssText = "padding:6px 14px;font-weight:500;";
|
|
666
761
|
btnRow.append(cancelBtn, createBtn);
|
|
667
|
-
panel.append(heading, hint, ta, status, btnRow);
|
|
762
|
+
panel.append(heading, hint, ta, calRow, status, btnRow);
|
|
668
763
|
backdrop.append(panel);
|
|
669
764
|
document.body.append(backdrop);
|
|
670
765
|
setTimeout(() => ta.focus(), 0);
|
|
@@ -726,131 +821,89 @@ function openNewEventDialog() {
|
|
|
726
821
|
return;
|
|
727
822
|
}
|
|
728
823
|
}
|
|
729
|
-
//
|
|
730
|
-
//
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
const api = window.mailxapi;
|
|
824
|
+
// Save each extracted event (the text may describe several) as a
|
|
825
|
+
// local row; the store-sync queue pushes it to Google and binds the
|
|
826
|
+
// provider id when that lands. Local-first: the row is in the
|
|
827
|
+
// sidebar before Google has heard of it. The saved title is named
|
|
828
|
+
// in the status line, never "saved" alone.
|
|
735
829
|
const targets = events.length ? events : [null];
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
// names the account, so the event lands where the sidebar looks.
|
|
740
|
-
let authuser = "";
|
|
830
|
+
const saved = [];
|
|
831
|
+
const calendarId = calSelect.value || "primary";
|
|
832
|
+
const calendarName = calSelect.selectedOptions[0]?.textContent || "";
|
|
741
833
|
try {
|
|
742
|
-
|
|
743
|
-
}
|
|
744
|
-
catch (e) {
|
|
745
|
-
console.warn("[calendar] primary account lookup failed, opening without authuser:", e?.message || e);
|
|
834
|
+
localStorage.setItem(LAST_CALENDAR_PREF, calendarId);
|
|
746
835
|
}
|
|
836
|
+
catch { /* per-viewer convenience only */ }
|
|
747
837
|
for (const ev of targets) {
|
|
748
|
-
const
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
838
|
+
const row = { ...eventToLocalRow(ev, text), calendarId };
|
|
839
|
+
try {
|
|
840
|
+
await createCalendarEvent(row);
|
|
841
|
+
saved.push(row.title);
|
|
842
|
+
}
|
|
843
|
+
catch (e) {
|
|
844
|
+
// Stop at the first failure and leave the dialog open with the
|
|
845
|
+
// reason: a silent partial save is worse than none.
|
|
846
|
+
status.textContent = `Couldn't save "${row.title}": ${e?.message || e}`;
|
|
847
|
+
createBtn.disabled = false;
|
|
848
|
+
cancelBtn.disabled = false;
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
753
851
|
}
|
|
754
|
-
armPostCreateRefresh();
|
|
755
852
|
close();
|
|
853
|
+
const st = document.getElementById("status-sync");
|
|
854
|
+
const where = calendarName && !calRow.hidden ? ` to ${calendarName}` : "";
|
|
855
|
+
if (st)
|
|
856
|
+
st.textContent = saved.length === 1 ? `Saved event${where}: ${saved[0]}` : `Saved ${saved.length} events${where}: ${saved.join("; ")}`;
|
|
857
|
+
await refresh();
|
|
756
858
|
});
|
|
757
859
|
}
|
|
758
|
-
/**
|
|
759
|
-
*
|
|
760
|
-
*
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
const
|
|
768
|
-
|
|
769
|
-
let
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
const pull = async (why) => {
|
|
773
|
-
try {
|
|
774
|
-
const r = await refreshCalendarNow();
|
|
775
|
-
if (!r?.ok)
|
|
776
|
-
console.log(`[calendar] post-create refresh (${why}) not started: ${r?.reason || "unknown"}`);
|
|
777
|
-
}
|
|
778
|
-
catch (e) {
|
|
779
|
-
console.warn(`[calendar] post-create refresh (${why}) failed:`, e?.message || e);
|
|
780
|
-
}
|
|
781
|
-
};
|
|
782
|
-
postCreateFocusHandler = () => { void pull("window focus"); };
|
|
783
|
-
window.addEventListener("focus", postCreateFocusHandler);
|
|
784
|
-
postCreateTimers = POST_CREATE_REFRESH_DELAYS_MS.map((ms, i) => window.setTimeout(() => {
|
|
785
|
-
void pull(`${ms / 1000} s after create`);
|
|
786
|
-
if (i === POST_CREATE_REFRESH_DELAYS_MS.length - 1)
|
|
787
|
-
disarmPostCreateRefresh();
|
|
788
|
-
}, ms));
|
|
789
|
-
}
|
|
790
|
-
function disarmPostCreateRefresh() {
|
|
791
|
-
for (const t of postCreateTimers)
|
|
792
|
-
window.clearTimeout(t);
|
|
793
|
-
postCreateTimers = [];
|
|
794
|
-
if (postCreateFocusHandler)
|
|
795
|
-
window.removeEventListener("focus", postCreateFocusHandler);
|
|
796
|
-
postCreateFocusHandler = null;
|
|
797
|
-
}
|
|
798
|
-
/** Compose Google Calendar's "render?action=TEMPLATE" URL with extracted
|
|
799
|
-
* fields. When `event` is null we still produce a useful URL — the raw
|
|
800
|
-
* description goes into `text=` (becomes the title) and Google's UI lets
|
|
801
|
-
* the user fill in dates manually. Times are emitted in compact form
|
|
802
|
-
* without `Z` so Google interprets them in the user's calendar timezone. */
|
|
803
|
-
function buildGoogleEventUrl(event, fallbackText, authuser = "") {
|
|
804
|
-
const base = "https://calendar.google.com/calendar/render?action=TEMPLATE";
|
|
805
|
-
const params = [];
|
|
806
|
-
// Which Google account the page opens in — the one mailx's calendar
|
|
807
|
-
// reads, not whichever the browser signed in last (2026-09-10).
|
|
808
|
-
if (authuser)
|
|
809
|
-
params.push(`authuser=${encodeURIComponent(authuser)}`);
|
|
810
|
-
const fmt = (iso) => {
|
|
811
|
-
// ISO "2026-05-06T12:00:00" → "20260506T120000". Google accepts
|
|
812
|
-
// local-naive times in this form.
|
|
813
|
-
const m = iso.match(/(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?/);
|
|
814
|
-
if (!m)
|
|
815
|
-
return "";
|
|
816
|
-
const [, y, mo, d, hh, mm, ss] = m;
|
|
817
|
-
const date = `${y}${mo}${d}`;
|
|
818
|
-
if (!hh)
|
|
819
|
-
return date; // all-day form: dates=YYYYMMDD/YYYYMMDD
|
|
820
|
-
return `${date}T${hh}${mm}${ss || "00"}`;
|
|
821
|
-
};
|
|
822
|
-
if (event?.title)
|
|
823
|
-
params.push(`text=${encodeURIComponent(event.title)}`);
|
|
860
|
+
/** The extracted event (wall-clock ISO strings in its own zone) as the
|
|
861
|
+
* row createCalendarEvent takes (epoch ms). A null event — "Create as
|
|
862
|
+
* text" — becomes an all-day event today titled with the raw text. */
|
|
863
|
+
function eventToLocalRow(event, fallbackText) {
|
|
864
|
+
const startOfToday = new Date();
|
|
865
|
+
startOfToday.setHours(0, 0, 0, 0);
|
|
866
|
+
if (!event?.startISO) {
|
|
867
|
+
return { title: (event?.title || fallbackText).slice(0, 200), startMs: startOfToday.getTime(), endMs: startOfToday.getTime() + 86400_000, allDay: true };
|
|
868
|
+
}
|
|
869
|
+
const allDay = !!event.allDay || !event.startISO.includes("T");
|
|
870
|
+
const startMs = allDay ? new Date(event.startISO.slice(0, 10) + "T00:00:00").getTime() : wallClockToMs(event.startISO, event.timeZone);
|
|
871
|
+
let endMs;
|
|
872
|
+
if (event.endISO)
|
|
873
|
+
endMs = allDay ? new Date(event.endISO.slice(0, 10) + "T00:00:00").getTime() : wallClockToMs(event.endISO, event.timeZone);
|
|
824
874
|
else
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
875
|
+
endMs = startMs + (allDay ? 86400_000 : 3600_000);
|
|
876
|
+
// An all-day end is exclusive; the extractor may hand back the same day
|
|
877
|
+
// for start and end, which would be a zero-length event.
|
|
878
|
+
if (allDay && endMs <= startMs)
|
|
879
|
+
endMs = startMs + 86400_000;
|
|
880
|
+
return { title: event.title || fallbackText.slice(0, 200), startMs, endMs, allDay, location: event.location || undefined, notes: event.notes || undefined };
|
|
881
|
+
}
|
|
882
|
+
/** Epoch ms for a wall-clock ISO string ("2026-09-12T09:00:00") read in
|
|
883
|
+
* `timeZone` — an invite that says "9am Kuala Lumpur" stays 9am there.
|
|
884
|
+
* Without a zone the string is read as local time, which is what
|
|
885
|
+
* `new Date(iso)` does for an offset-less string. */
|
|
886
|
+
function wallClockToMs(iso, timeZone) {
|
|
887
|
+
const local = new Date(iso).getTime();
|
|
888
|
+
if (!timeZone)
|
|
889
|
+
return local;
|
|
890
|
+
try {
|
|
891
|
+
// Offset of the zone at that instant: format the local guess back in
|
|
892
|
+
// the zone, and the difference is the correction. Two passes settle a
|
|
893
|
+
// guess that straddles a DST change.
|
|
894
|
+
let ms = local;
|
|
895
|
+
for (let i = 0; i < 2; i++) {
|
|
896
|
+
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" })
|
|
897
|
+
.formatToParts(new Date(ms)).reduce((o, p) => (o[p.type] = p.value, o), {});
|
|
898
|
+
const asIfLocal = new Date(`${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}`).getTime();
|
|
899
|
+
ms += local - asIfLocal;
|
|
843
900
|
}
|
|
901
|
+
return ms;
|
|
902
|
+
}
|
|
903
|
+
catch (e) {
|
|
904
|
+
console.warn(`[calendar] unknown time zone "${timeZone}", reading the time as local:`, e?.message || e);
|
|
905
|
+
return local;
|
|
844
906
|
}
|
|
845
|
-
if (event?.location)
|
|
846
|
-
params.push(`location=${encodeURIComponent(event.location)}`);
|
|
847
|
-
if (event?.notes)
|
|
848
|
-
params.push(`details=${encodeURIComponent(event.notes)}`);
|
|
849
|
-
// Times above are wall-clock in the event's own zone (an invite that says
|
|
850
|
-
// "9am Kuala Lumpur" stays 9am); ctz tells Google which zone that is.
|
|
851
|
-
if (event?.timeZone)
|
|
852
|
-
params.push(`ctz=${encodeURIComponent(event.timeZone)}`);
|
|
853
|
-
return `${base}&${params.join("&")}`;
|
|
854
907
|
}
|
|
855
908
|
/** Show the sidebar (called from the View menu toggle). Idempotent. */
|
|
856
909
|
export async function showCalendarSidebar() {
|
|
@@ -900,9 +953,24 @@ export function initCalendarSidebar() {
|
|
|
900
953
|
el.__wired = true;
|
|
901
954
|
el.addEventListener("click", fn);
|
|
902
955
|
};
|
|
903
|
-
wireOnce("cal-side-prev", () => { const d = new Date(viewYear, viewMonth, viewDay - 1); viewYear = d.getFullYear(); viewMonth = d.getMonth(); viewDay = d.getDate(); refresh(); });
|
|
904
|
-
wireOnce("cal-side-next", () => { const d = new Date(viewYear, viewMonth, viewDay + 1); viewYear = d.getFullYear(); viewMonth = d.getMonth(); viewDay = d.getDate(); refresh(); });
|
|
905
|
-
wireOnce("cal-side-today", () => {
|
|
956
|
+
wireOnce("cal-side-prev", () => { viewPinned = true; const d = new Date(viewYear, viewMonth, viewDay - 1); viewYear = d.getFullYear(); viewMonth = d.getMonth(); viewDay = d.getDate(); refresh(); });
|
|
957
|
+
wireOnce("cal-side-next", () => { viewPinned = true; const d = new Date(viewYear, viewMonth, viewDay + 1); viewYear = d.getFullYear(); viewMonth = d.getMonth(); viewDay = d.getDate(); refresh(); });
|
|
958
|
+
wireOnce("cal-side-today", () => { viewPinned = false; followToday(); refresh(); });
|
|
959
|
+
if (!window.__mailxCalRolloverArmed) {
|
|
960
|
+
window.__mailxCalRolloverArmed = true;
|
|
961
|
+
armMidnightRollover();
|
|
962
|
+
// Coming back to mailx from elsewhere — Google Calendar in the
|
|
963
|
+
// browser, most likely — pull what changed there. The daemon's 20 s
|
|
964
|
+
// floor turns a flapping window into at most one call per 20 s.
|
|
965
|
+
window.addEventListener("focus", () => {
|
|
966
|
+
if (!isCalendarSidebarOn())
|
|
967
|
+
return;
|
|
968
|
+
void refreshCalendarNow().then(r => {
|
|
969
|
+
if (!r?.ok && r?.reason && !/refreshed \d+ s ago/.test(r.reason))
|
|
970
|
+
console.log(`[calendar] focus refresh not started: ${r.reason}`);
|
|
971
|
+
}).catch((e) => console.warn("[calendar] focus refresh failed:", e?.message || e));
|
|
972
|
+
});
|
|
973
|
+
}
|
|
906
974
|
wireOnce("cal-side-new", () => { openNewEventDialog(); });
|
|
907
975
|
wireOnce("cal-side-new-task", async () => {
|
|
908
976
|
const title = prompt("Task title:");
|
|
@@ -947,7 +1015,17 @@ export function initCalendarSidebar() {
|
|
|
947
1015
|
try {
|
|
948
1016
|
// Also re-enumerate calendars — the user may have selected /
|
|
949
1017
|
// deselected one in Google since the sidebar opened.
|
|
950
|
-
|
|
1018
|
+
// refreshCalendarNow is what makes this button honest: refresh()
|
|
1019
|
+
// alone re-reads local rows, and the daemon's 5-minute throttle
|
|
1020
|
+
// turned "Refresh events from Google" into a no-op most of the
|
|
1021
|
+
// time (Bob 2026-09-10: "still showing me yesterday's calendar").
|
|
1022
|
+
// Its emit re-renders the list when the pull changes anything.
|
|
1023
|
+
const [, , pulled] = await Promise.all([renderCalendarList(), refresh(), refreshCalendarNow()]);
|
|
1024
|
+
if (!pulled?.ok) {
|
|
1025
|
+
const st = document.getElementById("status-sync");
|
|
1026
|
+
if (st)
|
|
1027
|
+
st.textContent = `Calendar not pulled from Google: ${pulled?.reason || "unknown"}`;
|
|
1028
|
+
}
|
|
951
1029
|
}
|
|
952
1030
|
finally {
|
|
953
1031
|
setTimeout(() => {
|