heyhank 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 (199) hide show
  1. package/README.md +40 -0
  2. package/bin/cli.ts +168 -0
  3. package/bin/ctl.ts +528 -0
  4. package/bin/generate-token.ts +28 -0
  5. package/dist/apple-touch-icon.png +0 -0
  6. package/dist/assets/AgentsPage-BPhirnCe.js +7 -0
  7. package/dist/assets/AssistantPage-DJ-cMQfb.js +1 -0
  8. package/dist/assets/CronManager-DDbz-yiT.js +1 -0
  9. package/dist/assets/HelpPage-DMfkzERp.js +1 -0
  10. package/dist/assets/IntegrationsPage-CrOitCmJ.js +1 -0
  11. package/dist/assets/MediaPage-CE5rdvkC.js +1 -0
  12. package/dist/assets/PlatformDashboard-Do6F0O2p.js +1 -0
  13. package/dist/assets/Playground-Fc5cdc5p.js +109 -0
  14. package/dist/assets/ProcessPanel-CslEiZkI.js +2 -0
  15. package/dist/assets/PromptsPage-D2EhsdNO.js +4 -0
  16. package/dist/assets/RunsPage-C5BZF5Rx.js +1 -0
  17. package/dist/assets/SandboxManager-a1AVI5q2.js +8 -0
  18. package/dist/assets/SettingsPage-DirhjQrJ.js +51 -0
  19. package/dist/assets/SocialMediaPage-DBuM28vD.js +1 -0
  20. package/dist/assets/TailscalePage-CHiFhZXF.js +1 -0
  21. package/dist/assets/TelephonyPage-x0VV0fOo.js +1 -0
  22. package/dist/assets/TerminalPage-Drwyrnfd.js +1 -0
  23. package/dist/assets/gemini-audio-t-TSU-To.js +17 -0
  24. package/dist/assets/gemini-live-client-C7rqAW7G.js +166 -0
  25. package/dist/assets/index-C8M_PUmX.css +32 -0
  26. package/dist/assets/index-CEqZnThB.js +204 -0
  27. package/dist/assets/sw-register-LSSpj6RU.js +1 -0
  28. package/dist/assets/time-ago-B6r_l9u1.js +1 -0
  29. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  30. package/dist/favicon-32-original.png +0 -0
  31. package/dist/favicon-32.png +0 -0
  32. package/dist/favicon.ico +0 -0
  33. package/dist/favicon.svg +8 -0
  34. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  35. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  36. package/dist/heyhank-mascot-poster.png +0 -0
  37. package/dist/heyhank-mascot.mp4 +0 -0
  38. package/dist/heyhank-mascot.webm +0 -0
  39. package/dist/icon-192-original.png +0 -0
  40. package/dist/icon-192.png +0 -0
  41. package/dist/icon-512-original.png +0 -0
  42. package/dist/icon-512.png +0 -0
  43. package/dist/index.html +21 -0
  44. package/dist/logo-192.png +0 -0
  45. package/dist/logo-512.png +0 -0
  46. package/dist/logo-codex.svg +14 -0
  47. package/dist/logo-docker.svg +4 -0
  48. package/dist/logo-original.png +0 -0
  49. package/dist/logo.png +0 -0
  50. package/dist/logo.svg +14 -0
  51. package/dist/manifest.json +24 -0
  52. package/dist/push-sw.js +34 -0
  53. package/dist/sw.js +1 -0
  54. package/dist/workbox-d2a0910a.js +1 -0
  55. package/package.json +109 -0
  56. package/server/agent-cron-migrator.ts +85 -0
  57. package/server/agent-executor.ts +357 -0
  58. package/server/agent-store.ts +185 -0
  59. package/server/agent-timeout.ts +107 -0
  60. package/server/agent-types.ts +122 -0
  61. package/server/ai-validation-settings.ts +37 -0
  62. package/server/ai-validator.ts +181 -0
  63. package/server/anthropic-provider-migration.ts +48 -0
  64. package/server/assistant-store.ts +272 -0
  65. package/server/auth-manager.ts +150 -0
  66. package/server/auto-approve.ts +153 -0
  67. package/server/auto-namer.ts +36 -0
  68. package/server/backend-adapter.ts +54 -0
  69. package/server/cache-headers.ts +61 -0
  70. package/server/calendar-service.ts +434 -0
  71. package/server/claude-adapter.ts +889 -0
  72. package/server/claude-container-auth.ts +30 -0
  73. package/server/claude-session-discovery.ts +157 -0
  74. package/server/claude-session-history.ts +410 -0
  75. package/server/cli-launcher.ts +1303 -0
  76. package/server/codex-adapter.ts +3027 -0
  77. package/server/codex-container-auth.ts +24 -0
  78. package/server/codex-home.ts +27 -0
  79. package/server/codex-ws-proxy.cjs +226 -0
  80. package/server/commands-discovery.ts +81 -0
  81. package/server/constants.ts +7 -0
  82. package/server/container-manager.ts +1053 -0
  83. package/server/cost-tracker.ts +222 -0
  84. package/server/cron-scheduler.ts +243 -0
  85. package/server/cron-store.ts +148 -0
  86. package/server/cron-types.ts +63 -0
  87. package/server/email-service.ts +354 -0
  88. package/server/env-manager.ts +161 -0
  89. package/server/event-bus-types.ts +75 -0
  90. package/server/event-bus.ts +124 -0
  91. package/server/execution-store.ts +170 -0
  92. package/server/federation/node-connection.ts +190 -0
  93. package/server/federation/node-manager.ts +366 -0
  94. package/server/federation/node-store.ts +86 -0
  95. package/server/federation/node-types.ts +121 -0
  96. package/server/fs-utils.ts +15 -0
  97. package/server/git-utils.ts +421 -0
  98. package/server/github-pr.ts +379 -0
  99. package/server/google-media.ts +342 -0
  100. package/server/image-pull-manager.ts +279 -0
  101. package/server/index.ts +491 -0
  102. package/server/internal-ai.ts +237 -0
  103. package/server/kill-switch.ts +99 -0
  104. package/server/llm-providers.ts +342 -0
  105. package/server/logger.ts +259 -0
  106. package/server/mcp-registry.ts +401 -0
  107. package/server/message-bus.ts +271 -0
  108. package/server/message-delivery.ts +128 -0
  109. package/server/metrics-collector.ts +350 -0
  110. package/server/metrics-types.ts +108 -0
  111. package/server/middleware/managed-auth.ts +195 -0
  112. package/server/novnc-proxy.ts +99 -0
  113. package/server/path-resolver.ts +186 -0
  114. package/server/paths.ts +13 -0
  115. package/server/pr-poller.ts +162 -0
  116. package/server/prompt-manager.ts +211 -0
  117. package/server/protocol/claude-upstream/README.md +19 -0
  118. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  119. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  120. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  121. package/server/protocol/codex-upstream/README.md +18 -0
  122. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  123. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  124. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  125. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  126. package/server/protocol-monitor.ts +50 -0
  127. package/server/provider-manager.ts +111 -0
  128. package/server/provider-registry.ts +393 -0
  129. package/server/push-notifications.ts +221 -0
  130. package/server/recorder.ts +374 -0
  131. package/server/recording-hub/compat-validator.ts +284 -0
  132. package/server/recording-hub/diagnostics.ts +299 -0
  133. package/server/recording-hub/hub-config.ts +19 -0
  134. package/server/recording-hub/hub-routes.ts +236 -0
  135. package/server/recording-hub/hub-store.ts +265 -0
  136. package/server/recording-hub/replay-adapter.ts +207 -0
  137. package/server/relay-client.ts +320 -0
  138. package/server/reminder-scheduler.ts +38 -0
  139. package/server/replay.ts +78 -0
  140. package/server/routes/agent-routes.ts +264 -0
  141. package/server/routes/assistant-routes.ts +90 -0
  142. package/server/routes/cron-routes.ts +103 -0
  143. package/server/routes/env-routes.ts +95 -0
  144. package/server/routes/federation-routes.ts +76 -0
  145. package/server/routes/fs-routes.ts +622 -0
  146. package/server/routes/git-routes.ts +97 -0
  147. package/server/routes/llm-routes.ts +166 -0
  148. package/server/routes/media-routes.ts +135 -0
  149. package/server/routes/metrics-routes.ts +13 -0
  150. package/server/routes/platform-routes.ts +1379 -0
  151. package/server/routes/prompt-routes.ts +67 -0
  152. package/server/routes/provider-routes.ts +109 -0
  153. package/server/routes/sandbox-routes.ts +127 -0
  154. package/server/routes/settings-routes.ts +285 -0
  155. package/server/routes/skills-routes.ts +100 -0
  156. package/server/routes/socialmedia-routes.ts +208 -0
  157. package/server/routes/system-routes.ts +228 -0
  158. package/server/routes/tailscale-routes.ts +22 -0
  159. package/server/routes/telephony-routes.ts +259 -0
  160. package/server/routes.ts +1379 -0
  161. package/server/sandbox-manager.ts +168 -0
  162. package/server/service.ts +718 -0
  163. package/server/session-creation-service.ts +457 -0
  164. package/server/session-git-info.ts +104 -0
  165. package/server/session-names.ts +67 -0
  166. package/server/session-orchestrator.ts +824 -0
  167. package/server/session-state-machine.ts +207 -0
  168. package/server/session-store.ts +146 -0
  169. package/server/session-types.ts +511 -0
  170. package/server/settings-manager.ts +149 -0
  171. package/server/shared-context.ts +157 -0
  172. package/server/socialmedia/adapter.ts +15 -0
  173. package/server/socialmedia/adapters/ayrshare-adapter.ts +169 -0
  174. package/server/socialmedia/adapters/buffer-adapter.ts +299 -0
  175. package/server/socialmedia/adapters/postiz-adapter.ts +298 -0
  176. package/server/socialmedia/manager.ts +227 -0
  177. package/server/socialmedia/store.ts +98 -0
  178. package/server/socialmedia/types.ts +89 -0
  179. package/server/tailscale-manager.ts +451 -0
  180. package/server/telephony/audio-bridge.ts +331 -0
  181. package/server/telephony/call-manager.ts +457 -0
  182. package/server/telephony/call-types.ts +108 -0
  183. package/server/telephony/telephony-store.ts +119 -0
  184. package/server/terminal-manager.ts +240 -0
  185. package/server/update-checker.ts +192 -0
  186. package/server/usage-limits.ts +225 -0
  187. package/server/web-push.d.ts +51 -0
  188. package/server/worktree-tracker.ts +84 -0
  189. package/server/ws-auth.ts +41 -0
  190. package/server/ws-bridge-browser-ingest.ts +72 -0
  191. package/server/ws-bridge-browser.ts +112 -0
  192. package/server/ws-bridge-cli-ingest.ts +81 -0
  193. package/server/ws-bridge-codex.ts +266 -0
  194. package/server/ws-bridge-controls.ts +20 -0
  195. package/server/ws-bridge-persist.ts +66 -0
  196. package/server/ws-bridge-publish.ts +79 -0
  197. package/server/ws-bridge-replay.ts +61 -0
  198. package/server/ws-bridge-types.ts +121 -0
  199. package/server/ws-bridge.ts +1240 -0
@@ -0,0 +1,434 @@
1
+ // ─── Calendar Service ─────────────────────────────────────────────────────────
2
+ // Multi-account CalDAV calendar integration for the personal assistant.
3
+ // Supports Google Calendar, iCloud, Nextcloud, and any CalDAV-compatible server.
4
+
5
+ import { DAVClient, DAVCalendar } from "tsdav";
6
+ import ICAL from "ical.js";
7
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { HEYHANK_HOME } from "./paths.js";
10
+
11
+ const CONFIG_PATH = join(HEYHANK_HOME, "assistant", "calendar-accounts.json");
12
+
13
+ // ─── Types ───────────────────────────────────────────────────────────────────
14
+
15
+ export interface CalendarAccount {
16
+ id: string;
17
+ name: string; // display name, e.g. "Google" or "iCloud"
18
+ provider: "google" | "icloud" | "caldav"; // preset or custom
19
+ serverUrl: string; // CalDAV server URL
20
+ auth: {
21
+ user: string;
22
+ pass: string; // App Password for Google/iCloud
23
+ };
24
+ defaultCalendarId?: string; // preferred calendar for this account
25
+ }
26
+
27
+ export interface CalendarEvent {
28
+ uid: string;
29
+ summary: string;
30
+ description?: string;
31
+ location?: string;
32
+ start: string; // ISO datetime
33
+ end: string; // ISO datetime
34
+ allDay: boolean;
35
+ recurrence?: string;
36
+ status?: string; // CONFIRMED, TENTATIVE, CANCELLED
37
+ organizer?: string;
38
+ attendees?: string[];
39
+ calendarName?: string;
40
+ accountId: string;
41
+ accountName: string;
42
+ }
43
+
44
+ export interface CalendarInfo {
45
+ url: string;
46
+ displayName: string;
47
+ ctag?: string;
48
+ accountId: string;
49
+ }
50
+
51
+ // ─── Provider Presets ────────────────────────────────────────────────────────
52
+
53
+ export const PROVIDER_PRESETS: Record<string, { serverUrl: string; label: string; helpText: string }> = {
54
+ google: {
55
+ serverUrl: "https://apidata.googleusercontent.com/caldav/v2/",
56
+ label: "Google Calendar",
57
+ helpText: "Use your Gmail address and an App Password (myaccount.google.com/apppasswords). 2FA must be enabled.",
58
+ },
59
+ icloud: {
60
+ serverUrl: "https://caldav.icloud.com/",
61
+ label: "iCloud Calendar",
62
+ helpText: "Use your Apple ID email and an App-Specific Password (appleid.apple.com → Sign-In and Security → App-Specific Passwords).",
63
+ },
64
+ outlook: {
65
+ serverUrl: "https://outlook.office365.com/caldav/",
66
+ label: "Outlook Calendar",
67
+ helpText: "Use your Outlook/Hotmail email and an App Password (account.microsoft.com → Security → App passwords). 2-Step Verification must be enabled.",
68
+ },
69
+ caldav: {
70
+ serverUrl: "",
71
+ label: "Custom CalDAV",
72
+ helpText: "Enter the full CalDAV URL of your server (e.g. https://cloud.example.com/remote.php/dav/ for Nextcloud).",
73
+ },
74
+ };
75
+
76
+ // ─── Account Management ──────────────────────────────────────────────────────
77
+
78
+ function ensureDir(): void {
79
+ const dir = join(HEYHANK_HOME, "assistant");
80
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
81
+ }
82
+
83
+ export function loadAccounts(): CalendarAccount[] {
84
+ ensureDir();
85
+ try {
86
+ if (existsSync(CONFIG_PATH)) {
87
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) as CalendarAccount[];
88
+ }
89
+ } catch { /* ignore */ }
90
+ return [];
91
+ }
92
+
93
+ export function saveAccounts(accounts: CalendarAccount[]): void {
94
+ ensureDir();
95
+ writeFileSync(CONFIG_PATH, JSON.stringify(accounts, null, 2), { encoding: "utf-8", mode: 0o600 });
96
+ }
97
+
98
+ export function addAccount(data: Omit<CalendarAccount, "id">): CalendarAccount {
99
+ const accounts = loadAccounts();
100
+ const account: CalendarAccount = {
101
+ ...data,
102
+ id: `cal-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
103
+ };
104
+ accounts.push(account);
105
+ saveAccounts(accounts);
106
+ return account;
107
+ }
108
+
109
+ export function removeAccount(id: string): boolean {
110
+ const accounts = loadAccounts();
111
+ const idx = accounts.findIndex((a) => a.id === id);
112
+ if (idx === -1) return false;
113
+ accounts.splice(idx, 1);
114
+ saveAccounts(accounts);
115
+ return true;
116
+ }
117
+
118
+ // Fuzzy match for Gemini tool calls
119
+ function similarity(a: string, b: string): number {
120
+ a = a.toLowerCase().trim();
121
+ b = b.toLowerCase().trim();
122
+ if (a === b) return 1;
123
+ if (a.includes(b) || b.includes(a)) return 0.8;
124
+ // Dice coefficient
125
+ const bigrams = (s: string) => {
126
+ const set: string[] = [];
127
+ for (let i = 0; i < s.length - 1; i++) set.push(s.slice(i, i + 2));
128
+ return set;
129
+ };
130
+ const aBi = bigrams(a);
131
+ const bBi = bigrams(b);
132
+ let matches = 0;
133
+ for (const bi of aBi) if (bBi.includes(bi)) matches++;
134
+ return (2 * matches) / (aBi.length + bBi.length) || 0;
135
+ }
136
+
137
+ export function getAccount(idOrName: string): CalendarAccount | undefined {
138
+ const accounts = loadAccounts();
139
+ const exact = accounts.find((a) => a.id === idOrName || a.name.toLowerCase() === idOrName.toLowerCase());
140
+ if (exact) return exact;
141
+ let best: CalendarAccount | undefined;
142
+ let bestScore = 0;
143
+ for (const a of accounts) {
144
+ const score = Math.max(similarity(idOrName, a.name), similarity(idOrName, a.auth.user));
145
+ if (score > bestScore) { bestScore = score; best = a; }
146
+ }
147
+ return bestScore >= 0.4 ? best : undefined;
148
+ }
149
+
150
+ // ─── CalDAV Client ───────────────────────────────────────────────────────────
151
+
152
+ async function createClient(account: CalendarAccount): Promise<DAVClient> {
153
+ const client = new DAVClient({
154
+ serverUrl: account.serverUrl,
155
+ credentials: {
156
+ username: account.auth.user,
157
+ password: account.auth.pass,
158
+ },
159
+ authMethod: "Basic",
160
+ defaultAccountType: "caldav",
161
+ });
162
+ await client.login();
163
+ return client;
164
+ }
165
+
166
+ // ─── Calendar Operations ─────────────────────────────────────────────────────
167
+
168
+ /** Filter to only VEVENT calendars (exclude VTODO/Reminders) */
169
+ function isEventCalendar(cal: DAVCalendar): boolean {
170
+ const components = (cal as unknown as { components?: string[] }).components;
171
+ if (!components || components.length === 0) return true; // assume event calendar if unknown
172
+ return components.includes("VEVENT");
173
+ }
174
+
175
+ /** List all calendars for an account */
176
+ export async function listCalendars(account: CalendarAccount): Promise<CalendarInfo[]> {
177
+ const client = await createClient(account);
178
+ const calendars = await client.fetchCalendars();
179
+ return calendars.filter(isEventCalendar).map((cal) => ({
180
+ url: cal.url,
181
+ displayName: (typeof cal.displayName === "string" ? cal.displayName : null) || "Unnamed",
182
+ ctag: cal.ctag,
183
+ accountId: account.id,
184
+ }));
185
+ }
186
+
187
+ /** Parse ICS data into CalendarEvent objects */
188
+ function parseICS(icsData: string, accountId: string, accountName: string, calendarName?: string): CalendarEvent[] {
189
+ const events: CalendarEvent[] = [];
190
+ try {
191
+ const jcal = ICAL.parse(icsData);
192
+ const comp = new ICAL.Component(jcal);
193
+ const vevents = comp.getAllSubcomponents("vevent");
194
+
195
+ for (const vevent of vevents) {
196
+ const event = new ICAL.Event(vevent);
197
+ const dtstart = vevent.getFirstPropertyValue("dtstart") as ICAL.Time | null;
198
+ const dtend = vevent.getFirstPropertyValue("dtend") as ICAL.Time | null;
199
+
200
+ const allDay = dtstart?.isDate || false;
201
+ const start = dtstart ? (allDay ? dtstart.toString() : dtstart.toJSDate().toISOString()) : "";
202
+ const end = dtend ? (allDay ? dtend.toString() : dtend.toJSDate().toISOString()) : start;
203
+
204
+ const attendeeProps = vevent.getAllProperties("attendee");
205
+ const attendees = attendeeProps.map((a) => {
206
+ const cn = a.getParameter("cn");
207
+ const val = (a.getFirstValue() as string || "").replace("mailto:", "");
208
+ return cn ? `${cn} <${val}>` : val;
209
+ });
210
+
211
+ events.push({
212
+ uid: event.uid || "",
213
+ summary: event.summary || "(No title)",
214
+ description: event.description || undefined,
215
+ location: event.location || undefined,
216
+ start,
217
+ end,
218
+ allDay,
219
+ status: vevent.getFirstPropertyValue("status") as string || undefined,
220
+ organizer: (vevent.getFirstPropertyValue("organizer") as string || "").replace("mailto:", "") || undefined,
221
+ attendees: attendees.length > 0 ? attendees : undefined,
222
+ calendarName,
223
+ accountId,
224
+ accountName,
225
+ });
226
+ }
227
+ } catch (err) {
228
+ console.error("[calendar-service] ICS parse error:", err);
229
+ }
230
+ return events;
231
+ }
232
+
233
+ /** Fetch events from a specific date range */
234
+ export async function listEvents(
235
+ account: CalendarAccount,
236
+ opts: { from?: string; to?: string; calendarUrl?: string } = {},
237
+ ): Promise<CalendarEvent[]> {
238
+ const client = await createClient(account);
239
+ const allCalendars = await client.fetchCalendars();
240
+ const calendars = allCalendars.filter(isEventCalendar);
241
+
242
+ // Filter to specific calendar if requested
243
+ const targetCalendars = opts.calendarUrl
244
+ ? calendars.filter((c) => c.url === opts.calendarUrl)
245
+ : calendars;
246
+
247
+ if (targetCalendars.length === 0) return [];
248
+
249
+ // Default: today to 7 days ahead
250
+ const now = new Date();
251
+ const from = opts.from ? new Date(opts.from) : new Date(now.getFullYear(), now.getMonth(), now.getDate());
252
+ const to = opts.to ? new Date(opts.to) : new Date(from.getTime() + 7 * 24 * 60 * 60 * 1000);
253
+
254
+ const allEvents: CalendarEvent[] = [];
255
+
256
+ for (const cal of targetCalendars) {
257
+ try {
258
+ const objects = await client.fetchCalendarObjects({
259
+ calendar: cal as DAVCalendar,
260
+ timeRange: {
261
+ start: from.toISOString(),
262
+ end: to.toISOString(),
263
+ },
264
+ });
265
+
266
+ for (const obj of objects) {
267
+ if (obj.data) {
268
+ const calName = typeof cal.displayName === "string" ? cal.displayName : undefined;
269
+ const events = parseICS(obj.data, account.id, account.name, calName);
270
+ allEvents.push(...events);
271
+ }
272
+ }
273
+ } catch (err) {
274
+ console.error(`[calendar-service] Error fetching from calendar "${cal.displayName}":`, err);
275
+ }
276
+ }
277
+
278
+ // Sort by start time
279
+ allEvents.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
280
+ return allEvents;
281
+ }
282
+
283
+ /** Create a new calendar event */
284
+ export async function createEvent(
285
+ account: CalendarAccount,
286
+ event: {
287
+ summary: string;
288
+ description?: string;
289
+ location?: string;
290
+ start: string; // ISO datetime or YYYY-MM-DD for all-day
291
+ end: string; // ISO datetime or YYYY-MM-DD for all-day
292
+ allDay?: boolean;
293
+ calendarUrl?: string;
294
+ },
295
+ ): Promise<{ success: boolean; uid: string }> {
296
+ const client = await createClient(account);
297
+ const allCalendars = await client.fetchCalendars();
298
+ const calendars = allCalendars.filter(isEventCalendar);
299
+
300
+ // Pick target calendar (only event calendars, never Reminders/VTODO)
301
+ let targetCal = event.calendarUrl
302
+ ? calendars.find((c) => c.url === event.calendarUrl)
303
+ : (account.defaultCalendarId
304
+ ? calendars.find((c) => c.url === account.defaultCalendarId)
305
+ : calendars[0]);
306
+
307
+ if (!targetCal) targetCal = calendars[0];
308
+ if (!targetCal) throw new Error("No calendar found on this account");
309
+
310
+ const uid = `${Date.now()}-${Math.random().toString(36).slice(2)}@heyhank`;
311
+
312
+ // Build ICS
313
+ const dtStart = event.allDay
314
+ ? `;VALUE=DATE:${event.start.replace(/-/g, "")}`
315
+ : `:${new Date(event.start).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "")}`;
316
+ const dtEnd = event.allDay
317
+ ? `;VALUE=DATE:${event.end.replace(/-/g, "")}`
318
+ : `:${new Date(event.end).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "")}`;
319
+
320
+ const lines = [
321
+ "BEGIN:VCALENDAR",
322
+ "VERSION:2.0",
323
+ "PRODID:-//HeyHank//Calendar//EN",
324
+ "BEGIN:VEVENT",
325
+ `UID:${uid}`,
326
+ `DTSTART${dtStart}`,
327
+ `DTEND${dtEnd}`,
328
+ `SUMMARY:${escapeICS(event.summary)}`,
329
+ ];
330
+ if (event.description) lines.push(`DESCRIPTION:${escapeICS(event.description)}`);
331
+ if (event.location) lines.push(`LOCATION:${escapeICS(event.location)}`);
332
+ lines.push(`DTSTAMP:${new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "")}`);
333
+ lines.push("END:VEVENT", "END:VCALENDAR");
334
+
335
+ const icsData = lines.join("\r\n");
336
+
337
+ await client.createCalendarObject({
338
+ calendar: targetCal as DAVCalendar,
339
+ filename: `${uid}.ics`,
340
+ iCalString: icsData,
341
+ });
342
+
343
+ return { success: true, uid };
344
+ }
345
+
346
+ /** Delete a calendar event by UID */
347
+ export async function deleteEvent(
348
+ account: CalendarAccount,
349
+ uid: string,
350
+ ): Promise<boolean> {
351
+ const client = await createClient(account);
352
+ const allCalendars = await client.fetchCalendars();
353
+ const calendars = allCalendars.filter(isEventCalendar);
354
+
355
+ for (const cal of calendars) {
356
+ try {
357
+ const objects = await client.fetchCalendarObjects({ calendar: cal as DAVCalendar });
358
+ for (const obj of objects) {
359
+ if (obj.data?.includes(uid) && obj.url) {
360
+ await client.deleteCalendarObject({ calendarObject: { url: obj.url, etag: obj.etag || "" } });
361
+ return true;
362
+ }
363
+ }
364
+ } catch { /* continue searching */ }
365
+ }
366
+ return false;
367
+ }
368
+
369
+ /** Search events by text query */
370
+ export async function searchEvents(
371
+ account: CalendarAccount,
372
+ query: string,
373
+ opts: { from?: string; to?: string } = {},
374
+ ): Promise<CalendarEvent[]> {
375
+ // CalDAV text search isn't universally supported, so fetch and filter locally
376
+ const now = new Date();
377
+ const from = opts.from || new Date(now.getFullYear(), now.getMonth(), now.getDate()).toISOString();
378
+ const to = opts.to || new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString(); // 30 days
379
+
380
+ const events = await listEvents(account, { from, to });
381
+ const q = query.toLowerCase();
382
+ return events.filter((e) =>
383
+ e.summary.toLowerCase().includes(q) ||
384
+ (e.description || "").toLowerCase().includes(q) ||
385
+ (e.location || "").toLowerCase().includes(q),
386
+ );
387
+ }
388
+
389
+ /** Get a summary of upcoming events across all accounts */
390
+ export async function getUpcomingSummary(): Promise<{
391
+ account: string;
392
+ todayCount: number;
393
+ weekCount: number;
394
+ nextEvent?: { summary: string; start: string };
395
+ }[]> {
396
+ const accounts = loadAccounts();
397
+ const results = [];
398
+ const now = new Date();
399
+ const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
400
+ const weekEnd = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
401
+
402
+ for (const account of accounts) {
403
+ try {
404
+ const weekEvents = await listEvents(account, {
405
+ from: now.toISOString(),
406
+ to: weekEnd.toISOString(),
407
+ });
408
+ const todayEvents = weekEvents.filter((e) => new Date(e.start) < todayEnd);
409
+ results.push({
410
+ account: account.name,
411
+ todayCount: todayEvents.length,
412
+ weekCount: weekEvents.length,
413
+ nextEvent: weekEvents[0] ? { summary: weekEvents[0].summary, start: weekEvents[0].start } : undefined,
414
+ });
415
+ } catch (err) {
416
+ results.push({
417
+ account: account.name,
418
+ todayCount: 0,
419
+ weekCount: 0,
420
+ });
421
+ }
422
+ }
423
+ return results;
424
+ }
425
+
426
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
427
+
428
+ function escapeICS(text: string): string {
429
+ return text
430
+ .replace(/\\/g, "\\\\")
431
+ .replace(/;/g, "\\;")
432
+ .replace(/,/g, "\\,")
433
+ .replace(/\n/g, "\\n");
434
+ }