@bobfrankston/mailx 1.0.384 → 1.0.385

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/bin/mailx.js CHANGED
@@ -1213,6 +1213,15 @@ RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable i
1213
1213
  // with one IPC write per message — lets the UI flip many rows at once.
1214
1214
  let pendingCached = [];
1215
1215
  let cachedTimer = null;
1216
+ // Calendar/tasks refresh completion — service emits when a pull merged
1217
+ // new rows or reconciled a server-side delete. Client re-renders its
1218
+ // sidebar on receipt. No payload beyond accountId.
1219
+ imapManager.on("calendarUpdated", (payload) => {
1220
+ handle.send({ _event: "calendarUpdated", type: "calendarUpdated", ...payload });
1221
+ });
1222
+ imapManager.on("tasksUpdated", (payload) => {
1223
+ handle.send({ _event: "tasksUpdated", type: "tasksUpdated", ...payload });
1224
+ });
1216
1225
  imapManager.on("bodyCached", (accountId, uid) => {
1217
1226
  pendingCached.push({ accountId, uid });
1218
1227
  if (!cachedTimer) {
@@ -1328,6 +1337,24 @@ RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable i
1328
1337
  setInterval(() => {
1329
1338
  svc.drainStoreSync().catch((e) => console.error(` [store_sync] periodic drain error: ${e?.message || e}`));
1330
1339
  }, 30_000);
1340
+ // Calendar + Tasks poll — pulls server-side changes so the sidebar
1341
+ // reflects events added/edited/deleted on another device without
1342
+ // needing a sidebar nav click. 5-minute cadence is well under the
1343
+ // per-user rate limit and the 1M/day project quota. Webhooks would
1344
+ // be cheaper in theory but need a public HTTPS endpoint; poll is
1345
+ // the pragmatic choice (confirmed 2026-04-23). getCalendarEvents /
1346
+ // getTasks emit the refresh event via imapManager, so the sidebar
1347
+ // re-renders automatically.
1348
+ const CAL_POLL_MS = 5 * 60_000;
1349
+ const horizonDays = 90; // larger than sidebar's default so background
1350
+ // poll catches upcoming-week events the sidebar hasn't asked for yet.
1351
+ setInterval(() => {
1352
+ const now = Date.now();
1353
+ svc.getCalendarEvents(now, now + horizonDays * 86400_000)
1354
+ .catch((e) => console.error(` [calendar] poll error: ${e?.message || e}`));
1355
+ svc.getTasks(false)
1356
+ .catch((e) => console.error(` [tasks] poll error: ${e?.message || e}`));
1357
+ }, CAL_POLL_MS);
1331
1358
  // Auto-update: periodically check npm for a newer version and push a
1332
1359
  // notification to the WebView so the user can update with one click.
1333
1360
  const UPDATE_CHECK_MS = 30 * 60_000; // 30 minutes
package/client/app.js CHANGED
@@ -1061,16 +1061,40 @@ document.getElementById("rail-help")?.addEventListener("click", () => {
1061
1061
  document.getElementById("btn-about")?.click();
1062
1062
  });
1063
1063
  document.getElementById("rail-theme")?.addEventListener("click", () => {
1064
- // Cycle theme: system → dark → light → system.
1064
+ // Rail theme icon cycles system → dark → light → system. Settings menu
1065
+ // exposes the same three as radio buttons for direct selection.
1065
1066
  const root = document.documentElement;
1066
1067
  const cur = root.getAttribute("data-theme") || "system";
1067
1068
  const next = cur === "system" ? "dark" : cur === "dark" ? "light" : "system";
1068
- root.setAttribute("data-theme", next);
1069
+ applyTheme(next);
1070
+ });
1071
+ function applyTheme(theme) {
1072
+ document.documentElement.setAttribute("data-theme", theme);
1069
1073
  try {
1070
- localStorage.setItem("mailx-theme", next);
1074
+ localStorage.setItem("mailx-theme", theme);
1071
1075
  }
1072
1076
  catch { /* private mode */ }
1073
- });
1077
+ // Reflect in the Settings menu radios so the two paths stay in sync.
1078
+ const radio = document.getElementById(`opt-theme-${theme}`);
1079
+ if (radio)
1080
+ radio.checked = true;
1081
+ }
1082
+ // Restore saved theme + wire the Settings radios. Defaults to "system".
1083
+ (() => {
1084
+ const saved = (() => { try {
1085
+ return localStorage.getItem("mailx-theme") || "system";
1086
+ }
1087
+ catch {
1088
+ return "system";
1089
+ } })();
1090
+ applyTheme(saved);
1091
+ for (const t of ["system", "light", "dark"]) {
1092
+ document.getElementById(`opt-theme-${t}`)?.addEventListener("change", (e) => {
1093
+ if (e.target.checked)
1094
+ applyTheme(t);
1095
+ });
1096
+ }
1097
+ })();
1074
1098
  // Highlight the current rail target. For now just inbox is the default; once
1075
1099
  // calendar/tasks ship, update this on view change.
1076
1100
  function setRailActive(id) {
@@ -15,19 +15,42 @@
15
15
  */
16
16
  import { getCalendarEvents, createCalendarEvent, getTasks, createTask, updateTask, deleteTask, } from "../lib/api-client.js";
17
17
  const SIDEBAR_PREF = "mailx-calendar-sidebar-on";
18
+ const SHOW_RECURRING_PREF = "mailx-cal-show-recurring";
19
+ const HORIZON_DAYS_PREF = "mailx-cal-horizon-days";
20
+ const HORIZON_DEFAULT_DAYS = 30;
18
21
  let viewYear = new Date().getFullYear();
19
22
  let viewMonth = new Date().getMonth();
20
23
  let viewDay = new Date().getDate();
21
24
  let lastEvents = [];
25
+ function getHorizonDays() {
26
+ try {
27
+ const v = localStorage.getItem(HORIZON_DAYS_PREF);
28
+ const n = v ? parseInt(v, 10) : NaN;
29
+ if (Number.isFinite(n) && n > 0 && n <= 365)
30
+ return n;
31
+ }
32
+ catch { /* */ }
33
+ return HORIZON_DEFAULT_DAYS;
34
+ }
35
+ function getShowRecurring() {
36
+ try {
37
+ return localStorage.getItem(SHOW_RECURRING_PREF) !== "false";
38
+ }
39
+ catch {
40
+ return true;
41
+ }
42
+ }
22
43
  /** Fetch events from the local two-way cache; service returns local rows
23
44
  * immediately and kicks a background refresh from Google. Next render
24
45
  * (view-nav or user action) picks up the refreshed rows. No localStorage
25
46
  * — everything lives in the service-side DB so phone / desktop share
26
47
  * the same events. */
27
48
  async function fetchUpcoming(from) {
28
- const horizon = from.getTime() + 30 * 86400_000;
49
+ const horizon = from.getTime() + getHorizonDays() * 86400_000;
29
50
  const rows = await getCalendarEvents(from.getTime(), horizon);
30
- return rows.map((r) => ({
51
+ const showRecurring = getShowRecurring();
52
+ const filtered = showRecurring ? rows : rows.filter((r) => !r.recurringEventId);
53
+ return filtered.map((r) => ({
31
54
  id: r.uuid,
32
55
  title: r.title,
33
56
  start: r.startMs,
@@ -36,6 +59,8 @@ async function fetchUpcoming(from) {
36
59
  location: r.location,
37
60
  notes: r.notes,
38
61
  source: r.providerId ? "google" : "local",
62
+ recurringEventId: r.recurringEventId,
63
+ htmlLink: r.htmlLink,
39
64
  }));
40
65
  }
41
66
  function formatDayHeader(d, today, tomorrow) {
@@ -81,13 +106,29 @@ function renderEvents(events) {
81
106
  html += `<div class="cal-side-day">${escapeHtml(formatDayHeader(d, today, tomorrow))}</div>`;
82
107
  lastDayKey = dayKey;
83
108
  }
84
- html += `<div class="cal-side-event" data-id="${e.id}">
109
+ const recurMark = e.recurringEventId ? `<span class="cal-side-event-recur" title="Recurring event">↻</span>` : "";
110
+ const link = e.htmlLink || "";
111
+ html += `<div class="cal-side-event" data-id="${e.id}" data-link="${escapeHtml(link)}" ${link ? 'title="Click to open in Google Calendar"' : ""}>
85
112
  <span class="cal-side-event-dot ${e.source === "google" ? "g" : "l"}"></span>
86
113
  <span class="cal-side-event-time">${escapeHtml(formatTime(e))}</span>
87
- <span class="cal-side-event-title">${escapeHtml(e.title)}</span>
114
+ <span class="cal-side-event-title">${escapeHtml(e.title)}${recurMark}</span>
88
115
  </div>`;
89
116
  }
90
117
  body.innerHTML = html;
118
+ // Click-to-open — interim per user 2026-04-23: route to Google Calendar's
119
+ // web UI via openExternal until we build an in-app event editor.
120
+ body.querySelectorAll(".cal-side-event").forEach(el => {
121
+ el.addEventListener("click", () => {
122
+ const link = el.dataset.link;
123
+ if (link) {
124
+ const api = window.mailxapi;
125
+ if (api?.openExternal)
126
+ api.openExternal(link);
127
+ else
128
+ window.open(link, "_blank");
129
+ }
130
+ });
131
+ });
91
132
  }
92
133
  async function renderTasks() {
93
134
  const showDone = document.getElementById("cal-side-show-done")?.checked || false;
@@ -209,5 +250,53 @@ export function initCalendarSidebar() {
209
250
  showDoneCb.__wired = true;
210
251
  showDoneCb.addEventListener("change", () => renderTasks());
211
252
  }
253
+ // Recurring-events filter toggle — hides expanded recurring-series
254
+ // instances when unchecked. Default on so new users see everything.
255
+ const recurCb = document.getElementById("cal-side-show-recurring");
256
+ if (recurCb && !recurCb.__wired) {
257
+ recurCb.__wired = true;
258
+ recurCb.checked = getShowRecurring();
259
+ recurCb.addEventListener("change", () => {
260
+ try {
261
+ localStorage.setItem(SHOW_RECURRING_PREF, String(recurCb.checked));
262
+ }
263
+ catch { /* */ }
264
+ refresh();
265
+ });
266
+ }
267
+ // Horizon input — how many days ahead to list events. Bounded 1..365.
268
+ const horizonInput = document.getElementById("cal-side-horizon");
269
+ if (horizonInput && !horizonInput.__wired) {
270
+ horizonInput.__wired = true;
271
+ horizonInput.value = String(getHorizonDays());
272
+ const commit = () => {
273
+ const n = parseInt(horizonInput.value, 10);
274
+ const clamped = Number.isFinite(n) && n > 0 ? Math.min(365, Math.max(1, n)) : HORIZON_DEFAULT_DAYS;
275
+ horizonInput.value = String(clamped);
276
+ try {
277
+ localStorage.setItem(HORIZON_DAYS_PREF, String(clamped));
278
+ }
279
+ catch { /* */ }
280
+ refresh();
281
+ };
282
+ horizonInput.addEventListener("change", commit);
283
+ horizonInput.addEventListener("blur", commit);
284
+ }
285
+ // Subscribe to service events — when a background Google pull finishes
286
+ // and upserted/reconciled rows, re-render without waiting for a nav
287
+ // click. Uses the mailxapi.onEvent bus. Idempotent flag keeps multiple
288
+ // initCalendarSidebar calls from dup-subscribing.
289
+ if (!window.__mailxCalEventsWired) {
290
+ const api = window.mailxapi;
291
+ if (api?.onEvent) {
292
+ window.__mailxCalEventsWired = true;
293
+ api.onEvent((event) => {
294
+ if (event?.type === "calendarUpdated")
295
+ refresh();
296
+ else if (event?.type === "tasksUpdated")
297
+ renderTasks();
298
+ });
299
+ }
300
+ }
212
301
  }
213
302
  //# sourceMappingURL=calendar-sidebar.js.map
@@ -2,7 +2,7 @@
2
2
  * Message viewer component -- displays full message in sandboxed iframe.
3
3
  * Subscribes to message-state: clears when selected becomes null.
4
4
  */
5
- import { getMessage, updateFlags, allowRemoteContent, getAttachment, addContact } from "../lib/api-client.js";
5
+ import { getMessage, updateFlags, allowRemoteContent, getAttachment, addContact, listContacts, upsertContact } from "../lib/api-client.js";
6
6
  import { showContextMenu } from "./context-menu.js";
7
7
  import * as state from "../lib/message-state.js";
8
8
  /** Currently displayed message (for reply/forward) */
@@ -297,10 +297,7 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
297
297
  items.push({
298
298
  label: `Add to contacts: ${addr.address}`,
299
299
  action: async () => {
300
- try {
301
- await addContact(name, addr.address);
302
- }
303
- catch { /* ignore */ }
300
+ await showAddContactDialog(name, addr.address);
304
301
  },
305
302
  });
306
303
  items.push({ label: "", action: () => { }, separator: true });
@@ -746,6 +743,84 @@ function escapeText(s) {
746
743
  div.textContent = s;
747
744
  return div.innerHTML;
748
745
  }
746
+ /** Minimal add-contact modal: name + email + organization with a duplicate
747
+ * check (checks the contacts DB for an existing row with the same email
748
+ * and surfaces it so the user can update instead of creating a second
749
+ * row with a different name). Future: AI-extracted fields from the letter
750
+ * body populate the form before it opens. */
751
+ async function showAddContactDialog(nameIn, emailIn) {
752
+ let dup = null;
753
+ try {
754
+ const existing = await listContacts(emailIn, 1, 10);
755
+ const match = (existing?.items || []).find((c) => (c.email || "").toLowerCase() === emailIn.toLowerCase());
756
+ if (match)
757
+ dup = match;
758
+ }
759
+ catch { /* non-fatal — dialog still works without dup info */ }
760
+ const backdrop = document.createElement("div");
761
+ backdrop.className = "mailx-modal-backdrop";
762
+ const panel = document.createElement("div");
763
+ panel.className = "mailx-modal";
764
+ panel.innerHTML = `
765
+ <div class="mailx-modal-title">
766
+ <span class="mailx-modal-title-text">${dup ? "Update contact" : "Add contact"}</span>
767
+ <button type="button" class="mailx-modal-close" id="ac-close" aria-label="Close">&times;</button>
768
+ </div>
769
+ ${dup ? `<div class="mailx-modal-info">Already in address book as <strong>${escapeText(dup.name || "(no name)")}</strong> (${escapeText(dup.source)}). Saving will update the name.</div>` : ""}
770
+ <label class="mailx-modal-label">Name
771
+ <input class="mailx-modal-input" id="ac-name" type="text" value="${escapeText(dup?.name || nameIn || "")}" autofocus>
772
+ </label>
773
+ <label class="mailx-modal-label">Email
774
+ <input class="mailx-modal-input" id="ac-email" type="email" value="${escapeText(emailIn)}" readonly>
775
+ </label>
776
+ <label class="mailx-modal-label">Organization <span style="color:var(--color-text-muted);font-size:0.85em">(optional)</span>
777
+ <input class="mailx-modal-input" id="ac-org" type="text" placeholder="">
778
+ </label>
779
+ <div class="mailx-modal-buttons">
780
+ <span class="mailx-modal-spacer"></span>
781
+ <button type="button" class="mailx-modal-btn" data-action="cancel">Cancel</button>
782
+ <button type="button" class="mailx-modal-btn mailx-modal-btn-primary" data-action="save">${dup ? "Update" : "Save"}</button>
783
+ </div>`;
784
+ backdrop.appendChild(panel);
785
+ document.body.appendChild(backdrop);
786
+ const close = () => backdrop.remove();
787
+ panel.querySelector("#ac-close").addEventListener("click", close);
788
+ panel.querySelectorAll(".mailx-modal-btn").forEach(btn => {
789
+ btn.addEventListener("click", async () => {
790
+ if (btn.dataset.action === "cancel") {
791
+ close();
792
+ return;
793
+ }
794
+ const nameEl = panel.querySelector("#ac-name");
795
+ const emailEl = panel.querySelector("#ac-email");
796
+ btn.disabled = true;
797
+ btn.textContent = "Saving…";
798
+ try {
799
+ // upsertContact is the two-way cache path (enqueues a Google
800
+ // People push); for pure local-first addContact would also
801
+ // work but skips the Google sync. Use upsertContact so the
802
+ // row propagates to Google Contacts next drain tick.
803
+ await upsertContact(nameEl.value.trim(), emailEl.value.trim());
804
+ close();
805
+ }
806
+ catch (e) {
807
+ btn.disabled = false;
808
+ btn.textContent = dup ? "Update" : "Save";
809
+ alert(`Couldn't save: ${e?.message || e}`);
810
+ }
811
+ });
812
+ });
813
+ const onKey = (e) => {
814
+ if (e.key === "Escape") {
815
+ close();
816
+ document.removeEventListener("keydown", onKey, true);
817
+ }
818
+ };
819
+ document.addEventListener("keydown", onKey, true);
820
+ // addContact is kept as a legacy silent path (no-form) for any caller
821
+ // that still invokes it — currently none after this refactor.
822
+ void addContact;
823
+ }
749
824
  function formatSize(bytes) {
750
825
  if (bytes < 1024)
751
826
  return `${bytes} B`;
package/client/index.html CHANGED
@@ -172,6 +172,12 @@
172
172
  </header>
173
173
  <div class="cal-side-actions">
174
174
  <button class="cal-side-new" id="cal-side-new" title="New event">+ New event</button>
175
+ <label class="cal-side-opt" title="Include expanded instances of recurring events">
176
+ <input type="checkbox" id="cal-side-show-recurring" checked> Show recurring
177
+ </label>
178
+ <label class="cal-side-opt" title="How many days ahead to list">
179
+ Horizon <input type="number" id="cal-side-horizon" min="1" max="365" step="1" style="width:4em">
180
+ </label>
175
181
  </div>
176
182
  <div class="cal-side-body" id="cal-side-body">
177
183
  <div class="cal-side-empty">Loading…</div>
@@ -900,6 +900,19 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
900
900
  list empty when the selection is a singleton thread. */
901
901
  .ml-row.thread-filter-hidden { display: none; }
902
902
 
903
+ /* Info banner inside a modal — used by the Add-contact dialog to surface
904
+ "already in address book" duplicate notices. Amber tone so it reads as
905
+ "heads up" not "error". */
906
+ .mailx-modal-info {
907
+ padding: var(--gap-sm) var(--gap-md);
908
+ background: oklch(0.95 0.05 75);
909
+ border: 1px solid oklch(0.80 0.10 75);
910
+ border-radius: var(--radius-sm);
911
+ color: oklch(0.35 0.08 75);
912
+ font-size: var(--font-size-sm);
913
+ margin-bottom: var(--gap-sm);
914
+ }
915
+
903
916
  /* Offline indicator — sits in the status bar; amber tone so it's visible
904
917
  but doesn't scream (being offline is normal local-first behavior, not
905
918
  an error). */
@@ -996,6 +1009,21 @@ body.calendar-sidebar-on .calendar-sidebar { display: flex; }
996
1009
  .cal-side-event-dot.g { background: oklch(0.65 0.20 250); } /* google — blue */
997
1010
  .cal-side-event-time { color: var(--color-text); min-width: 44px; font-variant-numeric: tabular-nums; }
998
1011
  .cal-side-event-title { flex: 1; }
1012
+ .cal-side-event-recur {
1013
+ color: var(--color-text-muted);
1014
+ margin-left: 4px;
1015
+ font-size: 0.85em;
1016
+ opacity: 0.8;
1017
+ }
1018
+ .cal-side-opt {
1019
+ display: block;
1020
+ padding: 2px 0;
1021
+ font-size: 0.85em;
1022
+ color: var(--color-text-muted);
1023
+ cursor: pointer;
1024
+ }
1025
+ .cal-side-opt input[type=number] { margin-left: 4px; }
1026
+ .cal-side-event { cursor: pointer; }
999
1027
  .cal-side-empty {
1000
1028
  padding: var(--gap-md) var(--gap-sm);
1001
1029
  color: var(--color-text-muted);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.384",
3
+ "version": "1.0.385",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -24,7 +24,7 @@
24
24
  "@bobfrankston/iflow-node": "^0.1.7",
25
25
  "@bobfrankston/miscinfo": "^1.0.9",
26
26
  "@bobfrankston/oauthsupport": "^1.0.24",
27
- "@bobfrankston/msger": "^0.1.347",
27
+ "@bobfrankston/msger": "^0.1.348",
28
28
  "@bobfrankston/mailx-host": "^0.1.4",
29
29
  "@capacitor/android": "^8.3.0",
30
30
  "@capacitor/cli": "^8.3.0",
@@ -88,7 +88,7 @@
88
88
  "@bobfrankston/iflow-node": "^0.1.7",
89
89
  "@bobfrankston/miscinfo": "^1.0.9",
90
90
  "@bobfrankston/oauthsupport": "^1.0.24",
91
- "@bobfrankston/msger": "^0.1.347",
91
+ "@bobfrankston/msger": "^0.1.348",
92
92
  "@bobfrankston/mailx-host": "^0.1.4",
93
93
  "@capacitor/android": "^8.3.0",
94
94
  "@capacitor/cli": "^8.3.0",
@@ -24,6 +24,11 @@ export interface GCalEvent {
24
24
  location?: string;
25
25
  description?: string;
26
26
  etag?: string;
27
+ /** Set on instances expanded from a recurring series (singleEvents=true).
28
+ * Absent for singletons. Lets the UI filter out recurring expansions. */
29
+ recurringEventId?: string;
30
+ /** Link to open the event in Google Calendar web. */
31
+ htmlLink?: string;
27
32
  }
28
33
  export declare function listCalendarEvents(tokenProvider: TokenProvider, fromMs: number, toMs: number, calendarId?: string): Promise<GCalEvent[]>;
29
34
  export declare function createCalendarEvent(tokenProvider: TokenProvider, event: any, calendarId?: string): Promise<GCalEvent>;
@@ -55,6 +60,8 @@ export declare function calendarEventToLocal(ev: GCalEvent, accountId: string):
55
60
  location: string;
56
61
  notes: string;
57
62
  etag: string;
63
+ recurringEventId: string | undefined;
64
+ htmlLink: string | undefined;
58
65
  };
59
66
  export declare function localToCalendarEvent(local: {
60
67
  title: string;
@@ -106,6 +106,8 @@ export function calendarEventToLocal(ev, accountId) {
106
106
  startMs, endMs, allDay,
107
107
  location: ev.location || "", notes: ev.description || "",
108
108
  etag: ev.etag || "",
109
+ recurringEventId: ev.recurringEventId,
110
+ htmlLink: ev.htmlLink,
109
111
  };
110
112
  }
111
113
  export function localToCalendarEvent(local) {
@@ -49,9 +49,14 @@ export declare class MailxService {
49
49
  getPrimaryAccount(feature?: string): any;
50
50
  private primaryTokenProvider;
51
51
  /** Return cal events visible in [fromMs..toMs), refreshing from Google
52
- * in the background if enough time has passed. Caller displays local
53
- * results immediately; events updated from Google emit an event. */
52
+ * in the background. Caller displays local results immediately; after
53
+ * the refresh completes the service emits `calendarUpdated` so the UI
54
+ * re-renders with pulled-in rows. Fire-and-forget-with-event, not
55
+ * fire-and-forget-and-pray. */
54
56
  getCalendarEvents(fromMs: number, toMs: number): Promise<any[]>;
57
+ /** Pull events in [fromMs..toMs) from Google, upsert locally, reconcile
58
+ * server-side deletions. Returns true if anything changed so callers
59
+ * can decide whether to emit a refresh event. */
55
60
  private refreshCalendarEvents;
56
61
  createCalendarEventLocal(ev: {
57
62
  title: string;
@@ -459,30 +459,56 @@ export class MailxService {
459
459
  };
460
460
  }
461
461
  /** Return cal events visible in [fromMs..toMs), refreshing from Google
462
- * in the background if enough time has passed. Caller displays local
463
- * results immediately; events updated from Google emit an event. */
462
+ * in the background. Caller displays local results immediately; after
463
+ * the refresh completes the service emits `calendarUpdated` so the UI
464
+ * re-renders with pulled-in rows. Fire-and-forget-with-event, not
465
+ * fire-and-forget-and-pray. */
464
466
  async getCalendarEvents(fromMs, toMs) {
465
467
  const acct = this.getPrimaryAccount("calendar");
466
468
  if (!acct)
467
469
  return [];
468
- // Fire-and-forget refresh; don't block the read on it.
469
- this.refreshCalendarEvents(acct.id, fromMs, toMs).catch(e => console.error(`[calendar] refresh failed: ${e.message}`));
470
+ this.refreshCalendarEvents(acct.id, fromMs, toMs)
471
+ .then(changed => {
472
+ if (changed)
473
+ this.imapManager.emit("calendarUpdated", { accountId: acct.id });
474
+ })
475
+ .catch(e => console.error(`[calendar] refresh failed: ${e.message}`));
470
476
  return this.db.getCalendarEvents(acct.id, fromMs, toMs);
471
477
  }
478
+ /** Pull events in [fromMs..toMs) from Google, upsert locally, reconcile
479
+ * server-side deletions. Returns true if anything changed so callers
480
+ * can decide whether to emit a refresh event. */
472
481
  async refreshCalendarEvents(accountId, fromMs, toMs) {
473
482
  const tp = await this.primaryTokenProvider("calendar");
474
483
  const events = await gsync.listCalendarEvents(tp, fromMs, toMs);
484
+ let changed = false;
485
+ // Upsert by provider_id — dedup globally, not just within the window,
486
+ // so an event whose start moves outside the prior query range doesn't
487
+ // get a second row on the next pull.
488
+ const seenProviderIds = new Set();
475
489
  for (const ev of events) {
476
490
  const local = gsync.calendarEventToLocal(ev, accountId);
477
- // Use provider_id as the uuid basis — existing local row with matching
478
- // providerId stays; if we invent a new uuid every time we'd create
479
- // duplicates on every poll. Look up by provider_id first.
480
- const existing = this.db.getCalendarEvents(accountId, fromMs, toMs).find(e => e.providerId === ev.id);
481
- this.db.upsertCalendarEvent({
482
- uuid: existing?.uuid,
483
- ...local,
484
- });
491
+ seenProviderIds.add(ev.id);
492
+ const existing = this.db.getCalendarEventByProviderId(accountId, ev.id);
493
+ this.db.upsertCalendarEvent({ uuid: existing?.uuid, ...local });
494
+ changed = true;
495
+ }
496
+ // Server-side delete reconciliation: any local non-dirty row whose
497
+ // start falls in the queried window and whose provider_id wasn't
498
+ // returned must have been deleted on Google. Purge it. Dirty rows
499
+ // are local-only edits that haven't been pushed yet — don't touch.
500
+ const localWindow = this.db.getCalendarEvents(accountId, fromMs, toMs);
501
+ for (const row of localWindow) {
502
+ if (!row.providerId)
503
+ continue; // local-only, never pushed
504
+ if (row.dirty)
505
+ continue; // locally edited, pending push
506
+ if (seenProviderIds.has(row.providerId))
507
+ continue;
508
+ this.db.purgeCalendarEvent(row.uuid);
509
+ changed = true;
485
510
  }
511
+ return changed;
486
512
  }
487
513
  async createCalendarEventLocal(ev) {
488
514
  const acct = this.getPrimaryAccount("calendar");
@@ -532,18 +558,38 @@ export class MailxService {
532
558
  const acct = this.getPrimaryAccount("tasks");
533
559
  if (!acct)
534
560
  return [];
535
- this.refreshTasks(acct.id, includeCompleted).catch(e => console.error(`[tasks] refresh failed: ${e.message}`));
561
+ this.refreshTasks(acct.id, includeCompleted)
562
+ .then(changed => {
563
+ if (changed)
564
+ this.imapManager.emit("tasksUpdated", { accountId: acct.id });
565
+ })
566
+ .catch(e => console.error(`[tasks] refresh failed: ${e.message}`));
536
567
  return this.db.getTasks(acct.id, includeCompleted);
537
568
  }
538
569
  async refreshTasks(accountId, includeCompleted) {
539
570
  const tp = await this.primaryTokenProvider("tasks");
540
571
  const tasks = await gsync.listTasks(tp, "@default", includeCompleted);
541
572
  const existing = this.db.getTasks(accountId, true);
573
+ let changed = false;
574
+ const seen = new Set();
542
575
  for (const t of tasks) {
543
576
  const local = gsync.taskToLocal(t, accountId);
544
577
  const prior = existing.find(e => e.providerId === t.id);
545
578
  this.db.upsertTask({ uuid: prior?.uuid, ...local });
579
+ seen.add(t.id);
580
+ changed = true;
581
+ }
582
+ // Server-side delete reconciliation: any non-dirty local task whose
583
+ // provider_id wasn't returned has been deleted on Google. Purge.
584
+ for (const row of existing) {
585
+ if (!row.providerId || row.dirty)
586
+ continue;
587
+ if (seen.has(row.providerId))
588
+ continue;
589
+ this.db.purgeTask(row.uuid);
590
+ changed = true;
546
591
  }
592
+ return changed;
547
593
  }
548
594
  async createTaskLocal(t) {
549
595
  const acct = this.getPrimaryAccount("tasks");
@@ -46,6 +46,8 @@ export declare class MailxDB {
46
46
  notes?: string;
47
47
  etag?: string;
48
48
  dirty?: boolean;
49
+ recurringEventId?: string;
50
+ htmlLink?: string;
49
51
  }): string;
50
52
  getCalendarEvents(accountId: string, fromMs: number, toMs: number): any[];
51
53
  /** Lookup by uuid only — used by patch/delete paths that don't have an
@@ -54,6 +56,9 @@ export declare class MailxDB {
54
56
  getTaskByUuid(uuid: string): any | null;
55
57
  getDirtyCalendarEvents(accountId: string): any[];
56
58
  private calendarRowToObject;
59
+ /** Find a calendar event by its Google Calendar event id (provider_id).
60
+ * Global lookup — not window-scoped — so repeat pulls dedup cleanly. */
61
+ getCalendarEventByProviderId(accountId: string, providerId: string): any | null;
57
62
  markCalendarEventClean(uuid: string, providerId: string, etag: string): void;
58
63
  deleteCalendarEventLocal(uuid: string): void;
59
64
  purgeCalendarEvent(uuid: string): void;
@@ -244,6 +244,11 @@ export class MailxDB {
244
244
  this.db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_uuid ON messages(uuid)");
245
245
  }
246
246
  catch { /* already exists */ }
247
+ // calendar_events: recurring_event_id carries the Google Calendar
248
+ // series id when the event is an expanded instance of a recurrence.
249
+ // Filters like "hide recurring events" check this column.
250
+ this.addColumnIfMissing("calendar_events", "recurring_event_id", "TEXT");
251
+ this.addColumnIfMissing("calendar_events", "html_link", "TEXT");
247
252
  // Backfill UUIDs for any pre-existing rows that were inserted before
248
253
  // this column landed. One UPDATE + an id roundtrip per row — cheap
249
254
  // at our row counts, runs once per DB upgrade.
@@ -375,8 +380,9 @@ export class MailxDB {
375
380
  this.db.prepare(`
376
381
  INSERT INTO calendar_events
377
382
  (uuid, account_id, provider_id, calendar_id, title, start_ms, end_ms,
378
- all_day, location, notes, etag, last_synced, dirty, deleted, updated_at)
379
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)
383
+ all_day, location, notes, etag, last_synced, dirty, deleted, updated_at,
384
+ recurring_event_id, html_link)
385
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)
380
386
  ON CONFLICT(uuid) DO UPDATE SET
381
387
  account_id=excluded.account_id, provider_id=excluded.provider_id,
382
388
  calendar_id=excluded.calendar_id, title=excluded.title,
@@ -384,8 +390,10 @@ export class MailxDB {
384
390
  all_day=excluded.all_day, location=excluded.location,
385
391
  notes=excluded.notes, etag=excluded.etag,
386
392
  last_synced=excluded.last_synced, dirty=excluded.dirty,
387
- updated_at=excluded.updated_at
388
- `).run(uuid, ev.accountId, ev.providerId || null, ev.calendarId || "primary", ev.title, ev.startMs, ev.endMs, ev.allDay ? 1 : 0, ev.location || "", ev.notes || "", ev.etag || null, ev.dirty ? 0 : Date.now(), ev.dirty ? 1 : 0, Date.now());
393
+ updated_at=excluded.updated_at,
394
+ recurring_event_id=excluded.recurring_event_id,
395
+ html_link=excluded.html_link
396
+ `).run(uuid, ev.accountId, ev.providerId || null, ev.calendarId || "primary", ev.title, ev.startMs, ev.endMs, ev.allDay ? 1 : 0, ev.location || "", ev.notes || "", ev.etag || null, ev.dirty ? 0 : Date.now(), ev.dirty ? 1 : 0, Date.now(), ev.recurringEventId || null, ev.htmlLink || null);
389
397
  return uuid;
390
398
  }
391
399
  getCalendarEvents(accountId, fromMs, toMs) {
@@ -418,8 +426,16 @@ export class MailxDB {
418
426
  calendarId: r.calendar_id, title: r.title, startMs: r.start_ms,
419
427
  endMs: r.end_ms, allDay: !!r.all_day, location: r.location, notes: r.notes,
420
428
  etag: r.etag, lastSynced: r.last_synced, dirty: !!r.dirty, deleted: !!r.deleted,
429
+ recurringEventId: r.recurring_event_id || null,
430
+ htmlLink: r.html_link || null,
421
431
  };
422
432
  }
433
+ /** Find a calendar event by its Google Calendar event id (provider_id).
434
+ * Global lookup — not window-scoped — so repeat pulls dedup cleanly. */
435
+ getCalendarEventByProviderId(accountId, providerId) {
436
+ const r = this.db.prepare("SELECT * FROM calendar_events WHERE account_id = ? AND provider_id = ?").get(accountId, providerId);
437
+ return r ? this.calendarRowToObject(r) : null;
438
+ }
423
439
  markCalendarEventClean(uuid, providerId, etag) {
424
440
  this.db.prepare(`
425
441
  UPDATE calendar_events SET dirty=0, provider_id=?, etag=?, last_synced=? WHERE uuid=?