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.
- package/AGENTS.md +4 -0
- package/CHANGELOG.md +36 -0
- package/README.md +112 -0
- package/dist/api-utils.d.ts +6 -0
- package/dist/api-utils.js +52 -0
- package/dist/api-utils.js.map +1 -0
- package/dist/auth.d.ts +16 -0
- package/dist/auth.js +74 -5
- package/dist/auth.js.map +1 -1
- package/dist/calendar-client.d.ts +135 -0
- package/dist/calendar-client.js +498 -0
- package/dist/calendar-client.js.map +1 -0
- package/dist/calendar-time.d.ts +24 -0
- package/dist/calendar-time.js +245 -0
- package/dist/calendar-time.js.map +1 -0
- package/dist/cli.js +5 -3
- package/dist/cli.js.map +1 -1
- package/dist/commands/auth-cmd.js +5 -5
- package/dist/commands/auth-cmd.js.map +1 -1
- package/dist/commands/calendar.d.ts +2 -0
- package/dist/commands/calendar.js +563 -0
- package/dist/commands/calendar.js.map +1 -0
- package/dist/generated/browser.d.ts +10 -0
- package/dist/generated/client.d.ts +10 -0
- package/dist/generated/internal/class.d.ts +22 -0
- package/dist/generated/internal/class.js +2 -2
- package/dist/generated/internal/class.js.map +1 -1
- package/dist/generated/internal/prismaNamespace.d.ts +174 -1
- package/dist/generated/internal/prismaNamespace.js +21 -0
- package/dist/generated/internal/prismaNamespace.js.map +1 -1
- package/dist/generated/internal/prismaNamespaceBrowser.d.ts +23 -0
- package/dist/generated/internal/prismaNamespaceBrowser.js +21 -0
- package/dist/generated/internal/prismaNamespaceBrowser.js.map +1 -1
- package/dist/generated/models/accounts.d.ts +281 -0
- package/dist/generated/models/calendar_events.d.ts +1433 -0
- package/dist/generated/models/calendar_events.js +2 -0
- package/dist/generated/models/calendar_events.js.map +1 -0
- package/dist/generated/models/calendar_lists.d.ts +1131 -0
- package/dist/generated/models/calendar_lists.js +2 -0
- package/dist/generated/models/calendar_lists.js.map +1 -0
- package/dist/generated/models.d.ts +2 -0
- package/dist/gmail-cache.d.ts +22 -0
- package/dist/gmail-cache.js +76 -0
- package/dist/gmail-cache.js.map +1 -1
- package/dist/gmail-client.js +1 -48
- package/dist/gmail-client.js.map +1 -1
- package/dist/output.d.ts +11 -0
- package/dist/output.js +42 -0
- package/dist/output.js.map +1 -1
- package/package.json +4 -2
- package/schema.prisma +39 -6
- package/scripts/test-device-code-clients.ts +186 -0
- package/scripts/test-micropython-scopes.ts +72 -0
- package/scripts/test-oauth-clients.ts +257 -0
- package/src/api-utils.ts +60 -0
- package/src/auth.ts +92 -5
- package/src/calendar-client.ts +758 -0
- package/src/calendar-time.ts +299 -0
- package/src/cli.ts +5 -3
- package/src/commands/auth-cmd.ts +5 -5
- package/src/commands/calendar.ts +634 -0
- package/src/gmail-cache.ts +96 -0
- package/src/gmail-client.ts +1 -57
- package/src/output.ts +51 -0
- package/src/schema.sql +22 -0
package/src/gmail-cache.ts
CHANGED
|
@@ -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
|
}
|
package/src/gmail-client.ts
CHANGED
|
@@ -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");
|