@bobfrankston/mailx 1.0.378 → 1.0.379
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/client/components/calendar-sidebar.js +40 -79
- package/client/index.html +4 -1
- package/client/lib/api-client.js +29 -0
- package/client/lib/mailxapi.js +13 -0
- package/client/styles/layout.css +17 -10
- package/package.json +1 -1
- package/packages/mailx-imap/index.js +5 -1
- package/packages/mailx-service/google-sync.d.ts +83 -0
- package/packages/mailx-service/google-sync.js +140 -0
- package/packages/mailx-service/index.d.ts +44 -2
- package/packages/mailx-service/index.js +218 -3
- package/packages/mailx-service/jsonrpc.js +25 -0
- package/packages/mailx-store/db.d.ts +45 -0
- package/packages/mailx-store/db.js +218 -2
|
@@ -10,68 +10,33 @@
|
|
|
10
10
|
* Sidebar and the full-screen calendar modal (calendar.ts) read the SAME
|
|
11
11
|
* underlying data — two views onto one source.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
13
|
+
* All storage goes through the service-side two-way cache (calendar_events
|
|
14
|
+
* and tasks tables); this file does not use localStorage for data.
|
|
14
15
|
*/
|
|
15
|
-
import {
|
|
16
|
-
const LOCAL_STORE_KEY = "mailx-cal-events-v1";
|
|
17
|
-
const TASK_STORE_KEY = "mailx-tasks-v1";
|
|
16
|
+
import { getCalendarEvents, createCalendarEvent, getTasks, createTask, updateTask, } from "../lib/api-client.js";
|
|
18
17
|
const SIDEBAR_PREF = "mailx-calendar-sidebar-on";
|
|
19
18
|
let viewYear = new Date().getFullYear();
|
|
20
19
|
let viewMonth = new Date().getMonth();
|
|
21
20
|
let viewDay = new Date().getDate();
|
|
22
21
|
let lastEvents = [];
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const arr = JSON.parse(raw);
|
|
29
|
-
return Array.isArray(arr) ? arr : [];
|
|
30
|
-
}
|
|
31
|
-
catch {
|
|
32
|
-
return [];
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
function loadTasks() {
|
|
36
|
-
try {
|
|
37
|
-
const raw = localStorage.getItem(TASK_STORE_KEY);
|
|
38
|
-
if (!raw)
|
|
39
|
-
return [];
|
|
40
|
-
const arr = JSON.parse(raw);
|
|
41
|
-
return Array.isArray(arr) ? arr : [];
|
|
42
|
-
}
|
|
43
|
-
catch {
|
|
44
|
-
return [];
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
function saveTasks(tasks) {
|
|
48
|
-
try {
|
|
49
|
-
localStorage.setItem(TASK_STORE_KEY, JSON.stringify(tasks));
|
|
50
|
-
}
|
|
51
|
-
catch { /* */ }
|
|
52
|
-
}
|
|
53
|
-
/** Fetch upcoming events from Google Calendar via the primary account's
|
|
54
|
-
* OAuth token. Returns merged Google + local events for the next 30 days
|
|
55
|
-
* starting at `from`. Quietly returns local-only on any error so the
|
|
56
|
-
* sidebar still works without network / without Google Calendar scope. */
|
|
22
|
+
/** Fetch events from the local two-way cache; service returns local rows
|
|
23
|
+
* immediately and kicks a background refresh from Google. Next render
|
|
24
|
+
* (view-nav or user action) picks up the refreshed rows. No localStorage
|
|
25
|
+
* — everything lives in the service-side DB so phone / desktop share
|
|
26
|
+
* the same events. */
|
|
57
27
|
async function fetchUpcoming(from) {
|
|
58
|
-
const local = loadLocalEvents();
|
|
59
|
-
let google = [];
|
|
60
|
-
try {
|
|
61
|
-
const primary = await getPrimaryAccount("calendar");
|
|
62
|
-
if (primary?.email) {
|
|
63
|
-
// The OAuth token is held by the service-side OAuthTokenManager;
|
|
64
|
-
// calendar fetch goes through a server-side proxy method (not
|
|
65
|
-
// implemented yet — this is the seam). For now: local-only.
|
|
66
|
-
// Wire-up: TODO — service.fetchGoogleCalendarEvents(accountId, from, days).
|
|
67
|
-
// Sidebar already renders correctly with the merged result.
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
catch { /* google unavailable, fall back to local */ }
|
|
71
28
|
const horizon = from.getTime() + 30 * 86400_000;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
.
|
|
29
|
+
const rows = await getCalendarEvents(from.getTime(), horizon);
|
|
30
|
+
return rows.map((r) => ({
|
|
31
|
+
id: r.uuid,
|
|
32
|
+
title: r.title,
|
|
33
|
+
start: r.startMs,
|
|
34
|
+
end: r.endMs,
|
|
35
|
+
allDay: !!r.allDay,
|
|
36
|
+
location: r.location,
|
|
37
|
+
notes: r.notes,
|
|
38
|
+
source: r.providerId ? "google" : "local",
|
|
39
|
+
}));
|
|
75
40
|
}
|
|
76
41
|
function formatDayHeader(d, today, tomorrow) {
|
|
77
42
|
const sameDay = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
|
@@ -124,9 +89,9 @@ function renderEvents(events) {
|
|
|
124
89
|
}
|
|
125
90
|
body.innerHTML = html;
|
|
126
91
|
}
|
|
127
|
-
function renderTasks() {
|
|
92
|
+
async function renderTasks() {
|
|
128
93
|
const showDone = document.getElementById("cal-side-show-done")?.checked || false;
|
|
129
|
-
const tasks =
|
|
94
|
+
const tasks = await getTasks(showDone);
|
|
130
95
|
const host = document.getElementById("cal-side-tasks");
|
|
131
96
|
if (!host)
|
|
132
97
|
return;
|
|
@@ -136,23 +101,19 @@ function renderTasks() {
|
|
|
136
101
|
}
|
|
137
102
|
let html = "<div class='cal-side-task-head'>Title</div>";
|
|
138
103
|
for (const t of tasks) {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
<
|
|
104
|
+
const done = !!t.completedMs;
|
|
105
|
+
html += `<div class="cal-side-task" data-uuid="${t.uuid}">
|
|
106
|
+
<input type="checkbox" ${done ? "checked" : ""} class="cal-side-task-check">
|
|
107
|
+
<span class="cal-side-task-title${done ? " done" : ""}">${escapeHtml(t.title)}</span>
|
|
142
108
|
</div>`;
|
|
143
109
|
}
|
|
144
110
|
host.innerHTML = html;
|
|
145
111
|
host.querySelectorAll(".cal-side-task").forEach(row => {
|
|
146
|
-
const
|
|
147
|
-
row.querySelector(".cal-side-task-check")?.addEventListener("change", (e) => {
|
|
112
|
+
const uuid = row.dataset.uuid;
|
|
113
|
+
row.querySelector(".cal-side-task-check")?.addEventListener("change", async (e) => {
|
|
148
114
|
const checked = e.target.checked;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (t) {
|
|
152
|
-
t.completed = checked ? Date.now() : undefined;
|
|
153
|
-
saveTasks(all);
|
|
154
|
-
renderTasks();
|
|
155
|
-
}
|
|
115
|
+
await updateTask(uuid, { completedMs: checked ? Date.now() : null });
|
|
116
|
+
renderTasks();
|
|
156
117
|
});
|
|
157
118
|
});
|
|
158
119
|
}
|
|
@@ -225,19 +186,19 @@ export function initCalendarSidebar() {
|
|
|
225
186
|
alert("Couldn't parse that date.");
|
|
226
187
|
return;
|
|
227
188
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
title, start,
|
|
231
|
-
|
|
232
|
-
};
|
|
233
|
-
const all = loadLocalEvents();
|
|
234
|
-
all.push(ev);
|
|
235
|
-
try {
|
|
236
|
-
localStorage.setItem(LOCAL_STORE_KEY, JSON.stringify(all));
|
|
237
|
-
}
|
|
238
|
-
catch { /* */ }
|
|
189
|
+
// Two-way cache: commit locally, service queues the push to Google.
|
|
190
|
+
await createCalendarEvent({
|
|
191
|
+
title, startMs: start, endMs: start + (allDay ? 86400_000 : 3600_000), allDay,
|
|
192
|
+
});
|
|
239
193
|
await refresh();
|
|
240
194
|
});
|
|
195
|
+
wireOnce("cal-side-new-task", async () => {
|
|
196
|
+
const title = prompt("Task title:");
|
|
197
|
+
if (!title)
|
|
198
|
+
return;
|
|
199
|
+
await createTask({ title });
|
|
200
|
+
renderTasks();
|
|
201
|
+
});
|
|
241
202
|
const showDoneCb = document.getElementById("cal-side-show-done");
|
|
242
203
|
if (showDoneCb && !showDoneCb.__wired) {
|
|
243
204
|
showDoneCb.__wired = true;
|
package/client/index.html
CHANGED
|
@@ -170,7 +170,10 @@
|
|
|
170
170
|
<div class="cal-side-empty">Loading…</div>
|
|
171
171
|
</div>
|
|
172
172
|
<footer class="cal-side-foot">
|
|
173
|
-
<
|
|
173
|
+
<div class="cal-side-task-header-row">
|
|
174
|
+
<label><input type="checkbox" id="cal-side-show-done"> Show completed Tasks</label>
|
|
175
|
+
<button class="cal-side-new" id="cal-side-new-task" title="New task">+ Task</button>
|
|
176
|
+
</div>
|
|
174
177
|
<div class="cal-side-tasks" id="cal-side-tasks"></div>
|
|
175
178
|
</footer>
|
|
176
179
|
</aside>
|
package/client/lib/api-client.js
CHANGED
|
@@ -141,6 +141,35 @@ export function getDiagnostics() {
|
|
|
141
141
|
export function getPrimaryAccount(feature) {
|
|
142
142
|
return ipc().getPrimaryAccount?.(feature) ?? Promise.resolve(null);
|
|
143
143
|
}
|
|
144
|
+
// Calendar / Tasks: two-way cache. Reads return local-cached rows; writes
|
|
145
|
+
// commit locally and queue a push to Google. Service layer handles drain.
|
|
146
|
+
export function getCalendarEvents(fromMs, toMs) {
|
|
147
|
+
return ipc().getCalendarEvents?.(fromMs, toMs) ?? Promise.resolve([]);
|
|
148
|
+
}
|
|
149
|
+
export function createCalendarEvent(ev) {
|
|
150
|
+
return ipc().createCalendarEvent?.(ev);
|
|
151
|
+
}
|
|
152
|
+
export function updateCalendarEvent(uuid, patch) {
|
|
153
|
+
return ipc().updateCalendarEvent?.(uuid, patch);
|
|
154
|
+
}
|
|
155
|
+
export function deleteCalendarEvent(uuid) {
|
|
156
|
+
return ipc().deleteCalendarEvent?.(uuid);
|
|
157
|
+
}
|
|
158
|
+
export function getTasks(includeCompleted = false) {
|
|
159
|
+
return ipc().getTasks?.(includeCompleted) ?? Promise.resolve([]);
|
|
160
|
+
}
|
|
161
|
+
export function createTask(t) {
|
|
162
|
+
return ipc().createTask?.(t);
|
|
163
|
+
}
|
|
164
|
+
export function updateTask(uuid, patch) {
|
|
165
|
+
return ipc().updateTask?.(uuid, patch);
|
|
166
|
+
}
|
|
167
|
+
export function deleteTask(uuid) {
|
|
168
|
+
return ipc().deleteTask?.(uuid);
|
|
169
|
+
}
|
|
170
|
+
export function drainStoreSync() {
|
|
171
|
+
return ipc().drainStoreSync?.();
|
|
172
|
+
}
|
|
144
173
|
export function getOutboxStatus() {
|
|
145
174
|
return ipc().getOutboxStatus();
|
|
146
175
|
}
|
package/client/lib/mailxapi.js
CHANGED
|
@@ -109,6 +109,19 @@
|
|
|
109
109
|
getThreadMessages: function(accountId, threadId) {
|
|
110
110
|
return callNode("getThreadMessages", { accountId: accountId, threadId: threadId });
|
|
111
111
|
},
|
|
112
|
+
// Calendar / Tasks two-way cache. Reads return local DB immediately;
|
|
113
|
+
// writes commit locally and queue a push-to-Google action.
|
|
114
|
+
getCalendarEvents: function(fromMs, toMs) {
|
|
115
|
+
return callNode("getCalendarEvents", { fromMs: fromMs, toMs: toMs });
|
|
116
|
+
},
|
|
117
|
+
createCalendarEvent: function(ev) { return callNode("createCalendarEvent", ev); },
|
|
118
|
+
updateCalendarEvent: function(uuid, patch) { return callNode("updateCalendarEvent", { uuid: uuid, patch: patch }); },
|
|
119
|
+
deleteCalendarEvent: function(uuid) { return callNode("deleteCalendarEvent", { uuid: uuid }); },
|
|
120
|
+
getTasks: function(includeCompleted) { return callNode("getTasks", { includeCompleted: !!includeCompleted }); },
|
|
121
|
+
createTask: function(t) { return callNode("createTask", t); },
|
|
122
|
+
updateTask: function(uuid, patch) { return callNode("updateTask", { uuid: uuid, patch: patch }); },
|
|
123
|
+
deleteTask: function(uuid) { return callNode("deleteTask", { uuid: uuid }); },
|
|
124
|
+
drainStoreSync: function() { return callNode("drainStoreSync"); },
|
|
112
125
|
readJsoncFile: function(name) {
|
|
113
126
|
return callNode("readJsoncFile", { name: name });
|
|
114
127
|
},
|
package/client/styles/layout.css
CHANGED
|
@@ -44,15 +44,17 @@ body.calendar-sidebar-on {
|
|
|
44
44
|
.main-area { grid-area: main; }
|
|
45
45
|
.status-bar { grid-area: status; }
|
|
46
46
|
|
|
47
|
-
/* Vertical icon rail
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
/* Vertical icon rail — Thunderbird Supernova style: dark background with
|
|
48
|
+
light icons so the rail reads as "chrome" and contrasts visibly against
|
|
49
|
+
the content area (whether the app's in light or dark theme). Always
|
|
50
|
+
visible on wide+medium tiers; hidden on narrow (icons fold into
|
|
51
|
+
hamburger — TBD). */
|
|
50
52
|
.icon-rail {
|
|
51
53
|
display: flex;
|
|
52
54
|
flex-direction: column;
|
|
53
55
|
justify-content: space-between;
|
|
54
|
-
background:
|
|
55
|
-
border-right: 1px solid
|
|
56
|
+
background: oklch(0.25 0.01 250);
|
|
57
|
+
border-right: 1px solid oklch(0.18 0.01 250);
|
|
56
58
|
padding: 6px 0;
|
|
57
59
|
overflow: hidden;
|
|
58
60
|
}
|
|
@@ -71,16 +73,21 @@ body.calendar-sidebar-on {
|
|
|
71
73
|
background: transparent;
|
|
72
74
|
cursor: pointer;
|
|
73
75
|
font-size: 16px;
|
|
74
|
-
color:
|
|
76
|
+
color: oklch(0.88 0.01 250);
|
|
75
77
|
border-left: 3px solid transparent;
|
|
76
|
-
transition: background 0.12s, border-color 0.12s;
|
|
78
|
+
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
|
77
79
|
}
|
|
78
80
|
.rail-btn:hover:not([disabled]) {
|
|
79
|
-
background:
|
|
81
|
+
background: oklch(0.32 0.02 250);
|
|
82
|
+
color: oklch(0.96 0.01 250);
|
|
80
83
|
}
|
|
81
84
|
.rail-btn[data-active="true"] {
|
|
82
|
-
background:
|
|
83
|
-
|
|
85
|
+
background: oklch(0.35 0.03 250);
|
|
86
|
+
color: oklch(0.98 0.01 250);
|
|
87
|
+
border-left-color: var(--color-accent, #4a9cff);
|
|
88
|
+
}
|
|
89
|
+
.rail-btn[disabled] {
|
|
90
|
+
color: oklch(0.55 0.01 250);
|
|
84
91
|
}
|
|
85
92
|
.rail-btn[disabled] {
|
|
86
93
|
opacity: 0.35;
|
package/package.json
CHANGED
|
@@ -564,7 +564,11 @@ export class ImapManager extends EventEmitter {
|
|
|
564
564
|
const hasToken = fs.existsSync(path.join(tokenDir, "oauth-token.json"));
|
|
565
565
|
const TOKEN_FETCH_TIMEOUT_MS = hasToken ? 30_000 : 120_000;
|
|
566
566
|
const authPromise = authenticateOAuth(credPath, {
|
|
567
|
-
|
|
567
|
+
// Scope set covers two-way sync of all mailx-managed local
|
|
568
|
+
// stores: mail (mail.google.com), contacts (full, not
|
|
569
|
+
// readonly — we write edits back), calendar (full), tasks
|
|
570
|
+
// (full), drive (for shared accounts.jsonc).
|
|
571
|
+
scope: "https://mail.google.com/ https://www.googleapis.com/auth/contacts https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/tasks https://www.googleapis.com/auth/drive",
|
|
568
572
|
tokenDirectory: tokenDir,
|
|
569
573
|
credentialsKey: "installed",
|
|
570
574
|
loginHint: account.imap.user,
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Calendar / Tasks / People (Contacts) two-way sync helpers.
|
|
3
|
+
*
|
|
4
|
+
* Used by MailxService to push local edits to Google and pull server
|
|
5
|
+
* changes into the local cache. All functions take a `getToken` function
|
|
6
|
+
* and a fetch implementation so they stay platform-agnostic (Node uses
|
|
7
|
+
* global `fetch` on Node 18+; browsers use window.fetch).
|
|
8
|
+
*
|
|
9
|
+
* Error handling: throws on network / HTTP errors. Caller catches and
|
|
10
|
+
* either retries via the store_sync drainer or surfaces to the UI.
|
|
11
|
+
*/
|
|
12
|
+
type TokenProvider = () => Promise<string>;
|
|
13
|
+
export interface GCalEvent {
|
|
14
|
+
id: string;
|
|
15
|
+
summary: string;
|
|
16
|
+
start: {
|
|
17
|
+
dateTime?: string;
|
|
18
|
+
date?: string;
|
|
19
|
+
};
|
|
20
|
+
end: {
|
|
21
|
+
dateTime?: string;
|
|
22
|
+
date?: string;
|
|
23
|
+
};
|
|
24
|
+
location?: string;
|
|
25
|
+
description?: string;
|
|
26
|
+
etag?: string;
|
|
27
|
+
}
|
|
28
|
+
export declare function listCalendarEvents(tokenProvider: TokenProvider, fromMs: number, toMs: number, calendarId?: string): Promise<GCalEvent[]>;
|
|
29
|
+
export declare function createCalendarEvent(tokenProvider: TokenProvider, event: any, calendarId?: string): Promise<GCalEvent>;
|
|
30
|
+
export declare function updateCalendarEvent(tokenProvider: TokenProvider, eventId: string, event: any, calendarId?: string): Promise<GCalEvent>;
|
|
31
|
+
export declare function deleteCalendarEvent(tokenProvider: TokenProvider, eventId: string, calendarId?: string): Promise<void>;
|
|
32
|
+
export interface GTask {
|
|
33
|
+
id: string;
|
|
34
|
+
title: string;
|
|
35
|
+
notes?: string;
|
|
36
|
+
due?: string;
|
|
37
|
+
completed?: string;
|
|
38
|
+
status?: "needsAction" | "completed";
|
|
39
|
+
etag?: string;
|
|
40
|
+
}
|
|
41
|
+
export declare function listTasks(tokenProvider: TokenProvider, listId?: string, showCompleted?: boolean): Promise<GTask[]>;
|
|
42
|
+
export declare function createTask(tokenProvider: TokenProvider, task: any, listId?: string): Promise<GTask>;
|
|
43
|
+
export declare function updateTask(tokenProvider: TokenProvider, taskId: string, task: any, listId?: string): Promise<GTask>;
|
|
44
|
+
export declare function deleteTask(tokenProvider: TokenProvider, taskId: string, listId?: string): Promise<void>;
|
|
45
|
+
export declare function createContact(tokenProvider: TokenProvider, person: any): Promise<any>;
|
|
46
|
+
export declare function updateContact(tokenProvider: TokenProvider, resourceName: string, updatePersonFields: string, person: any): Promise<any>;
|
|
47
|
+
export declare function deleteContact(tokenProvider: TokenProvider, resourceName: string): Promise<void>;
|
|
48
|
+
export declare function calendarEventToLocal(ev: GCalEvent, accountId: string): {
|
|
49
|
+
providerId: string;
|
|
50
|
+
accountId: string;
|
|
51
|
+
title: string;
|
|
52
|
+
startMs: number;
|
|
53
|
+
endMs: number;
|
|
54
|
+
allDay: boolean;
|
|
55
|
+
location: string;
|
|
56
|
+
notes: string;
|
|
57
|
+
etag: string;
|
|
58
|
+
};
|
|
59
|
+
export declare function localToCalendarEvent(local: {
|
|
60
|
+
title: string;
|
|
61
|
+
startMs: number;
|
|
62
|
+
endMs: number;
|
|
63
|
+
allDay?: boolean;
|
|
64
|
+
location?: string;
|
|
65
|
+
notes?: string;
|
|
66
|
+
}): any;
|
|
67
|
+
export declare function taskToLocal(t: GTask, accountId: string): {
|
|
68
|
+
providerId: string;
|
|
69
|
+
accountId: string;
|
|
70
|
+
title: string;
|
|
71
|
+
notes: string;
|
|
72
|
+
dueMs: number | undefined;
|
|
73
|
+
completedMs: number | undefined;
|
|
74
|
+
etag: string;
|
|
75
|
+
};
|
|
76
|
+
export declare function localToTask(local: {
|
|
77
|
+
title: string;
|
|
78
|
+
notes?: string;
|
|
79
|
+
dueMs?: number;
|
|
80
|
+
completedMs?: number;
|
|
81
|
+
}): any;
|
|
82
|
+
export {};
|
|
83
|
+
//# sourceMappingURL=google-sync.d.ts.map
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Calendar / Tasks / People (Contacts) two-way sync helpers.
|
|
3
|
+
*
|
|
4
|
+
* Used by MailxService to push local edits to Google and pull server
|
|
5
|
+
* changes into the local cache. All functions take a `getToken` function
|
|
6
|
+
* and a fetch implementation so they stay platform-agnostic (Node uses
|
|
7
|
+
* global `fetch` on Node 18+; browsers use window.fetch).
|
|
8
|
+
*
|
|
9
|
+
* Error handling: throws on network / HTTP errors. Caller catches and
|
|
10
|
+
* either retries via the store_sync drainer or surfaces to the UI.
|
|
11
|
+
*/
|
|
12
|
+
async function googleFetch(tokenProvider, url, init = {}) {
|
|
13
|
+
const token = await tokenProvider();
|
|
14
|
+
const headers = new Headers(init.headers || {});
|
|
15
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
16
|
+
if (init.body && !headers.has("Content-Type"))
|
|
17
|
+
headers.set("Content-Type", "application/json");
|
|
18
|
+
const res = await fetch(url, { ...init, headers });
|
|
19
|
+
if (!res.ok) {
|
|
20
|
+
const body = await res.text().catch(() => "");
|
|
21
|
+
throw new Error(`Google API ${res.status} ${res.statusText}: ${body.slice(0, 300)}`);
|
|
22
|
+
}
|
|
23
|
+
return res;
|
|
24
|
+
}
|
|
25
|
+
export async function listCalendarEvents(tokenProvider, fromMs, toMs, calendarId = "primary") {
|
|
26
|
+
const from = new Date(fromMs).toISOString();
|
|
27
|
+
const to = new Date(toMs).toISOString();
|
|
28
|
+
const url = `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events`
|
|
29
|
+
+ `?timeMin=${encodeURIComponent(from)}&timeMax=${encodeURIComponent(to)}`
|
|
30
|
+
+ `&singleEvents=true&orderBy=startTime&maxResults=250`;
|
|
31
|
+
const all = [];
|
|
32
|
+
let pageToken;
|
|
33
|
+
do {
|
|
34
|
+
const pagedUrl = pageToken ? `${url}&pageToken=${encodeURIComponent(pageToken)}` : url;
|
|
35
|
+
const res = await googleFetch(tokenProvider, pagedUrl);
|
|
36
|
+
const data = await res.json();
|
|
37
|
+
for (const ev of (data.items || []))
|
|
38
|
+
all.push(ev);
|
|
39
|
+
pageToken = data.nextPageToken;
|
|
40
|
+
} while (pageToken);
|
|
41
|
+
return all;
|
|
42
|
+
}
|
|
43
|
+
export async function createCalendarEvent(tokenProvider, event, calendarId = "primary") {
|
|
44
|
+
const url = `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events`;
|
|
45
|
+
const res = await googleFetch(tokenProvider, url, { method: "POST", body: JSON.stringify(event) });
|
|
46
|
+
return res.json();
|
|
47
|
+
}
|
|
48
|
+
export async function updateCalendarEvent(tokenProvider, eventId, event, calendarId = "primary") {
|
|
49
|
+
const url = `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`;
|
|
50
|
+
const res = await googleFetch(tokenProvider, url, { method: "PATCH", body: JSON.stringify(event) });
|
|
51
|
+
return res.json();
|
|
52
|
+
}
|
|
53
|
+
export async function deleteCalendarEvent(tokenProvider, eventId, calendarId = "primary") {
|
|
54
|
+
const url = `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`;
|
|
55
|
+
await googleFetch(tokenProvider, url, { method: "DELETE" });
|
|
56
|
+
}
|
|
57
|
+
export async function listTasks(tokenProvider, listId = "@default", showCompleted = false) {
|
|
58
|
+
const url = `https://tasks.googleapis.com/tasks/v1/lists/${encodeURIComponent(listId)}/tasks`
|
|
59
|
+
+ `?showCompleted=${showCompleted}&maxResults=100`;
|
|
60
|
+
const res = await googleFetch(tokenProvider, url);
|
|
61
|
+
const data = await res.json();
|
|
62
|
+
return data.items || [];
|
|
63
|
+
}
|
|
64
|
+
export async function createTask(tokenProvider, task, listId = "@default") {
|
|
65
|
+
const url = `https://tasks.googleapis.com/tasks/v1/lists/${encodeURIComponent(listId)}/tasks`;
|
|
66
|
+
const res = await googleFetch(tokenProvider, url, { method: "POST", body: JSON.stringify(task) });
|
|
67
|
+
return res.json();
|
|
68
|
+
}
|
|
69
|
+
export async function updateTask(tokenProvider, taskId, task, listId = "@default") {
|
|
70
|
+
const url = `https://tasks.googleapis.com/tasks/v1/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(taskId)}`;
|
|
71
|
+
const res = await googleFetch(tokenProvider, url, { method: "PATCH", body: JSON.stringify(task) });
|
|
72
|
+
return res.json();
|
|
73
|
+
}
|
|
74
|
+
export async function deleteTask(tokenProvider, taskId, listId = "@default") {
|
|
75
|
+
const url = `https://tasks.googleapis.com/tasks/v1/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(taskId)}`;
|
|
76
|
+
await googleFetch(tokenProvider, url, { method: "DELETE" });
|
|
77
|
+
}
|
|
78
|
+
// ── Contacts (People API) ──
|
|
79
|
+
export async function createContact(tokenProvider, person) {
|
|
80
|
+
const url = `https://people.googleapis.com/v1/people:createContact`;
|
|
81
|
+
const res = await googleFetch(tokenProvider, url, { method: "POST", body: JSON.stringify(person) });
|
|
82
|
+
return res.json();
|
|
83
|
+
}
|
|
84
|
+
export async function updateContact(tokenProvider, resourceName, updatePersonFields, person) {
|
|
85
|
+
const url = `https://people.googleapis.com/v1/${resourceName}:updateContact`
|
|
86
|
+
+ `?updatePersonFields=${encodeURIComponent(updatePersonFields)}`;
|
|
87
|
+
const res = await googleFetch(tokenProvider, url, { method: "PATCH", body: JSON.stringify(person) });
|
|
88
|
+
return res.json();
|
|
89
|
+
}
|
|
90
|
+
export async function deleteContact(tokenProvider, resourceName) {
|
|
91
|
+
const url = `https://people.googleapis.com/v1/${resourceName}:deleteContact`;
|
|
92
|
+
await googleFetch(tokenProvider, url, { method: "DELETE" });
|
|
93
|
+
}
|
|
94
|
+
// ── Conversion helpers ──
|
|
95
|
+
export function calendarEventToLocal(ev, accountId) {
|
|
96
|
+
const allDay = !!ev.start.date && !ev.start.dateTime;
|
|
97
|
+
const startMs = allDay
|
|
98
|
+
? Date.parse(ev.start.date)
|
|
99
|
+
: Date.parse(ev.start.dateTime);
|
|
100
|
+
const endMs = allDay
|
|
101
|
+
? Date.parse(ev.end.date)
|
|
102
|
+
: Date.parse(ev.end.dateTime);
|
|
103
|
+
return {
|
|
104
|
+
providerId: ev.id, accountId,
|
|
105
|
+
title: ev.summary || "(no title)",
|
|
106
|
+
startMs, endMs, allDay,
|
|
107
|
+
location: ev.location || "", notes: ev.description || "",
|
|
108
|
+
etag: ev.etag || "",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
export function localToCalendarEvent(local) {
|
|
112
|
+
const toIso = (ms) => new Date(ms).toISOString();
|
|
113
|
+
const toDate = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
114
|
+
return {
|
|
115
|
+
summary: local.title,
|
|
116
|
+
start: local.allDay ? { date: toDate(local.startMs) } : { dateTime: toIso(local.startMs) },
|
|
117
|
+
end: local.allDay ? { date: toDate(local.endMs) } : { dateTime: toIso(local.endMs) },
|
|
118
|
+
location: local.location || undefined,
|
|
119
|
+
description: local.notes || undefined,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
export function taskToLocal(t, accountId) {
|
|
123
|
+
return {
|
|
124
|
+
providerId: t.id, accountId, title: t.title || "",
|
|
125
|
+
notes: t.notes || "",
|
|
126
|
+
dueMs: t.due ? Date.parse(t.due) : undefined,
|
|
127
|
+
completedMs: t.completed ? Date.parse(t.completed) : undefined,
|
|
128
|
+
etag: t.etag || "",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
export function localToTask(local) {
|
|
132
|
+
return {
|
|
133
|
+
title: local.title,
|
|
134
|
+
notes: local.notes || undefined,
|
|
135
|
+
due: local.dueMs ? new Date(local.dueMs).toISOString() : undefined,
|
|
136
|
+
completed: local.completedMs ? new Date(local.completedMs).toISOString() : undefined,
|
|
137
|
+
status: local.completedMs ? "completed" : "needsAction",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
//# sourceMappingURL=google-sync.js.map
|
|
@@ -47,6 +47,46 @@ export declare class MailxService {
|
|
|
47
47
|
* Called without `feature` it returns the catch-all primary — same
|
|
48
48
|
* semantics as the original single-flag version for back-compat. */
|
|
49
49
|
getPrimaryAccount(feature?: string): any;
|
|
50
|
+
private primaryTokenProvider;
|
|
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. */
|
|
54
|
+
getCalendarEvents(fromMs: number, toMs: number): Promise<any[]>;
|
|
55
|
+
private refreshCalendarEvents;
|
|
56
|
+
createCalendarEventLocal(ev: {
|
|
57
|
+
title: string;
|
|
58
|
+
startMs: number;
|
|
59
|
+
endMs: number;
|
|
60
|
+
allDay?: boolean;
|
|
61
|
+
location?: string;
|
|
62
|
+
notes?: string;
|
|
63
|
+
}): Promise<string>;
|
|
64
|
+
updateCalendarEventLocal(uuid: string, patch: {
|
|
65
|
+
title?: string;
|
|
66
|
+
startMs?: number;
|
|
67
|
+
endMs?: number;
|
|
68
|
+
allDay?: boolean;
|
|
69
|
+
location?: string;
|
|
70
|
+
notes?: string;
|
|
71
|
+
}): Promise<void>;
|
|
72
|
+
deleteCalendarEventLocal(uuid: string): Promise<void>;
|
|
73
|
+
getTasks(includeCompleted?: boolean): Promise<any[]>;
|
|
74
|
+
private refreshTasks;
|
|
75
|
+
createTaskLocal(t: {
|
|
76
|
+
title: string;
|
|
77
|
+
notes?: string;
|
|
78
|
+
dueMs?: number;
|
|
79
|
+
}): Promise<string>;
|
|
80
|
+
updateTaskLocal(uuid: string, patch: {
|
|
81
|
+
title?: string;
|
|
82
|
+
notes?: string;
|
|
83
|
+
dueMs?: number;
|
|
84
|
+
completedMs?: number;
|
|
85
|
+
}): Promise<void>;
|
|
86
|
+
deleteTaskLocal(uuid: string): Promise<void>;
|
|
87
|
+
/** Drain the store_sync queue — calendar / tasks / contacts push-to-server.
|
|
88
|
+
* Called on every local edit, and on a periodic tick from the outbox worker. */
|
|
89
|
+
drainStoreSync(): Promise<void>;
|
|
50
90
|
/** List queued outgoing messages with parsed envelope headers so the UI
|
|
51
91
|
* can render a pink-row "pending" view before IMAP APPEND succeeds. */
|
|
52
92
|
listQueuedOutgoing(): any[];
|
|
@@ -95,11 +135,13 @@ export declare class MailxService {
|
|
|
95
135
|
addContact(name: string, email: string): boolean;
|
|
96
136
|
/** Address-book listing — paginated, filterable. */
|
|
97
137
|
listContacts(query: string, page?: number, pageSize?: number): any;
|
|
98
|
-
/** Upsert a contact from the address book UI (edit name).
|
|
138
|
+
/** Upsert a contact from the address book UI (edit name). Two-way cache:
|
|
139
|
+
* commits locally, queues a Google People push. */
|
|
99
140
|
upsertContact(name: string, email: string): {
|
|
100
141
|
ok: true;
|
|
101
142
|
};
|
|
102
|
-
/** Delete a contact from the address book.
|
|
143
|
+
/** Delete a contact from the address book. Also pushes the deletion to
|
|
144
|
+
* Google People if the contact had a resourceName (i.e. was synced). */
|
|
103
145
|
deleteContact(email: string): {
|
|
104
146
|
ok: true;
|
|
105
147
|
};
|
|
@@ -8,6 +8,7 @@ import * as fs from "node:fs";
|
|
|
8
8
|
import * as path from "node:path";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
import * as gsync from "./google-sync.js";
|
|
11
12
|
import { loadSettings, saveSettings, loadAccounts, loadAccountsAsync, saveAccounts, initCloudConfig, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, getStorageInfo, getConfigDir } from "@bobfrankston/mailx-settings";
|
|
12
13
|
import { sanitizeHtml, encodeQuotedPrintable } from "@bobfrankston/mailx-types";
|
|
13
14
|
import { simpleParser } from "mailparser";
|
|
@@ -445,6 +446,198 @@ export class MailxService {
|
|
|
445
446
|
}
|
|
446
447
|
return all.find((a) => a.primary) || all[0] || null;
|
|
447
448
|
}
|
|
449
|
+
// ── Calendar / Tasks / Contacts: two-way cache (2026-04-23) ──
|
|
450
|
+
async primaryTokenProvider(feature) {
|
|
451
|
+
const acct = this.getPrimaryAccount(feature);
|
|
452
|
+
if (!acct)
|
|
453
|
+
throw new Error(`No primary account for ${feature}`);
|
|
454
|
+
return async () => {
|
|
455
|
+
const tok = await this.imapManager.getOAuthToken(acct.id);
|
|
456
|
+
if (!tok)
|
|
457
|
+
throw new Error(`No OAuth token for ${acct.id}`);
|
|
458
|
+
return tok;
|
|
459
|
+
};
|
|
460
|
+
}
|
|
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. */
|
|
464
|
+
async getCalendarEvents(fromMs, toMs) {
|
|
465
|
+
const acct = this.getPrimaryAccount("calendar");
|
|
466
|
+
if (!acct)
|
|
467
|
+
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
|
+
return this.db.getCalendarEvents(acct.id, fromMs, toMs);
|
|
471
|
+
}
|
|
472
|
+
async refreshCalendarEvents(accountId, fromMs, toMs) {
|
|
473
|
+
const tp = await this.primaryTokenProvider("calendar");
|
|
474
|
+
const events = await gsync.listCalendarEvents(tp, fromMs, toMs);
|
|
475
|
+
for (const ev of events) {
|
|
476
|
+
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
|
+
});
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
async createCalendarEventLocal(ev) {
|
|
488
|
+
const acct = this.getPrimaryAccount("calendar");
|
|
489
|
+
if (!acct)
|
|
490
|
+
throw new Error("No primary calendar account");
|
|
491
|
+
const uuid = this.db.upsertCalendarEvent({
|
|
492
|
+
accountId: acct.id, ...ev, dirty: true,
|
|
493
|
+
});
|
|
494
|
+
this.db.enqueueStoreSync("calendar", "create", acct.id, uuid, ev);
|
|
495
|
+
this.drainStoreSync().catch(() => { });
|
|
496
|
+
return uuid;
|
|
497
|
+
}
|
|
498
|
+
async updateCalendarEventLocal(uuid, patch) {
|
|
499
|
+
// Merge with existing row before writing so partial patches don't
|
|
500
|
+
// null-out unspecified fields in upsert.
|
|
501
|
+
const existing = this.db.getCalendarEvents("", 0, Number.MAX_SAFE_INTEGER).find(e => e.uuid === uuid);
|
|
502
|
+
if (!existing)
|
|
503
|
+
throw new Error(`No calendar event ${uuid}`);
|
|
504
|
+
this.db.upsertCalendarEvent({
|
|
505
|
+
uuid, accountId: existing.accountId, providerId: existing.providerId,
|
|
506
|
+
calendarId: existing.calendarId, dirty: true,
|
|
507
|
+
title: patch.title ?? existing.title,
|
|
508
|
+
startMs: patch.startMs ?? existing.startMs,
|
|
509
|
+
endMs: patch.endMs ?? existing.endMs,
|
|
510
|
+
allDay: patch.allDay ?? existing.allDay,
|
|
511
|
+
location: patch.location ?? existing.location,
|
|
512
|
+
notes: patch.notes ?? existing.notes,
|
|
513
|
+
});
|
|
514
|
+
this.db.enqueueStoreSync("calendar", "update", existing.accountId, uuid, { providerId: existing.providerId, patch });
|
|
515
|
+
this.drainStoreSync().catch(() => { });
|
|
516
|
+
}
|
|
517
|
+
async deleteCalendarEventLocal(uuid) {
|
|
518
|
+
const ev = this.db.getCalendarEvents("", 0, Number.MAX_SAFE_INTEGER).find(e => e.uuid === uuid);
|
|
519
|
+
if (!ev)
|
|
520
|
+
return;
|
|
521
|
+
this.db.deleteCalendarEventLocal(uuid);
|
|
522
|
+
if (ev.providerId) {
|
|
523
|
+
this.db.enqueueStoreSync("calendar", "delete", ev.accountId, uuid, { providerId: ev.providerId });
|
|
524
|
+
this.drainStoreSync().catch(() => { });
|
|
525
|
+
}
|
|
526
|
+
else {
|
|
527
|
+
// Never made it to the server; just purge locally.
|
|
528
|
+
this.db.purgeCalendarEvent(uuid);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
async getTasks(includeCompleted = false) {
|
|
532
|
+
const acct = this.getPrimaryAccount("tasks");
|
|
533
|
+
if (!acct)
|
|
534
|
+
return [];
|
|
535
|
+
this.refreshTasks(acct.id, includeCompleted).catch(e => console.error(`[tasks] refresh failed: ${e.message}`));
|
|
536
|
+
return this.db.getTasks(acct.id, includeCompleted);
|
|
537
|
+
}
|
|
538
|
+
async refreshTasks(accountId, includeCompleted) {
|
|
539
|
+
const tp = await this.primaryTokenProvider("tasks");
|
|
540
|
+
const tasks = await gsync.listTasks(tp, "@default", includeCompleted);
|
|
541
|
+
const existing = this.db.getTasks(accountId, true);
|
|
542
|
+
for (const t of tasks) {
|
|
543
|
+
const local = gsync.taskToLocal(t, accountId);
|
|
544
|
+
const prior = existing.find(e => e.providerId === t.id);
|
|
545
|
+
this.db.upsertTask({ uuid: prior?.uuid, ...local });
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
async createTaskLocal(t) {
|
|
549
|
+
const acct = this.getPrimaryAccount("tasks");
|
|
550
|
+
if (!acct)
|
|
551
|
+
throw new Error("No primary tasks account");
|
|
552
|
+
const uuid = this.db.upsertTask({ accountId: acct.id, ...t, dirty: true });
|
|
553
|
+
this.db.enqueueStoreSync("tasks", "create", acct.id, uuid, t);
|
|
554
|
+
this.drainStoreSync().catch(() => { });
|
|
555
|
+
return uuid;
|
|
556
|
+
}
|
|
557
|
+
async updateTaskLocal(uuid, patch) {
|
|
558
|
+
const existing = this.db.getTasks("", true).find(t => t.uuid === uuid);
|
|
559
|
+
if (!existing)
|
|
560
|
+
throw new Error(`No task ${uuid}`);
|
|
561
|
+
this.db.upsertTask({
|
|
562
|
+
uuid, accountId: existing.accountId, providerId: existing.providerId,
|
|
563
|
+
listId: existing.listId, dirty: true,
|
|
564
|
+
title: patch.title ?? existing.title,
|
|
565
|
+
notes: patch.notes ?? existing.notes,
|
|
566
|
+
dueMs: patch.dueMs ?? existing.dueMs,
|
|
567
|
+
completedMs: patch.completedMs ?? existing.completedMs,
|
|
568
|
+
});
|
|
569
|
+
this.db.enqueueStoreSync("tasks", "update", existing.accountId, uuid, { providerId: existing.providerId, patch });
|
|
570
|
+
this.drainStoreSync().catch(() => { });
|
|
571
|
+
}
|
|
572
|
+
async deleteTaskLocal(uuid) {
|
|
573
|
+
const task = this.db.getTasks("", true).find(t => t.uuid === uuid);
|
|
574
|
+
if (!task)
|
|
575
|
+
return;
|
|
576
|
+
this.db.deleteTaskLocal(uuid);
|
|
577
|
+
if (task.providerId) {
|
|
578
|
+
this.db.enqueueStoreSync("tasks", "delete", task.accountId, uuid, { providerId: task.providerId });
|
|
579
|
+
this.drainStoreSync().catch(() => { });
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
this.db.purgeTask(uuid);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
/** Drain the store_sync queue — calendar / tasks / contacts push-to-server.
|
|
586
|
+
* Called on every local edit, and on a periodic tick from the outbox worker. */
|
|
587
|
+
async drainStoreSync() {
|
|
588
|
+
const queue = this.db.getStoreSyncQueue();
|
|
589
|
+
for (const entry of queue) {
|
|
590
|
+
try {
|
|
591
|
+
if (entry.kind === "calendar") {
|
|
592
|
+
const tp = await this.primaryTokenProvider("calendar");
|
|
593
|
+
if (entry.op === "create") {
|
|
594
|
+
const created = await gsync.createCalendarEvent(tp, gsync.localToCalendarEvent(entry.payload));
|
|
595
|
+
this.db.markCalendarEventClean(entry.targetUuid, created.id, created.etag || "");
|
|
596
|
+
}
|
|
597
|
+
else if (entry.op === "update") {
|
|
598
|
+
const updated = await gsync.updateCalendarEvent(tp, entry.payload.providerId, gsync.localToCalendarEvent(entry.payload.patch));
|
|
599
|
+
this.db.markCalendarEventClean(entry.targetUuid, updated.id, updated.etag || "");
|
|
600
|
+
}
|
|
601
|
+
else if (entry.op === "delete") {
|
|
602
|
+
await gsync.deleteCalendarEvent(tp, entry.payload.providerId);
|
|
603
|
+
this.db.purgeCalendarEvent(entry.targetUuid);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
else if (entry.kind === "tasks") {
|
|
607
|
+
const tp = await this.primaryTokenProvider("tasks");
|
|
608
|
+
if (entry.op === "create") {
|
|
609
|
+
const created = await gsync.createTask(tp, gsync.localToTask(entry.payload));
|
|
610
|
+
this.db.markTaskClean(entry.targetUuid, created.id, created.etag || "");
|
|
611
|
+
}
|
|
612
|
+
else if (entry.op === "update") {
|
|
613
|
+
const updated = await gsync.updateTask(tp, entry.payload.providerId, gsync.localToTask(entry.payload.patch));
|
|
614
|
+
this.db.markTaskClean(entry.targetUuid, updated.id, updated.etag || "");
|
|
615
|
+
}
|
|
616
|
+
else if (entry.op === "delete") {
|
|
617
|
+
await gsync.deleteTask(tp, entry.payload.providerId);
|
|
618
|
+
this.db.purgeTask(entry.targetUuid);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
else if (entry.kind === "contacts") {
|
|
622
|
+
const tp = await this.primaryTokenProvider("contacts");
|
|
623
|
+
if (entry.op === "create") {
|
|
624
|
+
await gsync.createContact(tp, entry.payload);
|
|
625
|
+
}
|
|
626
|
+
else if (entry.op === "update") {
|
|
627
|
+
await gsync.updateContact(tp, entry.payload.resourceName, entry.payload.updatePersonFields || "names,emailAddresses", entry.payload.person);
|
|
628
|
+
}
|
|
629
|
+
else if (entry.op === "delete") {
|
|
630
|
+
await gsync.deleteContact(tp, entry.payload.resourceName);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
this.db.completeStoreSync(entry.id);
|
|
634
|
+
}
|
|
635
|
+
catch (e) {
|
|
636
|
+
console.error(`[store_sync] ${entry.kind}/${entry.op}/${entry.targetUuid} failed: ${e.message}`);
|
|
637
|
+
this.db.failStoreSync(entry.id, e.message || String(e));
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
448
641
|
/** List queued outgoing messages with parsed envelope headers so the UI
|
|
449
642
|
* can render a pink-row "pending" view before IMAP APPEND succeeds. */
|
|
450
643
|
listQueuedOutgoing() {
|
|
@@ -985,14 +1178,36 @@ export class MailxService {
|
|
|
985
1178
|
listContacts(query, page = 1, pageSize = 100) {
|
|
986
1179
|
return this.db.listContacts(query || "", page, pageSize);
|
|
987
1180
|
}
|
|
988
|
-
/** Upsert a contact from the address book UI (edit name).
|
|
1181
|
+
/** Upsert a contact from the address book UI (edit name). Two-way cache:
|
|
1182
|
+
* commits locally, queues a Google People push. */
|
|
989
1183
|
upsertContact(name, email) {
|
|
990
1184
|
this.db.upsertContact(name || "", email);
|
|
1185
|
+
const acct = this.getPrimaryAccount("contacts");
|
|
1186
|
+
if (acct) {
|
|
1187
|
+
// Google People `createContact` — resourceName is assigned by the
|
|
1188
|
+
// server and stored back as google_id once the drainer gets an ACK.
|
|
1189
|
+
this.db.enqueueStoreSync("contacts", "create", acct.id, email, {
|
|
1190
|
+
names: [{ givenName: name || "" }],
|
|
1191
|
+
emailAddresses: [{ value: email }],
|
|
1192
|
+
});
|
|
1193
|
+
this.drainStoreSync().catch(() => { });
|
|
1194
|
+
}
|
|
991
1195
|
return { ok: true };
|
|
992
1196
|
}
|
|
993
|
-
/** Delete a contact from the address book.
|
|
1197
|
+
/** Delete a contact from the address book. Also pushes the deletion to
|
|
1198
|
+
* Google People if the contact had a resourceName (i.e. was synced). */
|
|
994
1199
|
deleteContact(email) {
|
|
995
|
-
this.
|
|
1200
|
+
const acct = this.getPrimaryAccount("contacts");
|
|
1201
|
+
// Look up the resourceName before deleting so we can push to Google.
|
|
1202
|
+
const contacts = this.db.listContacts("", 1, 10_000);
|
|
1203
|
+
const contact = (contacts.items || []).find((c) => c.email === email);
|
|
1204
|
+
this.db.deleteContactLocal(email);
|
|
1205
|
+
if (acct && contact?.googleId) {
|
|
1206
|
+
this.db.enqueueStoreSync("contacts", "delete", acct.id, email, {
|
|
1207
|
+
resourceName: contact.googleId,
|
|
1208
|
+
});
|
|
1209
|
+
this.drainStoreSync().catch(() => { });
|
|
1210
|
+
}
|
|
996
1211
|
return { ok: true };
|
|
997
1212
|
}
|
|
998
1213
|
/** Open a configured local path in the OS file explorer. Whitelisted to
|
|
@@ -98,6 +98,31 @@ async function dispatchAction(svc, action, p) {
|
|
|
98
98
|
return svc.getDiagnostics();
|
|
99
99
|
case "getPrimaryAccount":
|
|
100
100
|
return svc.getPrimaryAccount(p?.feature);
|
|
101
|
+
// Calendar / Tasks: two-way cache. Reads return local immediately
|
|
102
|
+
// and kick a background refresh that emits updates when done.
|
|
103
|
+
case "getCalendarEvents":
|
|
104
|
+
return await svc.getCalendarEvents(p.fromMs, p.toMs);
|
|
105
|
+
case "createCalendarEvent":
|
|
106
|
+
return { uuid: await svc.createCalendarEventLocal(p) };
|
|
107
|
+
case "updateCalendarEvent":
|
|
108
|
+
await svc.updateCalendarEventLocal(p.uuid, p.patch);
|
|
109
|
+
return { ok: true };
|
|
110
|
+
case "deleteCalendarEvent":
|
|
111
|
+
await svc.deleteCalendarEventLocal(p.uuid);
|
|
112
|
+
return { ok: true };
|
|
113
|
+
case "getTasks":
|
|
114
|
+
return await svc.getTasks(!!p.includeCompleted);
|
|
115
|
+
case "createTask":
|
|
116
|
+
return { uuid: await svc.createTaskLocal(p) };
|
|
117
|
+
case "updateTask":
|
|
118
|
+
await svc.updateTaskLocal(p.uuid, p.patch);
|
|
119
|
+
return { ok: true };
|
|
120
|
+
case "deleteTask":
|
|
121
|
+
await svc.deleteTaskLocal(p.uuid);
|
|
122
|
+
return { ok: true };
|
|
123
|
+
case "drainStoreSync":
|
|
124
|
+
await svc.drainStoreSync();
|
|
125
|
+
return { ok: true };
|
|
101
126
|
case "getOutboxStatus":
|
|
102
127
|
return svc.getOutboxStatus();
|
|
103
128
|
case "listQueuedOutgoing":
|
|
@@ -33,6 +33,50 @@ export declare class MailxDB {
|
|
|
33
33
|
* growing unboundedly. Default retention is 30 days; caller passes the
|
|
34
34
|
* actual cutoff in ms since epoch. */
|
|
35
35
|
pruneTombstones(olderThanMs: number): number;
|
|
36
|
+
upsertCalendarEvent(ev: {
|
|
37
|
+
uuid?: string;
|
|
38
|
+
accountId: string;
|
|
39
|
+
providerId?: string;
|
|
40
|
+
calendarId?: string;
|
|
41
|
+
title: string;
|
|
42
|
+
startMs: number;
|
|
43
|
+
endMs: number;
|
|
44
|
+
allDay?: boolean;
|
|
45
|
+
location?: string;
|
|
46
|
+
notes?: string;
|
|
47
|
+
etag?: string;
|
|
48
|
+
dirty?: boolean;
|
|
49
|
+
}): string;
|
|
50
|
+
getCalendarEvents(accountId: string, fromMs: number, toMs: number): any[];
|
|
51
|
+
getDirtyCalendarEvents(accountId: string): any[];
|
|
52
|
+
private calendarRowToObject;
|
|
53
|
+
markCalendarEventClean(uuid: string, providerId: string, etag: string): void;
|
|
54
|
+
deleteCalendarEventLocal(uuid: string): void;
|
|
55
|
+
purgeCalendarEvent(uuid: string): void;
|
|
56
|
+
upsertTask(t: {
|
|
57
|
+
uuid?: string;
|
|
58
|
+
accountId: string;
|
|
59
|
+
providerId?: string;
|
|
60
|
+
listId?: string;
|
|
61
|
+
title: string;
|
|
62
|
+
notes?: string;
|
|
63
|
+
dueMs?: number;
|
|
64
|
+
completedMs?: number;
|
|
65
|
+
etag?: string;
|
|
66
|
+
dirty?: boolean;
|
|
67
|
+
}): string;
|
|
68
|
+
getTasks(accountId: string, includeCompleted?: boolean): any[];
|
|
69
|
+
getDirtyTasks(accountId: string): any[];
|
|
70
|
+
private taskRowToObject;
|
|
71
|
+
markTaskClean(uuid: string, providerId: string, etag: string): void;
|
|
72
|
+
deleteTaskLocal(uuid: string): void;
|
|
73
|
+
purgeTask(uuid: string): void;
|
|
74
|
+
/** Local delete for the two-way cache drainer (symmetric with calendar/tasks). */
|
|
75
|
+
deleteContactLocal(email: string): void;
|
|
76
|
+
enqueueStoreSync(kind: string, op: string, accountId: string, targetUuid: string, payload: any): void;
|
|
77
|
+
getStoreSyncQueue(kind?: string, accountId?: string): any[];
|
|
78
|
+
completeStoreSync(id: number): void;
|
|
79
|
+
failStoreSync(id: number, error: string): void;
|
|
36
80
|
/** Idempotently add a column to a table if it's missing. */
|
|
37
81
|
private addColumnIfMissing;
|
|
38
82
|
/** Compute a thread id for an incoming message. Strategy:
|
|
@@ -142,6 +186,7 @@ export declare class MailxDB {
|
|
|
142
186
|
name: string;
|
|
143
187
|
email: string;
|
|
144
188
|
source: string;
|
|
189
|
+
googleId: string | null;
|
|
145
190
|
useCount: number;
|
|
146
191
|
lastUsed: number;
|
|
147
192
|
}[];
|
|
@@ -142,6 +142,72 @@ const SCHEMA = `
|
|
|
142
142
|
PRIMARY KEY (account_id, message_id)
|
|
143
143
|
);
|
|
144
144
|
CREATE INDEX IF NOT EXISTS idx_tombstones_deleted_at ON tombstones(deleted_at);
|
|
145
|
+
|
|
146
|
+
-- Calendar events: two-way cache of Google Calendar / local events.
|
|
147
|
+
-- uuid = local stable identity (survives provider_id rebinds).
|
|
148
|
+
-- provider_id = Google Calendar event id when known (null for local-only
|
|
149
|
+
-- events that haven't been pushed yet).
|
|
150
|
+
-- deleted = tombstone marker; drainer removes row from server then deletes
|
|
151
|
+
-- the row locally.
|
|
152
|
+
CREATE TABLE IF NOT EXISTS calendar_events (
|
|
153
|
+
uuid TEXT PRIMARY KEY,
|
|
154
|
+
account_id TEXT NOT NULL,
|
|
155
|
+
provider_id TEXT,
|
|
156
|
+
calendar_id TEXT DEFAULT 'primary',
|
|
157
|
+
title TEXT NOT NULL DEFAULT '',
|
|
158
|
+
start_ms INTEGER NOT NULL,
|
|
159
|
+
end_ms INTEGER NOT NULL,
|
|
160
|
+
all_day INTEGER DEFAULT 0,
|
|
161
|
+
location TEXT DEFAULT '',
|
|
162
|
+
notes TEXT DEFAULT '',
|
|
163
|
+
etag TEXT,
|
|
164
|
+
last_synced INTEGER DEFAULT 0,
|
|
165
|
+
dirty INTEGER DEFAULT 0,
|
|
166
|
+
deleted INTEGER DEFAULT 0,
|
|
167
|
+
updated_at INTEGER NOT NULL
|
|
168
|
+
);
|
|
169
|
+
CREATE INDEX IF NOT EXISTS idx_calendar_events_start ON calendar_events(account_id, start_ms);
|
|
170
|
+
CREATE INDEX IF NOT EXISTS idx_calendar_events_dirty ON calendar_events(dirty) WHERE dirty = 1;
|
|
171
|
+
|
|
172
|
+
-- Tasks: two-way cache of Google Tasks / local tasks. Same shape as
|
|
173
|
+
-- calendar_events minus the time range.
|
|
174
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
175
|
+
uuid TEXT PRIMARY KEY,
|
|
176
|
+
account_id TEXT NOT NULL,
|
|
177
|
+
provider_id TEXT,
|
|
178
|
+
list_id TEXT DEFAULT '@default',
|
|
179
|
+
title TEXT NOT NULL DEFAULT '',
|
|
180
|
+
notes TEXT DEFAULT '',
|
|
181
|
+
due_ms INTEGER,
|
|
182
|
+
completed_ms INTEGER,
|
|
183
|
+
etag TEXT,
|
|
184
|
+
last_synced INTEGER DEFAULT 0,
|
|
185
|
+
dirty INTEGER DEFAULT 0,
|
|
186
|
+
deleted INTEGER DEFAULT 0,
|
|
187
|
+
updated_at INTEGER NOT NULL
|
|
188
|
+
);
|
|
189
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_account ON tasks(account_id);
|
|
190
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_dirty ON tasks(dirty) WHERE dirty = 1;
|
|
191
|
+
|
|
192
|
+
-- Generic store-sync queue for domains OTHER than messages. Messages
|
|
193
|
+
-- use sync_actions above. This table queues push-to-server actions
|
|
194
|
+
-- for calendar / tasks / contacts / allowlist. Kind identifies the
|
|
195
|
+
-- domain; op is "create" / "update" / "delete"; payload is JSON the
|
|
196
|
+
-- drainer posts to the provider. Target URL isn't stored — the
|
|
197
|
+
-- drainer knows the provider endpoint from kind + payload.
|
|
198
|
+
CREATE TABLE IF NOT EXISTS store_sync (
|
|
199
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
200
|
+
kind TEXT NOT NULL,
|
|
201
|
+
op TEXT NOT NULL,
|
|
202
|
+
account_id TEXT NOT NULL,
|
|
203
|
+
target_uuid TEXT NOT NULL,
|
|
204
|
+
payload TEXT,
|
|
205
|
+
attempts INTEGER DEFAULT 0,
|
|
206
|
+
last_error TEXT,
|
|
207
|
+
created_at INTEGER NOT NULL,
|
|
208
|
+
UNIQUE(kind, target_uuid, op)
|
|
209
|
+
);
|
|
210
|
+
CREATE INDEX IF NOT EXISTS idx_store_sync_account ON store_sync(account_id, kind);
|
|
145
211
|
`;
|
|
146
212
|
export class MailxDB {
|
|
147
213
|
db;
|
|
@@ -303,6 +369,152 @@ export class MailxDB {
|
|
|
303
369
|
return 0;
|
|
304
370
|
}
|
|
305
371
|
}
|
|
372
|
+
// ── Calendar events (two-way cache) ──
|
|
373
|
+
upsertCalendarEvent(ev) {
|
|
374
|
+
const uuid = ev.uuid || randomUUID().replace(/-/g, "");
|
|
375
|
+
this.db.prepare(`
|
|
376
|
+
INSERT INTO calendar_events
|
|
377
|
+
(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, ?)
|
|
380
|
+
ON CONFLICT(uuid) DO UPDATE SET
|
|
381
|
+
account_id=excluded.account_id, provider_id=excluded.provider_id,
|
|
382
|
+
calendar_id=excluded.calendar_id, title=excluded.title,
|
|
383
|
+
start_ms=excluded.start_ms, end_ms=excluded.end_ms,
|
|
384
|
+
all_day=excluded.all_day, location=excluded.location,
|
|
385
|
+
notes=excluded.notes, etag=excluded.etag,
|
|
386
|
+
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());
|
|
389
|
+
return uuid;
|
|
390
|
+
}
|
|
391
|
+
getCalendarEvents(accountId, fromMs, toMs) {
|
|
392
|
+
const rows = this.db.prepare(`
|
|
393
|
+
SELECT * FROM calendar_events
|
|
394
|
+
WHERE account_id = ? AND deleted = 0 AND start_ms >= ? AND start_ms < ?
|
|
395
|
+
ORDER BY start_ms ASC
|
|
396
|
+
`).all(accountId, fromMs, toMs);
|
|
397
|
+
return rows.map(this.calendarRowToObject);
|
|
398
|
+
}
|
|
399
|
+
getDirtyCalendarEvents(accountId) {
|
|
400
|
+
const rows = this.db.prepare(`
|
|
401
|
+
SELECT * FROM calendar_events WHERE account_id = ? AND (dirty = 1 OR deleted = 1)
|
|
402
|
+
`).all(accountId);
|
|
403
|
+
return rows.map(this.calendarRowToObject);
|
|
404
|
+
}
|
|
405
|
+
calendarRowToObject(r) {
|
|
406
|
+
return {
|
|
407
|
+
uuid: r.uuid, accountId: r.account_id, providerId: r.provider_id,
|
|
408
|
+
calendarId: r.calendar_id, title: r.title, startMs: r.start_ms,
|
|
409
|
+
endMs: r.end_ms, allDay: !!r.all_day, location: r.location, notes: r.notes,
|
|
410
|
+
etag: r.etag, lastSynced: r.last_synced, dirty: !!r.dirty, deleted: !!r.deleted,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
markCalendarEventClean(uuid, providerId, etag) {
|
|
414
|
+
this.db.prepare(`
|
|
415
|
+
UPDATE calendar_events SET dirty=0, provider_id=?, etag=?, last_synced=? WHERE uuid=?
|
|
416
|
+
`).run(providerId, etag, Date.now(), uuid);
|
|
417
|
+
}
|
|
418
|
+
deleteCalendarEventLocal(uuid) {
|
|
419
|
+
this.db.prepare("UPDATE calendar_events SET deleted=1, dirty=1, updated_at=? WHERE uuid=?").run(Date.now(), uuid);
|
|
420
|
+
}
|
|
421
|
+
purgeCalendarEvent(uuid) {
|
|
422
|
+
this.db.prepare("DELETE FROM calendar_events WHERE uuid=?").run(uuid);
|
|
423
|
+
}
|
|
424
|
+
// ── Tasks (two-way cache) ──
|
|
425
|
+
upsertTask(t) {
|
|
426
|
+
const uuid = t.uuid || randomUUID().replace(/-/g, "");
|
|
427
|
+
this.db.prepare(`
|
|
428
|
+
INSERT INTO tasks
|
|
429
|
+
(uuid, account_id, provider_id, list_id, title, notes, due_ms, completed_ms,
|
|
430
|
+
etag, last_synced, dirty, deleted, updated_at)
|
|
431
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)
|
|
432
|
+
ON CONFLICT(uuid) DO UPDATE SET
|
|
433
|
+
account_id=excluded.account_id, provider_id=excluded.provider_id,
|
|
434
|
+
list_id=excluded.list_id, title=excluded.title, notes=excluded.notes,
|
|
435
|
+
due_ms=excluded.due_ms, completed_ms=excluded.completed_ms,
|
|
436
|
+
etag=excluded.etag, last_synced=excluded.last_synced,
|
|
437
|
+
dirty=excluded.dirty, updated_at=excluded.updated_at
|
|
438
|
+
`).run(uuid, t.accountId, t.providerId || null, t.listId || "@default", t.title, t.notes || "", t.dueMs || null, t.completedMs || null, t.etag || null, t.dirty ? 0 : Date.now(), t.dirty ? 1 : 0, Date.now());
|
|
439
|
+
return uuid;
|
|
440
|
+
}
|
|
441
|
+
getTasks(accountId, includeCompleted = false) {
|
|
442
|
+
const where = includeCompleted
|
|
443
|
+
? "account_id = ? AND deleted = 0"
|
|
444
|
+
: "account_id = ? AND deleted = 0 AND completed_ms IS NULL";
|
|
445
|
+
const rows = this.db.prepare(`SELECT * FROM tasks WHERE ${where} ORDER BY COALESCE(due_ms, updated_at) ASC`).all(accountId);
|
|
446
|
+
return rows.map(this.taskRowToObject);
|
|
447
|
+
}
|
|
448
|
+
getDirtyTasks(accountId) {
|
|
449
|
+
const rows = this.db.prepare(`SELECT * FROM tasks WHERE account_id = ? AND (dirty = 1 OR deleted = 1)`).all(accountId);
|
|
450
|
+
return rows.map(this.taskRowToObject);
|
|
451
|
+
}
|
|
452
|
+
taskRowToObject(r) {
|
|
453
|
+
return {
|
|
454
|
+
uuid: r.uuid, accountId: r.account_id, providerId: r.provider_id,
|
|
455
|
+
listId: r.list_id, title: r.title, notes: r.notes, dueMs: r.due_ms,
|
|
456
|
+
completedMs: r.completed_ms, etag: r.etag, lastSynced: r.last_synced,
|
|
457
|
+
dirty: !!r.dirty, deleted: !!r.deleted,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
markTaskClean(uuid, providerId, etag) {
|
|
461
|
+
this.db.prepare(`UPDATE tasks SET dirty=0, provider_id=?, etag=?, last_synced=? WHERE uuid=?`)
|
|
462
|
+
.run(providerId, etag, Date.now(), uuid);
|
|
463
|
+
}
|
|
464
|
+
deleteTaskLocal(uuid) {
|
|
465
|
+
this.db.prepare("UPDATE tasks SET deleted=1, dirty=1, updated_at=? WHERE uuid=?").run(Date.now(), uuid);
|
|
466
|
+
}
|
|
467
|
+
purgeTask(uuid) {
|
|
468
|
+
this.db.prepare("DELETE FROM tasks WHERE uuid=?").run(uuid);
|
|
469
|
+
}
|
|
470
|
+
// ── Contacts two-way: existing upsertContact / deleteContact handle the
|
|
471
|
+
// local side; the service layer adds store_sync push-queue entries.
|
|
472
|
+
// (No extra methods needed here — upsertContact/deleteContact at the
|
|
473
|
+
// regular contact-management section are the two-way cache's local
|
|
474
|
+
// writers.)
|
|
475
|
+
/** Local delete for the two-way cache drainer (symmetric with calendar/tasks). */
|
|
476
|
+
deleteContactLocal(email) { this.deleteContact(email); }
|
|
477
|
+
// ── Store-sync queue (calendar / tasks / contacts / allowlist) ──
|
|
478
|
+
enqueueStoreSync(kind, op, accountId, targetUuid, payload) {
|
|
479
|
+
try {
|
|
480
|
+
this.db.prepare(`
|
|
481
|
+
INSERT OR REPLACE INTO store_sync (kind, op, account_id, target_uuid, payload, created_at)
|
|
482
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
483
|
+
`).run(kind, op, accountId, targetUuid, JSON.stringify(payload), Date.now());
|
|
484
|
+
}
|
|
485
|
+
catch (e) {
|
|
486
|
+
console.error(` [store_sync] enqueue ${kind}/${op}/${targetUuid} failed: ${e.message}`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
getStoreSyncQueue(kind, accountId) {
|
|
490
|
+
let sql = "SELECT * FROM store_sync";
|
|
491
|
+
const params = [];
|
|
492
|
+
const wh = [];
|
|
493
|
+
if (kind) {
|
|
494
|
+
wh.push("kind = ?");
|
|
495
|
+
params.push(kind);
|
|
496
|
+
}
|
|
497
|
+
if (accountId) {
|
|
498
|
+
wh.push("account_id = ?");
|
|
499
|
+
params.push(accountId);
|
|
500
|
+
}
|
|
501
|
+
if (wh.length)
|
|
502
|
+
sql += " WHERE " + wh.join(" AND ");
|
|
503
|
+
sql += " ORDER BY created_at ASC";
|
|
504
|
+
const rows = this.db.prepare(sql).all(...params);
|
|
505
|
+
return rows.map(r => ({
|
|
506
|
+
id: r.id, kind: r.kind, op: r.op, accountId: r.account_id,
|
|
507
|
+
targetUuid: r.target_uuid,
|
|
508
|
+
payload: r.payload ? JSON.parse(r.payload) : null,
|
|
509
|
+
attempts: r.attempts, lastError: r.last_error,
|
|
510
|
+
}));
|
|
511
|
+
}
|
|
512
|
+
completeStoreSync(id) {
|
|
513
|
+
this.db.prepare("DELETE FROM store_sync WHERE id = ?").run(id);
|
|
514
|
+
}
|
|
515
|
+
failStoreSync(id, error) {
|
|
516
|
+
this.db.prepare("UPDATE store_sync SET attempts = attempts + 1, last_error = ? WHERE id = ?").run(error, id);
|
|
517
|
+
}
|
|
306
518
|
/** Idempotently add a column to a table if it's missing. */
|
|
307
519
|
addColumnIfMissing(table, column, sqlType) {
|
|
308
520
|
try {
|
|
@@ -800,12 +1012,16 @@ export class MailxDB {
|
|
|
800
1012
|
const params = hasQuery ? [q, q] : [];
|
|
801
1013
|
const totalRow = this.db.prepare(`SELECT COUNT(*) as c FROM contacts ${whereClause}`).get(...params);
|
|
802
1014
|
const offset = (page - 1) * pageSize;
|
|
803
|
-
const rows = this.db.prepare(`SELECT name, email, source, use_count, last_used FROM contacts
|
|
1015
|
+
const rows = this.db.prepare(`SELECT name, email, source, google_id, use_count, last_used FROM contacts
|
|
804
1016
|
${whereClause}
|
|
805
1017
|
ORDER BY use_count DESC, last_used DESC
|
|
806
1018
|
LIMIT ? OFFSET ?`).all(...params, pageSize, offset);
|
|
807
1019
|
return {
|
|
808
|
-
items: rows.map(r => ({
|
|
1020
|
+
items: rows.map(r => ({
|
|
1021
|
+
name: r.name, email: r.email, source: r.source,
|
|
1022
|
+
googleId: r.google_id || null,
|
|
1023
|
+
useCount: r.use_count, lastUsed: r.last_used,
|
|
1024
|
+
})),
|
|
809
1025
|
total: totalRow?.c || 0,
|
|
810
1026
|
page, pageSize,
|
|
811
1027
|
};
|