zele 0.1.2 → 0.2.0

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.
Files changed (65) hide show
  1. package/AGENTS.md +4 -0
  2. package/CHANGELOG.md +36 -0
  3. package/README.md +112 -0
  4. package/dist/api-utils.d.ts +6 -0
  5. package/dist/api-utils.js +52 -0
  6. package/dist/api-utils.js.map +1 -0
  7. package/dist/auth.d.ts +16 -0
  8. package/dist/auth.js +74 -5
  9. package/dist/auth.js.map +1 -1
  10. package/dist/calendar-client.d.ts +135 -0
  11. package/dist/calendar-client.js +498 -0
  12. package/dist/calendar-client.js.map +1 -0
  13. package/dist/calendar-time.d.ts +24 -0
  14. package/dist/calendar-time.js +245 -0
  15. package/dist/calendar-time.js.map +1 -0
  16. package/dist/cli.js +5 -3
  17. package/dist/cli.js.map +1 -1
  18. package/dist/commands/auth-cmd.js +5 -5
  19. package/dist/commands/auth-cmd.js.map +1 -1
  20. package/dist/commands/calendar.d.ts +2 -0
  21. package/dist/commands/calendar.js +563 -0
  22. package/dist/commands/calendar.js.map +1 -0
  23. package/dist/generated/browser.d.ts +10 -0
  24. package/dist/generated/client.d.ts +10 -0
  25. package/dist/generated/internal/class.d.ts +22 -0
  26. package/dist/generated/internal/class.js +2 -2
  27. package/dist/generated/internal/class.js.map +1 -1
  28. package/dist/generated/internal/prismaNamespace.d.ts +174 -1
  29. package/dist/generated/internal/prismaNamespace.js +21 -0
  30. package/dist/generated/internal/prismaNamespace.js.map +1 -1
  31. package/dist/generated/internal/prismaNamespaceBrowser.d.ts +23 -0
  32. package/dist/generated/internal/prismaNamespaceBrowser.js +21 -0
  33. package/dist/generated/internal/prismaNamespaceBrowser.js.map +1 -1
  34. package/dist/generated/models/accounts.d.ts +281 -0
  35. package/dist/generated/models/calendar_events.d.ts +1433 -0
  36. package/dist/generated/models/calendar_events.js +2 -0
  37. package/dist/generated/models/calendar_events.js.map +1 -0
  38. package/dist/generated/models/calendar_lists.d.ts +1131 -0
  39. package/dist/generated/models/calendar_lists.js +2 -0
  40. package/dist/generated/models/calendar_lists.js.map +1 -0
  41. package/dist/generated/models.d.ts +2 -0
  42. package/dist/gmail-cache.d.ts +22 -0
  43. package/dist/gmail-cache.js +76 -0
  44. package/dist/gmail-cache.js.map +1 -1
  45. package/dist/gmail-client.js +1 -48
  46. package/dist/gmail-client.js.map +1 -1
  47. package/dist/output.d.ts +11 -0
  48. package/dist/output.js +42 -0
  49. package/dist/output.js.map +1 -1
  50. package/package.json +4 -2
  51. package/schema.prisma +39 -6
  52. package/scripts/test-device-code-clients.ts +186 -0
  53. package/scripts/test-micropython-scopes.ts +72 -0
  54. package/scripts/test-oauth-clients.ts +257 -0
  55. package/src/api-utils.ts +60 -0
  56. package/src/auth.ts +92 -5
  57. package/src/calendar-client.ts +758 -0
  58. package/src/calendar-time.ts +299 -0
  59. package/src/cli.ts +5 -3
  60. package/src/commands/auth-cmd.ts +5 -5
  61. package/src/commands/calendar.ts +634 -0
  62. package/src/gmail-cache.ts +96 -0
  63. package/src/gmail-client.ts +1 -57
  64. package/src/output.ts +51 -0
  65. package/src/schema.sql +22 -0
@@ -12,6 +12,8 @@ export const TTL = {
12
12
  LABELS: 30 * 60 * 1000, // 30 minutes
13
13
  PROFILE: 24 * 60 * 60 * 1000, // 24 hours
14
14
  LABEL_COUNTS: 2 * 60 * 1000, // 2 minutes
15
+ CALENDAR_LIST: 30 * 60 * 1000, // 30 minutes
16
+ CALENDAR_EVENTS: 5 * 60 * 1000, // 5 minutes
15
17
  } as const
16
18
 
17
19
  function isExpired(createdAt: Date, ttlMs: number): boolean {
@@ -202,6 +204,90 @@ export async function setLastHistoryId(email: string, historyId: string): Promis
202
204
  })
203
205
  }
204
206
 
207
+ // ---------------------------------------------------------------------------
208
+ // Calendar list cache
209
+ // ---------------------------------------------------------------------------
210
+
211
+ export async function cacheCalendarList(email: string, data: unknown): Promise<void> {
212
+ const prisma = await getPrisma()
213
+ await prisma.calendar_lists.upsert({
214
+ where: { email },
215
+ create: { email, data: JSON.stringify(data), ttl_ms: TTL.CALENDAR_LIST },
216
+ update: { data: JSON.stringify(data), ttl_ms: TTL.CALENDAR_LIST, created_at: new Date() },
217
+ })
218
+ }
219
+
220
+ export async function getCachedCalendarList<T = unknown>(email: string): Promise<T | undefined> {
221
+ const prisma = await getPrisma()
222
+ const row = await prisma.calendar_lists.findUnique({ where: { email } })
223
+ if (!row || isExpired(row.created_at, row.ttl_ms)) return undefined
224
+ return JSON.parse(row.data) as T
225
+ }
226
+
227
+ export async function invalidateCalendarLists(email: string): Promise<void> {
228
+ const prisma = await getPrisma()
229
+ await prisma.calendar_lists.deleteMany({ where: { email } })
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Calendar events cache
234
+ // ---------------------------------------------------------------------------
235
+
236
+ export async function cacheCalendarEvents(
237
+ email: string,
238
+ params: { calendarId?: string; timeMin?: string; timeMax?: string; query?: string; maxResults?: number; pageToken?: string },
239
+ data: unknown,
240
+ ): Promise<void> {
241
+ const prisma = await getPrisma()
242
+ const where = {
243
+ email,
244
+ calendar_id: params.calendarId ?? '',
245
+ time_min: params.timeMin ?? '',
246
+ time_max: params.timeMax ?? '',
247
+ query: params.query ?? '',
248
+ max_results: params.maxResults ?? 0,
249
+ page_token: params.pageToken ?? '',
250
+ }
251
+
252
+ await prisma.calendar_events.upsert({
253
+ where: { email_calendar_id_time_min_time_max_query_max_results_page_token: where },
254
+ create: { ...where, data: JSON.stringify(data), ttl_ms: TTL.CALENDAR_EVENTS },
255
+ update: { data: JSON.stringify(data), ttl_ms: TTL.CALENDAR_EVENTS, created_at: new Date() },
256
+ })
257
+ }
258
+
259
+ export async function getCachedCalendarEvents<T = unknown>(
260
+ email: string,
261
+ params: { calendarId?: string; timeMin?: string; timeMax?: string; query?: string; maxResults?: number; pageToken?: string },
262
+ ): Promise<T | undefined> {
263
+ const prisma = await getPrisma()
264
+ const row = await prisma.calendar_events.findUnique({
265
+ where: {
266
+ email_calendar_id_time_min_time_max_query_max_results_page_token: {
267
+ email,
268
+ calendar_id: params.calendarId ?? '',
269
+ time_min: params.timeMin ?? '',
270
+ time_max: params.timeMax ?? '',
271
+ query: params.query ?? '',
272
+ max_results: params.maxResults ?? 0,
273
+ page_token: params.pageToken ?? '',
274
+ },
275
+ },
276
+ })
277
+
278
+ if (!row || isExpired(row.created_at, row.ttl_ms)) return undefined
279
+ return JSON.parse(row.data) as T
280
+ }
281
+
282
+ export async function invalidateCalendarEvents(email: string, calendarId?: string): Promise<void> {
283
+ const prisma = await getPrisma()
284
+ if (calendarId) {
285
+ await prisma.calendar_events.deleteMany({ where: { email, calendar_id: calendarId } })
286
+ } else {
287
+ await prisma.calendar_events.deleteMany({ where: { email } })
288
+ }
289
+ }
290
+
205
291
  // ---------------------------------------------------------------------------
206
292
  // Housekeeping
207
293
  // ---------------------------------------------------------------------------
@@ -230,6 +316,14 @@ export async function clearExpired(): Promise<void> {
230
316
  `DELETE FROM profiles WHERE (strftime('%s', created_at) * 1000 + ttl_ms) < ?`,
231
317
  now,
232
318
  )
319
+ await prisma.$executeRawUnsafe(
320
+ `DELETE FROM calendar_lists WHERE (strftime('%s', created_at) * 1000 + ttl_ms) < ?`,
321
+ now,
322
+ )
323
+ await prisma.$executeRawUnsafe(
324
+ `DELETE FROM calendar_events WHERE (strftime('%s', created_at) * 1000 + ttl_ms) < ?`,
325
+ now,
326
+ )
233
327
  }
234
328
 
235
329
  export async function clearAll(email: string): Promise<void> {
@@ -240,4 +334,6 @@ export async function clearAll(email: string): Promise<void> {
240
334
  await prisma.label_counts.deleteMany({ where: { email } })
241
335
  await prisma.profiles.deleteMany({ where: { email } })
242
336
  await prisma.sync_states.deleteMany({ where: { email } })
337
+ await prisma.calendar_lists.deleteMany({ where: { email } })
338
+ await prisma.calendar_events.deleteMany({ where: { email } })
243
339
  }
@@ -12,6 +12,7 @@ import { gmail as gmailApi, type gmail_v1 } from '@googleapis/gmail'
12
12
  import type { OAuth2Client } from 'google-auth-library'
13
13
  import { createMimeMessage } from 'mimetext'
14
14
  import { parseFrom, parseAddressList } from './email-utils.js'
15
+ import { withRetry, mapConcurrent } from './api-utils.js'
15
16
 
16
17
  // ---------------------------------------------------------------------------
17
18
  // Types
@@ -105,8 +106,6 @@ const SYSTEM_LABEL_IDS = new Set([
105
106
  'MUTED',
106
107
  ])
107
108
 
108
- const MAX_CONCURRENCY = 10
109
-
110
109
  // ---------------------------------------------------------------------------
111
110
  // Helpers
112
111
  // ---------------------------------------------------------------------------
@@ -121,61 +120,6 @@ function encodeBase64Url(data: string | Buffer) {
121
120
  return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
122
121
  }
123
122
 
124
- /** Run promises with bounded concurrency */
125
- async function mapConcurrent<T, R>(
126
- items: T[],
127
- fn: (item: T) => Promise<R>,
128
- concurrency = MAX_CONCURRENCY,
129
- ): Promise<R[]> {
130
- const results: R[] = []
131
- let index = 0
132
-
133
- async function worker() {
134
- while (index < items.length) {
135
- const i = index++
136
- results[i] = await fn(items[i]!)
137
- }
138
- }
139
-
140
- const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker())
141
- await Promise.all(workers)
142
- return results
143
- }
144
-
145
- /** Simple retry for rate limit errors (429 and 403 quota errors).
146
- * Matches Zero's gmail-rate-limit.ts schedule: up to 10 attempts, 60s base delay. */
147
- async function withRetry<T>(fn: () => Promise<T>, maxAttempts = 10, delayMs = 60000): Promise<T> {
148
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
149
- try {
150
- return await fn()
151
- } catch (err: any) {
152
- if (!isRateLimitError(err) || attempt === maxAttempts) throw err
153
- const wait = delayMs * Math.pow(2, attempt - 1)
154
- await new Promise((r) => setTimeout(r, wait))
155
- }
156
- }
157
- throw new Error('unreachable')
158
- }
159
-
160
- function isRateLimitError(err: any): boolean {
161
- const status = err?.code ?? err?.status ?? err?.response?.status
162
- if (status === 429) return true
163
- if (status === 403) {
164
- const errors = err?.errors ?? err?.response?.data?.error?.errors ?? []
165
- return errors.some((e: any) =>
166
- [
167
- 'userRateLimitExceeded',
168
- 'rateLimitExceeded',
169
- 'quotaExceeded',
170
- 'dailyLimitExceeded',
171
- 'limitExceeded',
172
- 'backendError',
173
- ].includes(e.reason),
174
- )
175
- }
176
- return false
177
- }
178
-
179
123
  // ---------------------------------------------------------------------------
180
124
  // GmailClient
181
125
  // ---------------------------------------------------------------------------
package/src/output.ts CHANGED
@@ -158,6 +158,57 @@ export function formatDate(dateStr: string): string {
158
158
  return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
159
159
  }
160
160
 
161
+ // ---------------------------------------------------------------------------
162
+ // Calendar event time formatting
163
+ // ---------------------------------------------------------------------------
164
+
165
+ /**
166
+ * Format event start/end times for display.
167
+ * Same day: "Feb 10, 2:00 - 3:00 PM"
168
+ * Different days: "Feb 10, 2:00 PM - Feb 11, 10:00 AM"
169
+ * All-day single: "Feb 10"
170
+ * All-day multi: "Feb 10 - Feb 12"
171
+ */
172
+ export function formatEventTime(start: string, end: string, allDay = false): { start: string; end: string } {
173
+ if (allDay) {
174
+ const startDate = new Date(start + 'T00:00:00')
175
+ const endDate = new Date(end + 'T00:00:00')
176
+ // Calendar API uses exclusive end date for all-day events
177
+ endDate.setDate(endDate.getDate() - 1)
178
+
179
+ const fmt = (d: Date) => d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
180
+
181
+ if (startDate.getTime() === endDate.getTime()) {
182
+ const s = fmt(startDate)
183
+ return { start: s, end: s }
184
+ }
185
+ return { start: fmt(startDate), end: fmt(endDate) }
186
+ }
187
+
188
+ const startDate = new Date(start)
189
+ const endDate = new Date(end)
190
+
191
+ const timeOpts: Intl.DateTimeFormatOptions = { hour: 'numeric', minute: '2-digit' }
192
+ const dateTimeOpts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }
193
+
194
+ const sameDay = startDate.toDateString() === endDate.toDateString()
195
+
196
+ if (sameDay) {
197
+ const dateStr = startDate.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
198
+ const startTime = startDate.toLocaleTimeString(undefined, timeOpts)
199
+ const endTime = endDate.toLocaleTimeString(undefined, timeOpts)
200
+ return {
201
+ start: `${dateStr}, ${startTime}`,
202
+ end: endTime,
203
+ }
204
+ }
205
+
206
+ return {
207
+ start: startDate.toLocaleString(undefined, dateTimeOpts),
208
+ end: endDate.toLocaleString(undefined, dateTimeOpts),
209
+ }
210
+ }
211
+
161
212
  // ---------------------------------------------------------------------------
162
213
  // Sender formatting
163
214
  // ---------------------------------------------------------------------------
package/src/schema.sql CHANGED
@@ -50,6 +50,27 @@ CREATE TABLE IF NOT EXISTS "profiles" (
50
50
  "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
51
51
  CONSTRAINT "profiles_email_fkey" FOREIGN KEY ("email") REFERENCES "accounts" ("email") ON DELETE CASCADE ON UPDATE CASCADE
52
52
  );
53
+ CREATE TABLE IF NOT EXISTS "calendar_lists" (
54
+ "email" TEXT NOT NULL PRIMARY KEY,
55
+ "data" TEXT NOT NULL,
56
+ "ttl_ms" INTEGER NOT NULL,
57
+ "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
58
+ CONSTRAINT "calendar_lists_email_fkey" FOREIGN KEY ("email") REFERENCES "accounts" ("email") ON DELETE CASCADE ON UPDATE CASCADE
59
+ );
60
+ CREATE TABLE IF NOT EXISTS "calendar_events" (
61
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
62
+ "email" TEXT NOT NULL,
63
+ "calendar_id" TEXT NOT NULL DEFAULT '',
64
+ "time_min" TEXT NOT NULL DEFAULT '',
65
+ "time_max" TEXT NOT NULL DEFAULT '',
66
+ "query" TEXT NOT NULL DEFAULT '',
67
+ "max_results" INTEGER NOT NULL DEFAULT 0,
68
+ "page_token" TEXT NOT NULL DEFAULT '',
69
+ "data" TEXT NOT NULL,
70
+ "ttl_ms" INTEGER NOT NULL,
71
+ "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
72
+ CONSTRAINT "calendar_events_email_fkey" FOREIGN KEY ("email") REFERENCES "accounts" ("email") ON DELETE CASCADE ON UPDATE CASCADE
73
+ );
53
74
  CREATE TABLE IF NOT EXISTS "sync_states" (
54
75
  "email" TEXT NOT NULL,
55
76
  "key" TEXT NOT NULL,
@@ -60,3 +81,4 @@ CREATE TABLE IF NOT EXISTS "sync_states" (
60
81
  );
61
82
  CREATE UNIQUE INDEX "thread_lists_email_folder_query_label_ids_page_token_max_results_key" ON "thread_lists"("email", "folder", "query", "label_ids", "page_token", "max_results");
62
83
  CREATE UNIQUE INDEX "threads_email_thread_id_key" ON "threads"("email", "thread_id");
84
+ CREATE UNIQUE INDEX "calendar_events_email_calendar_id_time_min_time_max_query_max_results_page_token_key" ON "calendar_events"("email", "calendar_id", "time_min", "time_max", "query", "max_results", "page_token");