/ server / api / wakatime.post.ts
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  }