@bobfrankston/mailx 1.0.378 → 1.0.382

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.
@@ -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
  },
@@ -205,6 +205,15 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
205
205
 
206
206
  .ft-folder-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
207
207
 
208
+ /* Case-duplicate warning — another folder exists with the same name in a
209
+ different case on the server. Hover for the full explanation via title. */
210
+ .ft-folder-duplicate .ft-folder-name::after {
211
+ content: " ⚠";
212
+ color: oklch(0.65 0.2 65);
213
+ font-weight: 600;
214
+ margin-left: 4px;
215
+ }
216
+
208
217
  .ft-badge {
209
218
  font-size: 0.7rem;
210
219
  padding: 0.1rem 0.4rem;
@@ -332,6 +341,18 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
332
341
  user-select: none;
333
342
 
334
343
  .ml-col { cursor: pointer; &:hover { color: var(--color-text); } }
344
+ .ml-col-sortable { position: relative; padding-right: 14px; }
345
+ .ml-col-sort-asc::after,
346
+ .ml-col-sort-desc::after {
347
+ position: absolute;
348
+ right: 2px;
349
+ top: 50%;
350
+ transform: translateY(-50%);
351
+ font-size: 10px;
352
+ color: var(--color-accent);
353
+ }
354
+ .ml-col-sort-asc::after { content: "▲"; }
355
+ .ml-col-sort-desc::after { content: "▼"; }
335
356
  }
336
357
 
337
358
  /* Narrow-mode folder title above the list — hidden on wide where the window
@@ -603,6 +624,64 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
603
624
  white-space: pre;
604
625
  tab-size: 2;
605
626
  }
627
+
628
+ /* JSONC editor with line-number gutter. Gutter + textarea share the same
629
+ line-height so numbers stay aligned; textarea owns the scroll and the
630
+ gutter syncs via JS. Error line is highlighted red in the gutter so the
631
+ "Line N, col M" error points at a visible marker. */
632
+ .jsonc-editor-wrap {
633
+ flex: 1;
634
+ display: flex;
635
+ min-height: 200px;
636
+ border: 1px solid var(--color-border);
637
+ border-radius: var(--radius-sm);
638
+ overflow: hidden;
639
+ background: var(--color-bg-surface);
640
+ }
641
+ .jsonc-gutter {
642
+ flex: 0 0 auto;
643
+ min-width: 3em;
644
+ padding: 6px 6px 6px 10px;
645
+ background: oklch(0.94 0.005 250);
646
+ border-right: 1px solid var(--color-border);
647
+ color: var(--color-text-muted);
648
+ font-family: var(--font-mono);
649
+ font-size: 13px;
650
+ line-height: 1.5;
651
+ text-align: right;
652
+ user-select: none;
653
+ overflow: hidden;
654
+ white-space: pre;
655
+ tab-size: 2;
656
+ }
657
+ .jsonc-gutter-line {
658
+ line-height: 1.5;
659
+ font-variant-numeric: tabular-nums;
660
+ }
661
+ .jsonc-gutter-error {
662
+ background: oklch(0.65 0.2 25);
663
+ color: #fff;
664
+ font-weight: 600;
665
+ border-radius: 2px;
666
+ padding: 0 4px;
667
+ margin: 0 -4px;
668
+ }
669
+ .jsonc-editor-wrap .jsonc-textarea {
670
+ flex: 1;
671
+ border: 0;
672
+ border-radius: 0;
673
+ line-height: 1.5;
674
+ resize: none;
675
+ /* The outer wrap draws the focus ring so gutter + area move together */
676
+ }
677
+ .jsonc-editor-wrap:focus-within {
678
+ outline: 2px solid var(--color-accent);
679
+ outline-offset: -1px;
680
+ }
681
+ .jsonc-editor-wrap:has(.mailx-modal-input-error) {
682
+ outline: 2px solid oklch(0.65 0.2 25);
683
+ outline-offset: -1px;
684
+ }
606
685
  .mailx-modal-input:focus {
607
686
  outline: 2px solid var(--color-accent);
608
687
  outline-offset: -1px;
@@ -816,6 +895,21 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
816
895
  opacity: 0.9;
817
896
  }
818
897
 
898
+ /* Filter: only this conversation — rows not in the current thread hide.
899
+ The selected row stays visible regardless so toggling doesn't leave the
900
+ list empty when the selection is a singleton thread. */
901
+ .ml-row.thread-filter-hidden { display: none; }
902
+
903
+ /* Offline indicator — sits in the status bar; amber tone so it's visible
904
+ but doesn't scream (being offline is normal local-first behavior, not
905
+ an error). */
906
+ .status-offline {
907
+ color: oklch(0.65 0.18 65);
908
+ font-weight: 600;
909
+ font-size: var(--font-size-sm);
910
+ padding: 0 6px;
911
+ }
912
+
819
913
  /* S51 — calendar sidebar (Thunderbird Lightning Events & Tasks pane).
820
914
  Right-docked, fixed width. Visible by default; user hides via View menu.
821
915
  Hides automatically on narrow screens (< 1100px) — Android uses the
@@ -926,11 +1020,41 @@ body.calendar-sidebar-on .calendar-sidebar { display: flex; }
926
1020
  align-items: center;
927
1021
  gap: 6px;
928
1022
  padding: 2px 0;
1023
+
1024
+ .cal-side-task-delete {
1025
+ margin-left: auto;
1026
+ opacity: 0;
1027
+ background: transparent;
1028
+ border: 0;
1029
+ cursor: pointer;
1030
+ color: var(--color-text-muted);
1031
+ padding: 0 4px;
1032
+ font-size: 14px;
1033
+ line-height: 1;
1034
+ transition: opacity 0.15s, color 0.15s;
1035
+ }
1036
+ &:hover .cal-side-task-delete { opacity: 1; }
1037
+ .cal-side-task-delete:hover { color: oklch(0.55 0.22 25); }
1038
+ }
1039
+ .cal-side-task-title {
1040
+ flex: 1;
1041
+ overflow: hidden;
1042
+ text-overflow: ellipsis;
1043
+ white-space: nowrap;
929
1044
  }
930
1045
  .cal-side-task-title.done {
931
1046
  text-decoration: line-through;
932
1047
  color: var(--color-text-muted);
933
1048
  }
1049
+ .cal-side-task-header-row {
1050
+ display: flex;
1051
+ align-items: center;
1052
+ gap: var(--gap-xs);
1053
+ padding: 2px 0;
1054
+
1055
+ label { flex: 1; }
1056
+ .cal-side-new { width: auto; padding: 2px 8px; font-size: 0.85em; }
1057
+ }
934
1058
 
935
1059
  .ml-empty {
936
1060
  grid-column: 1 / -1;
@@ -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 (Dovecot/Thunderbird-style). Always visible on
48
- wide+medium tiers; collapses on narrow (icons move into the hamburger
49
- menu TBD; for now hidden on narrow). */
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: var(--color-bg-alt, #f4f4f5);
55
- border-right: 1px solid var(--color-border);
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,38 @@ body.calendar-sidebar-on {
71
73
  background: transparent;
72
74
  cursor: pointer;
73
75
  font-size: 16px;
74
- color: var(--color-text);
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: var(--color-hover, rgba(0,0,0,0.06));
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: var(--color-hover, rgba(0,0,0,0.06));
83
- border-left-color: var(--color-accent, #1a6dd4);
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);
91
+ }
92
+ .rail-btn { position: relative; }
93
+ .rail-badge {
94
+ position: absolute;
95
+ top: 3px;
96
+ right: 6px;
97
+ min-width: 18px;
98
+ padding: 1px 4px;
99
+ border-radius: 9px;
100
+ background: oklch(0.65 0.22 25);
101
+ color: #fff;
102
+ font-size: 10px;
103
+ font-weight: 600;
104
+ line-height: 1.4;
105
+ text-align: center;
106
+ font-variant-numeric: tabular-nums;
107
+ pointer-events: none;
84
108
  }
85
109
  .rail-btn[disabled] {
86
110
  opacity: 0.35;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.378",
3
+ "version": "1.0.382",
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.345",
27
+ "@bobfrankston/msger": "^0.1.347",
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.345",
91
+ "@bobfrankston/msger": "^0.1.347",
92
92
  "@bobfrankston/mailx-host": "^0.1.4",
93
93
  "@capacitor/android": "^8.3.0",
94
94
  "@capacitor/cli": "^8.3.0",
@@ -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
- scope: "https://mail.google.com/ https://www.googleapis.com/auth/contacts.readonly https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/drive",
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,
@@ -2295,8 +2299,33 @@ export class ImapManager extends EventEmitter {
2295
2299
  await api.setFlags(folder.path, action.uid, action.flags || []);
2296
2300
  console.log(` [api] ${accountId}: flags synced UID ${action.uid}`);
2297
2301
  }
2302
+ else if ((action.action === "delete" || action.action === "trash") && api.trashMessage) {
2303
+ await api.trashMessage(folder.path, action.uid);
2304
+ console.log(` [api] ${accountId}: trashed UID ${action.uid} from ${folder.path}`);
2305
+ }
2306
+ else if (action.action === "move" && api.moveMessage) {
2307
+ const target = folders.find(f => f.id === action.targetFolderId);
2308
+ if (!target) {
2309
+ // Unreachable target — drop the action rather than loop.
2310
+ console.error(` [api] ${accountId}: move target folder ${action.targetFolderId} missing — dropping UID ${action.uid}`);
2311
+ this.db.completeSyncAction(action.id);
2312
+ continue;
2313
+ }
2314
+ await api.moveMessage(folder.path, action.uid, target.path);
2315
+ console.log(` [api] ${accountId}: moved UID ${action.uid} ${folder.path} → ${target.path}`);
2316
+ }
2298
2317
  else {
2299
- // move/delete/append via API not implemented yet leave queued
2318
+ // Unsupported action on Gmail. After 5 retries, drop it
2319
+ // so stale rows don't mark messages pending-reconcile
2320
+ // forever. Previously "continue" here caused the pink
2321
+ // rows that shouldn't have been pink.
2322
+ if (action.attempts >= 5) {
2323
+ console.warn(` [api] ${accountId}: dropping stale action "${action.action}" UID ${action.uid} after ${action.attempts} attempts (unsupported on Gmail API path)`);
2324
+ this.db.completeSyncAction(action.id);
2325
+ }
2326
+ else {
2327
+ this.db.failSyncAction(action.id, `unsupported Gmail action: ${action.action}`);
2328
+ }
2300
2329
  continue;
2301
2330
  }
2302
2331
  this.db.completeSyncAction(action.id);
@@ -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
  };