mysticals 0.1.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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +135 -0
  3. package/dist/cli.js +4219 -0
  4. package/package.json +58 -0
package/dist/cli.js ADDED
@@ -0,0 +1,4219 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { createElement } from "react";
5
+ import { render } from "ink";
6
+
7
+ // package.json
8
+ var version = "0.1.0";
9
+
10
+ // src/client.ts
11
+ import { spawn } from "child_process";
12
+ import { mkdirSync, openSync } from "fs";
13
+ import { createConnection } from "net";
14
+ import { join as join2 } from "path";
15
+ import { fileURLToPath } from "url";
16
+
17
+ // src/paths.ts
18
+ import { createHash } from "crypto";
19
+ import { homedir, tmpdir, userInfo } from "os";
20
+ import { join } from "path";
21
+ var isMock = () => process.env.MYSTICALS_MOCK === "1";
22
+ function homeDir() {
23
+ if (process.env.MYSTICALS_HOME) return process.env.MYSTICALS_HOME;
24
+ if (isMock()) return join(tmpdir(), `mysticals-mock-${userInfo().uid}`);
25
+ return join(dataRoot(), "mysticals-terminal");
26
+ }
27
+ function dataRoot(platform = process.platform, env = process.env) {
28
+ if (platform === "darwin") return join(homedir(), "Library", "Application Support");
29
+ if (platform === "win32") return env.APPDATA || join(homedir(), "AppData", "Roaming");
30
+ return env.XDG_DATA_HOME || join(homedir(), ".local", "share");
31
+ }
32
+ var MAX_SOCKET_PATH = 100;
33
+ var hash = (home) => createHash("sha256").update(home).digest("hex").slice(0, 16);
34
+ function socketPath(home = homeDir(), platform = process.platform) {
35
+ if (platform === "win32") return `\\\\.\\pipe\\mysticals-${hash(home)}`;
36
+ const p = join(home, "daemon.sock");
37
+ if (Buffer.byteLength(p) <= MAX_SOCKET_PATH) return p;
38
+ return join(tmpdir(), `mysticals-${hash(home)}.sock`);
39
+ }
40
+
41
+ // src/protocol.ts
42
+ import { StringDecoder } from "string_decoder";
43
+ var methodSet = {
44
+ "accounts.list": true,
45
+ "accounts.addGoogle": true,
46
+ "accounts.addCaldav": true,
47
+ "accounts.update": true,
48
+ "accounts.remove": true,
49
+ "calendars.list": true,
50
+ "calendars.setVisible": true,
51
+ "events.list": true,
52
+ "events.create": true,
53
+ "events.update": true,
54
+ "events.delete": true,
55
+ "events.respond": true,
56
+ "sync.now": true
57
+ };
58
+ var METHODS = Object.keys(methodSet);
59
+ var isMethod = (m) => typeof m === "string" && Object.hasOwn(methodSet, m);
60
+ var encode = (msg) => JSON.stringify(msg) + "\n";
61
+ function lineReader(onMessage, onBad = () => {
62
+ }) {
63
+ let buf = "";
64
+ let scanned = 0;
65
+ const utf8 = new StringDecoder("utf8");
66
+ return (chunk) => {
67
+ buf += typeof chunk === "string" ? chunk : utf8.write(chunk);
68
+ let nl;
69
+ while ((nl = buf.indexOf("\n", scanned)) >= 0) {
70
+ scanned = 0;
71
+ const line = buf.slice(0, nl);
72
+ buf = buf.slice(nl + 1);
73
+ if (!line.trim()) continue;
74
+ let msg;
75
+ try {
76
+ msg = JSON.parse(line);
77
+ } catch {
78
+ onBad(line);
79
+ continue;
80
+ }
81
+ onMessage(msg);
82
+ }
83
+ scanned = buf.length;
84
+ };
85
+ }
86
+ function dispatch(api, method, params) {
87
+ const [group, fn] = method.split(".");
88
+ const target = api[group];
89
+ return target[fn](...params);
90
+ }
91
+
92
+ // src/client.ts
93
+ var connectOnce = (path) => new Promise((resolve, reject) => {
94
+ const s = createConnection(path);
95
+ s.once("connect", () => {
96
+ s.off("error", reject);
97
+ resolve(s);
98
+ });
99
+ s.once("error", reject);
100
+ });
101
+ function spawnDaemon() {
102
+ const home = homeDir();
103
+ mkdirSync(home, { recursive: true, mode: 448 });
104
+ const log = openSync(join2(home, "daemon.log"), "a", 384);
105
+ const cli = fileURLToPath(import.meta.url);
106
+ spawn(process.execPath, [cli, "--daemon"], { detached: true, windowsHide: true, stdio: ["ignore", log, log], env: process.env }).unref();
107
+ }
108
+ async function dial(path = socketPath(), start = spawnDaemon) {
109
+ try {
110
+ return await connectOnce(path);
111
+ } catch {
112
+ start();
113
+ }
114
+ const deadline = Date.now() + 1e4;
115
+ for (; ; ) {
116
+ try {
117
+ return await connectOnce(path);
118
+ } catch (e) {
119
+ if (Date.now() > deadline) throw new Error(`Could not start the mysticals daemon (see ${join2(homeDir(), "daemon.log")})`, { cause: e });
120
+ await new Promise((r) => setTimeout(r, 100));
121
+ }
122
+ }
123
+ }
124
+ function clientFor(open) {
125
+ let seq = 0;
126
+ let current;
127
+ let closed = false;
128
+ const pending = /* @__PURE__ */ new Map();
129
+ const listeners = /* @__PURE__ */ new Set();
130
+ const emit = (accountId) => listeners.forEach((l) => l(accountId));
131
+ const authListeners = /* @__PURE__ */ new Set();
132
+ const attach = (sock) => {
133
+ sock.on(
134
+ "data",
135
+ lineReader((msg) => {
136
+ const m = msg;
137
+ if (m.event === "changed" && typeof m.accountId === "string") return emit(m.accountId);
138
+ if (m.event === "authUrl" && typeof m.url === "string") return authListeners.forEach((l) => l(m.url));
139
+ const p = pending.get(m.id);
140
+ if (!p) return;
141
+ pending.delete(m.id);
142
+ if (m.error !== void 0) p.reject(new Error(m.error));
143
+ else p.resolve(m.result);
144
+ })
145
+ );
146
+ sock.on("error", () => {
147
+ });
148
+ sock.on("close", () => {
149
+ current = void 0;
150
+ for (const p of pending.values()) p.reject(new Error("Lost connection to the mysticals daemon"));
151
+ pending.clear();
152
+ if (!closed) emit("");
153
+ });
154
+ return sock;
155
+ };
156
+ const socket = () => {
157
+ if (closed) return Promise.reject(new Error("Client closed"));
158
+ current ??= open().then(attach, (e) => {
159
+ current = void 0;
160
+ throw e;
161
+ });
162
+ return current;
163
+ };
164
+ const call = async (method, params) => {
165
+ const sock = await socket();
166
+ return new Promise((resolve, reject) => {
167
+ if (sock.destroyed) return reject(new Error("Lost connection to the mysticals daemon"));
168
+ const id2 = ++seq;
169
+ pending.set(id2, { resolve, reject });
170
+ sock.write(encode({ id: id2, method, params }));
171
+ });
172
+ };
173
+ const api = {};
174
+ for (const m of METHODS) {
175
+ const [group, fn] = m.split(".");
176
+ api[group] ??= {};
177
+ api[group][fn] = (...params) => call(m, params);
178
+ }
179
+ return Object.assign(api, {
180
+ onChanged(cb) {
181
+ listeners.add(cb);
182
+ return () => void listeners.delete(cb);
183
+ },
184
+ onAuthUrl(cb) {
185
+ authListeners.add(cb);
186
+ return () => void authListeners.delete(cb);
187
+ },
188
+ close() {
189
+ closed = true;
190
+ void current?.then((s) => s.end());
191
+ }
192
+ });
193
+ }
194
+ async function connect(path = socketPath(), start = spawnDaemon) {
195
+ const client = clientFor(() => dial(path, start));
196
+ await client.accounts.list();
197
+ return client;
198
+ }
199
+
200
+ // src/daemon/server.ts
201
+ import { chmodSync, mkdirSync as mkdirSync4, rmSync } from "fs";
202
+ import { createConnection as createConnection2, createServer as createServer2 } from "net";
203
+
204
+ // ../../packages/core/src/api.ts
205
+ import { z } from "zod";
206
+
207
+ // ../../packages/core/src/sync/engine.ts
208
+ import { addMonths, parseISO as parseISO2, subMonths } from "date-fns";
209
+
210
+ // ../../packages/core/src/sync/notify.ts
211
+ import { format, isSameDay, parseISO } from "date-fns";
212
+ var at = (iso2, allDay) => (allDay ? parseISO(iso2) : new Date(iso2)).getTime();
213
+ var key = (e) => `${e.calendarId}/${e.id}`;
214
+ var WATCHED = ["title", "start", "end", "location"];
215
+ function diffEvents(prev, next, now = /* @__PURE__ */ new Date()) {
216
+ const live = (e) => at(e.end, e.allDay) > now.getTime() && e.myStatus !== "declined";
217
+ const before = new Map(prev.map((e) => [key(e), e]));
218
+ const after = new Set(next.map(key));
219
+ const notes = [];
220
+ for (const e of next) {
221
+ if (!live(e)) continue;
222
+ const old = before.get(key(e));
223
+ if (!old) {
224
+ if (e.myStatus === "needsAction") notes.push({ kind: "invite", event: e });
225
+ } else if (WATCHED.some((f) => (old[f] ?? "") !== (e[f] ?? ""))) notes.push({ kind: "changed", event: e });
226
+ }
227
+ for (const e of prev) if (live(e) && !after.has(key(e))) notes.push({ kind: "cancelled", event: e });
228
+ return notes;
229
+ }
230
+ var TITLE = { invite: "New invite", changed: "Event changed", cancelled: "Event cancelled" };
231
+ function noteText(notes, account, now = /* @__PURE__ */ new Date()) {
232
+ if (notes.length > 3) return [{ title: `${notes.length} calendar updates`, body: account }];
233
+ return notes.map(({ kind, event: e }) => ({ title: `${TITLE[kind]}: ${e.title || "Untitled"}`, body: `${when(e, now)} \xB7 ${account}` }));
234
+ }
235
+ function when(e, now) {
236
+ const s = e.allDay ? parseISO(e.start) : new Date(e.start);
237
+ if (e.allDay) return `${format(s, "EEE, d MMM")} \xB7 all day`;
238
+ return `${isSameDay(s, now) ? "Today" : format(s, "EEE, d MMM")} \xB7 ${format(s, "HH:mm")}`;
239
+ }
240
+
241
+ // ../../packages/core/src/sync/engine.ts
242
+ var MIN = 6e4;
243
+ var SyncEngine = class {
244
+ constructor(store, onChanged, opts = {}) {
245
+ this.store = store;
246
+ this.onChanged = onChanged;
247
+ this.opts = opts;
248
+ this.intervalMs = opts.intervalMs ?? 2 * MIN;
249
+ this.maxBackoffMs = opts.maxBackoffMs ?? 30 * MIN;
250
+ }
251
+ store;
252
+ onChanged;
253
+ opts;
254
+ intervalMs;
255
+ maxBackoffMs;
256
+ timers = /* @__PURE__ */ new Map();
257
+ inflight = /* @__PURE__ */ new Map();
258
+ failures = /* @__PURE__ */ new Map();
259
+ running = false;
260
+ unsubscribe;
261
+ start() {
262
+ if (this.running) return;
263
+ this.running = true;
264
+ const fire = () => void this.syncNow();
265
+ this.unsubscribe = this.opts.triggers?.(fire);
266
+ void this.syncNow();
267
+ }
268
+ stop() {
269
+ this.running = false;
270
+ this.unsubscribe?.();
271
+ this.unsubscribe = void 0;
272
+ for (const t of this.timers.values()) clearTimeout(t);
273
+ this.timers.clear();
274
+ }
275
+ /**
276
+ * Sync one account, or all accounts independently when id omitted.
277
+ * A single-account call rejects with that account's error; the all-accounts call never rejects.
278
+ */
279
+ /** `quiet`: the change came from this app (own edit/RSVP), so don't notify about it. */
280
+ async syncNow(accountId, opts = {}) {
281
+ if (accountId === void 0) {
282
+ await Promise.allSettled(this.store.list().map((a) => this.syncNow(a.id)));
283
+ return;
284
+ }
285
+ const existing = this.inflight.get(accountId);
286
+ if (existing) return existing;
287
+ const p = this.syncAccount(accountId, !!opts.quiet).finally(() => {
288
+ this.inflight.delete(accountId);
289
+ this.schedule(accountId);
290
+ this.opts.onSyncing?.(accountId);
291
+ });
292
+ this.inflight.set(accountId, p);
293
+ this.opts.onSyncing?.(accountId);
294
+ return p;
295
+ }
296
+ /** True while a sync of this account is running. */
297
+ isSyncing(accountId) {
298
+ return this.inflight.has(accountId);
299
+ }
300
+ /** (Re)arms this account's own timer: normal interval, or backoff after failures. */
301
+ schedule(id2) {
302
+ clearTimeout(this.timers.get(id2));
303
+ this.timers.delete(id2);
304
+ if (!this.running || !this.store.list().some((a) => a.id === id2)) {
305
+ this.failures.delete(id2);
306
+ return;
307
+ }
308
+ const n = this.failures.get(id2) ?? 0;
309
+ const delay = n === 0 ? this.intervalMs : Math.min(this.intervalMs * 2 ** n, this.maxBackoffMs);
310
+ this.timers.set(
311
+ id2,
312
+ setTimeout(() => void this.syncNow(id2).catch(() => {
313
+ }), delay)
314
+ );
315
+ }
316
+ // ponytail: quiet silences the whole sync, so an external change landing in the same pass is not announced.
317
+ async syncAccount(id2, quiet) {
318
+ const hadError = !!this.store.list().find((a) => a.id === id2)?.error;
319
+ try {
320
+ const provider = this.store.getProvider(id2);
321
+ const now = /* @__PURE__ */ new Date();
322
+ const range = { start: subMonths(now, 3).toISOString(), end: addMonths(now, 3).toISOString() };
323
+ const calendars = (await provider.listCalendars()).map((c) => ({ ...c, accountId: id2 }));
324
+ const lists = await Promise.all(
325
+ calendars.map(
326
+ async (cal) => (await provider.listEvents(cal.id, range)).map((e) => ({ ...e, accountId: id2, calendarId: cal.id }))
327
+ )
328
+ );
329
+ const next = { calendars, events: lists.flat(), syncedAt: now.toISOString() };
330
+ let prev;
331
+ try {
332
+ prev = this.store.readCache(id2);
333
+ } catch {
334
+ prev = void 0;
335
+ }
336
+ const changed = !prev || JSON.stringify([prev.calendars, prev.events]) !== JSON.stringify([next.calendars, next.events]);
337
+ await this.store.writeCache(id2, next);
338
+ this.failures.delete(id2);
339
+ if (hadError) await this.store.update(id2, { error: void 0 });
340
+ if (changed || hadError) this.onChanged(id2);
341
+ if (changed && !quiet && prev?.syncedAt && this.opts.onEvents) {
342
+ const notes = diffEvents(prev.events, next.events, now);
343
+ if (notes.length) this.opts.onEvents(id2, notes);
344
+ }
345
+ } catch (e) {
346
+ this.failures.set(id2, (this.failures.get(id2) ?? 0) + 1);
347
+ const message = e instanceof Error ? e.message : String(e);
348
+ await this.store.update(id2, { error: message }).catch(() => {
349
+ });
350
+ if (!hadError) this.onChanged(id2);
351
+ throw e;
352
+ }
353
+ }
354
+ };
355
+ function queryEvents(store, range, visibleOnly = true) {
356
+ const from = parseISO2(range.start).getTime();
357
+ const to = parseISO2(range.end).getTime();
358
+ const out = [];
359
+ for (const acc of store.list()) {
360
+ let cache;
361
+ try {
362
+ cache = store.readCache(acc.id);
363
+ } catch {
364
+ continue;
365
+ }
366
+ const hidden = new Set(visibleOnly ? store.hiddenCalendars(acc.id) : []);
367
+ for (const e of cache.events) {
368
+ if (e.accountId !== acc.id || hidden.has(e.calendarId)) continue;
369
+ if (parseISO2(e.end).getTime() > from && parseISO2(e.start).getTime() < to) out.push(e);
370
+ }
371
+ }
372
+ return out;
373
+ }
374
+
375
+ // ../../packages/core/src/telemetry.ts
376
+ import { randomUUID } from "crypto";
377
+ import { mkdirSync as mkdirSync2, readFileSync, writeFileSync } from "fs";
378
+ import { join as join3 } from "path";
379
+ var HOST = "https://eu.i.posthog.com";
380
+ function telemetryOff(env = process.env) {
381
+ const no = (v) => v === "0" || v?.toLowerCase() === "false";
382
+ return !!env.DO_NOT_TRACK && !no(env.DO_NOT_TRACK) || no(env.MYSTICALS_TELEMETRY) || env.MYSTICALS_MOCK === "1";
383
+ }
384
+ var PRESETS = {
385
+ "dav.privateemail.com": "privateemail",
386
+ "caldav.icloud.com": "icloud",
387
+ "caldav.fastmail.com": "fastmail"
388
+ };
389
+ var caldavPreset = (serverUrl) => PRESETS[new URL(serverUrl).hostname] ?? "custom";
390
+ function installId(dir) {
391
+ const file = join3(dir, "telemetry-id");
392
+ try {
393
+ const id3 = readFileSync(file, "utf8").trim();
394
+ if (id3) return { id: id3, fresh: false };
395
+ } catch {
396
+ }
397
+ const id2 = randomUUID();
398
+ mkdirSync2(dir, { recursive: true, mode: 448 });
399
+ writeFileSync(file, id2);
400
+ return { id: id2, fresh: true };
401
+ }
402
+ function startTelemetry(o) {
403
+ const env = o.env ?? process.env;
404
+ if (!o.key || telemetryOff(env)) return void 0;
405
+ let install;
406
+ try {
407
+ install = installId(o.dir);
408
+ } catch {
409
+ return void 0;
410
+ }
411
+ const host = env.MYSTICALS_TELEMETRY_HOST || HOST;
412
+ const doFetch = o.fetch ?? fetch;
413
+ const track = (event, props) => {
414
+ if (o.allowed && !o.allowed()) return;
415
+ const properties = {
416
+ app: o.app,
417
+ version: o.version,
418
+ os: process.platform,
419
+ arch: process.arch,
420
+ ...props && { provider: props.provider, ...props.preset && { preset: props.preset } },
421
+ $process_person_profiles: false,
422
+ $geoip_disable: true
423
+ };
424
+ try {
425
+ doFetch(`${host}/i/v0/e/`, {
426
+ method: "POST",
427
+ headers: { "Content-Type": "application/json" },
428
+ body: JSON.stringify({ api_key: o.key, event, distinct_id: install.id, properties }),
429
+ // Never aborted early: the install id already exists, so a cancelled app_installed would never be resent.
430
+ signal: AbortSignal.timeout(5e3)
431
+ }).catch(() => {
432
+ });
433
+ } catch {
434
+ }
435
+ };
436
+ if (install.fresh) track("app_installed");
437
+ return { track, installed: install.fresh };
438
+ }
439
+
440
+ // ../../packages/core/src/api.ts
441
+ var PALETTE = ["#bd93f9", "#50fa7b", "#8be9fd", "#ff79c6", "#ffb86c", "#f1fa8c"];
442
+ var id = z.string().min(1).max(1024);
443
+ var isoDateOrTime = z.union([z.iso.datetime({ offset: true }), z.iso.date()]);
444
+ var color = z.string().regex(/^#[0-9a-fA-F]{6}$/);
445
+ var text = (max2) => z.string().max(max2);
446
+ var ordered = (v) => Date.parse(v.end) >= Date.parse(v.start);
447
+ var Range = z.object({ start: isoDateOrTime, end: isoDateOrTime }).refine(ordered, "end before start");
448
+ var NewEvent = z.object({
449
+ accountId: id,
450
+ calendarId: id,
451
+ title: text(1e3),
452
+ start: isoDateOrTime,
453
+ end: isoDateOrTime,
454
+ allDay: z.boolean(),
455
+ location: text(1e3).optional(),
456
+ description: text(2e4).optional(),
457
+ attendees: z.array(z.email()).max(500).optional()
458
+ }).refine(ordered, "end before start");
459
+ var Attendee = z.object({
460
+ email: text(320).min(1),
461
+ name: text(500).optional(),
462
+ status: z.enum(["accepted", "declined", "tentative", "needsAction"]),
463
+ self: z.boolean().optional(),
464
+ organizer: z.boolean().optional()
465
+ });
466
+ var EventRef = z.object({ id, accountId: id, calendarId: id });
467
+ var EventEdit = EventRef.extend({
468
+ title: text(1e3),
469
+ start: isoDateOrTime,
470
+ end: isoDateOrTime,
471
+ allDay: z.boolean(),
472
+ location: text(1e3).optional(),
473
+ description: text(2e4).optional(),
474
+ attendees: z.array(Attendee).max(500)
475
+ }).refine(ordered, "end before start");
476
+ var LOOPBACK = /* @__PURE__ */ new Set(["127.0.0.1", "localhost"]);
477
+ var CaldavInput = z.object({
478
+ label: text(200).min(1),
479
+ serverUrl: z.string().refine((u) => {
480
+ if (!URL.canParse(u)) return false;
481
+ const { protocol, hostname } = new URL(u);
482
+ return protocol === "https:" || protocol === "http:" && LOOPBACK.has(hostname);
483
+ }, "server URL must be https"),
484
+ username: text(500).min(1),
485
+ password: text(2e3).min(1),
486
+ color: color.optional()
487
+ });
488
+ var AccountPatch = z.object({ label: text(200).min(1).optional(), color: color.optional() }).strict();
489
+ var RsvpStatus = z.enum(["accepted", "declined", "tentative"]);
490
+ var Scope = z.enum(["one", "following", "all"]).default("one");
491
+ function createApi(store, sync, deps) {
492
+ const account = (accountId) => {
493
+ const a = store.get(id.parse(accountId));
494
+ if (!a) throw new Error("unknown account");
495
+ return a;
496
+ };
497
+ const syncOne = (accountId) => {
498
+ sync.syncNow(accountId, { quiet: true }).catch((e) => console.error(`sync ${accountId} failed`, e));
499
+ };
500
+ const nextColor = () => PALETTE[store.list().length % PALETTE.length];
501
+ const cachedEvent = (ref) => {
502
+ account(ref.accountId);
503
+ const ev = store.readCache(ref.accountId).events.find((e) => e.id === ref.id);
504
+ if (!ev || ev.accountId !== ref.accountId || ev.calendarId !== ref.calendarId)
505
+ throw new Error("event does not belong to this account/calendar");
506
+ return ev;
507
+ };
508
+ const writable = (ev) => {
509
+ const cal = store.readCache(ev.accountId).calendars.find((c) => c.id === ev.calendarId);
510
+ if (!cal || cal.readOnly) throw new Error("calendar is read-only");
511
+ return ev;
512
+ };
513
+ return {
514
+ accounts: {
515
+ list: async () => store.list().map((a) => ({ ...a, syncing: sync.isSyncing(a.id), synced: !!store.readCache(a.id).syncedAt })),
516
+ addGoogle: async () => {
517
+ const { email, credentials } = await deps.googleSignIn();
518
+ const a = await store.add({ kind: "google", label: email, email, color: nextColor() }, credentials);
519
+ syncOne(a.id);
520
+ deps.onAccountAdded?.({ provider: "google" });
521
+ return a;
522
+ },
523
+ addCaldav: async (raw) => {
524
+ const input = CaldavInput.parse(raw);
525
+ const { email } = await deps.verifyCaldav(input);
526
+ const { label, serverUrl, username, password } = input;
527
+ const a = await store.add(
528
+ { kind: "caldav", label, email, color: input.color ?? nextColor() },
529
+ { kind: "caldav", serverUrl, username, password }
530
+ );
531
+ syncOne(a.id);
532
+ deps.onAccountAdded?.({ provider: "caldav", preset: caldavPreset(serverUrl) });
533
+ return a;
534
+ },
535
+ update: async (accountId, patch) => {
536
+ const a = await store.update(account(accountId).id, AccountPatch.parse(patch));
537
+ deps.onChanged?.(a.id);
538
+ return a;
539
+ },
540
+ remove: async (accountId) => {
541
+ const a = account(accountId);
542
+ await store.remove(a.id);
543
+ deps.onChanged?.(a.id);
544
+ }
545
+ },
546
+ calendars: {
547
+ list: async () => store.list().flatMap((a) => {
548
+ const hidden = new Set(store.hiddenCalendars(a.id));
549
+ return store.readCache(a.id).calendars.map((c) => ({ ...c, accountId: a.id, visible: !hidden.has(c.id) }));
550
+ }),
551
+ setVisible: async (accountId, calendarId, visible) => {
552
+ const a = account(accountId);
553
+ const cal = id.parse(calendarId);
554
+ if (!store.readCache(a.id).calendars.some((c) => c.id === cal)) throw new Error("calendar does not belong to account");
555
+ await store.setCalendarVisible(a.id, cal, z.boolean().parse(visible));
556
+ deps.onChanged?.(a.id);
557
+ }
558
+ },
559
+ events: {
560
+ // `raw` stays in the daemon/main process: it can be huge (CalDAV series ICS) and writes re-read it from the cache.
561
+ list: async (raw) => queryEvents(store, Range.parse(raw), false).map(({ raw: _raw, ...e }) => e),
562
+ create: async (raw) => {
563
+ const input = NewEvent.parse(raw);
564
+ const a = account(input.accountId);
565
+ const cal = store.readCache(a.id).calendars.find((c) => c.id === input.calendarId);
566
+ if (!cal) throw new Error("calendar does not belong to account");
567
+ if (cal.readOnly) throw new Error("calendar is read-only");
568
+ const ev = await store.getProvider(a.id).createEvent(cal.id, { ...input, accountId: a.id });
569
+ syncOne(a.id);
570
+ return ev;
571
+ },
572
+ update: async (raw) => {
573
+ const edit = EventEdit.parse(raw);
574
+ const cached2 = writable(cachedEvent(edit));
575
+ const { title: title2, start, end, allDay, location, description, attendees } = edit;
576
+ const ev = await store.getProvider(cached2.accountId).updateEvent({ ...cached2, title: title2, start, end, allDay, location, description, attendees });
577
+ syncOne(cached2.accountId);
578
+ return ev;
579
+ },
580
+ delete: async (raw, scope) => {
581
+ const cached2 = writable(cachedEvent(EventRef.parse(raw)));
582
+ await store.getProvider(cached2.accountId).deleteEvent(cached2, Scope.parse(scope));
583
+ syncOne(cached2.accountId);
584
+ },
585
+ respond: async (raw, status) => {
586
+ const cached2 = cachedEvent(EventRef.parse(raw));
587
+ const ev = await store.getProvider(cached2.accountId).respond(cached2, RsvpStatus.parse(status));
588
+ syncOne(cached2.accountId);
589
+ return ev;
590
+ }
591
+ },
592
+ sync: {
593
+ now: async (accountId) => {
594
+ if (accountId === void 0 || accountId === null) return sync.syncNow();
595
+ return sync.syncNow(account(accountId).id);
596
+ }
597
+ }
598
+ };
599
+ }
600
+
601
+ // ../../packages/core/src/accounts/store.ts
602
+ import { randomUUID as randomUUID2 } from "crypto";
603
+ import { existsSync, readFileSync as readFileSync2 } from "fs";
604
+ import { mkdir, rename, rm, writeFile } from "fs/promises";
605
+ import { join as join4 } from "path";
606
+ var ID_RE = /^[a-zA-Z0-9-]{1,64}$/;
607
+ function readJson(file, fallback) {
608
+ if (!existsSync(file)) return fallback;
609
+ try {
610
+ return JSON.parse(readFileSync2(file, "utf8"));
611
+ } catch (e) {
612
+ throw new Error(`Could not read ${file}: ${e.message}`);
613
+ }
614
+ }
615
+ async function writeAtomic(file, data) {
616
+ const tmp = `${file}.${randomUUID2()}.tmp`;
617
+ await writeFile(tmp, data, { mode: 384 });
618
+ await rename(tmp, file);
619
+ }
620
+ var AccountStore = class {
621
+ constructor(dir, factories, crypto) {
622
+ this.dir = dir;
623
+ this.factories = factories;
624
+ this.crypto = crypto;
625
+ this.accounts = readJson(this.registryFile, []);
626
+ if (!Array.isArray(this.accounts)) throw new Error(`Could not read ${this.registryFile}: not a list of accounts`);
627
+ }
628
+ dir;
629
+ factories;
630
+ crypto;
631
+ accounts;
632
+ providers = /* @__PURE__ */ new Map();
633
+ /** Parsed cache.json per account; caches can be tens of MB, so they are read from disk once. */
634
+ caches = /* @__PURE__ */ new Map();
635
+ queue = Promise.resolve();
636
+ get registryFile() {
637
+ return join4(this.dir, "accounts.json");
638
+ }
639
+ /** Validates the id before it ever becomes part of a path (no traversal). */
640
+ accountDir(id2) {
641
+ if (!ID_RE.test(id2)) throw new Error(`Invalid account id: ${JSON.stringify(id2)}`);
642
+ return join4(this.dir, "accounts", id2);
643
+ }
644
+ require(id2) {
645
+ const acc = this.get(id2);
646
+ if (!acc) throw new Error(`Unknown account: ${id2}`);
647
+ return acc;
648
+ }
649
+ /** All writes run one at a time, so checks and file snapshots never race (e.g. a refresh vs. remove). */
650
+ // ponytail: one queue for all accounts; per-account queues if write throughput ever matters.
651
+ serial(fn) {
652
+ const run = this.queue.then(fn);
653
+ this.queue = run.catch(() => {
654
+ });
655
+ return run;
656
+ }
657
+ saveRegistry() {
658
+ return writeAtomic(this.registryFile, JSON.stringify(this.accounts, null, 2));
659
+ }
660
+ async writeCreds(id2, creds) {
661
+ const dir = this.accountDir(id2);
662
+ const data = this.crypto.encrypt(JSON.stringify(creds));
663
+ await mkdir(dir, { recursive: true });
664
+ await writeAtomic(join4(dir, "creds.bin"), data);
665
+ }
666
+ readCreds(id2) {
667
+ return JSON.parse(this.crypto.decrypt(readFileSync2(join4(this.accountDir(id2), "creds.bin"))));
668
+ }
669
+ list() {
670
+ return this.accounts.map((a) => ({ ...a }));
671
+ }
672
+ get(id2) {
673
+ const acc = this.accounts.find((a) => a.id === id2);
674
+ return acc && { ...acc };
675
+ }
676
+ async add(meta, creds) {
677
+ if (creds.kind !== meta.kind) throw new Error("Credentials kind does not match account kind");
678
+ const account = { id: randomUUID2(), kind: meta.kind, label: meta.label, email: meta.email, color: meta.color };
679
+ return this.serial(async () => {
680
+ await this.writeCreds(account.id, creds);
681
+ this.accounts.push(account);
682
+ await this.saveRegistry();
683
+ return { ...account };
684
+ });
685
+ }
686
+ async update(id2, patch) {
687
+ return this.serial(async () => {
688
+ const acc = this.accounts.find((a) => a.id === id2);
689
+ if (!acc) throw new Error(`Unknown account: ${id2}`);
690
+ if (patch.credentials) {
691
+ if (patch.credentials.kind !== acc.kind) throw new Error("Credentials kind does not match account kind");
692
+ await this.writeCreds(id2, patch.credentials);
693
+ this.providers.delete(id2);
694
+ }
695
+ if (patch.label !== void 0) acc.label = patch.label;
696
+ if (patch.color !== void 0) acc.color = patch.color;
697
+ if ("error" in patch) {
698
+ if (patch.error) acc.error = patch.error;
699
+ else delete acc.error;
700
+ }
701
+ await this.saveRegistry();
702
+ return { ...acc };
703
+ });
704
+ }
705
+ /** Removes account and wipes its directory (creds + cache). */
706
+ async remove(id2) {
707
+ const dir = this.accountDir(id2);
708
+ return this.serial(async () => {
709
+ this.providers.delete(id2);
710
+ this.caches.delete(id2);
711
+ this.accounts = this.accounts.filter((a) => a.id !== id2);
712
+ await this.saveRegistry();
713
+ await rm(dir, { recursive: true, force: true });
714
+ });
715
+ }
716
+ /** Isolated provider instance for this account only (memoized per account). */
717
+ getProvider(id2) {
718
+ const cached2 = this.providers.get(id2);
719
+ if (cached2) return cached2;
720
+ const acc = this.require(id2);
721
+ const provider = this.factories[acc.kind]({
722
+ accountId: acc.id,
723
+ email: acc.email,
724
+ credentials: this.readCreds(acc.id),
725
+ // Bound to this account only; a no-op once the account is removed (never resurrects its dir).
726
+ saveCredentials: (creds) => this.serial(async () => {
727
+ if (this.providers.get(acc.id) !== provider) return;
728
+ if (creds.kind !== acc.kind) throw new Error("Credentials kind does not match account kind");
729
+ await this.writeCreds(acc.id, creds);
730
+ })
731
+ });
732
+ this.providers.set(id2, provider);
733
+ return provider;
734
+ }
735
+ readCache(id2) {
736
+ const hit = this.caches.get(id2);
737
+ if (hit) return hit;
738
+ const cache = readJson(join4(this.accountDir(id2), "cache.json"), { calendars: [], events: [] });
739
+ if (this.get(id2)) this.caches.set(id2, cache);
740
+ return cache;
741
+ }
742
+ writeCache(id2, cache) {
743
+ return this.serial(async () => {
744
+ const dir = this.accountDir(id2);
745
+ this.require(id2);
746
+ await mkdir(dir, { recursive: true });
747
+ await writeAtomic(join4(dir, "cache.json"), JSON.stringify(cache));
748
+ this.caches.set(id2, cache);
749
+ });
750
+ }
751
+ hiddenCalendars(id2) {
752
+ const file = join4(this.accountDir(id2), "prefs.json");
753
+ try {
754
+ const hidden = readJson(file, {}).hidden;
755
+ return Array.isArray(hidden) ? hidden.filter((h) => typeof h === "string") : [];
756
+ } catch {
757
+ return [];
758
+ }
759
+ }
760
+ setCalendarVisible(id2, calendarId, visible) {
761
+ return this.serial(async () => {
762
+ this.require(id2);
763
+ const hidden = new Set(this.hiddenCalendars(id2));
764
+ if (visible) hidden.delete(calendarId);
765
+ else hidden.add(calendarId);
766
+ const dir = this.accountDir(id2);
767
+ await mkdir(dir, { recursive: true });
768
+ await writeAtomic(join4(dir, "prefs.json"), JSON.stringify({ hidden: [...hidden] }));
769
+ });
770
+ }
771
+ };
772
+
773
+ // ../../packages/core/src/mock/mockApi.ts
774
+ import { addDays, format as format2 } from "date-fns";
775
+
776
+ // ../../packages/core/src/mock/MockProvider.ts
777
+ import { randomUUID as randomUUID3 } from "crypto";
778
+ import { parseISO as parseISO3 } from "date-fns";
779
+ var MockProvider = class {
780
+ constructor(accountId, email, calendars) {
781
+ this.accountId = accountId;
782
+ this.email = email;
783
+ this.calendars = calendars.map((c) => ({ ...c, accountId }));
784
+ }
785
+ accountId;
786
+ email;
787
+ calendars;
788
+ events = [];
789
+ async listCalendars() {
790
+ return structuredClone(this.calendars);
791
+ }
792
+ async listEvents(calendarId, range) {
793
+ const t = (iso2) => parseISO3(iso2).getTime();
794
+ return structuredClone(
795
+ this.events.filter((e) => e.calendarId === calendarId && t(e.end) > t(range.start) && t(e.start) < t(range.end))
796
+ );
797
+ }
798
+ async createEvent(calendarId, input) {
799
+ const ev = {
800
+ id: randomUUID3(),
801
+ accountId: this.accountId,
802
+ calendarId,
803
+ title: input.title,
804
+ start: input.start,
805
+ end: input.end,
806
+ allDay: input.allDay,
807
+ location: input.location,
808
+ description: input.description,
809
+ organizer: { email: this.email },
810
+ attendees: (input.attendees ?? []).map((email) => ({ email, status: "needsAction" }))
811
+ };
812
+ this.events.push(ev);
813
+ return structuredClone(ev);
814
+ }
815
+ async updateEvent(event) {
816
+ const i = this.events.findIndex((e) => e.id === event.id);
817
+ if (i < 0) throw new Error("not found");
818
+ this.events[i] = { ...event, accountId: this.accountId };
819
+ return structuredClone(this.events[i]);
820
+ }
821
+ async deleteEvent(event, scope = "one") {
822
+ const series = event.recurringEventId;
823
+ const hit = (e) => !series || scope === "one" ? e.id === event.id : e.recurringEventId === series && (scope === "all" || e.start >= event.start);
824
+ this.events = this.events.filter((e) => !hit(e));
825
+ }
826
+ async respond(event, status) {
827
+ const ev = this.events.find((e) => e.id === event.id);
828
+ if (!ev) throw new Error("not found");
829
+ ev.myStatus = status;
830
+ ev.attendees = ev.attendees.map((a) => a.self ? { ...a, status } : a);
831
+ return structuredClone(ev);
832
+ }
833
+ };
834
+
835
+ // ../../packages/core/src/mock/mockApi.ts
836
+ var iso = (dayOffset, h, m = 0) => {
837
+ const d = /* @__PURE__ */ new Date();
838
+ d.setHours(h, m, 0, 0);
839
+ d.setDate(d.getDate() + dayOffset);
840
+ return d.toISOString();
841
+ };
842
+ function createMockApi(onChanged) {
843
+ const accounts = [
844
+ { id: "work", kind: "caldav", label: "Work", email: "me@work.example", color: "#8be9fd" },
845
+ { id: "personal", kind: "google", label: "Personal", email: "me@gmail.example", color: "#50fa7b" }
846
+ ];
847
+ const providers = /* @__PURE__ */ new Map([
848
+ ["work", new MockProvider("work", "me@work.example", [{ id: "work-main", name: "Work", color: "#8be9fd", readOnly: false }])],
849
+ [
850
+ "personal",
851
+ new MockProvider("personal", "me@gmail.example", [
852
+ { id: "p-main", name: "Personal", color: "#50fa7b", readOnly: false },
853
+ { id: "p-holidays", name: "Holidays", color: "#f1fa8c", readOnly: true }
854
+ ])
855
+ ]
856
+ ]);
857
+ const hidden = /* @__PURE__ */ new Set();
858
+ const work = providers.get("work");
859
+ const personal = providers.get("personal");
860
+ const seed = (p, calendarId, e) => {
861
+ p.events.push({ id: `${p.accountId}-${p.events.length}`, accountId: p.accountId, calendarId, allDay: false, attendees: [], ...e });
862
+ };
863
+ for (let d = -3; d <= 7; d++) {
864
+ seed(work, "work-main", {
865
+ title: "Daily standup",
866
+ start: iso(d, 10),
867
+ end: iso(d, 10, 15),
868
+ location: "https://meet.google.com/abc-defg-hij",
869
+ organizer: { email: "lead@work.example" },
870
+ myStatus: "accepted",
871
+ attendees: [{ email: "lead@work.example", status: "accepted", organizer: true }, { email: "me@work.example", status: "accepted", self: true }]
872
+ });
873
+ }
874
+ seed(work, "work-main", {
875
+ title: "Sprint planning",
876
+ start: iso(1, 14),
877
+ end: iso(1, 15, 30),
878
+ location: "Room 3 / https://meet.example.com/sprint-planning",
879
+ organizer: { email: "pm@work.example" },
880
+ myStatus: "needsAction",
881
+ attendees: [{ email: "pm@work.example", status: "accepted", organizer: true }, { email: "me@work.example", status: "needsAction", self: true }]
882
+ });
883
+ seed(personal, "p-main", { title: "Gym", start: iso(0, 19), end: iso(0, 20) });
884
+ for (let d = -2; d <= 4; d++) {
885
+ seed(personal, "p-main", { title: "Morning run", start: iso(d, 7), end: iso(d, 7, 45), recurringEventId: "run-series" });
886
+ }
887
+ seed(personal, "p-main", { title: "Dinner with friends", start: iso(2, 20), end: iso(2, 22), location: "Kyiv" });
888
+ const day = (offset) => format2(addDays(/* @__PURE__ */ new Date(), offset), "yyyy-MM-dd");
889
+ seed(personal, "p-holidays", { title: "Holiday", start: day(0), end: day(1), allDay: true });
890
+ const prov = (id2) => {
891
+ const p = providers.get(id2);
892
+ if (!p) throw new Error(`unknown account ${id2}`);
893
+ return p;
894
+ };
895
+ const own = (accountId, calendarId) => {
896
+ const p = prov(accountId);
897
+ if (!p.calendars.some((c) => c.id === calendarId)) throw new Error("calendar does not belong to account");
898
+ return p;
899
+ };
900
+ return {
901
+ accounts: {
902
+ list: async () => structuredClone(accounts),
903
+ addGoogle: async () => {
904
+ throw new Error("Not available in mock mode");
905
+ },
906
+ addCaldav: async () => {
907
+ throw new Error("Not available in mock mode");
908
+ },
909
+ update: async (id2, patch) => {
910
+ const a = accounts.find((x) => x.id === id2);
911
+ if (!a) throw new Error("unknown account");
912
+ Object.assign(a, patch);
913
+ onChanged(id2);
914
+ return structuredClone(a);
915
+ },
916
+ remove: async (id2) => {
917
+ const i = accounts.findIndex((a) => a.id === id2);
918
+ if (i >= 0) accounts.splice(i, 1);
919
+ providers.delete(id2);
920
+ onChanged(id2);
921
+ }
922
+ },
923
+ calendars: {
924
+ list: async () => (await Promise.all([...providers.values()].map((p) => p.listCalendars()))).flat().map((c) => ({ ...c, visible: !hidden.has(`${c.accountId}/${c.id}`) })),
925
+ setVisible: async (accountId, calendarId, visible) => {
926
+ const k = `${accountId}/${calendarId}`;
927
+ if (visible) hidden.delete(k);
928
+ else hidden.add(k);
929
+ onChanged(accountId);
930
+ }
931
+ },
932
+ events: {
933
+ list: async (range) => {
934
+ const out = [];
935
+ for (const p of providers.values())
936
+ for (const c of p.calendars) out.push(...await p.listEvents(c.id, range));
937
+ return out;
938
+ },
939
+ create: async (input) => {
940
+ const ev = await own(input.accountId, input.calendarId).createEvent(input.calendarId, input);
941
+ onChanged(input.accountId);
942
+ return ev;
943
+ },
944
+ update: async (ev) => {
945
+ const r = await own(ev.accountId, ev.calendarId).updateEvent(ev);
946
+ onChanged(ev.accountId);
947
+ return r;
948
+ },
949
+ delete: async (ev, scope) => {
950
+ await own(ev.accountId, ev.calendarId).deleteEvent(ev, scope);
951
+ onChanged(ev.accountId);
952
+ },
953
+ respond: async (ev, status) => {
954
+ const r = await own(ev.accountId, ev.calendarId).respond(ev, status);
955
+ onChanged(ev.accountId);
956
+ return r;
957
+ }
958
+ },
959
+ sync: { now: async () => {
960
+ } }
961
+ };
962
+ }
963
+
964
+ // ../../packages/core/src/providers/caldav/index.ts
965
+ import { randomUUID as randomUUID4 } from "crypto";
966
+ import {
967
+ createAccount,
968
+ createCalendarObject,
969
+ deleteCalendarObject,
970
+ fetchCalendarObjects,
971
+ fetchCalendars,
972
+ fetchCalendarUserAddresses,
973
+ getBasicAuthHeaders,
974
+ updateCalendarObject
975
+ } from "tsdav";
976
+
977
+ // ../../packages/core/src/providers/caldav/ics.ts
978
+ import ICAL from "ical.js";
979
+ var MAX_INSTANCES = 1e5;
980
+ var PARTSTAT_IN = {
981
+ ACCEPTED: "accepted",
982
+ DECLINED: "declined",
983
+ TENTATIVE: "tentative",
984
+ "NEEDS-ACTION": "needsAction"
985
+ };
986
+ var PARTSTAT_OUT = {
987
+ accepted: "ACCEPTED",
988
+ declined: "DECLINED",
989
+ tentative: "TENTATIVE",
990
+ needsAction: "NEEDS-ACTION"
991
+ };
992
+ var mapPartStat = (v) => PARTSTAT_IN[String(v ?? "").toUpperCase()] ?? "needsAction";
993
+ var cleanEmail = (v) => String(v ?? "").replace(/^mailto:/i, "").trim().toLowerCase();
994
+ var sameEmail = (a, b) => cleanEmail(a) !== "" && cleanEmail(a) === cleanEmail(b);
995
+ function parse(ics) {
996
+ const root = new ICAL.Component(ICAL.parse(ics));
997
+ for (const vtz of root.getAllSubcomponents("vtimezone")) {
998
+ const tz = new ICAL.Timezone(vtz);
999
+ if (!ICAL.TimezoneService.has(tz.tzid)) ICAL.TimezoneService.register(tz);
1000
+ }
1001
+ return root;
1002
+ }
1003
+ var toIso = (t) => t.isDate ? t.toString() : t.toJSDate().toISOString();
1004
+ function fromIso(iso2, allDay) {
1005
+ if (allDay) return ICAL.Time.fromDateString(iso2.slice(0, 10));
1006
+ const d = new Date(iso2);
1007
+ if (Number.isNaN(d.getTime())) throw new Error(`Invalid date: ${iso2}`);
1008
+ return ICAL.Time.fromJSDate(d, true);
1009
+ }
1010
+ function setTime(vevent, name, t) {
1011
+ vevent.removeAllProperties(name);
1012
+ vevent.addPropertyWithValue(name, t);
1013
+ }
1014
+ function setText(vevent, name, value) {
1015
+ vevent.removeAllProperties(name);
1016
+ if (value) vevent.addPropertyWithValue(name, value);
1017
+ }
1018
+ function touch(vevent) {
1019
+ vevent.updatePropertyWithValue("dtstamp", ICAL.Time.now());
1020
+ vevent.updatePropertyWithValue("last-modified", ICAL.Time.now());
1021
+ }
1022
+ function mapPeople(vevent, email) {
1023
+ const orgProp = vevent.getFirstProperty("organizer");
1024
+ const organizer = orgProp ? { email: cleanEmail(orgProp.getFirstValue()), name: orgProp.getParameter("cn") || void 0 } : void 0;
1025
+ const attendees = vevent.getAllProperties("attendee").map((p) => {
1026
+ const a = { email: cleanEmail(p.getFirstValue()), status: mapPartStat(p.getParameter("partstat")) };
1027
+ const cn = p.getParameter("cn");
1028
+ if (cn) a.name = String(cn);
1029
+ if (sameEmail(a.email, email)) a.self = true;
1030
+ if (organizer && sameEmail(a.email, organizer.email)) a.organizer = true;
1031
+ return a;
1032
+ });
1033
+ return { organizer, attendees, myStatus: attendees.find((a) => a.self)?.status };
1034
+ }
1035
+ function parseEvents(ics, href, etag, ctx, range) {
1036
+ const root = parse(ics);
1037
+ const vevents = root.getAllSubcomponents("vevent");
1038
+ const rangeStart = new Date(range.start).getTime();
1039
+ const rangeEnd = new Date(range.end).getTime();
1040
+ const inRange = (s, e) => {
1041
+ const sm = s.toJSDate().getTime();
1042
+ const em = e.toJSDate().getTime();
1043
+ return sm < rangeEnd && (em > rangeStart || em === sm && sm >= rangeStart);
1044
+ };
1045
+ const build = (item, start, end, recurrenceId) => {
1046
+ const rid = recurrenceId?.toString();
1047
+ const raw = { href, ics, ...rid ? { recurrenceId: rid } : {} };
1048
+ return {
1049
+ id: rid ? `${href}#${rid}` : href,
1050
+ accountId: ctx.accountId,
1051
+ calendarId: ctx.calendarId,
1052
+ title: item.summary ?? "",
1053
+ start: toIso(start),
1054
+ end: toIso(end),
1055
+ allDay: start.isDate,
1056
+ location: item.location || void 0,
1057
+ description: item.description || void 0,
1058
+ ...mapPeople(item.component, ctx.email),
1059
+ etag,
1060
+ raw,
1061
+ ...rid ? { recurringEventId: href } : {}
1062
+ };
1063
+ };
1064
+ const master = vevents.find((v) => !v.hasProperty("recurrence-id"));
1065
+ if (!master) {
1066
+ return vevents.map((v) => new ICAL.Event(v)).filter((e) => inRange(e.startDate, e.endDate)).map((e) => build(e, e.startDate, e.endDate, e.recurrenceId));
1067
+ }
1068
+ const event = new ICAL.Event(master);
1069
+ for (const v of vevents) if (v !== master) event.relateException(v);
1070
+ if (!event.isRecurring()) return inRange(event.startDate, event.endDate) ? [build(event, event.startDate, event.endDate)] : [];
1071
+ const out = [];
1072
+ const it = event.iterator();
1073
+ const lastOverride = Math.max(
1074
+ -Infinity,
1075
+ ...vevents.filter((v) => v !== master).map((v) => v.getFirstPropertyValue("recurrence-id").toJSDate().getTime())
1076
+ );
1077
+ for (let i = 0, next = it.next(); next && i < MAX_INSTANCES; i++, next = it.next()) {
1078
+ const ms = next.toJSDate().getTime();
1079
+ if (ms >= rangeEnd && ms > lastOverride) break;
1080
+ const d = event.getOccurrenceDetails(next);
1081
+ if (inRange(d.startDate, d.endDate)) out.push(build(d.item, d.startDate, d.endDate, d.recurrenceId));
1082
+ }
1083
+ return out;
1084
+ }
1085
+ function buildIcs(uid, input, email) {
1086
+ const root = new ICAL.Component(["vcalendar", [], []]);
1087
+ root.addPropertyWithValue("prodid", "-//Mysticals//EN");
1088
+ root.addPropertyWithValue("version", "2.0");
1089
+ const v = new ICAL.Component("vevent");
1090
+ v.addPropertyWithValue("uid", uid);
1091
+ v.addPropertyWithValue("dtstamp", ICAL.Time.now());
1092
+ v.addPropertyWithValue("created", ICAL.Time.now());
1093
+ v.addPropertyWithValue("sequence", 0);
1094
+ setTime(v, "dtstart", fromIso(input.start, input.allDay));
1095
+ setTime(v, "dtend", fromIso(input.end, input.allDay));
1096
+ setText(v, "summary", input.title);
1097
+ setText(v, "location", input.location);
1098
+ setText(v, "description", input.description);
1099
+ setAttendees(v, input.attendees ?? [], email);
1100
+ root.addSubcomponent(v);
1101
+ return root.toString();
1102
+ }
1103
+ function setAttendees(v, emails, email) {
1104
+ const wanted = [...new Set(emails.map(cleanEmail).filter(Boolean))];
1105
+ const existing = new Map(v.getAllProperties("attendee").map((p) => [cleanEmail(p.getFirstValue()), p]));
1106
+ v.removeAllProperties("attendee");
1107
+ for (const e of wanted) {
1108
+ let p = existing.get(e);
1109
+ if (!p) {
1110
+ p = new ICAL.Property("attendee");
1111
+ p.setValue(`mailto:${e}`);
1112
+ p.setParameter("partstat", "NEEDS-ACTION");
1113
+ p.setParameter("rsvp", "TRUE");
1114
+ }
1115
+ v.addProperty(p);
1116
+ }
1117
+ if (wanted.length && !v.hasProperty("organizer")) v.addPropertyWithValue("organizer", `mailto:${cleanEmail(email)}`);
1118
+ }
1119
+ var findMaster = (root) => root.getAllSubcomponents("vevent").find((v) => !v.hasProperty("recurrence-id"));
1120
+ function masterOf(root) {
1121
+ const m = findMaster(root);
1122
+ if (!m) throw new Error("Calendar object has no master VEVENT");
1123
+ return m;
1124
+ }
1125
+ function ridTime(master, rid) {
1126
+ const time = rid.length === 10 ? ICAL.Time.fromDateString(rid) : ICAL.Time.fromDateTimeString(rid);
1127
+ const tzid = rid.endsWith("Z") ? void 0 : master.getFirstProperty("dtstart")?.getParameter("tzid");
1128
+ const zone = tzid ? ICAL.TimezoneService.get(tzid) : void 0;
1129
+ if (zone) time.zone = zone;
1130
+ return { time, tzid };
1131
+ }
1132
+ function findOverride(root, rid) {
1133
+ const master = findMaster(root);
1134
+ const want = master ? ridTime(master, rid).time.toJSDate().getTime() : NaN;
1135
+ return root.getAllSubcomponents("vevent").find((v) => {
1136
+ const t = v.getFirstPropertyValue("recurrence-id");
1137
+ return !!t && (String(t) === rid || t.toJSDate().getTime() === want);
1138
+ });
1139
+ }
1140
+ function ridProp(master, name, rid) {
1141
+ const { time, tzid } = ridTime(master, rid);
1142
+ const p = new ICAL.Property(name);
1143
+ p.setValue(time);
1144
+ if (tzid) p.setParameter("tzid", tzid);
1145
+ return p;
1146
+ }
1147
+ function targetVevent(root, raw) {
1148
+ if (!raw.recurrenceId) return masterOf(root);
1149
+ const existing = findOverride(root, raw.recurrenceId);
1150
+ if (existing) return existing;
1151
+ const master = masterOf(root);
1152
+ const o = new ICAL.Component(structuredClone(master.toJSON()));
1153
+ for (const n of ["rrule", "rdate", "exdate", "exrule"]) o.removeAllProperties(n);
1154
+ o.addProperty(ridProp(master, "recurrence-id", raw.recurrenceId));
1155
+ root.addSubcomponent(o);
1156
+ return o;
1157
+ }
1158
+ function applyUpdate(event, email) {
1159
+ const raw = event.raw;
1160
+ const root = parse(raw.ics);
1161
+ const v = targetVevent(root, raw);
1162
+ setTime(v, "dtstart", fromIso(event.start, event.allDay));
1163
+ v.removeAllProperties("duration");
1164
+ setTime(v, "dtend", fromIso(event.end, event.allDay));
1165
+ setText(v, "summary", event.title);
1166
+ setText(v, "location", event.location);
1167
+ setText(v, "description", event.description);
1168
+ setAttendees(
1169
+ v,
1170
+ event.attendees.map((a) => a.email),
1171
+ email
1172
+ );
1173
+ v.updatePropertyWithValue("sequence", Number(v.getFirstPropertyValue("sequence") ?? 0) + 1);
1174
+ touch(v);
1175
+ return root.toString();
1176
+ }
1177
+ function applyDeleteInstance(raw) {
1178
+ if (!raw.recurrenceId) throw new Error("Not a recurring instance");
1179
+ const root = parse(raw.ics);
1180
+ const override = findOverride(root, raw.recurrenceId);
1181
+ if (override) root.removeSubcomponent(override);
1182
+ const master = findMaster(root);
1183
+ if (!master) return root.getAllSubcomponents("vevent").length ? root.toString() : null;
1184
+ master.addProperty(ridProp(master, "exdate", raw.recurrenceId));
1185
+ master.updatePropertyWithValue("sequence", Number(master.getFirstPropertyValue("sequence") ?? 0) + 1);
1186
+ touch(master);
1187
+ return root.toString();
1188
+ }
1189
+ function applyDeleteFollowing(raw) {
1190
+ if (!raw.recurrenceId) throw new Error("Not a recurring instance");
1191
+ const root = parse(raw.ics);
1192
+ const master = findMaster(root);
1193
+ if (!master) return null;
1194
+ const { time } = ridTime(master, raw.recurrenceId);
1195
+ const cut = time.toJSDate().getTime();
1196
+ if (cut <= master.getFirstPropertyValue("dtstart").toJSDate().getTime()) return null;
1197
+ for (const v of root.getAllSubcomponents("vevent")) {
1198
+ const t = v.getFirstPropertyValue("recurrence-id");
1199
+ if (t && t.toJSDate().getTime() >= cut) root.removeSubcomponent(v);
1200
+ }
1201
+ let until = time.clone();
1202
+ if (until.isDate) until.adjust(-1, 0, 0, 0);
1203
+ else {
1204
+ until.adjust(0, 0, 0, -1);
1205
+ if (until.zone !== ICAL.Timezone.localTimezone) until = until.convertToZone(ICAL.Timezone.utcTimezone);
1206
+ }
1207
+ for (const p of master.getAllProperties("rrule")) {
1208
+ const r = p.getFirstValue().clone();
1209
+ r.count = null;
1210
+ r.until = until;
1211
+ p.setValue(r);
1212
+ }
1213
+ master.updatePropertyWithValue("sequence", Number(master.getFirstPropertyValue("sequence") ?? 0) + 1);
1214
+ touch(master);
1215
+ return root.toString();
1216
+ }
1217
+ function applyRespond(raw, email, status) {
1218
+ const root = parse(raw.ics);
1219
+ let found = false;
1220
+ for (const v of root.getAllSubcomponents("vevent")) {
1221
+ for (const p of v.getAllProperties("attendee")) {
1222
+ if (!sameEmail(p.getFirstValue(), email)) continue;
1223
+ p.setParameter("partstat", PARTSTAT_OUT[status]);
1224
+ p.removeParameter("rsvp");
1225
+ p.removeParameter("schedule-status");
1226
+ found = true;
1227
+ }
1228
+ if (found) v.updatePropertyWithValue("dtstamp", ICAL.Time.now());
1229
+ }
1230
+ if (!found) throw new Error("This account is not an attendee of the event");
1231
+ return root.toString();
1232
+ }
1233
+
1234
+ // ../../packages/core/src/providers/http.ts
1235
+ var http = { timeoutMs: 3e4 };
1236
+ var timedFetch = async (input, init) => {
1237
+ const timeout = AbortSignal.timeout(http.timeoutMs);
1238
+ try {
1239
+ return await fetch(input, { ...init, signal: init?.signal ? AbortSignal.any([init.signal, timeout]) : timeout });
1240
+ } catch (e) {
1241
+ if (timeout.aborted) throw new Error(`Request to ${new URL(String(input instanceof Request ? input.url : input)).host} timed out`);
1242
+ throw e;
1243
+ }
1244
+ };
1245
+
1246
+ // ../../packages/core/src/providers/caldav/index.ts
1247
+ var FALLBACK_COLOR = "#8e8e93";
1248
+ var DAY = 864e5;
1249
+ async function connect2(serverUrl, username, password) {
1250
+ try {
1251
+ new URL(serverUrl);
1252
+ } catch {
1253
+ throw new Error(`Invalid server URL: ${serverUrl}`);
1254
+ }
1255
+ const headers = getBasicAuthHeaders({ username, password });
1256
+ let unauthorized = false;
1257
+ const trackingFetch = async (input, init) => {
1258
+ const res = await timedFetch(input, init);
1259
+ if (res.status === 401) unauthorized = true;
1260
+ return res;
1261
+ };
1262
+ try {
1263
+ const account = await createAccount({
1264
+ account: { serverUrl, accountType: "caldav", credentials: { username, password } },
1265
+ headers,
1266
+ fetch: trackingFetch
1267
+ });
1268
+ return { account, headers };
1269
+ } catch (e) {
1270
+ if (unauthorized) throw new Error("CalDAV login failed: wrong username or password");
1271
+ throw new Error(`Could not find a CalDAV service at ${serverUrl}: ${e.message}`);
1272
+ }
1273
+ }
1274
+ function toCalendar(c, accountId) {
1275
+ const name = typeof c.displayName === "string" && c.displayName ? c.displayName : decodeURIComponent(c.url.split("/").filter(Boolean).pop() ?? c.url);
1276
+ const color2 = typeof c.calendarColor === "string" && /^#[0-9a-f]{6}/i.test(c.calendarColor) ? c.calendarColor.slice(0, 7) : FALLBACK_COLOR;
1277
+ const privileges = JSON.stringify(c.projectedProps?.currentUserPrivilegeSet ?? "");
1278
+ const readOnly = privileges.includes("privilege") && !/"(write|writeContent|all)"/.test(privileges);
1279
+ return { id: c.url, accountId, name, color: color2, readOnly };
1280
+ }
1281
+ var CALENDAR_PROPS = {
1282
+ "d:displayname": {},
1283
+ "ca:calendar-color": {},
1284
+ "cs:getctag": {},
1285
+ "d:resourcetype": {},
1286
+ "c:supported-calendar-component-set": {},
1287
+ "d:sync-token": {},
1288
+ "d:current-user-privilege-set": {}
1289
+ };
1290
+ async function listVeventCalendars(conn) {
1291
+ const cals = await fetchCalendars({
1292
+ account: conn.account,
1293
+ headers: conn.headers,
1294
+ props: CALENDAR_PROPS,
1295
+ projectedProps: { currentUserPrivilegeSet: true },
1296
+ fetch: timedFetch
1297
+ });
1298
+ return cals.filter((c) => !c.components?.length || c.components.includes("VEVENT"));
1299
+ }
1300
+ function check(res, what) {
1301
+ if (res.ok) return;
1302
+ if (res.status === 412) throw new Error(`${what} failed: the event was changed on the server, refresh and try again`);
1303
+ throw new Error(`${what} failed: ${res.status} ${res.statusText}`);
1304
+ }
1305
+ var createCaldavProvider = (ctx) => {
1306
+ if (ctx.credentials.kind !== "caldav") throw new Error("CalDAV provider needs caldav credentials");
1307
+ const { serverUrl, username, password } = ctx.credentials;
1308
+ let conn;
1309
+ const getConn = () => {
1310
+ conn ??= connect2(serverUrl, username, password).catch((e) => {
1311
+ conn = void 0;
1312
+ throw e;
1313
+ });
1314
+ return conn;
1315
+ };
1316
+ const mapCtx = (calendarId) => ({ accountId: ctx.accountId, calendarId, email: ctx.email });
1317
+ async function reload(calendarId, href, id2, around) {
1318
+ const { headers } = await getConn();
1319
+ const [obj] = await fetchCalendarObjects({ calendar: { url: calendarId }, objectUrls: [href], headers, urlFilter: () => true, fetch: timedFetch });
1320
+ if (!obj?.data) throw new Error("Saved event could not be read back from the server");
1321
+ const range = { start: new Date(new Date(around.start).getTime() - DAY).toISOString(), end: new Date(new Date(around.end).getTime() + DAY).toISOString() };
1322
+ const events = parseEvents(obj.data, id2 === void 0 ? obj.url : href, obj.etag, mapCtx(calendarId), range);
1323
+ const ev = id2 === void 0 ? events[0] : events.find((e) => e.id === id2);
1324
+ if (!ev) throw new Error("Saved event not found on the server");
1325
+ return ev;
1326
+ }
1327
+ const put = async (event, ics, what) => {
1328
+ const { headers } = await getConn();
1329
+ const raw = event.raw;
1330
+ check(await updateCalendarObject({ calendarObject: { url: raw.href, data: ics, etag: event.etag }, headers, fetch: timedFetch }), what);
1331
+ };
1332
+ const provider = {
1333
+ async listCalendars() {
1334
+ const c = await getConn();
1335
+ return (await listVeventCalendars(c)).map((cal) => toCalendar(cal, ctx.accountId));
1336
+ },
1337
+ async listEvents(calendarId, range) {
1338
+ const { headers } = await getConn();
1339
+ const objs = await fetchCalendarObjects({ calendar: { url: calendarId }, timeRange: range, headers, urlFilter: () => true, fetch: timedFetch });
1340
+ return objs.flatMap((o) => o.data ? parseEvents(o.data, o.url, o.etag, mapCtx(calendarId), range) : []);
1341
+ },
1342
+ async createEvent(calendarId, input) {
1343
+ const { headers } = await getConn();
1344
+ const uid = randomUUID4();
1345
+ const filename = `${uid}.ics`;
1346
+ const ics = buildIcs(uid, input, ctx.email);
1347
+ check(await createCalendarObject({ calendar: { url: calendarId }, filename, iCalString: ics, headers, fetch: timedFetch }), "Create event");
1348
+ const href = new URL(filename, calendarId.endsWith("/") ? calendarId : `${calendarId}/`).href;
1349
+ return reload(calendarId, href, void 0, input);
1350
+ },
1351
+ async updateEvent(event) {
1352
+ await put(event, applyUpdate(event, ctx.email), "Update event");
1353
+ return reload(event.calendarId, event.raw.href, event.id, event);
1354
+ },
1355
+ async deleteEvent(event, scope = "one") {
1356
+ const raw = event.raw;
1357
+ const rest = !raw.recurrenceId || scope === "all" ? null : scope === "following" ? applyDeleteFollowing(raw) : applyDeleteInstance(raw);
1358
+ if (rest) return put(event, rest, "Delete event");
1359
+ const { headers } = await getConn();
1360
+ check(await deleteCalendarObject({ calendarObject: { url: raw.href, etag: event.etag }, headers, fetch: timedFetch }), "Delete event");
1361
+ },
1362
+ async respond(event, status) {
1363
+ await put(event, applyRespond(event.raw, ctx.email, status), "Respond");
1364
+ return reload(event.calendarId, event.raw.href, event.id, event);
1365
+ }
1366
+ };
1367
+ return provider;
1368
+ };
1369
+ async function verifyCaldav(input) {
1370
+ const c = await connect2(input.serverUrl.trim(), input.username, input.password);
1371
+ await listVeventCalendars(c);
1372
+ const addresses = await fetchCalendarUserAddresses({ account: c.account, headers: c.headers, fetch: timedFetch }).catch(() => []);
1373
+ const mails = addresses.filter((a) => /^mailto:/i.test(a)).map(cleanEmail);
1374
+ const login = cleanEmail(input.username);
1375
+ const email = mails.find((m) => m === login) ?? mails[0] ?? (login.includes("@") ? login : void 0);
1376
+ if (!email) throw new Error("Could not determine the account email; use your full email address as the username");
1377
+ return { email };
1378
+ }
1379
+
1380
+ // ../../packages/core/src/providers/google/oauth.ts
1381
+ import { createHash as createHash2, randomBytes } from "crypto";
1382
+ import { createServer } from "http";
1383
+ var AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
1384
+ var TOKEN_URL = "https://oauth2.googleapis.com/token";
1385
+ var SCOPES = "openid email https://www.googleapis.com/auth/calendar";
1386
+ var TIMEOUT_MS = 5 * 60 * 1e3;
1387
+ var clientConfig = {};
1388
+ function setClientConfig(cfg) {
1389
+ clientConfig = cfg;
1390
+ }
1391
+ function getClientConfig() {
1392
+ const { clientId, clientSecret } = clientConfig;
1393
+ if (!clientId || !clientSecret) throw new Error("Google sign-in is not configured in this build: add a CalDAV account, or build from source with MYSTICALS_GOOGLE_CLIENT_ID/SECRET set");
1394
+ return { clientId, clientSecret };
1395
+ }
1396
+ function createPkce() {
1397
+ const verifier = randomBytes(32).toString("base64url");
1398
+ return { verifier, challenge: createHash2("sha256").update(verifier).digest("base64url") };
1399
+ }
1400
+ function buildAuthUrl(p) {
1401
+ const url = new URL(AUTH_URL);
1402
+ url.search = new URLSearchParams({
1403
+ client_id: p.clientId,
1404
+ redirect_uri: p.redirectUri,
1405
+ response_type: "code",
1406
+ scope: SCOPES,
1407
+ state: p.state,
1408
+ code_challenge: p.codeChallenge,
1409
+ code_challenge_method: "S256",
1410
+ access_type: "offline",
1411
+ prompt: "consent"
1412
+ }).toString();
1413
+ return url.toString();
1414
+ }
1415
+ function emailFromIdToken(idToken) {
1416
+ try {
1417
+ const payload = JSON.parse(Buffer.from(idToken.split(".")[1], "base64url").toString("utf8"));
1418
+ return typeof payload.email === "string" ? payload.email : void 0;
1419
+ } catch {
1420
+ return void 0;
1421
+ }
1422
+ }
1423
+ function parseTokenResponse(json, now = Date.now()) {
1424
+ const j = json ?? {};
1425
+ if (typeof j.error === "string") throw new Error(`Google token error: ${j.error}${j.error_description ? ` (${j.error_description})` : ""}`);
1426
+ if (typeof j.access_token !== "string") throw new Error("Google token response has no access_token");
1427
+ const expiresIn = typeof j.expires_in === "number" ? j.expires_in : 3600;
1428
+ return {
1429
+ accessToken: j.access_token,
1430
+ expiresAt: now + expiresIn * 1e3,
1431
+ refreshToken: typeof j.refresh_token === "string" ? j.refresh_token : void 0,
1432
+ email: typeof j.id_token === "string" ? emailFromIdToken(j.id_token) : void 0
1433
+ };
1434
+ }
1435
+ async function postToken(params) {
1436
+ const res = await timedFetch(TOKEN_URL, {
1437
+ method: "POST",
1438
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1439
+ body: new URLSearchParams(params).toString()
1440
+ });
1441
+ const json = await res.json().catch(() => ({ error: `http_${res.status}` }));
1442
+ if (!res.ok && !json.error) json.error = `http_${res.status}`;
1443
+ return parseTokenResponse(json);
1444
+ }
1445
+ var DONE_HTML = (msg) => `<!doctype html><meta charset="utf-8"><title>Mysticals</title><body style="font:15px -apple-system,sans-serif;text-align:center;padding-top:80px"><h2>${msg}</h2><p>You can close this tab and return to Mysticals.</p></body>`;
1446
+ async function runOAuthFlow(openUrl3) {
1447
+ const cfg = getClientConfig();
1448
+ const { verifier, challenge } = createPkce();
1449
+ const state = randomBytes(16).toString("base64url");
1450
+ const server = createServer();
1451
+ try {
1452
+ await new Promise((resolve, reject) => {
1453
+ server.once("error", reject);
1454
+ server.listen(0, "127.0.0.1", resolve);
1455
+ });
1456
+ const redirectUri = `http://127.0.0.1:${server.address().port}`;
1457
+ const code = await new Promise((resolve, reject) => {
1458
+ const timer = setTimeout(() => reject(new Error("Google sign-in timed out")), TIMEOUT_MS);
1459
+ const finish = (err, value) => {
1460
+ clearTimeout(timer);
1461
+ if (err) reject(err);
1462
+ else resolve(value);
1463
+ };
1464
+ server.on("request", (req, res) => {
1465
+ const params = new URL(req.url ?? "/", redirectUri).searchParams;
1466
+ const reply = (status, msg, then) => {
1467
+ res.writeHead(status, { "Content-Type": "text/html; charset=utf-8", Connection: "close" });
1468
+ res.end(DONE_HTML(msg), then);
1469
+ };
1470
+ if (!params.has("code") && !params.has("error")) {
1471
+ res.writeHead(404, { Connection: "close" }).end();
1472
+ return;
1473
+ }
1474
+ if (params.get("state") !== state) {
1475
+ reply(400, "Sign-in failed", () => finish(new Error("Google sign-in failed: state mismatch")));
1476
+ } else if (params.has("error")) {
1477
+ reply(400, "Sign-in cancelled", () => finish(new Error(`Google sign-in failed: ${params.get("error")}`)));
1478
+ } else {
1479
+ const code2 = params.get("code");
1480
+ reply(200, "Signed in", () => finish(null, code2));
1481
+ }
1482
+ });
1483
+ openUrl3(buildAuthUrl({ clientId: cfg.clientId, redirectUri, state, codeChallenge: challenge })).catch(
1484
+ (e) => finish(e instanceof Error ? e : new Error(String(e)))
1485
+ );
1486
+ });
1487
+ const tok = await postToken({
1488
+ grant_type: "authorization_code",
1489
+ code,
1490
+ code_verifier: verifier,
1491
+ redirect_uri: redirectUri,
1492
+ client_id: cfg.clientId,
1493
+ client_secret: cfg.clientSecret
1494
+ });
1495
+ if (!tok.refreshToken) throw new Error("Google did not return a refresh token");
1496
+ const email = tok.email ?? await fetchUserEmail(tok.accessToken);
1497
+ return { email, credentials: { kind: "google", refreshToken: tok.refreshToken, accessToken: tok.accessToken, expiresAt: tok.expiresAt } };
1498
+ } finally {
1499
+ server.closeAllConnections();
1500
+ server.close();
1501
+ }
1502
+ }
1503
+ async function fetchUserEmail(accessToken) {
1504
+ const res = await timedFetch("https://openidconnect.googleapis.com/v1/userinfo", { headers: { Authorization: `Bearer ${accessToken}` } });
1505
+ const json = await res.json().catch(() => ({}));
1506
+ if (!res.ok || typeof json.email !== "string") throw new Error("Could not read Google account email");
1507
+ return json.email;
1508
+ }
1509
+
1510
+ // ../../packages/core/src/providers/google/provider.ts
1511
+ var API = "https://www.googleapis.com/calendar/v3";
1512
+ var STATUSES = ["accepted", "declined", "tentative", "needsAction"];
1513
+ var toStatus = (s) => STATUSES.includes(s) ? s : "needsAction";
1514
+ function mapEvent(g, accountId, calendarId) {
1515
+ const allDay = !!g.start.date;
1516
+ const time = (t) => allDay ? t.date : new Date(t.dateTime).toISOString();
1517
+ const attendees = (g.attendees ?? []).map((a) => ({
1518
+ email: a.email,
1519
+ name: a.displayName,
1520
+ status: toStatus(a.responseStatus),
1521
+ self: a.self || void 0,
1522
+ organizer: a.organizer || void 0
1523
+ }));
1524
+ const self = g.attendees?.find((a) => a.self);
1525
+ return {
1526
+ id: g.id,
1527
+ accountId,
1528
+ calendarId,
1529
+ title: g.summary ?? "",
1530
+ start: time(g.start),
1531
+ end: time(g.end),
1532
+ allDay,
1533
+ location: g.location,
1534
+ description: g.description,
1535
+ organizer: g.organizer?.email ? { email: g.organizer.email, name: g.organizer.displayName } : void 0,
1536
+ attendees,
1537
+ myStatus: self ? toStatus(self.responseStatus) : void 0,
1538
+ etag: g.etag,
1539
+ raw: g,
1540
+ recurringEventId: g.recurringEventId
1541
+ };
1542
+ }
1543
+ function truncateRecurrence(recurrence, cutoff) {
1544
+ const until = cutoff.date ? new Date(Date.parse(cutoff.date) - 864e5).toISOString().slice(0, 10).replace(/-/g, "") : new Date(Date.parse(cutoff.dateTime) - 1e3).toISOString().replace(/[-:]|\.\d{3}/g, "");
1545
+ return recurrence.map((line) => {
1546
+ if (!line.startsWith("RRULE:")) return line;
1547
+ const parts = line.slice(6).split(";").filter((p) => !/^(COUNT|UNTIL)=/i.test(p));
1548
+ return `RRULE:${[...parts, `UNTIL=${until}`].join(";")}`;
1549
+ });
1550
+ }
1551
+ var toGTime = (iso2, allDay) => allDay ? { date: iso2.slice(0, 10), dateTime: null, timeZone: null } : { dateTime: iso2, date: null, timeZone: null };
1552
+ function createGoogleProviderImpl(ctx) {
1553
+ if (ctx.credentials.kind !== "google") throw new Error("Google provider needs Google credentials");
1554
+ let creds = ctx.credentials;
1555
+ let refreshing;
1556
+ const refresh = () => refreshing ??= (async () => {
1557
+ const { clientId, clientSecret } = getClientConfig();
1558
+ const tok = await postToken({ grant_type: "refresh_token", refresh_token: creds.refreshToken, client_id: clientId, client_secret: clientSecret });
1559
+ creds = { kind: "google", refreshToken: tok.refreshToken ?? creds.refreshToken, accessToken: tok.accessToken, expiresAt: tok.expiresAt };
1560
+ await ctx.saveCredentials(creds);
1561
+ return tok.accessToken;
1562
+ })().finally(() => refreshing = void 0);
1563
+ const token = async () => creds.accessToken && (creds.expiresAt ?? 0) > Date.now() + 6e4 ? creds.accessToken : refresh();
1564
+ async function api(method, path, query = {}, body) {
1565
+ const qs = new URLSearchParams(query).toString();
1566
+ const url = `${API}${path}${qs ? `?${qs}` : ""}`;
1567
+ const send = async (accessToken) => timedFetch(url, {
1568
+ method,
1569
+ headers: { Authorization: `Bearer ${accessToken}`, ...body ? { "Content-Type": "application/json" } : {} },
1570
+ body: body ? JSON.stringify(body) : void 0
1571
+ });
1572
+ let res = await send(await token());
1573
+ if (res.status === 401) res = await send(await refresh());
1574
+ if (res.status === 204 || method === "DELETE" && res.status === 410) return void 0;
1575
+ if (!res.ok) {
1576
+ const err = await res.json().catch(() => ({}));
1577
+ throw new Error(`Google API ${res.status}: ${err.error?.message ?? res.statusText}`);
1578
+ }
1579
+ return await res.json();
1580
+ }
1581
+ const eventPath = (calendarId, eventId) => `/calendars/${encodeURIComponent(calendarId)}/events${eventId ? `/${encodeURIComponent(eventId)}` : ""}`;
1582
+ const rawOf = (e) => e.raw;
1583
+ const isOrganizer = (e) => {
1584
+ const org = rawOf(e)?.organizer;
1585
+ if (org?.self !== void 0) return org.self;
1586
+ return !e.organizer || e.organizer.email.toLowerCase() === ctx.email.toLowerCase();
1587
+ };
1588
+ const map = (g, calendarId) => mapEvent(g, ctx.accountId, calendarId);
1589
+ return {
1590
+ async listCalendars() {
1591
+ const out = [];
1592
+ let pageToken;
1593
+ do {
1594
+ const page = await api(
1595
+ "GET",
1596
+ "/users/me/calendarList",
1597
+ pageToken ? { pageToken } : {}
1598
+ );
1599
+ for (const c of page.items ?? [])
1600
+ out.push({
1601
+ id: c.id,
1602
+ accountId: ctx.accountId,
1603
+ name: c.summaryOverride ?? c.summary ?? c.id,
1604
+ color: c.backgroundColor ?? "#4285f4",
1605
+ readOnly: c.accessRole !== "owner" && c.accessRole !== "writer"
1606
+ });
1607
+ pageToken = page.nextPageToken;
1608
+ } while (pageToken);
1609
+ return out;
1610
+ },
1611
+ async listEvents(calendarId, range) {
1612
+ const out = [];
1613
+ let pageToken;
1614
+ do {
1615
+ const page = await api("GET", eventPath(calendarId), {
1616
+ singleEvents: "true",
1617
+ timeMin: new Date(range.start).toISOString(),
1618
+ timeMax: new Date(range.end).toISOString(),
1619
+ maxResults: "2500",
1620
+ ...pageToken ? { pageToken } : {}
1621
+ });
1622
+ for (const g of page.items ?? []) if (g.status !== "cancelled") out.push(map(g, calendarId));
1623
+ pageToken = page.nextPageToken;
1624
+ } while (pageToken);
1625
+ return out;
1626
+ },
1627
+ async createEvent(calendarId, input) {
1628
+ const attendees = (input.attendees ?? []).map((email) => ({ email }));
1629
+ const g = await api(
1630
+ "POST",
1631
+ eventPath(calendarId),
1632
+ { sendUpdates: attendees.length ? "all" : "none" },
1633
+ {
1634
+ summary: input.title,
1635
+ start: toGTime(input.start, input.allDay),
1636
+ end: toGTime(input.end, input.allDay),
1637
+ location: input.location,
1638
+ description: input.description,
1639
+ attendees
1640
+ }
1641
+ );
1642
+ return map(g, calendarId);
1643
+ },
1644
+ async updateEvent(event) {
1645
+ const organizer = isOrganizer(event);
1646
+ const body = {
1647
+ summary: event.title,
1648
+ start: toGTime(event.start, event.allDay),
1649
+ end: toGTime(event.end, event.allDay),
1650
+ location: event.location ?? null,
1651
+ description: event.description ?? null
1652
+ };
1653
+ if (organizer) {
1654
+ const prev = new Map((rawOf(event)?.attendees ?? []).map((a) => [a.email.toLowerCase(), a]));
1655
+ body.attendees = event.attendees.map((a) => prev.get(a.email.toLowerCase()) ?? { email: a.email, displayName: a.name });
1656
+ }
1657
+ const g = await api("PATCH", eventPath(event.calendarId, event.id), { sendUpdates: organizer ? "all" : "none" }, body);
1658
+ return map(g, event.calendarId);
1659
+ },
1660
+ async deleteEvent(event, scope = "one") {
1661
+ const q = { sendUpdates: isOrganizer(event) ? "all" : "none" };
1662
+ const series = event.recurringEventId;
1663
+ if (!series || scope === "one") return void await api("DELETE", eventPath(event.calendarId, event.id), q);
1664
+ const cutoff = rawOf(event)?.originalStartTime;
1665
+ if (scope === "following" && cutoff) {
1666
+ const master = await api("GET", eventPath(event.calendarId, series));
1667
+ const first = master.start.dateTime ?? master.start.date;
1668
+ const at3 = cutoff.dateTime ?? cutoff.date;
1669
+ if (Date.parse(at3) > Date.parse(first)) {
1670
+ const recurrence = truncateRecurrence(master.recurrence ?? [], cutoff);
1671
+ return void await api("PATCH", eventPath(event.calendarId, series), q, { recurrence });
1672
+ }
1673
+ }
1674
+ await api("DELETE", eventPath(event.calendarId, series), q);
1675
+ },
1676
+ async respond(event, status) {
1677
+ const attendees = rawOf(event)?.attendees ?? event.attendees.map((a) => ({ email: a.email, displayName: a.name, responseStatus: a.status, self: a.self, organizer: a.organizer }));
1678
+ if (!attendees.some((a) => a.self)) throw new Error("This account is not an attendee of the event");
1679
+ const g = await api(
1680
+ "PATCH",
1681
+ eventPath(event.calendarId, event.id),
1682
+ { sendUpdates: "all" },
1683
+ { attendees: attendees.map((a) => a.self ? { ...a, responseStatus: status } : a) }
1684
+ );
1685
+ return map(g, event.calendarId);
1686
+ }
1687
+ };
1688
+ }
1689
+
1690
+ // ../../packages/core/src/providers/google/index.ts
1691
+ var createGoogleProvider = createGoogleProviderImpl;
1692
+ async function googleSignIn(openUrl3) {
1693
+ return runOAuthFlow(openUrl3);
1694
+ }
1695
+
1696
+ // src/telemetry.ts
1697
+ var TELEMETRY_NOTICE = "Anonymous usage stats: 2 events \xB7 opt out: MYSTICALS_TELEMETRY=0";
1698
+ var telemetry = () => startTelemetry({ key: true ? "phc_uiJmu94yHJfh6cp5AdjUohhkqzCrysWzKqRZgfdH9iEL" : "", app: "terminal", version, dir: homeDir() });
1699
+
1700
+ // src/daemon/crypto.ts
1701
+ import { execFileSync, spawnSync } from "child_process";
1702
+ import { createCipheriv, createDecipheriv, randomBytes as randomBytes2 } from "crypto";
1703
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
1704
+ import { dirname, join as join5 } from "path";
1705
+ var SERVICE = "mysticals-terminal";
1706
+ var ACCOUNT = "master-key";
1707
+ var NOT_FOUND = 44;
1708
+ var TIMEOUT = 6e4;
1709
+ function keychain(service = SERVICE) {
1710
+ return {
1711
+ get() {
1712
+ const r = spawnSync("security", ["find-generic-password", "-s", service, "-a", ACCOUNT, "-w"], { encoding: "utf8", timeout: TIMEOUT });
1713
+ if (r.status === NOT_FOUND) return void 0;
1714
+ if (r.status !== 0) throw new Error(`Keychain read failed: ${r.error?.message ?? r.stderr?.trim()}`);
1715
+ return Buffer.from(r.stdout.trim(), "base64");
1716
+ },
1717
+ set(key3) {
1718
+ const cmd = `add-generic-password -U -s ${service} -a ${ACCOUNT} -w ${key3.toString("base64")}
1719
+ `;
1720
+ execFileSync("security", ["-i"], { input: cmd, stdio: ["pipe", "ignore", "pipe"], timeout: TIMEOUT });
1721
+ }
1722
+ };
1723
+ }
1724
+ var exec = (run, cmd, args, input) => run(cmd, args, { input, encoding: "utf8", timeout: TIMEOUT, windowsHide: true });
1725
+ function secretTool(service = SERVICE, run = spawnSync) {
1726
+ const attrs = ["service", service, "account", ACCOUNT];
1727
+ const fail = (what, r) => {
1728
+ throw new Error(
1729
+ `Secret Service ${what} failed (${r.error?.message ?? r.stderr?.trim()}); install libsecret-tools and run a keyring such as gnome-keyring`
1730
+ );
1731
+ };
1732
+ return {
1733
+ get() {
1734
+ const r = exec(run, "secret-tool", ["lookup", ...attrs]);
1735
+ if (r.status === 1 && !r.error && !r.stdout.trim() && !r.stderr.trim()) return void 0;
1736
+ if (r.status !== 0)
1737
+ throw new Error(
1738
+ `Secret Service unavailable (${r.error?.message ?? r.stderr?.trim()}); start or unlock GNOME Keyring or KWallet (and install libsecret-tools)`
1739
+ );
1740
+ return Buffer.from(r.stdout.trim(), "base64");
1741
+ },
1742
+ set(key3) {
1743
+ const r = exec(run, "secret-tool", ["store", "--label=mysticals terminal master key", ...attrs], key3.toString("base64"));
1744
+ if (r.status !== 0) fail("write", r);
1745
+ }
1746
+ };
1747
+ }
1748
+ var dpapiScript = (op) => `Add-Type -AssemblyName System.Security; $b = [Convert]::FromBase64String([Console]::In.ReadToEnd().Trim()); [Convert]::ToBase64String([Security.Cryptography.ProtectedData]::${op}($b, $null, 'CurrentUser'))`;
1749
+ function dpapi(file = join5(homeDir(), "master.key"), run = spawnSync) {
1750
+ const ps = (op, b64) => {
1751
+ const r = exec(run, "powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", dpapiScript(op)], b64);
1752
+ if (r.status !== 0) throw new Error(`DPAPI ${op} failed: ${r.error?.message ?? r.stderr?.trim()}`);
1753
+ return r.stdout.trim();
1754
+ };
1755
+ return {
1756
+ get() {
1757
+ if (!existsSync2(file)) return void 0;
1758
+ return Buffer.from(ps("Unprotect", readFileSync3(file, "utf8").trim()), "base64");
1759
+ },
1760
+ set(key3) {
1761
+ mkdirSync3(dirname(file), { recursive: true, mode: 448 });
1762
+ writeFileSync2(file, ps("Protect", key3.toString("base64")), { mode: 384 });
1763
+ }
1764
+ };
1765
+ }
1766
+ function platformKeys(platform = process.platform) {
1767
+ if (platform === "darwin") return keychain();
1768
+ if (platform === "linux") return secretTool();
1769
+ if (platform === "win32") return dpapi();
1770
+ return void 0;
1771
+ }
1772
+ var hasCredentials = (home) => {
1773
+ const dir = join5(home, "accounts");
1774
+ return existsSync2(dir) && readdirSync(dir).some((id2) => existsSync2(join5(dir, id2, "creds.bin")));
1775
+ };
1776
+ var VERSION = 1;
1777
+ var IV = 12;
1778
+ var TAG = 16;
1779
+ function createCrypto(store = platformKeys(), home) {
1780
+ if (!store) {
1781
+ const unavailable = () => {
1782
+ throw new Error(`Secure storage unavailable: no supported OS key store on ${process.platform}`);
1783
+ };
1784
+ return { encrypt: unavailable, decrypt: unavailable };
1785
+ }
1786
+ const keys = store;
1787
+ let cached2;
1788
+ const key3 = (create) => {
1789
+ if (cached2) return cached2;
1790
+ let k = keys.get();
1791
+ if (!k) {
1792
+ if (!create) throw new Error('Master key missing from the OS key store (service "mysticals-terminal"); re-add your accounts');
1793
+ if (home && hasCredentials(home))
1794
+ throw new Error(
1795
+ `Master key missing from the OS key store but saved credentials exist; unlock the key store, or remove all accounts (or ${join5(home, "accounts")}) to start over`
1796
+ );
1797
+ k = randomBytes2(32);
1798
+ keys.set(k);
1799
+ }
1800
+ if (k.length !== 32) throw new Error("Master key in the OS key store is corrupt");
1801
+ return cached2 = k;
1802
+ };
1803
+ return {
1804
+ encrypt(plain) {
1805
+ const iv = randomBytes2(IV);
1806
+ const c = createCipheriv("aes-256-gcm", key3(true), iv);
1807
+ const body = Buffer.concat([c.update(plain, "utf8"), c.final()]);
1808
+ return Buffer.concat([Buffer.from([VERSION]), iv, c.getAuthTag(), body]);
1809
+ },
1810
+ decrypt(data) {
1811
+ if (data.length < 1 + IV + TAG || data[0] !== VERSION) throw new Error("Unsupported credential format");
1812
+ const d = createDecipheriv("aes-256-gcm", key3(false), data.subarray(1, 1 + IV));
1813
+ d.setAuthTag(data.subarray(1 + IV, 1 + IV + TAG));
1814
+ return Buffer.concat([d.update(data.subarray(1 + IV + TAG)), d.final()]).toString("utf8");
1815
+ }
1816
+ };
1817
+ }
1818
+
1819
+ // src/daemon/google.ts
1820
+ import { execFile } from "child_process";
1821
+ var built = {
1822
+ clientId: true ? "199638928915-h7u8l4cbran2rmsalqplc5vlohll8d3l.apps.googleusercontent.com" : "",
1823
+ clientSecret: true ? "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" : ""
1824
+ };
1825
+ function googleConfig(env = process.env, defaults = built) {
1826
+ const id2 = env.MYSTICALS_GOOGLE_CLIENT_ID;
1827
+ const src = id2 ? { clientId: id2, clientSecret: env.MYSTICALS_GOOGLE_CLIENT_SECRET } : defaults;
1828
+ return { clientId: src.clientId || void 0, clientSecret: src.clientSecret || void 0 };
1829
+ }
1830
+ function openCommand(url, platform = process.platform) {
1831
+ if (platform === "darwin") return ["open", [url]];
1832
+ if (platform === "win32") return ["rundll32", ["url.dll,FileProtocolHandler", url]];
1833
+ return ["xdg-open", [url]];
1834
+ }
1835
+ var openUrl = (url) => new Promise((resolve, reject) => {
1836
+ const [file, args] = openCommand(url);
1837
+ execFile(file, args, (e) => e ? reject(e) : resolve());
1838
+ });
1839
+
1840
+ // src/daemon/notify.ts
1841
+ import { execFile as execFile2 } from "child_process";
1842
+ var SCRIPT = ["on run argv", "display notification (item 2 of argv) with title (item 1 of argv)", "end run"];
1843
+ var TOAST = [
1844
+ "$null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]",
1845
+ "$x = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)",
1846
+ '$t = $x.GetElementsByTagName("text")',
1847
+ "$null = $t.Item(0).AppendChild($x.CreateTextNode($env:MYSTICALS_NOTE_TITLE))",
1848
+ "$null = $t.Item(1).AppendChild($x.CreateTextNode($env:MYSTICALS_NOTE_BODY))",
1849
+ '$id = "{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe"',
1850
+ "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($id).Show([Windows.UI.Notifications.ToastNotification]::new($x))"
1851
+ ].join("; ");
1852
+ function bannerCommand(title2, body, platform = process.platform) {
1853
+ if (platform === "darwin") return { file: "osascript", args: [...SCRIPT.flatMap((l) => ["-e", l]), title2, body] };
1854
+ if (platform === "linux") return { file: "notify-send", args: ["--app-name=mysticals", "--", title2, body] };
1855
+ if (platform === "win32")
1856
+ return {
1857
+ file: "powershell.exe",
1858
+ args: ["-NoProfile", "-NonInteractive", "-Command", TOAST],
1859
+ env: { ...process.env, MYSTICALS_NOTE_TITLE: title2, MYSTICALS_NOTE_BODY: body }
1860
+ };
1861
+ return void 0;
1862
+ }
1863
+ function notify(store, accountId, notes, run = execFile2, platform = process.platform) {
1864
+ if (isMock()) return;
1865
+ const account = store.get(accountId);
1866
+ if (!account) return;
1867
+ const hidden = new Set(store.hiddenCalendars(accountId));
1868
+ const shown = notes.filter((n) => !hidden.has(n.event.calendarId));
1869
+ for (const { title: title2, body } of noteText(shown, account.label)) {
1870
+ const cmd = bannerCommand(title2, body, platform);
1871
+ if (!cmd) return;
1872
+ run(cmd.file, cmd.args, { env: cmd.env, windowsHide: true }, (e) => e && console.error("notification failed", e.message));
1873
+ }
1874
+ }
1875
+
1876
+ // src/daemon/triggers.ts
1877
+ var wakeTriggers = (tickMs = 3e4, slackMs = 6e4) => (fire) => {
1878
+ let last = Date.now();
1879
+ const timer = setInterval(() => {
1880
+ const now = Date.now();
1881
+ if (now - last > tickMs + slackMs) fire();
1882
+ last = now;
1883
+ }, tickMs);
1884
+ timer.unref();
1885
+ return () => clearInterval(timer);
1886
+ };
1887
+
1888
+ // src/daemon/server.ts
1889
+ var alive = (path) => new Promise((resolve) => {
1890
+ const s = createConnection2(path);
1891
+ s.once("connect", () => (s.destroy(), resolve(true)));
1892
+ s.once("error", () => resolve(false));
1893
+ });
1894
+ function listen(server, path) {
1895
+ return new Promise((resolve, reject) => {
1896
+ server.once("error", reject);
1897
+ server.listen(path, () => {
1898
+ server.off("error", reject);
1899
+ resolve();
1900
+ });
1901
+ });
1902
+ }
1903
+ async function serve(api, path, opts) {
1904
+ const clients = /* @__PURE__ */ new Set();
1905
+ let idle;
1906
+ const armIdle = () => {
1907
+ clearTimeout(idle);
1908
+ idle = setTimeout(opts.onIdle, opts.graceMs ?? 3e3);
1909
+ };
1910
+ const server = createServer2((sock) => {
1911
+ clients.add(sock);
1912
+ clearTimeout(idle);
1913
+ opts.onConnect?.();
1914
+ const send = (msg) => {
1915
+ if (!sock.destroyed) sock.write(encode(msg));
1916
+ };
1917
+ sock.on(
1918
+ "data",
1919
+ lineReader((msg) => {
1920
+ const { id: id2, method, params } = msg ?? {};
1921
+ if (typeof id2 !== "number") return;
1922
+ if (!isMethod(method)) return send({ id: id2, error: `Unknown method: ${String(method)}` });
1923
+ dispatch(api, method, Array.isArray(params) ? params : []).then(
1924
+ (result) => send({ id: id2, result }),
1925
+ (e) => send({ id: id2, error: e instanceof Error ? e.message : String(e) })
1926
+ );
1927
+ })
1928
+ );
1929
+ sock.on("error", () => sock.destroy());
1930
+ sock.on("close", () => {
1931
+ clients.delete(sock);
1932
+ if (clients.size === 0) armIdle();
1933
+ });
1934
+ });
1935
+ try {
1936
+ await listen(server, path);
1937
+ } catch (e) {
1938
+ if (e.code !== "EADDRINUSE" || await alive(path)) throw e;
1939
+ if (process.platform !== "win32") rmSync(path, { force: true });
1940
+ await listen(server, path);
1941
+ }
1942
+ server.on("error", (e) => console.error("daemon socket error", e));
1943
+ if (process.platform !== "win32") chmodSync(path, 384);
1944
+ armIdle();
1945
+ const push = (msg) => {
1946
+ const line = encode(msg);
1947
+ for (const c of clients) if (!c.destroyed) c.write(line);
1948
+ };
1949
+ return {
1950
+ server,
1951
+ push,
1952
+ broadcast: (accountId) => push({ event: "changed", accountId }),
1953
+ clientCount: () => clients.size,
1954
+ close: () => new Promise((resolve) => {
1955
+ clearTimeout(idle);
1956
+ for (const c of clients) c.destroy();
1957
+ server.close(() => resolve());
1958
+ })
1959
+ };
1960
+ }
1961
+ async function runDaemon() {
1962
+ const home = homeDir();
1963
+ mkdirSync4(home, { recursive: true, mode: 448 });
1964
+ let broadcast = (_accountId) => {
1965
+ };
1966
+ let push = (_msg) => {
1967
+ };
1968
+ let sync;
1969
+ let api;
1970
+ if (isMock()) {
1971
+ api = createMockApi((id2) => broadcast(id2));
1972
+ } else {
1973
+ setClientConfig(googleConfig());
1974
+ const signInBrowser = (url) => (push({ event: "authUrl", url }), openUrl(url).catch(() => {
1975
+ }));
1976
+ const factories = { caldav: createCaldavProvider, google: createGoogleProvider };
1977
+ const store = new AccountStore(home, factories, createCrypto(void 0, home));
1978
+ sync = new SyncEngine(store, (id2) => broadcast(id2), {
1979
+ triggers: wakeTriggers(),
1980
+ onEvents: (id2, notes) => notify(store, id2, notes),
1981
+ onSyncing: (id2) => broadcast(id2)
1982
+ // clients re-read accounts.list for the `syncing` flag
1983
+ });
1984
+ const track = telemetry()?.track;
1985
+ api = createApi(store, sync, {
1986
+ verifyCaldav,
1987
+ googleSignIn: () => googleSignIn(signInBrowser),
1988
+ onChanged: (id2) => broadcast(id2),
1989
+ onAccountAdded: (info) => track?.("account_added", info)
1990
+ });
1991
+ }
1992
+ let daemon;
1993
+ const shutdown = async () => {
1994
+ sync?.stop();
1995
+ await daemon?.close();
1996
+ process.exit(0);
1997
+ };
1998
+ try {
1999
+ daemon = await serve(api, socketPath(home), { onIdle: () => void shutdown(), onConnect: () => void sync?.syncNow() });
2000
+ } catch (e) {
2001
+ if (e.code === "EADDRINUSE") process.exit(0);
2002
+ throw e;
2003
+ }
2004
+ broadcast = daemon.broadcast;
2005
+ push = daemon.push;
2006
+ process.on("SIGTERM", () => void shutdown());
2007
+ process.on("SIGINT", () => void shutdown());
2008
+ sync?.start();
2009
+ }
2010
+
2011
+ // src/ui/hooks.ts
2012
+ import { createContext, useCallback, useContext, useEffect, useState } from "react";
2013
+ import { useStdout } from "ink";
2014
+ import { addDays as addDays3, addWeeks as addWeeks2, startOfDay as startOfDay2 } from "date-fns";
2015
+
2016
+ // ../../packages/core/src/logic/layout.ts
2017
+ import {
2018
+ addDays as addDays2,
2019
+ addMonths as addMonths2,
2020
+ addWeeks,
2021
+ format as format3,
2022
+ isSameDay as isSameDay2,
2023
+ parseISO as parseISO4,
2024
+ startOfDay,
2025
+ startOfMonth,
2026
+ startOfWeek
2027
+ } from "date-fns";
2028
+ var WEEK_STARTS_ON = 1;
2029
+ function eventBounds(e) {
2030
+ const start = parseISO4(e.start);
2031
+ let end = parseISO4(e.end);
2032
+ if (end <= start) end = e.allDay ? addDays2(start, 1) : new Date(start.getTime() + 6e4);
2033
+ return { start, end };
2034
+ }
2035
+ var isPast = (e, now) => eventBounds(e).end.getTime() <= +now;
2036
+ function overlapsDay(e, day) {
2037
+ const d0 = startOfDay(day);
2038
+ const { start, end } = eventBounds(e);
2039
+ return start < addDays2(d0, 1) && end > d0;
2040
+ }
2041
+ function eventsOnDay(events, day) {
2042
+ return events.filter((e) => overlapsDay(e, day)).sort((a, b) => {
2043
+ if (a.allDay !== b.allDay) return a.allDay ? -1 : 1;
2044
+ const x = eventBounds(a), y = eventBounds(b);
2045
+ return x.start.getTime() - y.start.getTime() || y.end.getTime() - x.end.getTime();
2046
+ });
2047
+ }
2048
+ function packColumns(items, minDur = 0) {
2049
+ const sorted = [...items].sort((a, b) => a.start - b.start || b.end - a.end);
2050
+ const out = [];
2051
+ let cluster = [];
2052
+ let colEnds = [];
2053
+ let clusterEnd = -Infinity;
2054
+ const flush = () => {
2055
+ for (const p of cluster) p.cols = colEnds.length;
2056
+ cluster = [];
2057
+ colEnds = [];
2058
+ };
2059
+ for (const it of sorted) {
2060
+ const end = Math.max(it.end, it.start + minDur);
2061
+ if (it.start >= clusterEnd) flush();
2062
+ let col = colEnds.findIndex((e) => e <= it.start);
2063
+ if (col < 0) col = colEnds.push(end) - 1;
2064
+ else colEnds[col] = end;
2065
+ clusterEnd = Math.max(clusterEnd, end);
2066
+ const p = { ...it, col, cols: 0 };
2067
+ cluster.push(p);
2068
+ out.push(p);
2069
+ }
2070
+ flush();
2071
+ return out;
2072
+ }
2073
+ var wallMinutes = (d) => d.getHours() * 60 + d.getMinutes();
2074
+ function layoutDay(events, day, minDur = 0) {
2075
+ const items = events.filter((e) => !e.allDay && overlapsDay(e, day)).map((e) => {
2076
+ const { start, end } = eventBounds(e);
2077
+ return {
2078
+ item: e,
2079
+ start: isSameDay2(start, day) ? wallMinutes(start) : 0,
2080
+ end: isSameDay2(end, day) ? wallMinutes(end) : 1440
2081
+ };
2082
+ });
2083
+ return packColumns(items, minDur);
2084
+ }
2085
+ function monthGrid(date) {
2086
+ const first = startOfWeek(startOfMonth(date), { weekStartsOn: WEEK_STARTS_ON });
2087
+ return Array.from({ length: 42 }, (_, i) => addDays2(first, i));
2088
+ }
2089
+ function viewDays(view, date) {
2090
+ if (view === "month") return monthGrid(date);
2091
+ if (view === "day") return [startOfDay(date)];
2092
+ if (view === "3day") return [0, 1, 2].map((i) => addDays2(startOfDay(date), i));
2093
+ const first = startOfWeek(date, { weekStartsOn: WEEK_STARTS_ON });
2094
+ return Array.from({ length: 7 }, (_, i) => addDays2(first, i));
2095
+ }
2096
+ function viewRange(view, date) {
2097
+ const days = viewDays(view, date);
2098
+ return { start: days[0].toISOString(), end: addDays2(days[days.length - 1], 1).toISOString() };
2099
+ }
2100
+ function shiftDate(view, date, dir) {
2101
+ if (view === "day") return addDays2(date, dir);
2102
+ if (view === "3day") return addDays2(date, 3 * dir);
2103
+ if (view === "week") return addWeeks(date, dir);
2104
+ return addMonths2(date, dir);
2105
+ }
2106
+ function rangeLabel(a, b) {
2107
+ const head = a.getFullYear() !== b.getFullYear() ? "d MMM yyyy" : a.getMonth() !== b.getMonth() ? "d MMM" : "d";
2108
+ return `${format3(a, head)} \u2013 ${format3(b, "d MMM yyyy")}`;
2109
+ }
2110
+ var ymd = (d) => format3(d, "yyyy-MM-dd");
2111
+
2112
+ // ../../packages/core/src/logic/visible.ts
2113
+ var key2 = (accountId, calendarId) => `${accountId}/${calendarId}`;
2114
+ var visibleEvents = (events, calendars) => {
2115
+ const hidden = new Set(calendars.filter((c) => c.visible === false).map((c) => key2(c.accountId, c.id)));
2116
+ return hidden.size ? events.filter((e) => !hidden.has(key2(e.accountId, e.calendarId))) : events;
2117
+ };
2118
+
2119
+ // src/ui/hooks.ts
2120
+ var ApiContext = createContext(null);
2121
+ function useApi() {
2122
+ const api = useContext(ApiContext);
2123
+ if (!api) throw new Error("useApi outside <ApiContext.Provider>");
2124
+ return api;
2125
+ }
2126
+ var eventKey = (e) => `${e.accountId}/${e.calendarId}/${e.id}`;
2127
+ function useLive(load, deps) {
2128
+ const api = useApi();
2129
+ const [state, setState] = useState({});
2130
+ const [bump, setBump] = useState(0);
2131
+ useEffect(() => {
2132
+ let seq = 0;
2133
+ let alive2 = true;
2134
+ const run = () => {
2135
+ const my = ++seq;
2136
+ load(api).then(
2137
+ (data) => alive2 && my === seq && setState({ data }),
2138
+ (e) => alive2 && my === seq && setState((s) => ({ ...s, error: e instanceof Error ? e.message : String(e) }))
2139
+ );
2140
+ };
2141
+ run();
2142
+ const off = api.onChanged(run);
2143
+ return () => {
2144
+ alive2 = false;
2145
+ off();
2146
+ };
2147
+ }, [api, bump, ...deps]);
2148
+ return { ...state, reload: useCallback(() => setBump((b) => b + 1), []) };
2149
+ }
2150
+ function useDirectory() {
2151
+ const { data, error, reload } = useLive((api) => Promise.all([api.accounts.list(), api.calendars.list()]), []);
2152
+ return { accounts: data?.[0] ?? [], calendars: data?.[1] ?? [], loaded: !!data, error, reload };
2153
+ }
2154
+ function useEvents(range) {
2155
+ const { data, error, reload } = useLive(
2156
+ async (api) => {
2157
+ const [events, calendars] = await Promise.all([api.events.list(range), api.calendars.list()]);
2158
+ return { events: visibleEvents(events, calendars), for: `${range.start}/${range.end}` };
2159
+ },
2160
+ [range.start, range.end]
2161
+ );
2162
+ return { events: data?.events ?? [], loaded: !!data, loadedFor: data?.for, error, reload };
2163
+ }
2164
+ var AGENDA_DAYS = 14;
2165
+ function navRange({ view, date }) {
2166
+ if (view === "agenda" || view === "2day") {
2167
+ const start = startOfDay2(date);
2168
+ return { start: start.toISOString(), end: addDays3(start, view === "agenda" ? AGENDA_DAYS : 2).toISOString() };
2169
+ }
2170
+ return viewRange(view, date);
2171
+ }
2172
+ var shiftNav = ({ view, date }, dir) => ({
2173
+ view,
2174
+ date: view === "agenda" ? addWeeks2(date, dir) : view === "2day" ? addDays3(date, 2 * dir) : shiftDate(view, date, dir)
2175
+ });
2176
+ function useNav(initial = { view: "agenda", date: /* @__PURE__ */ new Date() }) {
2177
+ const [nav, setNav] = useState(initial);
2178
+ return {
2179
+ nav,
2180
+ setView: useCallback((view) => setNav((n) => ({ ...n, view })), []),
2181
+ setDate: useCallback((date) => setNav((n) => ({ ...n, date })), []),
2182
+ shift: useCallback((dir) => setNav((n) => shiftNav(n, dir)), []),
2183
+ today: useCallback(() => setNav((n) => ({ ...n, date: /* @__PURE__ */ new Date() })), [])
2184
+ };
2185
+ }
2186
+ function useNow(ms = 3e4) {
2187
+ const [now, setNow] = useState(() => /* @__PURE__ */ new Date());
2188
+ useEffect(() => {
2189
+ const t = setInterval(() => setNow(/* @__PURE__ */ new Date()), ms);
2190
+ return () => clearInterval(t);
2191
+ }, [ms]);
2192
+ return now;
2193
+ }
2194
+ function useTermSize() {
2195
+ const { stdout } = useStdout();
2196
+ const read = () => ({ width: stdout.columns || 80, height: stdout.rows || 24 });
2197
+ const [size, setSize] = useState(read);
2198
+ useEffect(() => {
2199
+ const on = () => setSize(read());
2200
+ stdout.on("resize", on);
2201
+ return () => void stdout.off("resize", on);
2202
+ }, [stdout]);
2203
+ return size;
2204
+ }
2205
+
2206
+ // src/ui/App.tsx
2207
+ import { useEffect as useEffect6, useMemo as useMemo6, useRef as useRef7, useState as useState8 } from "react";
2208
+ import { Box as Box11, Text as Text11, useApp, useStdin as useStdin2 } from "ink";
2209
+ import { addDays as addDays9, differenceInMinutes as differenceInMinutes3, format as format11, isSameMonth as isSameMonth2, max, set, startOfDay as startOfDay7 } from "date-fns";
2210
+
2211
+ // ../../packages/core/src/logic/editor.ts
2212
+ import { addDays as addDays4, addHours, format as format4, parseISO as parseISO5, startOfHour } from "date-fns";
2213
+ var LOCAL = "yyyy-MM-dd'T'HH:mm";
2214
+ var toLocalInput = (iso2) => format4(iso2.length === 10 ? parseISO5(iso2) : new Date(iso2), LOCAL);
2215
+ var fromLocalInput = (local) => new Date(local).toISOString();
2216
+ var writableCalendars = (calendars, accountId) => calendars.filter((c) => c.accountId === accountId && !c.readOnly);
2217
+ var writableAccounts = (accounts, calendars) => accounts.filter((a) => writableCalendars(calendars, a.id).length > 0);
2218
+ var soleId = (items) => items.length === 1 ? items[0].id : "";
2219
+ var EMAIL = /^[^\s@,;]+@[^\s@,;]+\.[^\s@,;]+$/;
2220
+ var splitEmails = (text2) => text2.split(/[\s,;]+/).map((s) => s.trim()).filter(Boolean);
2221
+ var isEmail = (s) => EMAIL.test(s);
2222
+ function emptyForm(accounts, calendars, prefill = {}, now = /* @__PURE__ */ new Date()) {
2223
+ const accountId = soleId(writableAccounts(accounts, calendars));
2224
+ const start = prefill.start ? toLocalInput(prefill.start) : format4(startOfHour(addHours(now, 1)), LOCAL);
2225
+ let end = prefill.end ? toLocalInput(prefill.end) : format4(addHours(new Date(start), 1), LOCAL);
2226
+ if (prefill.allDay && prefill.end) end = inclusiveEnd(start, end);
2227
+ return {
2228
+ accountId,
2229
+ calendarId: accountId ? soleId(writableCalendars(calendars, accountId)) : "",
2230
+ title: "",
2231
+ start,
2232
+ end,
2233
+ allDay: !!prefill.allDay,
2234
+ location: "",
2235
+ description: "",
2236
+ attendees: []
2237
+ };
2238
+ }
2239
+ var inclusiveEnd = (startLocal, endLocal) => {
2240
+ const d = format4(addDays4(new Date(endLocal), -1), LOCAL);
2241
+ return d.slice(0, 10) < startLocal.slice(0, 10) ? startLocal : d;
2242
+ };
2243
+ function formFromEvent(e) {
2244
+ const start = toLocalInput(e.start);
2245
+ const endLocal = toLocalInput(e.end);
2246
+ return {
2247
+ accountId: e.accountId,
2248
+ calendarId: e.calendarId,
2249
+ title: e.title,
2250
+ start,
2251
+ end: e.allDay ? inclusiveEnd(start, endLocal) : endLocal,
2252
+ allDay: e.allDay,
2253
+ location: e.location ?? "",
2254
+ description: e.description ?? "",
2255
+ attendees: e.attendees.filter((a) => !a.self && !a.organizer).map((a) => a.email)
2256
+ };
2257
+ }
2258
+ function setAllDay(f, allDay) {
2259
+ if (allDay || f.end > f.start) return { ...f, allDay };
2260
+ return { ...f, allDay, end: format4(addHours(new Date(f.start), 1), LOCAL) };
2261
+ }
2262
+ function moveStart(f, start) {
2263
+ const end = new Date(new Date(f.end).getTime() + new Date(start).getTime() - new Date(f.start).getTime());
2264
+ return { ...f, start, end: format4(end, LOCAL) };
2265
+ }
2266
+ function times(f) {
2267
+ if (f.allDay) {
2268
+ const start2 = f.start.slice(0, 10);
2269
+ const endIncl = f.end.slice(0, 10);
2270
+ if (endIncl < start2) throw new Error("End date is before start date");
2271
+ return { allDay: true, start: start2, end: format4(addDays4(parseISO5(endIncl), 1), "yyyy-MM-dd") };
2272
+ }
2273
+ const start = fromLocalInput(f.start);
2274
+ const end = fromLocalInput(f.end);
2275
+ if (end <= start) throw new Error("End must be after start");
2276
+ return { allDay: false, start, end };
2277
+ }
2278
+ function checkEmails(list) {
2279
+ const bad = list.filter((e) => !isEmail(e));
2280
+ if (bad.length) throw new Error(`Invalid email: ${bad.join(", ")}`);
2281
+ return [...new Set(list.map((e) => e.toLowerCase()))];
2282
+ }
2283
+ function formToInput(f) {
2284
+ if (!f.accountId) throw new Error("Choose an account");
2285
+ if (!f.calendarId) throw new Error("Choose a calendar");
2286
+ const attendees = checkEmails(f.attendees);
2287
+ return {
2288
+ accountId: f.accountId,
2289
+ calendarId: f.calendarId,
2290
+ title: f.title.trim() || "New Event",
2291
+ ...times(f),
2292
+ location: f.location.trim() || void 0,
2293
+ description: f.description.trim() || void 0,
2294
+ ...attendees.length ? { attendees } : {}
2295
+ };
2296
+ }
2297
+ function applyForm(e, f) {
2298
+ const emails = checkEmails(f.attendees);
2299
+ const keep = e.attendees.filter((a) => a.self || a.organizer);
2300
+ const byEmail = new Map(e.attendees.map((a) => [a.email.toLowerCase(), a]));
2301
+ const invited = emails.filter((m) => !keep.some((a) => a.email.toLowerCase() === m)).map((m) => byEmail.get(m) ?? { email: m, status: "needsAction" });
2302
+ return {
2303
+ ...e,
2304
+ title: f.title.trim() || "New Event",
2305
+ ...times(f),
2306
+ location: f.location.trim() || void 0,
2307
+ description: f.description.trim() || void 0,
2308
+ attendees: [...keep, ...invited]
2309
+ };
2310
+ }
2311
+ var errorText = (e) => String(e instanceof Error ? e.message : e).replace(/^Error invoking remote method '[^']+': (\w*Error: )?/, "");
2312
+
2313
+ // src/ui/mouse.tsx
2314
+ import { createContext as createContext2, useCallback as useCallback2, useContext as useContext2, useEffect as useEffect2, useLayoutEffect, useRef } from "react";
2315
+
2316
+ // src/ui/theme.ts
2317
+ var C = {
2318
+ red: "red",
2319
+ green: "green",
2320
+ yellow: "yellow",
2321
+ blue: "blue",
2322
+ magenta: "magenta",
2323
+ cyan: "cyan",
2324
+ /** Active tab, keycaps, focus. */
2325
+ accent: "magenta",
2326
+ /** Current time: clock labels, now-lines, today. */
2327
+ now: "red",
2328
+ /** Secondary text. */
2329
+ muted: "gray"
2330
+ };
2331
+ function ansiOf(hex) {
2332
+ const m = /^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(hex);
2333
+ if (!m) return C.muted;
2334
+ const [r, g, b] = m.slice(1).map((x) => parseInt(x, 16) / 255);
2335
+ const max2 = Math.max(r, g, b);
2336
+ const d = max2 - Math.min(r, g, b);
2337
+ if (d < 0.15) return max2 > 0.8 ? "white" : C.muted;
2338
+ const h = (max2 === r ? ((g - b) / d + 6) % 6 : max2 === g ? (b - r) / d + 2 : (r - g) / d + 4) * 60;
2339
+ if (h < 15 || h >= 345) return "red";
2340
+ if (h < 45) return "yellowBright";
2341
+ if (h < 75) return "yellow";
2342
+ if (h < 165) return "green";
2343
+ if (h < 200) return "cyan";
2344
+ if (h < 250) return "blue";
2345
+ if (h < 300) return "magenta";
2346
+ return "magentaBright";
2347
+ }
2348
+
2349
+ // src/ui/mouse.tsx
2350
+ import { Box, Text, useInput, useStdin } from "ink";
2351
+ import { jsx, jsxs } from "react/jsx-runtime";
2352
+ var REPORT = /^\[<(\d+);(\d+);(\d+)([Mm])$/;
2353
+ var isMouse = (input) => REPORT.test(input);
2354
+ function useKeys(handler, options) {
2355
+ const latest = useRef(handler);
2356
+ useLayoutEffect(() => void (latest.current = handler));
2357
+ const stable = useCallback2((input, key3) => {
2358
+ if (!isMouse(input)) latest.current(input, key3);
2359
+ }, []);
2360
+ useInput(stable, options);
2361
+ }
2362
+ var Regions = createContext2(null);
2363
+ function rect(el) {
2364
+ if (!el.yogaNode) return;
2365
+ let x = 0;
2366
+ let y = 0;
2367
+ for (let n = el; n?.yogaNode; n = n.parentNode) {
2368
+ x += n.yogaNode.getComputedLeft();
2369
+ y += n.yogaNode.getComputedTop();
2370
+ }
2371
+ return { x, y, w: el.yogaNode.getComputedWidth(), h: el.yogaNode.getComputedHeight() };
2372
+ }
2373
+ function Dispatcher({ regions }) {
2374
+ const { isRawModeSupported } = useStdin();
2375
+ useInput((input) => {
2376
+ const m = REPORT.exec(input);
2377
+ if (!m || m[4] !== "M") return;
2378
+ const b = Number(m[1]);
2379
+ const x = Number(m[2]) - 1;
2380
+ const y = Number(m[3]) - 1;
2381
+ const wheel = b & 64 ? (b & 1) === 0 ? -1 : 1 : 0;
2382
+ if (!wheel && (b & ~(4 | 8 | 16)) !== 0) return;
2383
+ let best;
2384
+ for (const r of regions) {
2385
+ const h = r.handlers.current;
2386
+ if (!(wheel ? h.onWheel : h.onClick) || !r.node.current) continue;
2387
+ const box = rect(r.node.current);
2388
+ if (!box || x < box.x || y < box.y || x >= box.x + box.w || y >= box.y + box.h) continue;
2389
+ if (!best || box.w * box.h < best.area) best = { h, area: box.w * box.h };
2390
+ }
2391
+ if (wheel) best?.h.onWheel?.(wheel);
2392
+ else best?.h.onClick?.();
2393
+ }, { isActive: isRawModeSupported === true });
2394
+ return null;
2395
+ }
2396
+ function MouseProvider({ children }) {
2397
+ const regions = useRef(/* @__PURE__ */ new Set()).current;
2398
+ return /* @__PURE__ */ jsxs(Regions.Provider, { value: regions, children: [
2399
+ /* @__PURE__ */ jsx(Dispatcher, { regions }),
2400
+ children
2401
+ ] });
2402
+ }
2403
+ function Clickable({ onClick, onWheel, children, ...box }) {
2404
+ const node = useRef(null);
2405
+ const handlers = useRef({});
2406
+ handlers.current = { onClick, onWheel };
2407
+ const regions = useContext2(Regions);
2408
+ useEffect2(() => {
2409
+ if (!regions) return;
2410
+ const r = { node, handlers };
2411
+ regions.add(r);
2412
+ return () => void regions.delete(r);
2413
+ }, [regions]);
2414
+ return /* @__PURE__ */ jsx(Box, { ref: node, ...box, children });
2415
+ }
2416
+ function Button({ k, label, onPress, color: color2 = C.accent }) {
2417
+ return /* @__PURE__ */ jsx(Clickable, { onClick: onPress, marginRight: 2, flexShrink: 0, children: /* @__PURE__ */ jsxs(Text, { children: [
2418
+ /* @__PURE__ */ jsx(Text, { inverse: true, color: color2, bold: true, children: k }),
2419
+ " ",
2420
+ label
2421
+ ] }) });
2422
+ }
2423
+
2424
+ // src/ui/views/Agenda.tsx
2425
+ import { useMemo, useReducer, useRef as useRef2 } from "react";
2426
+ import { Box as Box3, Text as Text3 } from "ink";
2427
+ import { addDays as addDays6, differenceInCalendarDays, differenceInMinutes as differenceInMinutes2, format as format7, isSameDay as isSameDay4, startOfDay as startOfDay3 } from "date-fns";
2428
+
2429
+ // ../../packages/core/src/logic/details.ts
2430
+ import { addDays as addDays5, format as format5, isSameDay as isSameDay3, parseISO as parseISO6 } from "date-fns";
2431
+ var same = (a, b) => !!a && !!b && a.trim().toLowerCase() === b.trim().toLowerCase();
2432
+ function ownerLine(calendar, label, email) {
2433
+ return { calendar: same(calendar, label) || same(calendar, email) ? void 0 : calendar, label, email: same(email, label) ? void 0 : email };
2434
+ }
2435
+ function canEdit(e, account, calendar) {
2436
+ if (!account || !calendar || calendar.readOnly) return false;
2437
+ if (e.attendees.length === 0) return true;
2438
+ return same(e.organizer?.email, account.email) || e.attendees.some((a) => a.self && a.organizer);
2439
+ }
2440
+ var at2 = (iso2, allDay) => (allDay ? parseISO6(iso2) : new Date(iso2)).getTime();
2441
+ var pendingInvites = (events, now = /* @__PURE__ */ new Date()) => events.filter((e) => e.myStatus === "needsAction" && at2(e.end, e.allDay) > now.getTime()).sort((a, b) => at2(a.start, a.allDay) - at2(b.start, b.allDay));
2442
+ function formatWhen(e) {
2443
+ if (e.allDay) {
2444
+ const s2 = parseISO6(e.start);
2445
+ const last = addDays5(parseISO6(e.end), -1);
2446
+ return last > s2 ? `${format5(s2, "EEE, d MMM")} \u2013 ${format5(last, "EEE, d MMM")} \xB7 all day` : `${format5(s2, "EEE, d MMM")} \xB7 all day`;
2447
+ }
2448
+ const s = new Date(e.start);
2449
+ const t = new Date(e.end);
2450
+ return isSameDay3(s, t) ? `${format5(s, "EEE, d MMM")} \xB7 ${format5(s, "HH:mm")} \u2013 ${format5(t, "HH:mm")}` : `${format5(s, "EEE, d MMM HH:mm")} \u2013 ${format5(t, "EEE, d MMM HH:mm")}`;
2451
+ }
2452
+ var STATUS_ICON = {
2453
+ accepted: "\u2713",
2454
+ tentative: "?",
2455
+ declined: "\u2715",
2456
+ needsAction: "\u2022"
2457
+ };
2458
+ var cleanNotes = (s = "") => s.replace(/^@color(hex)?:.*$\n?/gim, "").trim();
2459
+
2460
+ // ../../packages/core/src/logic/meeting.ts
2461
+ function meetingUrl(location) {
2462
+ return location?.match(/https?:\/\/[^\s<>"]+/i)?.[0].replace(/[.,;:!?)\]}'>]+$/, "");
2463
+ }
2464
+
2465
+ // ../../packages/core/src/logic/status.ts
2466
+ import { differenceInMinutes, format as format6 } from "date-fns";
2467
+ function pickNowNext(events, now) {
2468
+ const t = now.getTime();
2469
+ const timed = events.filter((e) => !e.allDay && e.myStatus !== "declined").sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
2470
+ return {
2471
+ current: timed.filter((e) => Date.parse(e.start) <= t && t < Date.parse(e.end)),
2472
+ next: timed.find((e) => Date.parse(e.start) > t)
2473
+ };
2474
+ }
2475
+ function startsLabel(start, now) {
2476
+ const s = new Date(start);
2477
+ const m = differenceInMinutes(s, now, { roundingMethod: "ceil" });
2478
+ return m <= 60 ? `in ${m}m` : `at ${format6(s, "HH:mm")}`;
2479
+ }
2480
+
2481
+ // src/ui/screens/EventDetails.tsx
2482
+ import { useState as useState2 } from "react";
2483
+ import { Box as Box2, Text as Text2 } from "ink";
2484
+ import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
2485
+ var REPLY_KEYS = { y: "accepted", n: "declined", m: "tentative" };
2486
+ var SCOPE_KEYS = { "1": "one", "2": "following", "3": "all" };
2487
+ var STATUS_LABEL = { accepted: "accepted", declined: "declined", tentative: "maybe", needsAction: "not answered" };
2488
+ var STATUS_COLOR = { accepted: C.green, declined: C.red, tentative: C.yellow, needsAction: C.muted };
2489
+ var openUrl2 = (url) => void openUrl(url).catch(() => {
2490
+ });
2491
+ function EventDetails({ event: initial, onClose, onEdit }) {
2492
+ const api = useApi();
2493
+ const { accounts, calendars } = useDirectory();
2494
+ const [event, setEvent] = useState2(initial);
2495
+ const [busy, setBusy] = useState2();
2496
+ const [error, setError] = useState2("");
2497
+ const [confirm, setConfirm] = useState2(false);
2498
+ const [showAll, setShowAll] = useState2(false);
2499
+ const manyAttendees = initial.attendees.length > ATTENDEES_SHOWN;
2500
+ const account = accounts.find((a) => a.id === event.accountId);
2501
+ const calendar = calendars.find((c) => c.accountId === event.accountId && c.id === event.calendarId);
2502
+ const editable = canEdit(event, account, calendar);
2503
+ const recurring = !!event.recurringEventId;
2504
+ const url = meetingUrl(event.location);
2505
+ const now = useNow();
2506
+ const run = (label, fn) => {
2507
+ setBusy(label);
2508
+ setError("");
2509
+ fn().then(
2510
+ () => setBusy(void 0),
2511
+ (e) => {
2512
+ setBusy(void 0);
2513
+ setError(errorText(e));
2514
+ }
2515
+ );
2516
+ };
2517
+ const respond = (status) => run("Replying\u2026", async () => setEvent(await api.events.respond(event, status)));
2518
+ const remove = (scope) => run("Deleting\u2026", async () => {
2519
+ await api.events.delete(event, scope);
2520
+ onClose();
2521
+ });
2522
+ useKeys((input, key3) => {
2523
+ if (key3.escape && confirm) return setConfirm(false);
2524
+ if (key3.escape || input === "q") return onClose();
2525
+ if (busy) return;
2526
+ if (confirm) {
2527
+ if (recurring && Object.hasOwn(SCOPE_KEYS, input)) return remove(SCOPE_KEYS[input]);
2528
+ if (!recurring && (input === "y" || key3.return)) return remove("one");
2529
+ if (input === "n") setConfirm(false);
2530
+ return;
2531
+ }
2532
+ if (event.myStatus && Object.hasOwn(REPLY_KEYS, input)) return respond(REPLY_KEYS[input]);
2533
+ if (input === "o" && url) return openUrl2(url);
2534
+ if (input === "e" && editable) return onEdit(event);
2535
+ if (input === "x" && editable) return setConfirm(true);
2536
+ if (input === "a" && manyAttendees) return setShowAll(!showAll);
2537
+ });
2538
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", borderStyle: "round", borderColor: C.muted, paddingX: 1, children: [
2539
+ /* @__PURE__ */ jsx2(EventInfo, { event, account, calendar, now, showAll, onToggleAll: () => setShowAll(!showAll) }),
2540
+ error && /* @__PURE__ */ jsx2(Text2, { color: C.red, children: error }),
2541
+ /* @__PURE__ */ jsx2(Box2, { marginTop: 1, flexWrap: "wrap", children: busy ? /* @__PURE__ */ jsx2(Text2, { color: C.yellow, children: busy }) : confirm && recurring ? /* @__PURE__ */ jsxs2(Fragment, { children: [
2542
+ /* @__PURE__ */ jsx2(Text2, { color: C.red, children: "Delete recurring event: " }),
2543
+ /* @__PURE__ */ jsx2(Button, { k: "1", label: "this event", color: C.red, onPress: () => remove("one") }),
2544
+ /* @__PURE__ */ jsx2(Button, { k: "2", label: "this and following", color: C.red, onPress: () => remove("following") }),
2545
+ /* @__PURE__ */ jsx2(Button, { k: "3", label: "all events", color: C.red, onPress: () => remove("all") }),
2546
+ /* @__PURE__ */ jsx2(Button, { k: "esc", label: "cancel", onPress: () => setConfirm(false) })
2547
+ ] }) : confirm ? /* @__PURE__ */ jsxs2(Fragment, { children: [
2548
+ /* @__PURE__ */ jsx2(Text2, { color: C.red, children: "Delete this event? " }),
2549
+ /* @__PURE__ */ jsx2(Button, { k: "y", label: "yes", color: C.red, onPress: () => remove("one") }),
2550
+ /* @__PURE__ */ jsx2(Button, { k: "n", label: "no", onPress: () => setConfirm(false) })
2551
+ ] }) : /* @__PURE__ */ jsxs2(Fragment, { children: [
2552
+ event.myStatus && /* @__PURE__ */ jsx2(Button, { k: "y", label: "accept", color: C.green, onPress: () => respond("accepted") }),
2553
+ event.myStatus && /* @__PURE__ */ jsx2(Button, { k: "n", label: "decline", color: C.red, onPress: () => respond("declined") }),
2554
+ event.myStatus && /* @__PURE__ */ jsx2(Button, { k: "m", label: "maybe", color: C.yellow, onPress: () => respond("tentative") }),
2555
+ url && /* @__PURE__ */ jsx2(Button, { k: "o", label: "open link", onPress: () => openUrl2(url) }),
2556
+ editable && /* @__PURE__ */ jsx2(Button, { k: "e", label: "edit", onPress: () => onEdit(event) }),
2557
+ editable && /* @__PURE__ */ jsx2(Button, { k: "x", label: "delete", onPress: () => setConfirm(true) }),
2558
+ manyAttendees && /* @__PURE__ */ jsx2(Button, { k: "a", label: showAll ? "fewer attendees" : "all attendees", onPress: () => setShowAll(!showAll) }),
2559
+ /* @__PURE__ */ jsx2(Button, { k: "esc", label: "close", onPress: onClose })
2560
+ ] }) })
2561
+ ] });
2562
+ }
2563
+ function duration(e) {
2564
+ if (e.allDay) return "";
2565
+ const m = Math.round((Date.parse(e.end) - Date.parse(e.start)) / 6e4);
2566
+ const h = Math.floor(m / 60);
2567
+ return h ? m % 60 ? `${h}h ${m % 60}m` : `${h}h` : `${m}m`;
2568
+ }
2569
+ function relative(e, now) {
2570
+ if (e.allDay) return "";
2571
+ const { start, end } = eventBounds(e);
2572
+ if (end <= now) return "ended";
2573
+ if (start <= now) return "now";
2574
+ return +start - +now < 24 * 36e5 ? startsLabel(e.start, now) : "";
2575
+ }
2576
+ var tally = (attendees) => ["accepted", "tentative", "declined", "needsAction"].map((s) => [attendees.filter((a) => a.status === s).length, s]).filter(([n]) => n).map(([n, s]) => `${n} ${STATUS_LABEL[s]}`).join(", ");
2577
+ var ATTENDEES_SHOWN = 5;
2578
+ function EventInfo({ event, account, calendar, now, showAll, onToggleAll }) {
2579
+ const url = meetingUrl(event.location);
2580
+ const notes = cleanNotes(event.description);
2581
+ const owner = ownerLine(calendar?.name ?? "Calendar", account?.label ?? event.accountId, account?.email);
2582
+ const when2 = [duration(event), relative(event, now)].filter(Boolean).join(" \xB7 ");
2583
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
2584
+ /* @__PURE__ */ jsx2(Text2, { bold: true, children: event.title || "Untitled" }),
2585
+ /* @__PURE__ */ jsxs2(Text2, { children: [
2586
+ formatWhen(event),
2587
+ when2 && /* @__PURE__ */ jsxs2(Text2, { color: relative(event, now) === "now" ? C.green : C.muted, children: [
2588
+ " (",
2589
+ when2,
2590
+ ")"
2591
+ ] }),
2592
+ event.recurringEventId && /* @__PURE__ */ jsx2(Text2, { color: C.muted, children: " \u21BB repeats" })
2593
+ ] }),
2594
+ /* @__PURE__ */ jsxs2(Text2, { color: C.muted, children: [
2595
+ /* @__PURE__ */ jsx2(Text2, { color: ansiOf(calendar?.color ?? account?.color ?? ""), children: "\u25CF" }),
2596
+ " ",
2597
+ owner.calendar && `${owner.calendar} in `,
2598
+ /* @__PURE__ */ jsx2(Text2, { bold: true, children: owner.label }),
2599
+ owner.email && ` \xB7 ${owner.email}`,
2600
+ calendar?.readOnly && " \xB7 read-only"
2601
+ ] }),
2602
+ event.location && /* @__PURE__ */ jsxs2(Text2, { children: [
2603
+ "Location: ",
2604
+ event.location
2605
+ ] }),
2606
+ url && url !== event.location && /* @__PURE__ */ jsx2(Clickable, { onClick: () => openUrl2(url), children: /* @__PURE__ */ jsx2(Text2, { color: C.cyan, underline: true, children: url }) }),
2607
+ event.organizer && /* @__PURE__ */ jsxs2(Text2, { children: [
2608
+ "Organizer: ",
2609
+ event.organizer.name ? `${event.organizer.name} <${event.organizer.email}>` : event.organizer.email
2610
+ ] }),
2611
+ event.attendees.length > 0 && /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", marginTop: 1, children: [
2612
+ /* @__PURE__ */ jsxs2(Text2, { color: C.muted, children: [
2613
+ "Attendees (",
2614
+ event.attendees.length,
2615
+ "): ",
2616
+ tally(event.attendees)
2617
+ ] }),
2618
+ (showAll ? event.attendees : event.attendees.slice(0, ATTENDEES_SHOWN)).map((a) => /* @__PURE__ */ jsxs2(Text2, { wrap: "truncate", children: [
2619
+ /* @__PURE__ */ jsx2(Text2, { color: STATUS_COLOR[a.status], children: STATUS_ICON[a.status] }),
2620
+ " ",
2621
+ a.name ?? a.email,
2622
+ a.name && /* @__PURE__ */ jsxs2(Text2, { color: C.muted, children: [
2623
+ " ",
2624
+ a.email
2625
+ ] }),
2626
+ a.self ? " (you)" : "",
2627
+ a.organizer ? " (organizer)" : ""
2628
+ ] }, a.email)),
2629
+ event.attendees.length > ATTENDEES_SHOWN && /* @__PURE__ */ jsx2(Clickable, { onClick: onToggleAll, children: /* @__PURE__ */ jsx2(Text2, { color: C.accent, children: showAll ? "\u25B4 show less" : `\u25BE show ${event.attendees.length - ATTENDEES_SHOWN} more` }) })
2630
+ ] }),
2631
+ notes && /* @__PURE__ */ jsx2(Box2, { marginTop: 1, children: /* @__PURE__ */ jsx2(Text2, { children: notes }) }),
2632
+ event.myStatus && /* @__PURE__ */ jsx2(Box2, { marginTop: 1, children: /* @__PURE__ */ jsxs2(Text2, { children: [
2633
+ "Your reply: ",
2634
+ /* @__PURE__ */ jsxs2(Text2, { bold: true, color: STATUS_COLOR[event.myStatus], children: [
2635
+ STATUS_ICON[event.myStatus],
2636
+ " ",
2637
+ STATUS_LABEL[event.myStatus]
2638
+ ] }),
2639
+ /* @__PURE__ */ jsxs2(Text2, { color: C.muted, children: [
2640
+ " as ",
2641
+ account?.email ?? event.accountId
2642
+ ] })
2643
+ ] }) })
2644
+ ] });
2645
+ }
2646
+
2647
+ // src/ui/views/Agenda.tsx
2648
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
2649
+ function useOwnerOf() {
2650
+ const { accounts, calendars } = useDirectory();
2651
+ return useMemo(() => {
2652
+ const cal = new Map(calendars.map((c) => [`${c.accountId}/${c.id}`, c]));
2653
+ const acc = new Map(accounts.map((a) => [a.id, a]));
2654
+ return {
2655
+ colorOf: (e) => ansiOf(cal.get(`${e.accountId}/${e.calendarId}`)?.color ?? acc.get(e.accountId)?.color ?? ""),
2656
+ accountOf: (e) => acc.get(e.accountId)?.label ?? e.accountId,
2657
+ calendarOf: (e) => cal.get(`${e.accountId}/${e.calendarId}`)?.name ?? ""
2658
+ };
2659
+ }, [accounts, calendars]);
2660
+ }
2661
+ var useColorOf = () => useOwnerOf().colorOf;
2662
+ var clickEvent = (e, selected, { onSelect, onOpen }) => () => selected ? onOpen(e) : onSelect(e);
2663
+ function useScroll(first, last, height, total) {
2664
+ const off = useRef2(void 0);
2665
+ const followed = useRef2("");
2666
+ const [, rerender] = useReducer((n) => n + 1, 0);
2667
+ const clamp = (o2) => Math.max(0, Math.min(o2, total - height));
2668
+ let o = off.current ?? first;
2669
+ if (followed.current !== `${first}/${last}`) {
2670
+ followed.current = `${first}/${last}`;
2671
+ if (last >= o + height) o = last - height + 1;
2672
+ if (first < o) o = first;
2673
+ }
2674
+ o = clamp(o);
2675
+ off.current = o;
2676
+ const scrollBy = (rows) => {
2677
+ const next = clamp((off.current ?? 0) + rows);
2678
+ if (next === off.current) return;
2679
+ off.current = next;
2680
+ rerender();
2681
+ };
2682
+ return [o, scrollBy];
2683
+ }
2684
+ var WHEEL_STEP = 3;
2685
+ var rsvpMark = (e) => e.myStatus && e.myStatus !== "accepted" ? `${STATUS_ICON[e.myStatus]} ` : "";
2686
+ function place(location = "") {
2687
+ const url = meetingUrl(location);
2688
+ const rest = (url ? location.replace(url, "") : location).replace(/^[\s/|,·-]+|[\s/|,·-]+$/g, "");
2689
+ return rest || (url ? "video call" : "");
2690
+ }
2691
+ var span = (m) => {
2692
+ const h = Math.floor(m / 60);
2693
+ return h ? m % 60 ? `${h}h ${m % 60}m` : `${h}h` : `${m}m`;
2694
+ };
2695
+ function busyMinutes(list, day) {
2696
+ const from = +startOfDay3(day);
2697
+ const to = +addDays6(day, 1);
2698
+ const iv = list.filter((e) => !e.allDay && e.myStatus !== "declined").map((e) => [Math.max(+eventBounds(e).start, from), Math.min(+eventBounds(e).end, to)]).sort((a, b) => a[0] - b[0]);
2699
+ let total = 0;
2700
+ let end = -Infinity;
2701
+ for (const [s, t] of iv) {
2702
+ if (t <= end) continue;
2703
+ total += t - Math.max(s, end);
2704
+ end = t;
2705
+ }
2706
+ return Math.round(total / 6e4);
2707
+ }
2708
+ var overlaps = (e, list) => !e.allDay && e.myStatus !== "declined" && list.some(
2709
+ (o) => o !== e && !o.allDay && o.myStatus !== "declined" && eventBounds(o).start < eventBounds(e).end && eventBounds(e).start < eventBounds(o).end
2710
+ );
2711
+ function relativeDay(day, now) {
2712
+ const d = differenceInCalendarDays(day, now);
2713
+ return d === 0 ? "today" : d === 1 ? "tomorrow" : d === -1 ? "yesterday" : d > 0 ? `in ${d} days` : `${-d} days ago`;
2714
+ }
2715
+ function fitParts(parts, w) {
2716
+ const out = [];
2717
+ let left = w;
2718
+ for (const p of parts) {
2719
+ if (left <= 0) break;
2720
+ if (p.text.length <= left) {
2721
+ out.push(p);
2722
+ left -= p.text.length;
2723
+ } else {
2724
+ out.push({ ...p, text: p.text.slice(0, Math.max(left - 1, 0)) + "\u2026" });
2725
+ left = 0;
2726
+ }
2727
+ }
2728
+ if (left > 0) out.push({ text: " ".repeat(left) });
2729
+ return out;
2730
+ }
2731
+ function Line({ parts, inverse, muted }) {
2732
+ return /* @__PURE__ */ jsx3(Text3, { inverse, color: inverse ? C.accent : void 0, children: parts.map((p, i) => /* @__PURE__ */ jsx3(Text3, { color: inverse ? C.accent : muted ? C.muted : p.color, bold: p.bold || inverse, strikethrough: p.strike, children: p.text }, i)) });
2733
+ }
2734
+ var TIME_W = 9;
2735
+ var linesOf = (r) => "e" in r ? 2 : 1;
2736
+ function Agenda({ events, date, now, selectedKey, width, height, onSelect, onOpen, onPickDay }) {
2737
+ const { colorOf, accountOf, calendarOf } = useOwnerOf();
2738
+ const rows = useMemo(() => {
2739
+ const out = [];
2740
+ for (let i = 0; i < AGENDA_DAYS; i++) {
2741
+ const day = addDays6(startOfDay3(date), i);
2742
+ const list = eventsOnDay(events, day);
2743
+ if (!list.length) {
2744
+ const last = out.at(-1);
2745
+ if (last && "free" in last) last.free[1] = day;
2746
+ else out.push({ free: [day, day] });
2747
+ continue;
2748
+ }
2749
+ const items = list.map((e) => ({ day, e }));
2750
+ if (isSameDay4(day, now)) {
2751
+ const next = list.findIndex((e) => eventBounds(e).start > now);
2752
+ items.splice(next < 0 ? items.length : next, 0, { day, nowLine: true });
2753
+ }
2754
+ out.push({ day });
2755
+ items.forEach((it, j) => {
2756
+ if (j > 0 && "e" in it && "e" in items[j - 1]) out.push({ day, divider: true });
2757
+ out.push(it);
2758
+ });
2759
+ }
2760
+ return out;
2761
+ }, [events, date, now]);
2762
+ const offsets = [];
2763
+ let total = 0;
2764
+ for (const r of rows) {
2765
+ offsets.push(total);
2766
+ total += linesOf(r);
2767
+ }
2768
+ const sel = rows.findIndex((r) => "e" in r && eventKey(r.e) === selectedKey);
2769
+ const first = sel > 0 && !("e" in rows[sel - 1]) && !("nowLine" in rows[sel - 1]) && !("divider" in rows[sel - 1]) ? offsets[sel - 1] : offsets[Math.max(sel, 0)] ?? 0;
2770
+ const [top, scrollBy] = useScroll(first, sel >= 0 ? offsets[sel] + 1 : 0, height, total);
2771
+ if (!events.length) return /* @__PURE__ */ jsxs3(Text3, { color: C.muted, children: [
2772
+ "No events in the next ",
2773
+ AGENDA_DAYS,
2774
+ " days"
2775
+ ] });
2776
+ const upNext = events.filter((e) => !e.allDay && eventBounds(e).start > now && isSameDay4(eventBounds(e).start, now)).sort((a, b) => +eventBounds(a).start - +eventBounds(b).start)[0];
2777
+ const lines = [];
2778
+ rows.forEach((r, i) => {
2779
+ const key3 = "free" in r ? `free${ymd(r.free[0])}` : "e" in r ? `${ymd(r.day)}/${eventKey(r.e)}` : "nowLine" in r ? `${ymd(r.day)}/now` : "divider" in r ? `${ymd(r.day)}/d${i}` : ymd(r.day);
2780
+ if ("free" in r) {
2781
+ const [a, b] = r.free;
2782
+ const label = isSameDay4(a, b) ? format7(a, "EEE d MMM") : `${format7(a, "EEE d MMM")} \u2013 ${format7(b, "EEE d MMM")}`;
2783
+ lines.push(
2784
+ /* @__PURE__ */ jsx3(Box3, { width, height: 1, overflow: "hidden", children: /* @__PURE__ */ jsx3(Text3, { color: C.muted, wrap: "truncate", children: ` ${label} free` }) }, key3)
2785
+ );
2786
+ return;
2787
+ }
2788
+ if ("divider" in r) {
2789
+ lines.push(
2790
+ /* @__PURE__ */ jsx3(Box3, { width, height: 1, overflow: "hidden", children: /* @__PURE__ */ jsx3(Text3, { color: C.muted, dimColor: true, children: " ".repeat(TIME_W) + "\u2500".repeat(Math.max(width - TIME_W - 1, 0)) }) }, key3)
2791
+ );
2792
+ return;
2793
+ }
2794
+ if ("nowLine" in r) {
2795
+ lines.push(
2796
+ /* @__PURE__ */ jsx3(Box3, { width, height: 1, overflow: "hidden", children: /* @__PURE__ */ jsx3(Text3, { color: C.now, bold: true, wrap: "truncate", children: ` ${format7(now, "HH:mm")} ${"\u2500".repeat(width)}` }) }, key3)
2797
+ );
2798
+ return;
2799
+ }
2800
+ if (!("e" in r)) {
2801
+ const today = isSameDay4(r.day, now);
2802
+ const list = eventsOnDay(events, r.day);
2803
+ const busy = busyMinutes(list, r.day);
2804
+ const left = ` ${format7(r.day, "EEE d MMM")} `;
2805
+ const rel = relativeDay(r.day, now);
2806
+ const right = ` ${list.length} ${list.length === 1 ? "event" : "events"}${busy ? ` \xB7 ${span(busy)} busy` : ""} `;
2807
+ const rule = "\u2500".repeat(Math.max(width - left.length - rel.length - right.length - 1, 1));
2808
+ lines.push(
2809
+ /* @__PURE__ */ jsx3(Clickable, { width, height: 1, onClick: onPickDay && (() => onPickDay(r.day)), children: /* @__PURE__ */ jsxs3(Text3, { wrap: "truncate", children: [
2810
+ /* @__PURE__ */ jsx3(Text3, { bold: true, color: today ? C.now : C.accent, children: left }),
2811
+ /* @__PURE__ */ jsx3(Text3, { color: today ? C.now : C.muted, children: rel }),
2812
+ /* @__PURE__ */ jsx3(Text3, { color: C.muted, children: ` ${rule}${right}` })
2813
+ ] }) }, key3)
2814
+ );
2815
+ return;
2816
+ }
2817
+ const e = r.e;
2818
+ const selected = i === sel;
2819
+ const { start, end } = eventBounds(e);
2820
+ const running = !e.allDay && start <= now && now < end;
2821
+ const past = isPast(e, now) || e.myStatus === "declined";
2822
+ const clockOf = (d, edge) => isSameDay4(d, r.day) ? format7(d, "HH:mm") : +d === +addDays6(r.day, 1) ? "24:00" : edge;
2823
+ const badges = [];
2824
+ if (running) badges.push({ text: ` \u25CF now \xB7 ends in ${span(Math.max(differenceInMinutes2(end, now, { roundingMethod: "ceil" }), 1))}`, color: C.green, bold: true });
2825
+ else if (e === upNext) badges.push({ text: ` ${startsLabel(e.start, now)}`, color: C.yellow });
2826
+ if (e.myStatus === "needsAction") badges.push({ text: " RSVP", color: C.yellow, bold: true });
2827
+ if (e.myStatus === "tentative") badges.push({ text: " maybe", color: C.yellow });
2828
+ if (e.myStatus === "declined") badges.push({ text: " declined", color: C.muted });
2829
+ const dur = e.allDay ? "" : duration(e);
2830
+ const titleW = Math.max(width - TIME_W - 2 - (dur ? dur.length + 2 : 0), 1);
2831
+ const row1 = [
2832
+ { text: (e.allDay ? " all day" : ` ${clockOf(start, "\u2026")}`).padEnd(TIME_W), color: running ? C.green : void 0, bold: running },
2833
+ { text: "\u258C ", color: colorOf(e) },
2834
+ ...fitParts([{ text: `${rsvpMark(e)}${e.title || "(no title)"}`, bold: true, strike: e.myStatus === "declined" }, ...badges], titleW),
2835
+ ...dur ? [{ text: ` ${dur}`, color: C.muted }] : []
2836
+ ];
2837
+ const who = [accountOf(e), calendarOf(e)].filter((x, j, a) => x && a.indexOf(x) === j).join(" \xB7 ");
2838
+ const details = [{ text: who, color: C.muted }];
2839
+ if (e.recurringEventId) details.push({ text: " \u21BB repeats", color: C.muted });
2840
+ if (overlaps(e, eventsOnDay(events, r.day))) details.push({ text: " \u26A0 overlaps", color: C.yellow });
2841
+ const where = place(e.location);
2842
+ if (where) details.push({ text: ` ${where}`, color: C.cyan });
2843
+ if (e.attendees.length) details.push({ text: ` ${e.attendees.length} ${e.attendees.length === 1 ? "person" : "people"}`, color: C.muted });
2844
+ const row2 = [
2845
+ { text: ` ${e.allDay ? "" : clockOf(end, "\u2026")}`.padEnd(TIME_W), color: C.muted },
2846
+ { text: "\u258C ", color: colorOf(e) },
2847
+ ...fitParts(details, width - TIME_W - 2)
2848
+ ];
2849
+ const onClick = clickEvent(e, selected, { onSelect, onOpen });
2850
+ lines.push(
2851
+ /* @__PURE__ */ jsx3(Clickable, { width, height: 1, onClick, children: /* @__PURE__ */ jsx3(Line, { parts: row1, inverse: selected, muted: past }) }, key3),
2852
+ /* @__PURE__ */ jsx3(Clickable, { width, height: 1, onClick, children: /* @__PURE__ */ jsx3(Line, { parts: row2, inverse: selected, muted: past }) }, `${key3}/2`)
2853
+ );
2854
+ });
2855
+ return /* @__PURE__ */ jsx3(Clickable, { flexDirection: "column", width, onWheel: (dir) => scrollBy(dir * WHEEL_STEP), children: lines.slice(top, top + height) });
2856
+ }
2857
+
2858
+ // src/ui/views/Day.tsx
2859
+ import { useMemo as useMemo3 } from "react";
2860
+ import { addDays as addDays7, startOfDay as startOfDay5 } from "date-fns";
2861
+
2862
+ // src/ui/views/TimeGrid.tsx
2863
+ import { useMemo as useMemo2 } from "react";
2864
+ import { Box as Box5, Text as Text5 } from "ink";
2865
+ import { format as format9, isSameDay as isSameDay6 } from "date-fns";
2866
+
2867
+ // src/ui/views/Month.tsx
2868
+ import { Box as Box4, Text as Text4 } from "ink";
2869
+ import { format as format8, isSameDay as isSameDay5, isSameMonth, startOfDay as startOfDay4 } from "date-fns";
2870
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
2871
+ function fit(items, rows, selected) {
2872
+ if (items.length <= rows) return { shown: items, more: 0 };
2873
+ if (rows === 1 && selected >= 0) return { shown: [items[selected]], more: 0 };
2874
+ const n = Math.max(rows - 1, 0);
2875
+ const from = Math.max(0, Math.min(selected - n + 1, items.length - n));
2876
+ return { shown: items.slice(from, from + n), more: items.length - n };
2877
+ }
2878
+ function EventLine({ e, day, now, selected, time, onSelect, onOpen }) {
2879
+ const { start, end } = eventBounds(e);
2880
+ const prefix = !time || e.allDay ? "" : start < startOfDay4(day) ? "\u2026 " : `${format8(start, "HH:mm")} `;
2881
+ const live = !e.allDay && start <= now && now < end;
2882
+ return /* @__PURE__ */ jsx4(Clickable, { height: 1, onClick: clickEvent(e, selected, { onSelect, onOpen }), children: /* @__PURE__ */ jsxs4(
2883
+ Text4,
2884
+ {
2885
+ wrap: "truncate-end",
2886
+ inverse: selected,
2887
+ bold: selected,
2888
+ italic: e.myStatus === "needsAction",
2889
+ strikethrough: e.myStatus === "declined",
2890
+ color: selected ? C.accent : isPast(e, now) || e.myStatus === "declined" ? C.muted : live ? C.green : e.allDay ? C.yellow : void 0,
2891
+ children: [
2892
+ prefix,
2893
+ e.title
2894
+ ]
2895
+ }
2896
+ ) });
2897
+ }
2898
+ function Month({ events, date, now, cursor, selectedKey, width, height, onSelect, onOpen, onPickDay }) {
2899
+ const days = monthGrid(date);
2900
+ const colWidth = Math.max(Math.floor(width / 7), 4);
2901
+ const cellHeight = Math.max(Math.floor((height - 1) / 6), 1);
2902
+ const weeks = [0, 1, 2, 3, 4, 5].map((w) => days.slice(w * 7, w * 7 + 7));
2903
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
2904
+ /* @__PURE__ */ jsx4(Box4, { children: weeks[0].map((d) => /* @__PURE__ */ jsx4(Box4, { width: colWidth, children: /* @__PURE__ */ jsx4(Text4, { color: C.accent, bold: true, children: format8(d, "EEE") }) }, d.getDay())) }),
2905
+ weeks.map((week) => /* @__PURE__ */ jsx4(Box4, { height: cellHeight, children: week.map((day) => {
2906
+ const items = eventsOnDay(events, day);
2907
+ const { shown, more } = fit(items, cellHeight - 1, items.findIndex((e) => eventKey(e) === selectedKey));
2908
+ const today = isSameDay5(day, now);
2909
+ const here = !!cursor && isSameDay5(day, cursor);
2910
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", width: colWidth, paddingRight: 1, overflow: "hidden", children: [
2911
+ /* @__PURE__ */ jsx4(Clickable, { height: 1, onClick: onPickDay && (() => onPickDay(day)), children: /* @__PURE__ */ jsxs4(
2912
+ Text4,
2913
+ {
2914
+ bold: today || here,
2915
+ inverse: here,
2916
+ color: today ? C.now : here ? C.accent : isSameMonth(day, date) ? void 0 : C.muted,
2917
+ children: [
2918
+ here ? ` ${format8(day, "d")} ` : format8(day, "d"),
2919
+ cellHeight === 1 && items.length > 0 && /* @__PURE__ */ jsxs4(Text4, { color: C.muted, children: [
2920
+ " \xB7",
2921
+ items.length
2922
+ ] })
2923
+ ]
2924
+ }
2925
+ ) }),
2926
+ shown.map((e) => /* @__PURE__ */ jsx4(EventLine, { e, day, now, selected: eventKey(e) === selectedKey, time: false, onSelect, onOpen }, eventKey(e))),
2927
+ more > 0 && cellHeight > 1 && /* @__PURE__ */ jsxs4(Text4, { color: C.muted, children: [
2928
+ "+",
2929
+ more
2930
+ ] })
2931
+ ] }, day.toISOString());
2932
+ }) }, week[0].toISOString()))
2933
+ ] });
2934
+ }
2935
+
2936
+ // src/ui/views/TimeGrid.tsx
2937
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
2938
+ var GUTTER = 6;
2939
+ var ALL_DAY_MAX = 3;
2940
+ var COMPACT_BELOW = 18;
2941
+ function slots(events, day) {
2942
+ return packColumns(
2943
+ layoutDay(events, day).map((p) => {
2944
+ const start = Math.floor(p.start / 60) * 60;
2945
+ return { item: p, start, end: Math.max(Math.ceil(p.end / 60) * 60, start + 60) };
2946
+ })
2947
+ );
2948
+ }
2949
+ function TimeGrid({ days, events, now, cursor, selectedKey, width, height, onSelect, onOpen, onPickDay }) {
2950
+ const colorOf = useColorOf();
2951
+ const cols = useMemo2(
2952
+ () => days.map((day) => {
2953
+ const on = eventsOnDay(events, day);
2954
+ return { day, count: on.length, allDay: on.filter((e) => e.allDay), placed: slots(events, day) };
2955
+ }),
2956
+ [events, days]
2957
+ );
2958
+ const colW = Math.max(Math.floor((width - GUTTER) / days.length) - 1, 3);
2959
+ const allRows = Math.min(Math.max(0, ...cols.map((c) => c.allDay.length)), ALL_DAY_MAX);
2960
+ const gridH = Math.max(height - 2 - allRows, 1);
2961
+ const sel = cols.flatMap((c) => c.placed).find((p) => eventKey(p.item.item) === selectedKey);
2962
+ const today = days.some((d) => isSameDay6(d, now));
2963
+ const focus = sel ? sel.start / 60 : today ? now.getHours() : 8;
2964
+ const [top, scrollBy] = useScroll(sel ? focus : Math.max(focus - 1, 0), focus, gridH, 24);
2965
+ const bar = /* @__PURE__ */ jsx5(Text5, { color: C.muted, children: "\u2502" });
2966
+ const row = (key3, gutter, cells) => /* @__PURE__ */ jsxs5(Box5, { width, height: 1, overflow: "hidden", children: [
2967
+ gutter,
2968
+ cols.map((c) => /* @__PURE__ */ jsxs5(Box5, { flexShrink: 0, children: [
2969
+ bar,
2970
+ /* @__PURE__ */ jsx5(Box5, { width: colW, flexShrink: 0, overflow: "hidden", children: cells(c) })
2971
+ ] }, c.day.getTime()))
2972
+ ] }, key3);
2973
+ const gutterText = (text2) => /* @__PURE__ */ jsx5(Box5, { width: GUTTER, flexShrink: 0, children: /* @__PURE__ */ jsx5(Text5, { color: C.muted, children: text2 }) });
2974
+ const hourCells = (placed, day, h, nowLine) => {
2975
+ const out = [];
2976
+ let pos = 0;
2977
+ const gap = (to) => {
2978
+ if (to <= pos) return;
2979
+ out.push(
2980
+ /* @__PURE__ */ jsx5(Box5, { width: to - pos, flexShrink: 0, children: /* @__PURE__ */ jsx5(Text5, { color: C.now, children: (nowLine ? "\u2500" : " ").repeat(to - pos) }) }, `g${pos}`)
2981
+ );
2982
+ pos = to;
2983
+ };
2984
+ const segs = placed.filter((p) => p.start < (h + 1) * 60 && p.end > h * 60).sort((a, b) => a.col - b.col);
2985
+ for (const p of segs) {
2986
+ const e = p.item.item;
2987
+ const x = Math.floor(p.col * colW / p.cols);
2988
+ const w = Math.floor((p.col + 1) * colW / p.cols) - x;
2989
+ if (w < 1) continue;
2990
+ gap(x);
2991
+ const k = eventKey(e);
2992
+ const selected = k === selectedKey;
2993
+ const first = p.start / 60;
2994
+ const { start, end } = eventBounds(e);
2995
+ const from = isSameDay6(start, day) ? format9(start, "HH:mm") : "\u2026";
2996
+ const title2 = `${rsvpMark(e)}${e.title || "(no title)"}`;
2997
+ const where = place(e.location);
2998
+ const lines = colW < COMPACT_BELOW ? [p.end - p.start > 60 || start.getMinutes() === 0 ? title2 : `:${format9(start, "mm")} ${title2}`, `${from}\u2013${isSameDay6(end, day) ? format9(end, "HH:mm") : "\u2026"}`, where] : [`${from} ${title2}`, [where, duration(e)].filter(Boolean).join(" \xB7 ")];
2999
+ const text2 = lines[h - first] ?? "";
3000
+ const inner = p.col < p.cols - 1 && w > 1 ? w - 1 : w;
3001
+ const past = isPast(e, now) || e.myStatus === "declined";
3002
+ out.push(
3003
+ /* @__PURE__ */ jsx5(Clickable, { width: w, flexShrink: 0, onClick: clickEvent(e, selected, { onSelect, onOpen }), children: /* @__PURE__ */ jsx5(
3004
+ Text5,
3005
+ {
3006
+ inverse: true,
3007
+ color: selected ? C.accent : past ? C.muted : colorOf(e),
3008
+ bold: selected || h === first,
3009
+ italic: e.myStatus === "needsAction",
3010
+ strikethrough: e.myStatus === "declined",
3011
+ children: (" " + text2).slice(0, inner).padEnd(inner)
3012
+ }
3013
+ ) }, k)
3014
+ );
3015
+ pos = x + w;
3016
+ }
3017
+ gap(colW);
3018
+ return out;
3019
+ };
3020
+ return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", width, children: [
3021
+ row("head", gutterText(""), (c) => {
3022
+ const isToday = isSameDay6(c.day, now);
3023
+ const here = !!cursor && isSameDay6(c.day, cursor) && days.length > 1;
3024
+ return /* @__PURE__ */ jsx5(Clickable, { height: 1, width: colW, onClick: onPickDay && (() => onPickDay(c.day)), children: /* @__PURE__ */ jsxs5(Text5, { wrap: "truncate", children: [
3025
+ /* @__PURE__ */ jsx5(Text5, { bold: true, inverse: here, color: isToday ? C.now : C.accent, children: ` ${format9(c.day, "EEE d")} ` }),
3026
+ c.count > 0 && /* @__PURE__ */ jsxs5(Text5, { color: C.muted, children: [
3027
+ "\xB7",
3028
+ c.count
3029
+ ] })
3030
+ ] }) });
3031
+ }),
3032
+ Array.from(
3033
+ { length: allRows },
3034
+ (_, i) => row(`all${i}`, gutterText(i === 0 ? "all" : ""), (c) => {
3035
+ const { shown, more } = fit(c.allDay, allRows, c.allDay.findIndex((e2) => eventKey(e2) === selectedKey));
3036
+ const e = shown[i];
3037
+ if (!e) return i === shown.length && more > 0 ? /* @__PURE__ */ jsxs5(Text5, { color: C.muted, children: [
3038
+ " +",
3039
+ more,
3040
+ " more"
3041
+ ] }) : null;
3042
+ const selected = eventKey(e) === selectedKey;
3043
+ return /* @__PURE__ */ jsx5(Clickable, { height: 1, width: colW, onClick: clickEvent(e, selected, { onSelect, onOpen }), children: /* @__PURE__ */ jsxs5(Text5, { wrap: "truncate", inverse: selected, color: selected ? C.accent : C.yellow, bold: selected, children: [
3044
+ /* @__PURE__ */ jsx5(Text5, { color: selected ? C.accent : colorOf(e), children: " \u25CF" }),
3045
+ " ",
3046
+ rsvpMark(e),
3047
+ e.title || "(no title)"
3048
+ ] }) });
3049
+ })
3050
+ ),
3051
+ /* @__PURE__ */ jsx5(Box5, { width, height: 1, overflow: "hidden", children: /* @__PURE__ */ jsx5(Text5, { color: C.muted, children: "\u2500".repeat(GUTTER) + ("\u253C" + "\u2500".repeat(colW)).repeat(days.length) }) }),
3052
+ /* @__PURE__ */ jsx5(Clickable, { flexDirection: "column", width, onWheel: (dir) => scrollBy(dir), children: Array.from({ length: Math.min(gridH, 24) }, (_, i) => {
3053
+ const h = top + i;
3054
+ const current = today && h === now.getHours();
3055
+ const gutter = /* @__PURE__ */ jsx5(Box5, { width: GUTTER, flexShrink: 0, children: /* @__PURE__ */ jsx5(Text5, { color: current ? C.now : C.muted, bold: current, children: current ? format9(now, "HH:mm") : `${String(h).padStart(2, "0")}:00` }) });
3056
+ return row(`h${h}`, gutter, (c) => hourCells(c.placed, c.day, h, current && isSameDay6(c.day, now)));
3057
+ }) })
3058
+ ] });
3059
+ }
3060
+
3061
+ // src/ui/views/Day.tsx
3062
+ import { jsx as jsx6 } from "react/jsx-runtime";
3063
+ function useDays(date, n) {
3064
+ const t = startOfDay5(date).getTime();
3065
+ return useMemo3(() => Array.from({ length: n }, (_, i) => addDays7(t, i)), [t, n]);
3066
+ }
3067
+ var Day = (p) => /* @__PURE__ */ jsx6(TimeGrid, { ...p, days: useDays(p.date, 1) });
3068
+ var TwoDay = (p) => /* @__PURE__ */ jsx6(TimeGrid, { ...p, days: useDays(p.date, 2) });
3069
+
3070
+ // src/ui/views/Week.tsx
3071
+ import { useMemo as useMemo4 } from "react";
3072
+ import { jsx as jsx7 } from "react/jsx-runtime";
3073
+ function Week(p) {
3074
+ const t = p.date.getTime();
3075
+ const days = useMemo4(() => viewDays("week", new Date(t)), [t]);
3076
+ return /* @__PURE__ */ jsx7(TimeGrid, { ...p, days });
3077
+ }
3078
+
3079
+ // src/ui/screens/EventEditor.tsx
3080
+ import { useEffect as useEffect3, useRef as useRef3, useState as useState3 } from "react";
3081
+ import { Box as Box6, Text as Text6 } from "ink";
3082
+ import { format as format10, isValid, parse as parse2 } from "date-fns";
3083
+ import { jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
3084
+ var LABELS = {
3085
+ title: "Title",
3086
+ account: "Account",
3087
+ calendar: "Calendar",
3088
+ allDay: "All-day",
3089
+ startDate: "Start date",
3090
+ startTime: "Start time",
3091
+ endDate: "End date",
3092
+ endTime: "End time",
3093
+ location: "Location",
3094
+ description: "Notes",
3095
+ attendees: "Invitees"
3096
+ };
3097
+ var HINTS = {
3098
+ startDate: "YYYY-MM-DD",
3099
+ startTime: "HH:mm",
3100
+ endDate: "YYYY-MM-DD",
3101
+ endTime: "HH:mm",
3102
+ attendees: "a@x.com, b@y.com",
3103
+ title: "New Event"
3104
+ };
3105
+ function toLocal(date, time) {
3106
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !/^\d{2}:\d{2}$/.test(time)) return;
3107
+ const d = parse2(`${date}T${time}`, "yyyy-MM-dd'T'HH:mm", /* @__PURE__ */ new Date());
3108
+ return isValid(d) && format10(d, "yyyy-MM-dd'T'HH:mm") === `${date}T${time}` ? `${date}T${time}` : void 0;
3109
+ }
3110
+ var timeTexts = (f) => ({
3111
+ startDate: f.start.slice(0, 10),
3112
+ startTime: f.start.slice(11),
3113
+ endDate: f.end.slice(0, 10),
3114
+ endTime: f.end.slice(11)
3115
+ });
3116
+ var textsFrom = (f) => ({
3117
+ title: f.title,
3118
+ location: f.location,
3119
+ description: f.description,
3120
+ attendees: f.attendees.join(", "),
3121
+ ...timeTexts(f)
3122
+ });
3123
+ var fieldsFor = (form, editing) => [
3124
+ "title",
3125
+ ...editing ? [] : ["account", "calendar"],
3126
+ "allDay",
3127
+ "startDate",
3128
+ ...form.allDay ? [] : ["startTime"],
3129
+ "endDate",
3130
+ ...form.allDay ? [] : ["endTime"],
3131
+ "location",
3132
+ "description",
3133
+ "attendees"
3134
+ ];
3135
+ function EventEditor({ event, initialStart, onClose }) {
3136
+ const api = useApi();
3137
+ const { accounts, calendars, loaded } = useDirectory();
3138
+ const [state, setState] = useState3();
3139
+ const latest = useRef3(void 0);
3140
+ const update = (patch) => {
3141
+ if (!latest.current) return;
3142
+ latest.current = { ...latest.current, ...patch };
3143
+ setState(latest.current);
3144
+ };
3145
+ const [error, setError] = useState3("");
3146
+ const [saving, setSaving] = useState3(false);
3147
+ const busy = useRef3(false);
3148
+ const alive2 = useRef3(true);
3149
+ useEffect3(() => () => void (alive2.current = false), []);
3150
+ useEffect3(() => {
3151
+ if (latest.current || !loaded) return;
3152
+ const form2 = event ? formFromEvent(event) : emptyForm(accounts, calendars, {}, initialStart);
3153
+ latest.current = { form: form2, texts: textsFrom(form2), focus: 0 };
3154
+ setState(latest.current);
3155
+ }, [loaded]);
3156
+ const choices = writableAccounts(accounts, calendars);
3157
+ const save = async () => {
3158
+ if (!latest.current || busy.current) return;
3159
+ const { form: form2, texts: texts2 } = latest.current;
3160
+ setError("");
3161
+ const start = toLocal(texts2.startDate, form2.allDay ? form2.start.slice(11) : texts2.startTime);
3162
+ const end = toLocal(texts2.endDate, form2.allDay ? form2.end.slice(11) : texts2.endTime);
3163
+ if (!start) return setError(`Invalid start (use YYYY-MM-DD${form2.allDay ? "" : " and HH:mm"})`);
3164
+ if (!end) return setError(`Invalid end (use YYYY-MM-DD${form2.allDay ? "" : " and HH:mm"})`);
3165
+ const f = {
3166
+ ...form2,
3167
+ title: texts2.title,
3168
+ location: texts2.location,
3169
+ description: texts2.description,
3170
+ attendees: splitEmails(texts2.attendees),
3171
+ start,
3172
+ end
3173
+ };
3174
+ let request;
3175
+ try {
3176
+ request = event ? api.events.update(applyForm(event, f)) : api.events.create(formToInput(f));
3177
+ } catch (e) {
3178
+ return setError(errorText(e));
3179
+ }
3180
+ busy.current = true;
3181
+ setSaving(true);
3182
+ try {
3183
+ await request;
3184
+ if (alive2.current) onClose();
3185
+ } catch (e) {
3186
+ busy.current = false;
3187
+ if (alive2.current) {
3188
+ setError(errorText(e));
3189
+ setSaving(false);
3190
+ }
3191
+ }
3192
+ };
3193
+ const cycle2 = (items, id2, dir) => {
3194
+ if (!items.length) return "";
3195
+ const i = items.findIndex((x) => x.id === id2);
3196
+ return items[i < 0 ? dir > 0 ? 0 : items.length - 1 : (i + dir + items.length) % items.length].id;
3197
+ };
3198
+ const pick = ({ form: form2, texts: texts2 }, field, dir) => {
3199
+ if (field === "account") {
3200
+ const accountId = cycle2(choices, form2.accountId, dir);
3201
+ update({ form: { ...form2, accountId, calendarId: soleId(writableCalendars(calendars, accountId)) } });
3202
+ } else if (field === "calendar") {
3203
+ update({ form: { ...form2, calendarId: cycle2(writableCalendars(calendars, form2.accountId), form2.calendarId, dir) } });
3204
+ } else if (field === "allDay") {
3205
+ const f = setAllDay(form2, !form2.allDay);
3206
+ update({ form: f, texts: { ...texts2, ...timeTexts(f) } });
3207
+ }
3208
+ };
3209
+ const edit = ({ form: form2, texts: texts2 }, field, value2) => {
3210
+ const next = { ...texts2, [field]: value2 };
3211
+ if (field === "startDate" || field === "startTime") {
3212
+ const start = toLocal(next.startDate, form2.allDay ? form2.start.slice(11) : next.startTime);
3213
+ if (start) {
3214
+ const f = moveStart(form2, start);
3215
+ return update({ form: f, texts: { ...next, endDate: f.end.slice(0, 10), endTime: f.end.slice(11) } });
3216
+ }
3217
+ } else if (field === "endDate" || field === "endTime") {
3218
+ const end = toLocal(next.endDate, form2.allDay ? form2.end.slice(11) : next.endTime);
3219
+ if (end) return update({ form: { ...form2, end }, texts: next });
3220
+ }
3221
+ update({ texts: next });
3222
+ };
3223
+ const clickField = (f) => {
3224
+ const s = latest.current;
3225
+ if (!s || busy.current) return;
3226
+ const i = fieldsFor(s.form, !!event).indexOf(f);
3227
+ if (i < 0) return;
3228
+ if (i === s.focus && (f === "account" || f === "calendar" || f === "allDay")) return pick(s, f, 1);
3229
+ update({ focus: i });
3230
+ };
3231
+ useKeys((input, key3) => {
3232
+ if (key3.escape) return onClose();
3233
+ const s = latest.current;
3234
+ if (!s || busy.current) return;
3235
+ if (key3.ctrl && input === "s") return void save();
3236
+ const fields2 = fieldsFor(s.form, !!event);
3237
+ const i = Math.min(s.focus, fields2.length - 1);
3238
+ const field = fields2[i];
3239
+ const move = (dir) => update({ focus: (i + dir + fields2.length) % fields2.length });
3240
+ if (key3.tab) return move(key3.shift ? -1 : 1);
3241
+ if (key3.downArrow) return move(1);
3242
+ if (key3.upArrow) return move(-1);
3243
+ if (key3.return) return i === fields2.length - 1 ? void save() : move(1);
3244
+ if (field === "account" || field === "calendar" || field === "allDay") {
3245
+ if (key3.leftArrow) pick(s, field, -1);
3246
+ else if (key3.rightArrow || input === " ") pick(s, field, 1);
3247
+ return;
3248
+ }
3249
+ if (key3.backspace || key3.delete) return edit(s, field, s.texts[field].slice(0, -1));
3250
+ if (key3.ctrl || key3.meta || !input) return;
3251
+ const printable = input.replace(/[\u0000-\u001f\u007f]/g, "");
3252
+ if (printable) edit(s, field, s.texts[field] + printable);
3253
+ });
3254
+ if (!state) return /* @__PURE__ */ jsx8(Text6, { color: C.muted, children: "Loading\u2026" });
3255
+ const { form, texts } = state;
3256
+ const fields = fieldsFor(form, !!event);
3257
+ const current = fields[Math.min(state.focus, fields.length - 1)];
3258
+ const account = accounts.find((a) => a.id === form.accountId);
3259
+ const calendar = calendars.find((c) => c.accountId === form.accountId && c.id === form.calendarId);
3260
+ const value = (f) => {
3261
+ switch (f) {
3262
+ case "account":
3263
+ if (event) return { text: account ? `${account.label} \xB7 ${account.email}` : form.accountId };
3264
+ return account ? { text: `${account.label} \xB7 ${account.email}` } : { text: "\u2039 choose account \u203A", dim: true };
3265
+ case "calendar":
3266
+ if (event) return { text: calendar?.name ?? form.calendarId };
3267
+ if (!form.accountId) return { text: "choose an account first", dim: true };
3268
+ return calendar ? { text: calendar.name } : { text: "\u2039 choose calendar \u203A", dim: true };
3269
+ case "allDay":
3270
+ return { text: form.allDay ? "[x]" : "[ ]" };
3271
+ default:
3272
+ return texts[f] ? { text: texts[f] } : { text: HINTS[f] ?? "", dim: true };
3273
+ }
3274
+ };
3275
+ const rows = event ? ["title", "account", "calendar", ...fields.slice(1)] : fields;
3276
+ return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", borderStyle: "round", borderColor: C.muted, paddingX: 1, children: [
3277
+ /* @__PURE__ */ jsx8(Text6, { bold: true, children: event ? "Edit event" : "New event" }),
3278
+ rows.map((f) => {
3279
+ const on = f === current;
3280
+ const v = value(f);
3281
+ const picker = !event && (f === "account" || f === "calendar");
3282
+ return /* @__PURE__ */ jsxs6(Clickable, { onClick: () => clickField(f), children: [
3283
+ /* @__PURE__ */ jsxs6(Text6, { color: on ? C.cyan : void 0, children: [
3284
+ on ? "\u203A " : " ",
3285
+ LABELS[f].padEnd(11)
3286
+ ] }),
3287
+ /* @__PURE__ */ jsx8(Text6, { color: v.dim ? C.muted : void 0, inverse: on && !v.dim && !picker, children: v.text }),
3288
+ on && picker && /* @__PURE__ */ jsx8(Text6, { color: C.muted, children: " \u2190/\u2192 or click" })
3289
+ ] }, f);
3290
+ }),
3291
+ /* @__PURE__ */ jsx8(Box6, { marginTop: 1, children: /* @__PURE__ */ jsx8(Text6, { color: C.muted, children: account ? `Organizer: ${account.email}` : "Choose which account this event belongs to." }) }),
3292
+ error && /* @__PURE__ */ jsx8(Text6, { color: C.red, children: error }),
3293
+ saving ? /* @__PURE__ */ jsx8(Text6, { color: C.yellow, children: "Saving\u2026" }) : /* @__PURE__ */ jsxs6(Box6, { flexWrap: "wrap", children: [
3294
+ /* @__PURE__ */ jsx8(Box6, { marginRight: 2, children: /* @__PURE__ */ jsx8(Text6, { color: C.muted, children: "tab/\u2191\u2193 move \xB7 \u2190/\u2192/space pick" }) }),
3295
+ /* @__PURE__ */ jsx8(Button, { k: "ctrl+s", label: "save", color: C.green, onPress: () => void save() }),
3296
+ /* @__PURE__ */ jsx8(Button, { k: "esc", label: "cancel", onPress: onClose })
3297
+ ] })
3298
+ ] });
3299
+ }
3300
+
3301
+ // src/ui/screens/Accounts.tsx
3302
+ import { useEffect as useEffect4, useRef as useRef5, useState as useState5 } from "react";
3303
+ import { Box as Box8, Text as Text8, useStdout as useStdout2 } from "ink";
3304
+
3305
+ // src/ui/screens/AddCaldav.tsx
3306
+ import { useRef as useRef4, useState as useState4 } from "react";
3307
+ import { Box as Box7, Text as Text7 } from "ink";
3308
+ import { jsx as jsx9, jsxs as jsxs7 } from "react/jsx-runtime";
3309
+ var PALETTE2 = ["#bd93f9", "#50fa7b", "#8be9fd", "#ff79c6", "#ffb86c", "#f1fa8c"];
3310
+ var PRESETS2 = [
3311
+ { id: "privateemail", name: "Namecheap Private Email", url: "https://dav.privateemail.com/dav.php/" },
3312
+ { id: "icloud", name: "iCloud", url: "https://caldav.icloud.com/" },
3313
+ { id: "fastmail", name: "Fastmail", url: "https://caldav.fastmail.com/" },
3314
+ { id: "custom", name: "Custom", url: "" }
3315
+ ];
3316
+ var PERSONAL_DOMAINS = /^(gmail|googlemail|icloud|me|mac|outlook|hotmail|live|yahoo|fastmail|proton|protonmail|aol|gmx|ukr)\./i;
3317
+ function suggestLabel(email) {
3318
+ const domain = email.split("@")[1] ?? "";
3319
+ if (!domain) return "";
3320
+ return PERSONAL_DOMAINS.test(domain) ? "Personal" : "Work";
3321
+ }
3322
+ function editText(value, input, key3) {
3323
+ if (key3.backspace || key3.delete) return value.slice(0, -1);
3324
+ if (key3.ctrl || key3.meta || key3.escape || key3.return || key3.tab || key3.upArrow || key3.downArrow) return void 0;
3325
+ if (key3.leftArrow || key3.rightArrow) return void 0;
3326
+ return input ? value + input : void 0;
3327
+ }
3328
+ var cycle = (list, cur, dir) => list[(list.indexOf(cur) + dir + list.length) % list.length];
3329
+ var FIELDS = ["provider", "serverUrl", "username", "password", "label", "color"];
3330
+ var NAMES = {
3331
+ provider: "Provider",
3332
+ serverUrl: "Server URL",
3333
+ username: "Email",
3334
+ password: "App password",
3335
+ label: "Label",
3336
+ color: "Colour"
3337
+ };
3338
+ function useKeyState(init) {
3339
+ const ref = useRef4(init);
3340
+ const [, bump] = useState4(0);
3341
+ return [
3342
+ () => ref.current,
3343
+ (next) => {
3344
+ ref.current = next;
3345
+ bump((n) => n + 1);
3346
+ }
3347
+ ];
3348
+ }
3349
+ var INITIAL = {
3350
+ field: "provider",
3351
+ preset: PRESETS2[0].id,
3352
+ serverUrl: PRESETS2[0].url,
3353
+ username: "",
3354
+ password: "",
3355
+ label: "",
3356
+ labelTouched: false,
3357
+ color: PALETTE2[0],
3358
+ busy: false,
3359
+ error: ""
3360
+ };
3361
+ function AddCaldav({ onBack, onDone }) {
3362
+ const api = useApi();
3363
+ const [get, set2] = useKeyState(INITIAL);
3364
+ const patch = (p) => set2({ ...get(), ...p });
3365
+ const submit = () => {
3366
+ const v2 = get();
3367
+ const input = {
3368
+ serverUrl: v2.serverUrl.trim(),
3369
+ username: v2.username.trim(),
3370
+ password: v2.password,
3371
+ label: v2.label.trim() || v2.username.trim(),
3372
+ color: v2.color
3373
+ };
3374
+ if (!input.serverUrl || !input.username || !input.password)
3375
+ return patch({ error: "Server URL, email and app password are required" });
3376
+ patch({ busy: true, error: "" });
3377
+ api.accounts.addCaldav(input).then(onDone, (e) => patch({ error: errorText(e), busy: false }));
3378
+ };
3379
+ const clickField = (f) => {
3380
+ const v2 = get();
3381
+ if (v2.busy) return;
3382
+ if (f !== v2.field) return patch({ field: f });
3383
+ if (f === "color") patch({ color: cycle(PALETTE2, v2.color, 1) });
3384
+ if (f === "provider") {
3385
+ const p = cycle(PRESETS2, PRESETS2.find((x) => x.id === v2.preset), 1);
3386
+ patch({ preset: p.id, serverUrl: p.url });
3387
+ }
3388
+ };
3389
+ useKeys((input, key3) => {
3390
+ const v2 = get();
3391
+ if (v2.busy) return;
3392
+ if (key3.escape) return onBack();
3393
+ if (key3.return) return submit();
3394
+ if (key3.tab || key3.downArrow) return patch({ field: cycle(FIELDS, v2.field, key3.shift ? -1 : 1) });
3395
+ if (key3.upArrow) return patch({ field: cycle(FIELDS, v2.field, -1) });
3396
+ const { field } = v2;
3397
+ if (field === "provider" || field === "color") {
3398
+ const dir = key3.leftArrow || input === "h" ? -1 : key3.rightArrow || input === "l" || input === " " ? 1 : 0;
3399
+ if (!dir) return;
3400
+ if (field === "color") return patch({ color: cycle(PALETTE2, v2.color, dir) });
3401
+ const p = cycle(PRESETS2, PRESETS2.find((x) => x.id === v2.preset), dir);
3402
+ return patch({ preset: p.id, serverUrl: p.url });
3403
+ }
3404
+ const next = editText(v2[field], input, key3);
3405
+ if (next === void 0) return;
3406
+ patch({
3407
+ [field]: next,
3408
+ ...field === "username" && !v2.labelTouched && { label: suggestLabel(next) },
3409
+ ...field === "serverUrl" && { preset: "custom" },
3410
+ ...field === "label" && { labelTouched: true }
3411
+ });
3412
+ });
3413
+ const v = get();
3414
+ const shown = (f) => {
3415
+ if (f === "provider") return `\u2039 ${PRESETS2.find((p) => p.id === v.preset).name} \u203A`;
3416
+ if (f === "color") return /* @__PURE__ */ jsxs7(Text7, { color: ansiOf(v.color), children: [
3417
+ "\u2039 \u25CF ",
3418
+ v.color,
3419
+ " \u203A"
3420
+ ] });
3421
+ if (f === "password") return "\u2022".repeat(v.password.length);
3422
+ return v[f] || /* @__PURE__ */ jsx9(Text7, { color: C.muted, children: f === "serverUrl" ? "https://caldav.example.com/" : f === "username" ? "you@company.com" : "Work" });
3423
+ };
3424
+ return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
3425
+ /* @__PURE__ */ jsx9(Text7, { bold: true, children: "Add CalDAV account" }),
3426
+ /* @__PURE__ */ jsx9(Text7, { color: C.muted, children: "Each account is isolated: events and invitations are only sent from the account they belong to." }),
3427
+ FIELDS.map((f) => /* @__PURE__ */ jsx9(Clickable, { onClick: () => clickField(f), children: /* @__PURE__ */ jsxs7(Text7, { children: [
3428
+ /* @__PURE__ */ jsx9(Text7, { color: f === v.field ? C.cyan : void 0, children: `${f === v.field ? "\u203A" : " "} ${NAMES[f].padEnd(13)}` }),
3429
+ shown(f),
3430
+ f === v.field && f !== "provider" && f !== "color" ? /* @__PURE__ */ jsx9(Text7, { color: C.cyan, children: "\u258F" }) : null
3431
+ ] }) }, f)),
3432
+ /* @__PURE__ */ jsx9(Text7, { color: C.muted, children: "Check your provider's docs if connection fails." }),
3433
+ v.busy && /* @__PURE__ */ jsx9(Text7, { color: C.yellow, children: "Verifying\u2026" }),
3434
+ v.error && /* @__PURE__ */ jsx9(Text7, { color: C.red, children: v.error }),
3435
+ /* @__PURE__ */ jsxs7(Box7, { flexWrap: "wrap", children: [
3436
+ /* @__PURE__ */ jsx9(Box7, { marginRight: 2, children: /* @__PURE__ */ jsx9(Text7, { color: C.muted, children: "tab/\u2191\u2193 field \u2190\u2192 change provider/colour" }) }),
3437
+ /* @__PURE__ */ jsx9(Button, { k: "enter", label: "add account", color: C.green, onPress: () => !get().busy && submit() }),
3438
+ /* @__PURE__ */ jsx9(Button, { k: "esc", label: "back", onPress: () => !get().busy && onBack() })
3439
+ ] })
3440
+ ] });
3441
+ }
3442
+
3443
+ // src/ui/screens/Accounts.tsx
3444
+ import { Fragment as Fragment2, jsx as jsx10, jsxs as jsxs8 } from "react/jsx-runtime";
3445
+ function Accounts({ onClose }) {
3446
+ const api = useApi();
3447
+ const { accounts, calendars, loaded, error: loadError } = useDirectory();
3448
+ const [getCursor, setCursor] = useKeyState(0);
3449
+ const [getMode, setMode] = useKeyState({ kind: "list" });
3450
+ const mode = getMode();
3451
+ const [message, setMessage] = useState5();
3452
+ const [authUrl, setAuthUrl] = useState5();
3453
+ useEffect4(() => api.onAuthUrl(setAuthUrl), [api]);
3454
+ const { stdout } = useStdout2();
3455
+ const copyAuthUrl = () => {
3456
+ if (!authUrl) return;
3457
+ stdout.write(`\x1B]52;c;${Buffer.from(authUrl).toString("base64")}\x07`);
3458
+ say("Sign-in link sent to the clipboard (needs a terminal with OSC 52 clipboard support)");
3459
+ };
3460
+ const googleSession = useRef5(0);
3461
+ const alive2 = useRef5(true);
3462
+ useEffect4(() => () => void (alive2.current = false), []);
3463
+ const rows = accounts.flatMap((account) => [
3464
+ { account },
3465
+ ...calendars.filter((c) => c.accountId === account.id).map((calendar) => ({ account, calendar }))
3466
+ ]);
3467
+ const current = () => {
3468
+ const idx2 = Math.min(getCursor(), Math.max(rows.length - 1, 0));
3469
+ return { idx: idx2, row: rows[idx2] };
3470
+ };
3471
+ const { idx } = current();
3472
+ const say = (text2, error = false) => {
3473
+ if (alive2.current) setMessage({ text: text2, error });
3474
+ };
3475
+ const run = (pending, done, fn) => {
3476
+ say(pending);
3477
+ fn().then(
3478
+ () => say(done),
3479
+ (e) => say(errorText(e), true)
3480
+ );
3481
+ };
3482
+ const addGoogle = () => {
3483
+ const s = ++googleSession.current;
3484
+ setMode({ kind: "google" });
3485
+ setMessage(void 0);
3486
+ setAuthUrl(void 0);
3487
+ api.accounts.addGoogle().then(
3488
+ (a) => s === googleSession.current && (setMode({ kind: "list" }), say(`Added ${a.label}`)),
3489
+ (e) => s === googleSession.current && (setMode({ kind: "list" }), say(errorText(e), true))
3490
+ );
3491
+ };
3492
+ const move = (dir) => setCursor(Math.max(Math.min(current().idx + dir, rows.length - 1), 0));
3493
+ const act = (input) => {
3494
+ const { row: row2 } = current();
3495
+ if (input === "R") return run("Syncing all\u2026", "Synced all", () => api.sync.now());
3496
+ if (input === "g") return addGoogle();
3497
+ if (input === "a") return setMode({ kind: "caldav" });
3498
+ if (!row2) return;
3499
+ const a = row2.account;
3500
+ const c = row2.calendar;
3501
+ if (input === " " && c) {
3502
+ return run(
3503
+ "Saving\u2026",
3504
+ `${c.name} ${c.visible === false ? "shown" : "hidden"}`,
3505
+ () => api.calendars.setVisible(a.id, c.id, c.visible === false)
3506
+ );
3507
+ }
3508
+ if (input === "r") return run(`Syncing ${a.label}\u2026`, `Synced ${a.label}`, () => api.sync.now(a.id));
3509
+ if (input === "e") return setMode({ kind: "rename", account: a, value: a.label });
3510
+ if (input === "c") {
3511
+ const color2 = cycle(PALETTE2, a.color, 1);
3512
+ return run("Saving\u2026", `${a.label} colour changed`, () => api.accounts.update(a.id, { color: color2 }));
3513
+ }
3514
+ if (input === "x") return setMode({ kind: "confirm", account: a });
3515
+ };
3516
+ const confirmRemove = (yes) => {
3517
+ const mode2 = getMode();
3518
+ if (mode2.kind !== "confirm") return;
3519
+ if (yes) run(`Removing ${mode2.account.label}\u2026`, `Removed ${mode2.account.label}`, () => api.accounts.remove(mode2.account.id));
3520
+ setMode({ kind: "list" });
3521
+ };
3522
+ const listing = () => getMode().kind === "list";
3523
+ const click = (i) => {
3524
+ if (!listing()) return;
3525
+ if (i === current().idx && rows[i]?.calendar) return act(" ");
3526
+ setCursor(i);
3527
+ };
3528
+ useKeys(
3529
+ (input, key3) => {
3530
+ const mode2 = getMode();
3531
+ if (mode2.kind === "google") {
3532
+ if (key3.escape || input === "q") {
3533
+ googleSession.current++;
3534
+ setMode({ kind: "list" });
3535
+ }
3536
+ if (input === "c") copyAuthUrl();
3537
+ return;
3538
+ }
3539
+ if (mode2.kind === "confirm") return confirmRemove(input.toLowerCase() === "y");
3540
+ if (mode2.kind === "rename") {
3541
+ if (key3.escape) return setMode({ kind: "list" });
3542
+ if (key3.return) {
3543
+ const next = mode2.value.trim();
3544
+ if (next && next !== mode2.account.label) {
3545
+ const id2 = mode2.account.id;
3546
+ run("Saving\u2026", `Renamed to ${next}`, () => api.accounts.update(id2, { label: next }));
3547
+ }
3548
+ return setMode({ kind: "list" });
3549
+ }
3550
+ const value = editText(mode2.value, input, key3);
3551
+ if (value !== void 0) setMode({ ...mode2, value });
3552
+ return;
3553
+ }
3554
+ if (key3.escape || input === "q") return onClose();
3555
+ if (input === "j" || key3.downArrow) return move(1);
3556
+ if (input === "k" || key3.upArrow) return move(-1);
3557
+ act(input);
3558
+ },
3559
+ { isActive: mode.kind !== "caldav" }
3560
+ );
3561
+ if (mode.kind === "caldav") {
3562
+ return /* @__PURE__ */ jsx10(Box8, { flexDirection: "column", borderStyle: "round", borderColor: C.muted, paddingX: 1, children: /* @__PURE__ */ jsx10(
3563
+ AddCaldav,
3564
+ {
3565
+ onBack: () => setMode({ kind: "list" }),
3566
+ onDone: () => {
3567
+ setMode({ kind: "list" });
3568
+ say("Account added");
3569
+ }
3570
+ }
3571
+ ) });
3572
+ }
3573
+ const button = (k, label, input = k) => /* @__PURE__ */ jsx10(Button, { k, label, onPress: () => listing() && act(input) });
3574
+ const row = rows[idx];
3575
+ return /* @__PURE__ */ jsxs8(Clickable, { flexDirection: "column", borderStyle: "round", borderColor: C.muted, paddingX: 1, onWheel: (dir) => listing() && move(dir), children: [
3576
+ /* @__PURE__ */ jsx10(Text8, { bold: true, children: "Accounts & calendars" }),
3577
+ /* @__PURE__ */ jsx10(Text8, { color: C.muted, children: "Accounts sync automatically every 2 minutes." }),
3578
+ loaded && !accounts.length && /* @__PURE__ */ jsx10(Text8, { color: C.muted, children: "No accounts yet. Press g (Google) or a (CalDAV) to add one." }),
3579
+ rows.map((r, i) => {
3580
+ const sel = i === idx;
3581
+ const mark = /* @__PURE__ */ jsx10(Text8, { color: C.cyan, children: sel ? "\u203A " : " " });
3582
+ if (r.calendar) {
3583
+ const c = r.calendar;
3584
+ return /* @__PURE__ */ jsx10(Clickable, { onClick: () => click(i), children: /* @__PURE__ */ jsxs8(Text8, { inverse: sel, children: [
3585
+ mark,
3586
+ " ",
3587
+ c.visible === false ? "[ ]" : "[x]",
3588
+ " ",
3589
+ /* @__PURE__ */ jsx10(Text8, { color: ansiOf(c.color), children: "\u25CF" }),
3590
+ " ",
3591
+ c.name,
3592
+ c.readOnly && /* @__PURE__ */ jsx10(Text8, { color: C.muted, children: " (read-only)" })
3593
+ ] }) }, `${c.accountId}/${c.id}`);
3594
+ }
3595
+ const a = r.account;
3596
+ const renaming = mode.kind === "rename" && mode.account.id === a.id;
3597
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
3598
+ /* @__PURE__ */ jsx10(Clickable, { onClick: () => click(i), children: /* @__PURE__ */ jsxs8(Text8, { inverse: sel && !renaming, children: [
3599
+ mark,
3600
+ /* @__PURE__ */ jsx10(Text8, { color: ansiOf(a.color), children: "\u25CF" }),
3601
+ " ",
3602
+ renaming ? /* @__PURE__ */ jsxs8(Text8, { color: C.cyan, children: [
3603
+ mode.value,
3604
+ "\u258F"
3605
+ ] }) : /* @__PURE__ */ jsx10(Text8, { bold: true, children: a.label }),
3606
+ " ",
3607
+ /* @__PURE__ */ jsxs8(Text8, { color: C.muted, children: [
3608
+ a.email,
3609
+ " \xB7 ",
3610
+ a.kind === "google" ? "Google" : "CalDAV"
3611
+ ] })
3612
+ ] }) }),
3613
+ a.error && /* @__PURE__ */ jsx10(Text8, { color: C.red, children: ` Last sync failed: ${a.error}` })
3614
+ ] }, a.id);
3615
+ }),
3616
+ mode.kind === "confirm" && /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", marginTop: 1, children: [
3617
+ /* @__PURE__ */ jsxs8(Text8, { color: C.red, bold: true, children: [
3618
+ "Remove ",
3619
+ mode.account.label,
3620
+ " (",
3621
+ mode.account.email,
3622
+ ")? [y/N]"
3623
+ ] }),
3624
+ /* @__PURE__ */ jsxs8(Text8, { children: [
3625
+ "Removes local data and credentials for ",
3626
+ mode.account.email,
3627
+ ". Nothing is deleted on the server."
3628
+ ] }),
3629
+ /* @__PURE__ */ jsxs8(Box8, { children: [
3630
+ /* @__PURE__ */ jsx10(Button, { k: "y", label: "remove", color: C.red, onPress: () => confirmRemove(true) }),
3631
+ /* @__PURE__ */ jsx10(Button, { k: "n", label: "keep", onPress: () => confirmRemove(false) })
3632
+ ] })
3633
+ ] }),
3634
+ mode.kind === "google" && /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
3635
+ /* @__PURE__ */ jsx10(Text8, { color: C.yellow, children: "Opening browser \u2014 finish sign-in there\u2026 (esc to stop waiting)" }),
3636
+ authUrl && /* @__PURE__ */ jsxs8(Fragment2, { children: [
3637
+ /* @__PURE__ */ jsx10(Text8, { color: C.muted, children: "No browser? Open this link yourself (c copies it):" }),
3638
+ /* @__PURE__ */ jsx10(Text8, { children: authUrl }),
3639
+ /* @__PURE__ */ jsx10(Text8, { color: C.muted, children: `Over SSH, forward its port first: ssh -L ${loopbackPort(authUrl)}:127.0.0.1:${loopbackPort(authUrl)} <host>` })
3640
+ ] })
3641
+ ] }),
3642
+ mode.kind === "rename" && /* @__PURE__ */ jsx10(Text8, { color: C.muted, children: "enter save esc cancel" }),
3643
+ loadError && /* @__PURE__ */ jsx10(Text8, { color: C.red, children: loadError }),
3644
+ message && /* @__PURE__ */ jsx10(Text8, { color: message.error ? C.red : C.green, children: message.text }),
3645
+ mode.kind === "list" && /* @__PURE__ */ jsxs8(Box8, { marginTop: 1, flexWrap: "wrap", children: [
3646
+ /* @__PURE__ */ jsx10(Box8, { marginRight: 2, children: /* @__PURE__ */ jsx10(Text8, { color: C.muted, children: "j/k move" }) }),
3647
+ row?.calendar && button("space", "show/hide", " "),
3648
+ row && button("r", "sync"),
3649
+ button("R", "sync all"),
3650
+ row && button("e", "rename"),
3651
+ row && button("c", "colour"),
3652
+ row && button("x", "remove"),
3653
+ button("g", "add Google"),
3654
+ button("a", "add CalDAV"),
3655
+ /* @__PURE__ */ jsx10(Button, { k: "esc", label: "close", onPress: onClose })
3656
+ ] })
3657
+ ] });
3658
+ }
3659
+ var loopbackPort = (authUrl) => {
3660
+ try {
3661
+ return new URL(new URL(authUrl).searchParams.get("redirect_uri") ?? "").port;
3662
+ } catch {
3663
+ return "?";
3664
+ }
3665
+ };
3666
+
3667
+ // src/ui/screens/Invites.tsx
3668
+ import { useMemo as useMemo5, useRef as useRef6, useState as useState6 } from "react";
3669
+ import { Box as Box9, Text as Text9 } from "ink";
3670
+ import { addDays as addDays8, startOfDay as startOfDay6 } from "date-fns";
3671
+ import { jsx as jsx11, jsxs as jsxs9 } from "react/jsx-runtime";
3672
+ var INVITE_DAYS = 60;
3673
+ function useUpcoming(now) {
3674
+ const day = startOfDay6(now).getTime();
3675
+ const range = useMemo5(() => ({ start: new Date(day).toISOString(), end: addDays8(day, INVITE_DAYS).toISOString() }), [day]);
3676
+ return useEvents(range);
3677
+ }
3678
+ var REPLY = { y: "accepted", n: "declined", m: "tentative" };
3679
+ function Invites({ onClose }) {
3680
+ const api = useApi();
3681
+ const [now] = useState6(() => /* @__PURE__ */ new Date());
3682
+ const { events, loaded } = useUpcoming(now);
3683
+ const invites = useMemo5(() => pendingInvites(events, now), [events, now]);
3684
+ const { accounts } = useDirectory();
3685
+ const [idx, setIdx] = useState6(0);
3686
+ const [rows, setRows] = useState6({});
3687
+ const busy = useRef6(/* @__PURE__ */ new Set());
3688
+ const [error, setError] = useState6("");
3689
+ const sel = Math.min(idx, Math.max(invites.length - 1, 0));
3690
+ const reply = (e, status) => {
3691
+ const k = eventKey(e);
3692
+ if (busy.current.has(k)) return;
3693
+ busy.current.add(k);
3694
+ setError("");
3695
+ setRows((r) => ({ ...r, [k]: "sending\u2026" }));
3696
+ api.events.respond(e, status).then(
3697
+ () => setRows((r) => ({ ...r, [k]: "sent" })),
3698
+ (err) => {
3699
+ busy.current.delete(k);
3700
+ setRows(({ [k]: _, ...r }) => r);
3701
+ setError(errorText(err));
3702
+ }
3703
+ );
3704
+ };
3705
+ const move = (dir) => setIdx(Math.max(Math.min(sel + dir, invites.length - 1), 0));
3706
+ const replySelected = (status) => void (invites[sel] && reply(invites[sel], status));
3707
+ useKeys((input, key3) => {
3708
+ if (key3.escape || input === "q") return onClose();
3709
+ if (input === "j" || key3.downArrow) return move(1);
3710
+ if (input === "k" || key3.upArrow) return move(-1);
3711
+ if (Object.hasOwn(REPLY, input)) replySelected(REPLY[input]);
3712
+ });
3713
+ return /* @__PURE__ */ jsxs9(Clickable, { flexDirection: "column", borderStyle: "round", borderColor: C.muted, paddingX: 1, onWheel: move, children: [
3714
+ /* @__PURE__ */ jsx11(Text9, { bold: true, children: "Invitations" }),
3715
+ !loaded ? /* @__PURE__ */ jsx11(Text9, { color: C.muted, children: "Loading\u2026" }) : invites.length === 0 ? /* @__PURE__ */ jsx11(Text9, { color: C.muted, children: "No pending invites" }) : invites.map((e, i) => {
3716
+ const a = accounts.find((x) => x.id === e.accountId);
3717
+ const o = ownerLine(void 0, a?.label ?? e.accountId, a?.email);
3718
+ return /* @__PURE__ */ jsx11(Clickable, { height: 1, onClick: () => setIdx(i), children: /* @__PURE__ */ jsxs9(Text9, { wrap: "truncate", inverse: i === sel, children: [
3719
+ i === sel ? "\u25B8 " : " ",
3720
+ /* @__PURE__ */ jsx11(Text9, { bold: true, children: e.title || "Untitled" }),
3721
+ " ",
3722
+ /* @__PURE__ */ jsx11(Text9, { color: C.muted, children: formatWhen(e) }),
3723
+ " ",
3724
+ /* @__PURE__ */ jsx11(Text9, { color: ansiOf(a?.color ?? ""), children: "\u25CF" }),
3725
+ " ",
3726
+ o.label,
3727
+ o.email ? ` \xB7 ${o.email}` : "",
3728
+ rows[eventKey(e)] ? /* @__PURE__ */ jsxs9(Text9, { color: C.yellow, children: [
3729
+ " ",
3730
+ rows[eventKey(e)]
3731
+ ] }) : null
3732
+ ] }) }, eventKey(e));
3733
+ }),
3734
+ error ? /* @__PURE__ */ jsx11(Text9, { color: C.red, children: error }) : null,
3735
+ /* @__PURE__ */ jsxs9(Box9, { marginTop: 1, flexWrap: "wrap", children: [
3736
+ invites.length > 0 && /* @__PURE__ */ jsx11(Button, { k: "y", label: "accept", color: C.green, onPress: () => replySelected("accepted") }),
3737
+ invites.length > 0 && /* @__PURE__ */ jsx11(Button, { k: "n", label: "decline", color: C.red, onPress: () => replySelected("declined") }),
3738
+ invites.length > 0 && /* @__PURE__ */ jsx11(Button, { k: "m", label: "maybe", color: C.yellow, onPress: () => replySelected("tentative") }),
3739
+ /* @__PURE__ */ jsx11(Box9, { marginRight: 2, children: /* @__PURE__ */ jsx11(Text9, { color: C.muted, children: "j/k move" }) }),
3740
+ /* @__PURE__ */ jsx11(Button, { k: "esc", label: "close", onPress: onClose })
3741
+ ] })
3742
+ ] });
3743
+ }
3744
+
3745
+ // src/ui/StatusLine.tsx
3746
+ import { useEffect as useEffect5, useState as useState7 } from "react";
3747
+ import { Box as Box10, Text as Text10 } from "ink";
3748
+ import { Fragment as Fragment3, jsx as jsx12, jsxs as jsxs10 } from "react/jsx-runtime";
3749
+ var DAY_MS = 24 * 36e5;
3750
+ var noop = () => {
3751
+ };
3752
+ var FRAMES = "\u280B\u2819\u2839\u2838\u283C\u2834\u2826\u2827\u2807\u280F";
3753
+ function Spinner() {
3754
+ const [i, setI] = useState7(0);
3755
+ useEffect5(() => {
3756
+ const t = setInterval(() => setI((n) => (n + 1) % FRAMES.length), 100);
3757
+ return () => clearInterval(t);
3758
+ }, []);
3759
+ return /* @__PURE__ */ jsx12(Text10, { color: C.accent, children: FRAMES[i] });
3760
+ }
3761
+ function StatusLine({ now, message, update, width, onOpen, onInvites, onAccounts, onHelp, onNew, onSync, onQuit }) {
3762
+ const { events } = useUpcoming(now);
3763
+ const { accounts } = useDirectory();
3764
+ const failed = accounts.filter((a) => a.error);
3765
+ const syncing = accounts.filter((a) => a.syncing);
3766
+ const { current, next } = pickNowNext(events, now);
3767
+ const soon = next && Date.parse(next.start) - now.getTime() < DAY_MS ? next : void 0;
3768
+ const invites = pendingInvites(events, now).length;
3769
+ const parts = [];
3770
+ if (syncing.length) parts.push([/* @__PURE__ */ jsxs10(Fragment3, { children: [
3771
+ /* @__PURE__ */ jsx12(Spinner, {}),
3772
+ " ",
3773
+ /* @__PURE__ */ jsxs10(Text10, { color: C.muted, children: [
3774
+ "syncing ",
3775
+ syncing.map((a) => a.label).join(", ")
3776
+ ] })
3777
+ ] }), onAccounts]);
3778
+ for (const e of current) parts.push([/* @__PURE__ */ jsxs10(Fragment3, { children: [
3779
+ /* @__PURE__ */ jsx12(Text10, { color: C.green, bold: true, children: "now" }),
3780
+ " ",
3781
+ e.title || "Untitled"
3782
+ ] }), onOpen && (() => onOpen(e))]);
3783
+ if (soon) parts.push([/* @__PURE__ */ jsxs10(Fragment3, { children: [
3784
+ /* @__PURE__ */ jsx12(Text10, { color: C.cyan, bold: true, children: "next" }),
3785
+ " ",
3786
+ soon.title || "Untitled",
3787
+ " ",
3788
+ /* @__PURE__ */ jsx12(Text10, { color: C.yellow, children: startsLabel(soon.start, now) })
3789
+ ] }), onOpen && (() => onOpen(soon))]);
3790
+ if (invites) parts.push([/* @__PURE__ */ jsxs10(Text10, { color: C.yellow, children: [
3791
+ invites === 1 ? "1 invite" : `${invites} invites`,
3792
+ " (i)"
3793
+ ] }), onInvites]);
3794
+ if (failed.length) parts.push([/* @__PURE__ */ jsxs10(Text10, { color: C.red, children: [
3795
+ "\u26A0 ",
3796
+ failed.map((a) => a.label).join(", "),
3797
+ " sync failed (s)"
3798
+ ] }), onAccounts]);
3799
+ if (message) parts.push([message]);
3800
+ if (!parts.length) parts.push([/* @__PURE__ */ jsx12(Text10, { color: C.muted, children: "nothing on in the next 24h" })]);
3801
+ if (update) parts.push([/* @__PURE__ */ jsxs10(Text10, { color: C.yellow, children: [
3802
+ "\u2191 ",
3803
+ update,
3804
+ " available \xB7 npm i -g mysticals"
3805
+ ] })]);
3806
+ return /* @__PURE__ */ jsxs10(Box10, { flexDirection: "column", width, height: 2, children: [
3807
+ /* @__PURE__ */ jsx12(Box10, { width, height: 1, overflow: "hidden", children: parts.map(([p, onClick], i) => /* @__PURE__ */ jsx12(Clickable, { flexShrink: 0, onClick, children: /* @__PURE__ */ jsxs10(Text10, { children: [
3808
+ i > 0 && /* @__PURE__ */ jsx12(Text10, { color: C.muted, children: " \xB7 " }),
3809
+ p
3810
+ ] }) }, i)) }),
3811
+ /* @__PURE__ */ jsxs10(Box10, { width, height: 1, overflow: "hidden", children: [
3812
+ /* @__PURE__ */ jsx12(Button, { k: "n", label: "new", onPress: onNew ?? noop }),
3813
+ /* @__PURE__ */ jsx12(Button, { k: "r", label: "sync", onPress: onSync ?? noop }),
3814
+ /* @__PURE__ */ jsx12(Button, { k: "i", label: "invites", onPress: onInvites ?? noop }),
3815
+ /* @__PURE__ */ jsx12(Button, { k: "s", label: "accounts", onPress: onAccounts ?? noop }),
3816
+ /* @__PURE__ */ jsx12(Button, { k: "?", label: "help", onPress: onHelp ?? noop }),
3817
+ /* @__PURE__ */ jsx12(Button, { k: "q", label: "quit", onPress: onQuit ?? noop })
3818
+ ] })
3819
+ ] });
3820
+ }
3821
+
3822
+ // src/ui/App.tsx
3823
+ import { Fragment as Fragment4, jsx as jsx13, jsxs as jsxs11 } from "react/jsx-runtime";
3824
+ var VIEWS = { agenda: Agenda, day: Day, "2day": TwoDay, week: Week, month: Month };
3825
+ var VIEW_KEYS = { a: "agenda", d: "day", "2": "2day", w: "week", m: "month" };
3826
+ var VIEW_LABEL = { agenda: "Agenda", day: "Day", "2day": "2 Days", week: "Week", month: "Month" };
3827
+ var HELP = [
3828
+ ["a d 2 w m", "agenda / day / 2 days / week / month"],
3829
+ ["\u2190 \u2192", "previous / next day (agenda: event); also h l"],
3830
+ ["\u2191 \u2193", "previous / next event of the day (month: any day)"],
3831
+ ["j k", "next / previous event"],
3832
+ ["\u21E7\u2190 \u21E7\u2192", "previous / next page (also H L)"],
3833
+ ["t", "today"],
3834
+ ["enter", "open selected event"],
3835
+ ["n", "new event"],
3836
+ ["i", "invites"],
3837
+ ["s", "accounts & calendars"],
3838
+ ["r", "sync now"],
3839
+ ["?", "this help"],
3840
+ ["q", "quit"],
3841
+ ["click", "tabs, buttons, events (click again to open), day headers"],
3842
+ ["wheel", "scroll the list / hours"],
3843
+ process.platform === "darwin" ? ["\u2325 drag", "select text (Shift in some terminals)"] : ["\u21E7 drag", "select text"]
3844
+ ];
3845
+ function Help({ onClose }) {
3846
+ useKeys(() => onClose());
3847
+ return /* @__PURE__ */ jsxs11(Clickable, { flexDirection: "column", borderStyle: "round", borderColor: C.accent, paddingX: 1, onClick: onClose, children: [
3848
+ /* @__PURE__ */ jsx13(Text11, { bold: true, color: C.accent, children: "Keys" }),
3849
+ HELP.map(([k, d]) => /* @__PURE__ */ jsxs11(Text11, { children: [
3850
+ /* @__PURE__ */ jsx13(Text11, { color: C.cyan, children: k.padEnd(10) }),
3851
+ " ",
3852
+ d
3853
+ ] }, k)),
3854
+ /* @__PURE__ */ jsx13(Text11, { color: C.muted, children: "any key or click to close" })
3855
+ ] });
3856
+ }
3857
+ var TABS = ["agenda", "day", "2day", "week", "month"];
3858
+ var PANE_MIN_WIDTH = 100;
3859
+ function Header({ nav, width, onView, onShift, onToday }) {
3860
+ return /* @__PURE__ */ jsxs11(Box11, { width, height: 1, overflow: "hidden", children: [
3861
+ TABS.map((v) => /* @__PURE__ */ jsx13(Clickable, { flexShrink: 0, onClick: () => onView(v), children: /* @__PURE__ */ jsx13(Text11, { inverse: v === nav.view, color: v === nav.view ? C.accent : C.muted, bold: v === nav.view, children: ` ${VIEW_LABEL[v]} ` }) }, v)),
3862
+ /* @__PURE__ */ jsxs11(Box11, { flexShrink: 0, marginX: 1, children: [
3863
+ /* @__PURE__ */ jsx13(Clickable, { onClick: () => onShift(-1), children: /* @__PURE__ */ jsx13(Text11, { color: C.cyan, children: " \u2039 " }) }),
3864
+ /* @__PURE__ */ jsx13(Clickable, { onClick: onToday, children: /* @__PURE__ */ jsx13(Text11, { color: C.muted, children: "today" }) }),
3865
+ /* @__PURE__ */ jsx13(Clickable, { onClick: () => onShift(1), children: /* @__PURE__ */ jsx13(Text11, { color: C.cyan, children: " \u203A " }) })
3866
+ ] }),
3867
+ /* @__PURE__ */ jsx13(Box11, { flexGrow: 1, flexShrink: 1, children: /* @__PURE__ */ jsx13(Text11, { bold: true, wrap: "truncate", children: title(nav) }) })
3868
+ ] });
3869
+ }
3870
+ function Preview({ event, selected, now, width, height, onOpen }) {
3871
+ const { accounts, calendars } = useDirectory();
3872
+ const account = event && accounts.find((a) => a.id === event.accountId);
3873
+ const calendar = event && calendars.find((c) => c.accountId === event.accountId && c.id === event.calendarId);
3874
+ const [showAll, setShowAll] = useState8(false);
3875
+ return /* @__PURE__ */ jsx13(
3876
+ Clickable,
3877
+ {
3878
+ width,
3879
+ height,
3880
+ flexDirection: "column",
3881
+ borderStyle: "round",
3882
+ borderColor: selected ? C.accent : C.muted,
3883
+ paddingX: 1,
3884
+ overflow: "hidden",
3885
+ onClick: event && (() => onOpen(event)),
3886
+ children: event ? /* @__PURE__ */ jsxs11(Fragment4, { children: [
3887
+ /* @__PURE__ */ jsx13(Text11, { color: C.muted, children: selected ? "Selected \xB7 enter or click to open" : "Up next \xB7 \u2190/\u2192 to select" }),
3888
+ /* @__PURE__ */ jsx13(EventInfo, { event, account, calendar, now, showAll, onToggleAll: () => setShowAll(!showAll) })
3889
+ ] }) : /* @__PURE__ */ jsx13(Text11, { color: C.muted, children: "Nothing ahead in this range" })
3890
+ }
3891
+ );
3892
+ }
3893
+ function FirstSync({ pending, width, height, children }) {
3894
+ return pending ? /* @__PURE__ */ jsx13(FirstSyncCheck, { width, height, children }) : /* @__PURE__ */ jsx13(Fragment4, { children });
3895
+ }
3896
+ function FirstSyncCheck({ width, height, children }) {
3897
+ const first = useDirectory().accounts.filter((a) => a.syncing && !a.synced);
3898
+ if (!first.length) return /* @__PURE__ */ jsx13(Fragment4, { children });
3899
+ return /* @__PURE__ */ jsx13(Box11, { width, height, alignItems: "center", justifyContent: "center", children: /* @__PURE__ */ jsxs11(Text11, { children: [
3900
+ /* @__PURE__ */ jsx13(Spinner, {}),
3901
+ " First sync of ",
3902
+ first.map((a) => a.label).join(", "),
3903
+ "\u2026"
3904
+ ] }) });
3905
+ }
3906
+ function title({ view, date }) {
3907
+ if (view === "month") return format11(date, "MMMM yyyy");
3908
+ if (view === "day") return format11(date, "EEE d MMM yyyy");
3909
+ const days = view === "agenda" ? [date, addDays9(date, AGENDA_DAYS - 1)] : view === "2day" ? [date, addDays9(date, 1)] : viewDays(view, date);
3910
+ return rangeLabel(days[0], days[days.length - 1]);
3911
+ }
3912
+ var chronological = (a, b) => eventBounds(a).start.getTime() - eventBounds(b).start.getTime() || Number(b.allDay) - Number(a.allDay);
3913
+ function App(props) {
3914
+ return /* @__PURE__ */ jsx13(MouseProvider, { children: /* @__PURE__ */ jsx13(Shell, { ...props }) });
3915
+ }
3916
+ function Shell({ initialNav, updateCheck, notice }) {
3917
+ const api = useApi();
3918
+ const { exit } = useApp();
3919
+ const { isRawModeSupported } = useStdin2();
3920
+ const { nav, setView, setDate, shift, today } = useNav(initialNav);
3921
+ const range = useMemo6(() => navRange(nav), [nav]);
3922
+ const { events, loadedFor, error } = useEvents(range);
3923
+ const now = useNow();
3924
+ const { width, height } = useTermSize();
3925
+ const [selectedKey, setSelectedKey] = useState8();
3926
+ const [overlay, setOverlay] = useState8();
3927
+ const [message, setMessage] = useState8(notice);
3928
+ const [update, setUpdate] = useState8();
3929
+ useEffect6(() => void updateCheck?.then(setUpdate), [updateCheck]);
3930
+ const ordered2 = useMemo6(() => [...events].sort(chronological), [events]);
3931
+ const selectedIdx = ordered2.findIndex((e) => eventKey(e) === selectedKey);
3932
+ const close = () => setOverlay(void 0);
3933
+ const [cursorAt, setCursor] = useState8(() => startOfDay7(nav.date));
3934
+ const onPageOf = ({ view, date }, day) => {
3935
+ if (view === "month") return isSameMonth2(day, date);
3936
+ const r = navRange({ view, date });
3937
+ return day >= new Date(r.start) && day < new Date(r.end);
3938
+ };
3939
+ const cursor = onPageOf(nav, cursorAt) ? cursorAt : startOfDay7(nav.date);
3940
+ const grid = nav.view !== "agenda";
3941
+ const live = useRef7({ cursor, ...nav });
3942
+ const rendered = useRef7("");
3943
+ const snapshot = `${+cursor}|${nav.view}|${+nav.date}|${selectedKey}`;
3944
+ if (snapshot !== rendered.current) {
3945
+ rendered.current = snapshot;
3946
+ Object.assign(live.current, { cursor, ...nav, key: selectedKey });
3947
+ }
3948
+ const choose = (e) => {
3949
+ live.current.key = e && eventKey(e);
3950
+ setSelectedKey(live.current.key);
3951
+ };
3952
+ const select = (e) => {
3953
+ choose(e);
3954
+ if (!e) return;
3955
+ live.current.cursor = max([startOfDay7(eventBounds(e).start), startOfDay7(new Date(range.start))]);
3956
+ setCursor(live.current.cursor);
3957
+ };
3958
+ const pickOn = (list, day, minute) => {
3959
+ const on = eventsOnDay(list, day);
3960
+ if (minute === void 0) return on[0];
3961
+ const at3 = (e) => e.allDay ? Infinity : Math.max(differenceInMinutes3(eventBounds(e).start, day), 0);
3962
+ return on.reduce((best, e) => !best || Math.abs(at3(e) - minute) < Math.abs(at3(best) - minute) ? e : best, void 0);
3963
+ };
3964
+ const pending = useRef7(void 0);
3965
+ useEffect6(() => {
3966
+ const p = pending.current;
3967
+ const want = navRange(live.current);
3968
+ if (!p || loadedFor !== `${want.start}/${want.end}`) return;
3969
+ pending.current = void 0;
3970
+ if ("day" in p) return choose(pickOn(ordered2, p.day, p.minute));
3971
+ const at3 = (e) => eventBounds(e).start.getTime();
3972
+ const next = p.dir > 0 ? ordered2.find((e) => at3(e) > p.from) : [...ordered2].reverse().find((e) => at3(e) < p.from);
3973
+ select(next ?? ordered2[p.dir > 0 ? 0 : ordered2.length - 1]);
3974
+ }, [ordered2]);
3975
+ const move = (dir) => {
3976
+ const i = ordered2.findIndex((e) => eventKey(e) === live.current.key);
3977
+ if (i >= 0 && i + dir >= 0 && i + dir < ordered2.length) return select(ordered2[i + dir]);
3978
+ if (i < 0 && ordered2.length) return select(ordered2[dir > 0 ? 0 : ordered2.length - 1]);
3979
+ pending.current = { dir, from: i >= 0 ? eventBounds(ordered2[i]).start.getTime() : dir > 0 ? -Infinity : Infinity };
3980
+ live.current.date = shiftNav(live.current, dir).date;
3981
+ shift(dir);
3982
+ };
3983
+ const stepDay = (n) => {
3984
+ const { cursor: from, view, date, key: key3 } = live.current;
3985
+ const sel = ordered2.find((e) => eventKey(e) === key3);
3986
+ const minute = sel ? sel.allDay ? void 0 : Math.max(differenceInMinutes3(eventBounds(sel).start, from), 0) : live.current.minute;
3987
+ const day = addDays9(from, n);
3988
+ const nextDate = onPageOf({ view, date }, day) ? date : view === "2day" && n > 0 ? addDays9(day, -1) : day;
3989
+ Object.assign(live.current, { cursor: day, date: nextDate, minute });
3990
+ setCursor(day);
3991
+ if (+nextDate !== +date) setDate(nextDate);
3992
+ if (+nextDate === +nav.date && view === nav.view) return choose(pickOn(events, day, minute));
3993
+ choose();
3994
+ pending.current = { day, minute };
3995
+ };
3996
+ const stepInDay = (dir) => {
3997
+ const on = eventsOnDay(events, live.current.cursor);
3998
+ const i = on.findIndex((e2) => eventKey(e2) === live.current.key);
3999
+ const e = i < 0 ? on[dir > 0 ? 0 : on.length - 1] : on[Math.min(Math.max(i + dir, 0), on.length - 1)];
4000
+ if (e) choose(e);
4001
+ };
4002
+ const newStart = () => grid ? set(cursor, { hours: now.getHours(), minutes: now.getMinutes() }) : nav.date;
4003
+ const syncNow = () => {
4004
+ setMessage(void 0);
4005
+ api.sync.now().then(
4006
+ () => setMessage("Synced"),
4007
+ (e) => setMessage(`Sync failed: ${errorText(e)}`)
4008
+ );
4009
+ };
4010
+ const openDay = (day) => {
4011
+ setDate(day);
4012
+ setCursor(startOfDay7(day));
4013
+ setView("day");
4014
+ };
4015
+ const goToday = () => {
4016
+ setCursor(startOfDay7(/* @__PURE__ */ new Date()));
4017
+ today();
4018
+ };
4019
+ useKeys(
4020
+ (input, key3) => {
4021
+ if (Object.hasOwn(VIEW_KEYS, input)) {
4022
+ live.current.view = VIEW_KEYS[input];
4023
+ return setView(live.current.view);
4024
+ }
4025
+ if (input === "H" || key3.shift && key3.leftArrow) return shift(-1);
4026
+ if (input === "L" || key3.shift && key3.rightArrow) return shift(1);
4027
+ if (input === "t") return goToday();
4028
+ if (input === "j") return move(1);
4029
+ if (input === "k") return move(-1);
4030
+ const dir = input === "l" || key3.rightArrow ? 1 : input === "h" || key3.leftArrow ? -1 : 0;
4031
+ const vdir = key3.downArrow ? 1 : key3.upArrow ? -1 : 0;
4032
+ const view = live.current.view;
4033
+ if (view === "agenda" && (dir || vdir)) return move(dir || vdir);
4034
+ if (dir) return stepDay(dir);
4035
+ if (vdir) return view === "month" ? move(vdir) : stepInDay(vdir);
4036
+ if (key3.return) {
4037
+ const sel = ordered2.find((e) => eventKey(e) === live.current.key);
4038
+ return sel && setOverlay({ kind: "details", event: sel });
4039
+ }
4040
+ if (input === "n") return setOverlay({ kind: "editor", initialStart: newStart() });
4041
+ if (input === "i") return setOverlay({ kind: "invites" });
4042
+ if (input === "s") return setOverlay({ kind: "accounts" });
4043
+ if (input === "r") return syncNow();
4044
+ if (input === "?") return setOverlay({ kind: "help" });
4045
+ if (input === "q") return exit();
4046
+ },
4047
+ { isActive: !overlay && isRawModeSupported === true }
4048
+ );
4049
+ const ViewComponent = VIEWS[nav.view];
4050
+ const bodyHeight = Math.max(height - 3, 1);
4051
+ const paneWidth = !overlay && (nav.view === "agenda" || nav.view === "day" || nav.view === "2day") && width >= PANE_MIN_WIDTH ? Math.min(56, Math.floor(width * 0.4)) : 0;
4052
+ const previewed = selectedIdx >= 0 ? ordered2[selectedIdx] : ordered2.find((e) => eventBounds(e).end > now);
4053
+ const body = () => {
4054
+ switch (overlay?.kind) {
4055
+ case "details":
4056
+ return /* @__PURE__ */ jsx13(EventDetails, { event: overlay.event, onClose: close, onEdit: (event) => setOverlay({ kind: "editor", event }) });
4057
+ case "editor":
4058
+ return /* @__PURE__ */ jsx13(EventEditor, { event: overlay.event, initialStart: overlay.initialStart, onClose: close });
4059
+ case "accounts":
4060
+ return /* @__PURE__ */ jsx13(Accounts, { onClose: close });
4061
+ case "invites":
4062
+ return /* @__PURE__ */ jsx13(Invites, { onClose: close });
4063
+ case "help":
4064
+ return /* @__PURE__ */ jsx13(Help, { onClose: close });
4065
+ default:
4066
+ return /* @__PURE__ */ jsxs11(FirstSync, { pending: !events.length, width, height: bodyHeight, children: [
4067
+ /* @__PURE__ */ jsx13(
4068
+ ViewComponent,
4069
+ {
4070
+ events,
4071
+ date: nav.date,
4072
+ now,
4073
+ selectedKey: selectedIdx >= 0 ? selectedKey : void 0,
4074
+ cursor: grid ? cursor : void 0,
4075
+ onSelect: select,
4076
+ onOpen: (event) => setOverlay({ kind: "details", event }),
4077
+ onPickDay: openDay,
4078
+ width: width - paneWidth,
4079
+ height: bodyHeight
4080
+ }
4081
+ ),
4082
+ paneWidth > 0 && /* @__PURE__ */ jsx13(
4083
+ Preview,
4084
+ {
4085
+ event: previewed,
4086
+ selected: selectedIdx >= 0,
4087
+ now,
4088
+ width: paneWidth,
4089
+ height: bodyHeight,
4090
+ onOpen: (event) => setOverlay({ kind: "details", event })
4091
+ },
4092
+ previewed && eventKey(previewed)
4093
+ )
4094
+ ] });
4095
+ }
4096
+ };
4097
+ return /* @__PURE__ */ jsxs11(Box11, { flexDirection: "column", width, height, children: [
4098
+ /* @__PURE__ */ jsx13(
4099
+ Header,
4100
+ {
4101
+ nav,
4102
+ width,
4103
+ onView: (v) => !overlay && setView(v),
4104
+ onShift: (dir) => !overlay && shift(dir),
4105
+ onToday: () => !overlay && goToday()
4106
+ }
4107
+ ),
4108
+ /* @__PURE__ */ jsx13(Box11, { flexDirection: overlay ? "column" : "row", height: bodyHeight, children: body() }),
4109
+ /* @__PURE__ */ jsx13(
4110
+ StatusLine,
4111
+ {
4112
+ nav,
4113
+ events,
4114
+ now,
4115
+ message: error ? `Error: ${error}` : message,
4116
+ update,
4117
+ width,
4118
+ onOpen: (event) => !overlay && setOverlay({ kind: "details", event }),
4119
+ onInvites: () => !overlay && setOverlay({ kind: "invites" }),
4120
+ onAccounts: () => !overlay && setOverlay({ kind: "accounts" }),
4121
+ onHelp: () => !overlay && setOverlay({ kind: "help" }),
4122
+ onNew: () => !overlay && setOverlay({ kind: "editor", initialStart: newStart() }),
4123
+ onSync: syncNow,
4124
+ onQuit: () => !overlay && exit()
4125
+ }
4126
+ )
4127
+ ] });
4128
+ }
4129
+
4130
+ // src/update.ts
4131
+ import { mkdir as mkdir2, readFile, writeFile as writeFile2 } from "fs/promises";
4132
+ import { join as join6 } from "path";
4133
+ var DAY_MS2 = 24 * 36e5;
4134
+ function isNewer(a, b) {
4135
+ const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
4136
+ const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
4137
+ for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0);
4138
+ return false;
4139
+ }
4140
+ async function cached(file) {
4141
+ try {
4142
+ const c = JSON.parse(await readFile(file, "utf8"));
4143
+ return typeof c.checkedAt === "number" && Date.now() - c.checkedAt < DAY_MS2 ? c : void 0;
4144
+ } catch {
4145
+ return void 0;
4146
+ }
4147
+ }
4148
+ async function checkUpdate(signal, current = version) {
4149
+ const registry = process.env.MYSTICALS_UPDATE_REGISTRY;
4150
+ if (isMock() && !registry) return void 0;
4151
+ const file = join6(homeDir(), "update-check.json");
4152
+ try {
4153
+ let latest = (await cached(file))?.latest;
4154
+ if (latest === void 0) {
4155
+ const res = await fetch(`${registry || "https://registry.npmjs.org"}/mysticals/latest`, {
4156
+ signal: AbortSignal.any([AbortSignal.timeout(3e3), ...signal ? [signal] : []])
4157
+ });
4158
+ if (!res.ok) return void 0;
4159
+ const v = (await res.json()).version;
4160
+ if (typeof v !== "string") return void 0;
4161
+ latest = v;
4162
+ await mkdir2(homeDir(), { recursive: true });
4163
+ await writeFile2(file, JSON.stringify({ checkedAt: Date.now(), latest }));
4164
+ }
4165
+ return isNewer(latest, current) ? latest : void 0;
4166
+ } catch {
4167
+ return void 0;
4168
+ }
4169
+ }
4170
+
4171
+ // src/cli.ts
4172
+ var HELP2 = `mysticals ${version} \u2014 isolated multi-account calendar for the terminal
4173
+
4174
+ Usage: mysticals [--help | --version]
4175
+
4176
+ All open mysticals windows share one background daemon (accounts, sync, notifications);
4177
+ it stops a few seconds after the last window closes.
4178
+
4179
+ Environment:
4180
+ MYSTICALS_HOME data directory (default: mysticals-terminal in ~/Library/Application Support
4181
+ on macOS, %APPDATA% on Windows, $XDG_DATA_HOME or ~/.local/share on Linux)
4182
+ MYSTICALS_MOCK=1 demo mode with fake accounts; nothing real is touched
4183
+ (PowerShell: $env:MYSTICALS_MOCK=1; mysticals cmd: set "MYSTICALS_MOCK=1" && mysticals)
4184
+ MYSTICALS_TELEMETRY=0 (or DO_NOT_TRACK=1) turns off the anonymous usage stats
4185
+ (app installed, account added; no personal data)
4186
+
4187
+ Press ? inside the app for keys.`;
4188
+ var leaveScreen = () => {
4189
+ };
4190
+ async function main(argv) {
4191
+ if (argv.includes("--help") || argv.includes("-h")) return void console.log(HELP2);
4192
+ if (argv.includes("--version") || argv.includes("-v")) return void console.log(version);
4193
+ if (argv.includes("--daemon")) return runDaemon();
4194
+ if (argv.length) {
4195
+ console.error(`Unknown argument: ${argv[0]}
4196
+
4197
+ ${HELP2}`);
4198
+ process.exit(2);
4199
+ }
4200
+ const notice = telemetry()?.installed ? TELEMETRY_NOTICE : void 0;
4201
+ const api = await connect();
4202
+ const stopUpdate = new AbortController();
4203
+ if (process.stdin.isTTY && process.stdout.isTTY) {
4204
+ process.stdout.write("\x1B[?1049h\x1B[H\x1B[?1000h\x1B[?1006h");
4205
+ leaveScreen = () => void process.stdout.write("\x1B[?1006l\x1B[?1000l\x1B[?1049l");
4206
+ process.on("exit", leaveScreen);
4207
+ for (const s of ["SIGTERM", "SIGHUP"]) process.on(s, () => process.exit(0));
4208
+ }
4209
+ const app = render(createElement(ApiContext.Provider, { value: api }, createElement(App, { updateCheck: checkUpdate(stopUpdate.signal), notice })));
4210
+ if (!process.stdin.isTTY) process.stdin.on("end", () => app.unmount()).resume();
4211
+ await app.waitUntilExit();
4212
+ stopUpdate.abort();
4213
+ api.close();
4214
+ }
4215
+ main(process.argv.slice(2)).catch((e) => {
4216
+ leaveScreen();
4217
+ console.error(e instanceof Error ? e.message : e);
4218
+ process.exit(1);
4219
+ });