wakatime.post.ts
1 import path from "path"; 2 import { H3Event } from "h3"; 3 import { z } from "zod"; 4 import { processHeartbeatsByDate } from "~~/server/utils/summarize"; 5 import { handleApiError, handleLog } from "~~/server/utils/logging"; 6 7 interface WakApiHeartbeat { 8 id: string; 9 branch: string; 10 category: string; 11 entity: string; 12 is_write: boolean; 13 language: string; 14 project: string; 15 time: number; 16 type: string; 17 user_id: string; 18 machine_name_id: string; 19 user_agent_id: string; 20 lines: number; 21 lineno: number; 22 cursorpos: number; 23 line_deletions: number; 24 line_additions: number; 25 created_at: string; 26 } 27 28 const wakaApiRequestSchema = z.object({ 29 apiKey: z.string().min(1, "API key is required"), 30 instanceType: z.enum(["wakapi", "wakatime"]), 31 instanceUrl: z.string().url().optional(), 32 }); 33 34 const wakaTimeExportSchema = z.object({ 35 user: z 36 .object({ 37 username: z.string().nullable().optional(), 38 display_name: z.string().nullable().optional(), 39 }) 40 .passthrough(), 41 range: z.object({ 42 start: z.number(), 43 end: z.number(), 44 }), 45 days: z.array( 46 z.object({ 47 date: z.string(), 48 heartbeats: z.array( 49 z 50 .object({ 51 branch: z.string().optional().nullable(), 52 entity: z.string().optional().nullable(), 53 time: z.number(), 54 language: z.string().optional().nullable(), 55 project: z.string().optional().nullable(), 56 user_agent_id: z.string().optional().nullable(), 57 }) 58 .passthrough() 59 ), 60 }) 61 ), 62 }); 63 64 async function fetchRangeHeartbeats( 65 baseUrl: string, 66 userIdentifier: string, 67 headers: any, 68 startDate: Date, 69 endDate: Date, 70 userId: string 71 ) { 72 handleLog( 73 `Fetching heartbeats from ${startDate.toISOString()} to ${endDate.toISOString()}` 74 ); 75 76 const today = new Date(); 77 const adjustedEndDate = new Date(); 78 adjustedEndDate.setHours(23, 59, 59, 999); 79 80 if (endDate < adjustedEndDate) { 81 endDate = adjustedEndDate; 82 } 83 84 const allDateStrings: string[] = []; 85 const currentDate = new Date(startDate); 86 87 while (currentDate <= endDate) { 88 allDateStrings.push(currentDate.toISOString().split("T")[0]); 89 currentDate.setDate(currentDate.getDate() + 1); 90 } 91 92 const tomorrow = new Date(today); 93 tomorrow.setDate(tomorrow.getDate() + 1); 94 const tomorrowStr = tomorrow.toISOString().split("T")[0]; 95 96 if (!allDateStrings.includes(tomorrowStr)) { 97 allDateStrings.push(tomorrowStr); 98 } 99 100 handleLog( 101 `Generated ${allDateStrings.length} dates to check based on date range, including tomorrow to ensure all heartbeats are captured` 102 ); 103 104 const heartbeatsByDate = new Map<string, any[]>(); 105 const progressUpdateInterval = Math.max( 106 1, 107 Math.floor(allDateStrings.length / 10) 108 ); 109 110 for (let i = 0; i < allDateStrings.length; i++) { 111 const dateStr = allDateStrings[i]; 112 113 if (i % progressUpdateInterval === 0 || i === allDateStrings.length - 1) { 114 handleLog( 115 `Processing date ${i + 1}/${allDateStrings.length}: ${dateStr} (${Math.round(((i + 1) / allDateStrings.length) * 100)}% complete)` 116 ); 117 } 118 119 try { 120 const heartbeatsUrl = `${baseUrl}/users/${userIdentifier}/heartbeats`; 121 const heartbeatsResponse = await $fetch<{ 122 data: WakApiHeartbeat[]; 123 }>(heartbeatsUrl, { 124 params: { 125 date: dateStr, 126 }, 127 headers, 128 }); 129 130 if (!heartbeatsResponse?.data || heartbeatsResponse.data.length === 0) { 131 if (i % progressUpdateInterval === 0) { 132 handleLog(`No heartbeats found for ${dateStr}`); 133 } 134 continue; 135 } 136 137 if (i % progressUpdateInterval === 0) { 138 handleLog( 139 `Found ${heartbeatsResponse.data.length} heartbeats for ${dateStr}` 140 ); 141 } 142 143 const heartbeats = heartbeatsResponse.data.map((h) => 144 processHeartbeat(h, userId) 145 ); 146 147 if (heartbeats.length > 0) { 148 heartbeatsByDate.set(dateStr, heartbeats); 149 await processHeartbeatsByDate(userId, heartbeats); 150 } 151 } catch (error) { 152 handleApiError(500, `Error fetching heartbeats for ${dateStr} for user ${userId}: ${error instanceof Error ? error.message : String(error)}`, "An error occurred while fetching some activity data. The import may be incomplete."); 153 } 154 155 await new Promise((resolve) => setTimeout(resolve, 50)); 156 } 157 158 handleLog(`Completed processing all ${allDateStrings.length} dates`); 159 return heartbeatsByDate; 160 } 161 162 function processHeartbeat(heartbeat: WakApiHeartbeat | any, userId: string) { 163 return { 164 userId: userId, 165 timestamp: heartbeat.time 166 ? BigInt(heartbeat.time * 1000) 167 : BigInt(new Date(heartbeat.timestamp).getTime()), 168 project: heartbeat.project || null, 169 editor: heartbeat.user_agent_id 170 ? extractEditor(heartbeat.user_agent_id) 171 : null, 172 language: heartbeat.language || null, 173 os: heartbeat.user_agent_id 174 ? extractOS(heartbeat.user_agent_id) 175 : extractOS(heartbeat.entity || ""), 176 file: heartbeat.entity ? path.basename(heartbeat.entity) : null, 177 branch: heartbeat.branch || null, 178 createdAt: new Date(), 179 summariesId: null, 180 }; 181 } 182 183 export default defineEventHandler(async (event: H3Event) => { 184 const userId = event.context.user.id; 185 handleLog("Processing for user ID:", userId); 186 187 const formData = await readMultipartFormData(event); 188 if (!formData || formData.length === 0) { 189 const body = await readBody(event); 190 191 const validationResult = wakaApiRequestSchema.safeParse(body); 192 if (!validationResult.success) { 193 const errorDetail = `Invalid WakaTime API request data for user ${userId}: ${validationResult.error.errors[0].message}`; 194 throw handleApiError( 195 400, 196 errorDetail, 197 validationResult.error.errors[0].message || "Invalid API request data." 198 ); 199 } 200 201 const { apiKey, instanceType, instanceUrl } = validationResult.data; 202 handleLog("Received request with:", { 203 instanceType, 204 instanceUrl: instanceUrl ? "provided" : "not provided", 205 }); 206 207 if (instanceType === "wakatime") { 208 const errorDetail = `WakaTime import attempt via API for user ${userId}, but file upload is required.`; 209 throw handleApiError( 210 400, 211 errorDetail, 212 "File upload is required for WakaTime import." 213 ); 214 } 215 216 if (instanceType === "wakapi" && !instanceUrl) { 217 const errorDetail = `WakAPI instance URL missing for user ${userId}.`; 218 throw handleApiError(400, errorDetail, "WakAPI instance URL is missing."); 219 } 220 221 const headers = { 222 Authorization: `Basic ${Buffer.from(apiKey).toString("base64")}`, 223 }; 224 handleLog("Using headers:", { 225 ...headers, 226 Authorization: "Basic [REDACTED]", 227 }); 228 229 const userIdentifier = "current"; 230 let baseUrl = instanceUrl!.endsWith("/") 231 ? instanceUrl!.slice(0, -1) 232 : instanceUrl!; 233 baseUrl = `${baseUrl}/api/compat/wakatime/v1`; 234 235 handleLog("Using WakAPI with baseUrl:", baseUrl); 236 237 try { 238 const allTimeUrl = `${baseUrl}/users/${userIdentifier}/all_time_since_today`; 239 handleLog(`Requesting all-time summary from: ${allTimeUrl}`); 240 241 const allTimeResponse = await $fetch<{ 242 data: { 243 range: { 244 start_date: string; 245 end_date: string; 246 }; 247 }; 248 }>(allTimeUrl, { 249 headers, 250 }); 251 252 handleLog("Received all-time summary response"); 253 254 if (!allTimeResponse?.data?.range) { 255 const errorDetail = `Failed to fetch activity date range from WakAPI for user ${userId}. Response: ${JSON.stringify(allTimeResponse)}`; 256 throw handleApiError( 257 500, 258 errorDetail, 259 "Failed to fetch activity date range from WakAPI." 260 ); 261 } 262 263 const startDate = new Date(allTimeResponse.data.range.start_date); 264 const endDate = new Date(); 265 266 handleLog( 267 `Found activity range: ${startDate.toISOString()} to ${endDate.toISOString()}` 268 ); 269 270 const heartbeatsByDate = await fetchRangeHeartbeats( 271 baseUrl, 272 userIdentifier, 273 headers, 274 startDate, 275 endDate, 276 userId 277 ); 278 279 if (heartbeatsByDate.size === 0) { 280 handleLog("No days with activity found"); 281 return { success: true, message: "No data to import" }; 282 } 283 284 handleLog( 285 `Successfully imported data from ${heartbeatsByDate.size} days with activity` 286 ); 287 return { success: true, imported: heartbeatsByDate.size }; 288 } catch (error: any) { 289 if (error && typeof error === "object" && "__h3_error__" in error) { 290 throw error; 291 } 292 const detailedMessage = 293 error instanceof Error 294 ? error.message 295 : "Unknown error during WakAPI import."; 296 throw handleApiError( 297 500, 298 `Failed to import activity data via WakAPI for user ${userId}: ${detailedMessage}`, 299 "Failed to import activity data. Please try again." 300 ); 301 } 302 } 303 304 handleLog("Processing WakaTime exported file upload"); 305 const fileData = formData.find( 306 (item) => item.name === "file" && item.filename 307 ); 308 309 if (!fileData || !fileData.data) { 310 const errorDetail = `No file uploaded or file content is missing for WakaTime import by user ${userId}.`; 311 throw handleApiError( 312 400, 313 errorDetail, 314 "No file uploaded or file content is missing." 315 ); 316 } 317 318 try { 319 const fileContent = new TextDecoder().decode(fileData.data); 320 const parsedData = JSON.parse(fileContent); 321 322 const validationResult = wakaTimeExportSchema.safeParse(parsedData); 323 if (!validationResult.success) { 324 const errorDetail = `Invalid WakaTime export format for user ${userId}: ${validationResult.error.errors[0].message}`; 325 throw handleApiError( 326 400, 327 errorDetail, 328 validationResult.error.errors[0].message || "Invalid file format." 329 ); 330 } 331 332 const wakaData = validationResult.data; 333 334 handleLog( 335 `Parsing WakaTime export with ${wakaData.days.length} days of data` 336 ); 337 338 let totalHeartbeats = 0; 339 340 for (const day of wakaData.days) { 341 if (!day.heartbeats || day.heartbeats.length === 0) continue; 342 343 handleLog( 344 `Processing ${day.heartbeats.length} heartbeats for ${day.date}` 345 ); 346 totalHeartbeats += day.heartbeats.length; 347 348 try { 349 const processedHeartbeats = day.heartbeats.map((h) => { 350 return { 351 userId, 352 timestamp: BigInt(Math.floor(h.time * 1000)), 353 project: h.project || null, 354 editor: h.user_agent_id ? extractEditor(h.user_agent_id) : null, 355 language: h.language || null, 356 os: h.entity ? extractOS(h.entity) : null, 357 file: h.entity ? path.basename(h.entity) : null, 358 branch: h.branch || null, 359 createdAt: new Date(), 360 summariesId: null, 361 }; 362 }); 363 364 await processHeartbeatsByDate(userId, processedHeartbeats); 365 } catch (error) { 366 handleApiError(500, `Error processing or saving heartbeats for date ${day.date} during WakaTime export for user ${userId}: ${error instanceof Error ? error.message : String(error)}`, "An error occurred while processing a day's data from the WakaTime export. The import may be incomplete."); 367 } 368 } 369 370 handleLog("Database update complete"); 371 return { success: true, imported: totalHeartbeats }; 372 } catch (error: any) { 373 if (error && typeof error === "object" && "__h3_error__" in error) { 374 throw error; 375 } 376 const detailedMessage = 377 error instanceof Error 378 ? error.message 379 : "Unknown error during WakaTime file import."; 380 throw handleApiError( 381 500, 382 `Failed to process uploaded WakaTime file for user ${userId}: ${detailedMessage}`, 383 "Failed to process uploaded file. Please try again." 384 ); 385 } 386 }); 387 388 function extractEditor(userAgent: string) { 389 if (!userAgent) { 390 return "No user Agent"; 391 } 392 393 if (userAgent.includes("cursor/")) return "Cursor"; 394 if (userAgent.includes("vscode/") && !userAgent.includes("cursor/")) 395 return "VS Code"; 396 if (userAgent.includes("intellijidea/")) return "IntelliJ IDEA"; 397 if ( 398 userAgent.includes("Zed/") || 399 userAgent.includes("Zed Preview/") || 400 userAgent.includes("Zed Dev/") 401 ) 402 return "Zed"; 403 if (userAgent.includes("pearai/")) return "Pear AI"; 404 if (userAgent.includes("trae/")) return "Trae"; 405 406 const lowerUserAgent = userAgent.toLowerCase(); 407 if (lowerUserAgent.includes("goland")) return "GoLand"; 408 if (lowerUserAgent.includes("emacs")) return "emacs"; 409 if (lowerUserAgent.includes("kate")) return "kate"; 410 if (lowerUserAgent.includes("neovim")) return "neovim"; 411 if (lowerUserAgent.includes("skype")) return "Skype"; 412 if (lowerUserAgent.includes("notepad++")) return "Notepad++"; 413 if (lowerUserAgent.includes("hbuilder x")) return "HBuilder X"; 414 415 if (userAgent.includes("vscode-wakatime")) return "VS Code"; 416 if (userAgent.includes("intellijidea-wakatime")) return "IntelliJ IDEA"; 417 if (userAgent.includes("Zed-wakatime")) return "Zed"; 418 419 if (userAgent.includes("Unknown/")) return "Unknown"; 420 421 return null; 422 } 423 424 function extractOS(path: string): string | null { 425 if (!path) return null; 426 427 const osRegex = /\(([a-z]+(?:-[a-z]+)?)-[\d.]+(?:-[a-z0-9_]+)?\)/i; 428 const osMatch = path.match(osRegex); 429 430 if (osMatch && osMatch[1]) { 431 const os = osMatch[1].toLowerCase(); 432 if (os === "darwin" || os.startsWith("darwin-")) return "macOS"; 433 if (os === "windows" || os.startsWith("windows-")) return "Windows"; 434 if (os === "linux" || os.startsWith("linux-")) return "Linux"; 435 } 436 437 const lowerPath = path.toLowerCase(); 438 439 if ( 440 lowerPath.includes("linux") || 441 lowerPath.includes("wsl") || 442 lowerPath.match(/linux-[\d.]+/) || 443 lowerPath.includes("-x86_64") || 444 lowerPath.includes("ubuntu") || 445 lowerPath.includes("debian") || 446 lowerPath.includes("fedora") || 447 lowerPath.includes("arch") || 448 lowerPath.includes("centos") || 449 lowerPath.includes("redhat") || 450 lowerPath.includes("mint") || 451 lowerPath.includes("kali") || 452 (lowerPath.includes("gnu") && !lowerPath.includes("darwin")) 453 ) { 454 return "Linux"; 455 } 456 457 if ( 458 lowerPath.includes("win_") || 459 lowerPath.includes("windows") || 460 lowerPath.includes("windows_nt") || 461 lowerPath.match(/windows-[\d.]+/) || 462 lowerPath.includes("win32") || 463 lowerPath.includes("win64") || 464 lowerPath.includes("winnt") || 465 lowerPath.includes("mswin") || 466 lowerPath.includes("cygwin") || 467 lowerPath.includes("mingw") 468 ) { 469 return "Windows"; 470 } 471 472 if ( 473 lowerPath.includes("mac_") || 474 lowerPath.includes("mac") || 475 lowerPath.includes("darwin") || 476 lowerPath.includes("osx") || 477 lowerPath.includes("mac_arm64") || 478 lowerPath.includes("mac_x86-64") || 479 lowerPath.includes("macos") || 480 lowerPath.includes("apple") || 481 lowerPath.includes("macintosh") || 482 lowerPath.includes("ios") 483 ) { 484 return "macOS"; 485 } 486 487 if ( 488 path.match(/^[A-Za-z]:[\\/]/) || 489 path.match(/^\\\\/) || 490 path.includes("\\") 491 ) { 492 return "Windows"; 493 } else if (path.startsWith("/Users/")) { 494 return "macOS"; 495 } else if (path.startsWith("/home/")) { 496 return "Linux"; 497 } 498 499 if (path.includes("go1.") && path.includes("wakatime/")) { 500 if (path.includes("arm64") || path.includes("darwin")) { 501 return "macOS"; 502 } 503 if (path.includes("x86_64") || path.includes("x86-64")) { 504 if (path.includes("windows")) { 505 return "Windows"; 506 } 507 508 return "Linux"; 509 } 510 } 511 512 return null; 513 }