@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 +27 -0
- package/client/app.js +28 -4
- package/client/components/calendar-sidebar.js +93 -4
- package/client/components/message-viewer.js +80 -5
- package/client/index.html +6 -0
- package/client/styles/components.css +28 -0
- package/package.json +3 -3
- package/packages/mailx-service/google-sync.d.ts +7 -0
- package/packages/mailx-service/google-sync.js +2 -0
- package/packages/mailx-service/index.d.ts +7 -2
- package/packages/mailx-service/index.js +59 -13
- package/packages/mailx-store/db.d.ts +5 -0
- package/packages/mailx-store/db.js +20 -4
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
|
-
//
|
|
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
|
-
|
|
1069
|
+
applyTheme(next);
|
|
1070
|
+
});
|
|
1071
|
+
function applyTheme(theme) {
|
|
1072
|
+
document.documentElement.setAttribute("data-theme", theme);
|
|
1069
1073
|
try {
|
|
1070
|
-
localStorage.setItem("mailx-theme",
|
|
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() +
|
|
49
|
+
const horizon = from.getTime() + getHorizonDays() * 86400_000;
|
|
29
50
|
const rows = await getCalendarEvents(from.getTime(), horizon);
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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">×</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.
|
|
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.
|
|
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.
|
|
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
|
|
53
|
-
*
|
|
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
|
|
463
|
-
*
|
|
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
|
-
|
|
469
|
-
|
|
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
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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=?
|