create-interview-cockpit 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,714 @@
1
+ import { randomUUID } from "crypto";
2
+ import { google } from "googleapis";
3
+ import type { drive_v3 } from "googleapis";
4
+ import { Readable } from "stream";
5
+ import fs from "fs/promises";
6
+ import path from "path";
7
+ import { fileURLToPath } from "url";
8
+ import * as storage from "./storage.js";
9
+
10
+ const DRIVE_API = "https://www.googleapis.com/drive/v3";
11
+
12
+ function getApiKey(): string {
13
+ const key = process.env.GOOGLE_DRIVE_API_KEY || process.env.GOOGLE_API_KEY;
14
+ if (!key) throw new Error("No Google API key set (GOOGLE_DRIVE_API_KEY)");
15
+ return key;
16
+ }
17
+
18
+ /** Extract a folder ID from any Google Drive share URL format. */
19
+ export function extractFolderIdFromUrl(url: string): string | null {
20
+ // e.g. https://drive.google.com/drive/folders/FOLDER_ID?usp=sharing
21
+ const m = url.match(/\/folders\/([a-zA-Z0-9_-]+)/);
22
+ return m ? m[1] : null;
23
+ }
24
+
25
+ /** Validate that the folder is publicly accessible and return its metadata. */
26
+ export async function getFolderMeta(
27
+ folderId: string,
28
+ ): Promise<{ id: string; name: string }> {
29
+ const key = getApiKey();
30
+ const res = await fetch(
31
+ `${DRIVE_API}/files/${folderId}?fields=id,name&key=${encodeURIComponent(key)}`,
32
+ );
33
+ if (!res.ok) {
34
+ const body = await res.text();
35
+ throw new Error(
36
+ `Cannot access folder (HTTP ${res.status}). Make sure the folder is shared as "Anyone with the link". Details: ${body}`,
37
+ );
38
+ }
39
+ return res.json() as Promise<{ id: string; name: string }>;
40
+ }
41
+
42
+ export interface DriveItem {
43
+ id: string;
44
+ name: string;
45
+ mimeType: string;
46
+ }
47
+
48
+ async function listFolders(parentId: string): Promise<DriveItem[]> {
49
+ const key = getApiKey();
50
+ const q = encodeURIComponent(
51
+ `'${parentId}' in parents and mimeType = 'application/vnd.google-apps.folder' and trashed = false`,
52
+ );
53
+ const res = await fetch(
54
+ `${DRIVE_API}/files?q=${q}&fields=files(id,name,mimeType)&orderBy=name&pageSize=100&key=${encodeURIComponent(key)}`,
55
+ );
56
+ if (!res.ok) throw new Error(`Drive listFolders failed: ${res.status}`);
57
+ const data = (await res.json()) as { files?: DriveItem[] };
58
+ return data.files || [];
59
+ }
60
+
61
+ async function listFiles(folderId: string): Promise<DriveItem[]> {
62
+ const key = getApiKey();
63
+ const q = encodeURIComponent(
64
+ `'${folderId}' in parents and mimeType != 'application/vnd.google-apps.folder' and trashed = false`,
65
+ );
66
+ const res = await fetch(
67
+ `${DRIVE_API}/files?q=${q}&fields=files(id,name,mimeType)&orderBy=name&pageSize=200&key=${encodeURIComponent(key)}`,
68
+ );
69
+ if (!res.ok) throw new Error(`Drive listFiles failed: ${res.status}`);
70
+ const data = (await res.json()) as { files?: DriveItem[] };
71
+ return data.files || [];
72
+ }
73
+
74
+ async function downloadFile(fileId: string, mimeType: string): Promise<Buffer> {
75
+ const key = getApiKey();
76
+
77
+ if (mimeType === "application/vnd.google-apps.document") {
78
+ // Export Google Doc as plain text
79
+ const res = await fetch(
80
+ `${DRIVE_API}/files/${fileId}/export?mimeType=text%2Fplain&key=${encodeURIComponent(key)}`,
81
+ );
82
+ if (!res.ok) throw new Error(`Drive export failed: ${res.status}`);
83
+ return Buffer.from(await res.arrayBuffer());
84
+ }
85
+
86
+ const res = await fetch(
87
+ `${DRIVE_API}/files/${fileId}?alt=media&key=${encodeURIComponent(key)}`,
88
+ );
89
+ if (!res.ok) throw new Error(`Drive download failed: ${res.status}`);
90
+ return Buffer.from(await res.arrayBuffer());
91
+ }
92
+
93
+ /** OAuth-authenticated versions — used during sync when export tokens are available.
94
+ * These work regardless of public sharing settings. */
95
+ async function listFoldersAuthed(
96
+ drive: drive_v3.Drive,
97
+ parentId: string,
98
+ ): Promise<DriveItem[]> {
99
+ const res = await drive.files.list({
100
+ q: `'${parentId}' in parents and mimeType = 'application/vnd.google-apps.folder' and trashed = false`,
101
+ fields: "files(id,name,mimeType)",
102
+ orderBy: "name",
103
+ pageSize: 200,
104
+ });
105
+ return (res.data.files || []) as DriveItem[];
106
+ }
107
+
108
+ async function listFilesAuthed(
109
+ drive: drive_v3.Drive,
110
+ folderId: string,
111
+ ): Promise<DriveItem[]> {
112
+ const res = await drive.files.list({
113
+ q: `'${folderId}' in parents and mimeType != 'application/vnd.google-apps.folder' and trashed = false`,
114
+ fields: "files(id,name,mimeType)",
115
+ orderBy: "name",
116
+ pageSize: 200,
117
+ });
118
+ return (res.data.files || []) as DriveItem[];
119
+ }
120
+
121
+ async function downloadFileAuthed(
122
+ drive: drive_v3.Drive,
123
+ fileId: string,
124
+ mimeType: string,
125
+ ): Promise<Buffer> {
126
+ if (mimeType === "application/vnd.google-apps.document") {
127
+ const res = await drive.files.export(
128
+ { fileId, mimeType: "text/plain" },
129
+ { responseType: "arraybuffer" },
130
+ );
131
+ return Buffer.from(res.data as ArrayBuffer);
132
+ }
133
+ const res = await drive.files.get(
134
+ { fileId, alt: "media" },
135
+ { responseType: "arraybuffer" },
136
+ );
137
+ return Buffer.from(res.data as ArrayBuffer);
138
+ }
139
+
140
+ export interface SyncResult {
141
+ topicsUpserted: number;
142
+ filesImported: number;
143
+ filesSkipped: number;
144
+ errors: string[];
145
+ }
146
+
147
+ export async function syncWorkspace(
148
+ workspaceId: string,
149
+ extractText: (buffer: Buffer, filename: string) => Promise<string>,
150
+ ): Promise<SyncResult> {
151
+ const registry = await storage.getWorkspaces();
152
+ const ws = registry.workspaces.find((w) => w.id === workspaceId);
153
+ if (!ws?.driveConfig?.folderId) {
154
+ throw new Error("No Drive folder linked to this workspace");
155
+ }
156
+
157
+ const { folderId, subFolderId } = ws.driveConfig;
158
+ const syncRoot = subFolderId || folderId;
159
+
160
+ storage.setActiveWorkspaceId(workspaceId);
161
+
162
+ // Use OAuth client when available — it bypasses public sharing restrictions.
163
+ // Fall back to API-key functions for read-only public workspaces.
164
+ const authed = await isExportAuthed();
165
+ const drive = authed ? await getExportDriveClient() : null;
166
+
167
+ const doListFolders = drive
168
+ ? (id: string) => listFoldersAuthed(drive, id)
169
+ : listFolders;
170
+ const doListFiles = drive
171
+ ? (id: string) => listFilesAuthed(drive, id)
172
+ : listFiles;
173
+ const doDownload = drive
174
+ ? (id: string, mime: string) => downloadFileAuthed(drive, id, mime)
175
+ : downloadFile;
176
+
177
+ const result: SyncResult = {
178
+ topicsUpserted: 0,
179
+ filesImported: 0,
180
+ filesSkipped: 0,
181
+ errors: [],
182
+ };
183
+
184
+ // ── Phase 0: fast wipe (parallel deletes instead of serial deleteTopic calls) ──
185
+ await storage.clearWorkspaceData(workspaceId);
186
+
187
+ const importedQuestionKeys = new Set<string>();
188
+
189
+ // ── Phase 1: fetch all folder listings in parallel ──────────────────────────
190
+ const subfolders = await doListFolders(syncRoot);
191
+
192
+ const folderData = await Promise.all(
193
+ subfolders.map(async (folder) => {
194
+ const innerFolders = await doListFolders(folder.id);
195
+ const questionsFolder = innerFolders.find((f) => f.name === "questions");
196
+ const ctxFilesFolder = innerFolders.find(
197
+ (f) => f.name === "context-files",
198
+ );
199
+
200
+ const [qFiles, cfFiles] = await Promise.all([
201
+ questionsFolder
202
+ ? doListFiles(questionsFolder.id)
203
+ : doListFiles(folder.id),
204
+ ctxFilesFolder ? doListFiles(ctxFilesFolder.id) : Promise.resolve([]),
205
+ ]);
206
+
207
+ return { folder, qFiles, cfFiles };
208
+ }),
209
+ );
210
+
211
+ // ── Phase 2: write all topics in one batch (one topics.json write) ───────────
212
+ const topicRecords: storage.Topic[] = folderData.map(({ folder }) => ({
213
+ id: randomUUID(),
214
+ name: folder.name,
215
+ contextFiles: [],
216
+ createdAt: new Date().toISOString(),
217
+ }));
218
+ await storage.replaceAllTopics(topicRecords);
219
+ result.topicsUpserted = topicRecords.length;
220
+
221
+ const topicIdByFolderId = new Map(
222
+ folderData.map(({ folder }, i) => [folder.id, topicRecords[i].id]),
223
+ );
224
+
225
+ // ── Phase 3: download all files in parallel ──────────────────────────────────
226
+ type Pending = {
227
+ topicId: string;
228
+ filename: string;
229
+ buffer: Buffer;
230
+ isContextFile: boolean;
231
+ };
232
+
233
+ const pending: Pending[] = [];
234
+
235
+ await Promise.all(
236
+ folderData.flatMap(({ folder, qFiles, cfFiles }) => {
237
+ const topicId = topicIdByFolderId.get(folder.id)!;
238
+ return [
239
+ ...qFiles.map(async (file) => {
240
+ const title = file.name.replace(/\.[^/.]+$/, "") || file.name;
241
+ if (importedQuestionKeys.has(`${topicId}:${title}`)) {
242
+ result.filesSkipped++;
243
+ return;
244
+ }
245
+ try {
246
+ const buffer = await doDownload(file.id, file.mimeType);
247
+ pending.push({
248
+ topicId,
249
+ filename: file.name,
250
+ buffer,
251
+ isContextFile: false,
252
+ });
253
+ importedQuestionKeys.add(`${topicId}:${title}`);
254
+ } catch (err: any) {
255
+ result.errors.push(`${file.name}: ${err?.message ?? "failed"}`);
256
+ }
257
+ }),
258
+ ...cfFiles.map(async (cf) => {
259
+ try {
260
+ const buffer = await doDownload(cf.id, cf.mimeType);
261
+ pending.push({
262
+ topicId,
263
+ filename: cf.name,
264
+ buffer,
265
+ isContextFile: true,
266
+ });
267
+ } catch (err: any) {
268
+ result.errors.push(
269
+ `Context file "${cf.name}": ${err?.message ?? "failed"}`,
270
+ );
271
+ }
272
+ }),
273
+ ];
274
+ }),
275
+ );
276
+
277
+ // Root-level files in syncRoot → "General" topic
278
+ const rootFiles = await doListFiles(syncRoot);
279
+ if (rootFiles.length > 0) {
280
+ const generalTopic: storage.Topic = {
281
+ id: randomUUID(),
282
+ name: "General",
283
+ contextFiles: [],
284
+ createdAt: new Date().toISOString(),
285
+ };
286
+ await storage.saveTopic(generalTopic);
287
+ result.topicsUpserted++;
288
+
289
+ await Promise.all(
290
+ rootFiles.map(async (file) => {
291
+ const title = file.name.replace(/\.[^/.]+$/, "") || file.name;
292
+ if (importedQuestionKeys.has(`${generalTopic.id}:${title}`)) {
293
+ result.filesSkipped++;
294
+ return;
295
+ }
296
+ try {
297
+ const buffer = await doDownload(file.id, file.mimeType);
298
+ pending.push({
299
+ topicId: generalTopic.id,
300
+ filename: file.name,
301
+ buffer,
302
+ isContextFile: false,
303
+ });
304
+ importedQuestionKeys.add(`${generalTopic.id}:${title}`);
305
+ } catch (err: any) {
306
+ result.errors.push(`${file.name}: ${err?.message ?? "failed"}`);
307
+ }
308
+ }),
309
+ );
310
+ }
311
+
312
+ // ── Phase 4: save questions in parallel (each is an individual file) ─────────
313
+ const questions = pending.filter((p) => !p.isContextFile);
314
+ const contextFiles = pending.filter((p) => p.isContextFile);
315
+
316
+ await Promise.all(
317
+ questions.map(async ({ topicId, filename, buffer }) => {
318
+ try {
319
+ let title = filename.replace(/\.[^/.]+$/, "") || filename;
320
+ let systemContext = "";
321
+ let messages: storage.Question["messages"] = [];
322
+ let codeContextFiles: string[] = [];
323
+
324
+ if (filename.endsWith(".json")) {
325
+ // Our own exported JSON — restore fields directly, no text extraction
326
+ try {
327
+ const parsed = JSON.parse(buffer.toString("utf-8"));
328
+ title = parsed.title || title;
329
+ systemContext = parsed.systemContext || "";
330
+ messages = parsed.messages || [];
331
+ codeContextFiles = parsed.codeContextFiles || [];
332
+ } catch {
333
+ // Malformed JSON — fall through to raw text
334
+ systemContext = buffer.toString("utf-8");
335
+ }
336
+ } else {
337
+ // Non-JSON file (legacy .txt, .pdf, etc.) — extract text as before
338
+ systemContext = await extractText(buffer, filename);
339
+ }
340
+
341
+ const q: storage.Question = {
342
+ id: randomUUID(),
343
+ topicId,
344
+ title,
345
+ systemContext,
346
+ codeContextFiles,
347
+ contextFiles: [],
348
+ messages,
349
+ createdAt: new Date().toISOString(),
350
+ };
351
+ await storage.saveQuestion(q);
352
+ result.filesImported++;
353
+ } catch (err: any) {
354
+ result.errors.push(`${filename}: ${err?.message ?? "failed"}`);
355
+ }
356
+ }),
357
+ );
358
+
359
+ // ── Phase 5: save context file blobs in parallel, then update topics.json once ─
360
+ if (contextFiles.length > 0) {
361
+ // Write binary files in parallel
362
+ const cfRecords = await Promise.all(
363
+ contextFiles.map(async ({ topicId, filename, buffer }) => {
364
+ const fileId = randomUUID();
365
+ await storage.writeContextFileBlob(workspaceId, fileId, buffer);
366
+ return { topicId, fileId, filename };
367
+ }),
368
+ );
369
+ // Update topics.json once with all context file metadata
370
+ const allTopics = await storage.getTopics();
371
+ for (const { topicId, fileId, filename } of cfRecords) {
372
+ const topic = allTopics.find((t) => t.id === topicId);
373
+ if (topic) {
374
+ topic.contextFiles.push({
375
+ id: fileId,
376
+ name: filename,
377
+ originalName: filename,
378
+ createdAt: new Date().toISOString(),
379
+ });
380
+ }
381
+ }
382
+ await storage.replaceAllTopics(allTopics);
383
+ result.filesImported += cfRecords.length;
384
+ }
385
+
386
+ // Update lastSyncedAt
387
+ await storage.updateWorkspace(workspaceId, {
388
+ driveConfig: {
389
+ ...ws.driveConfig,
390
+ lastSyncedAt: new Date().toISOString(),
391
+ },
392
+ });
393
+
394
+ return result;
395
+ }
396
+
397
+ /** List subfolders directly inside the workspace's linked Drive folder (public API key read). */
398
+ export async function listDriveSubfolders(
399
+ workspaceId: string,
400
+ ): Promise<DriveItem[]> {
401
+ const registry = await storage.getWorkspaces();
402
+ const ws = registry.workspaces.find((w) => w.id === workspaceId);
403
+ if (!ws?.driveConfig?.folderId) {
404
+ throw new Error("No Drive folder linked to this workspace");
405
+ }
406
+ return listFolders(ws.driveConfig.folderId);
407
+ }
408
+
409
+ /** Create a new subfolder inside the workspace's linked Drive folder using OAuth. */
410
+ export async function createDriveSubfolder(
411
+ workspaceId: string,
412
+ name: string,
413
+ ): Promise<DriveItem> {
414
+ const registry = await storage.getWorkspaces();
415
+ const ws = registry.workspaces.find((w) => w.id === workspaceId);
416
+ if (!ws?.driveConfig?.folderId) {
417
+ throw new Error("No Drive folder linked to this workspace");
418
+ }
419
+ const drive = await getExportDriveClient();
420
+ const created = await drive.files.create({
421
+ requestBody: {
422
+ name,
423
+ mimeType: "application/vnd.google-apps.folder",
424
+ parents: [ws.driveConfig.folderId],
425
+ },
426
+ fields: "id,name,mimeType",
427
+ });
428
+ return created.data as DriveItem;
429
+ }
430
+
431
+ const __filedir = path.dirname(fileURLToPath(import.meta.url));
432
+ const EXPORT_TOKENS_FILE = path.resolve(
433
+ __filedir,
434
+ "../../data/export-tokens.json",
435
+ );
436
+
437
+ function createExportOAuthClient() {
438
+ return new google.auth.OAuth2(
439
+ process.env.GOOGLE_CLIENT_ID,
440
+ process.env.GOOGLE_CLIENT_SECRET,
441
+ process.env.GOOGLE_EXPORT_REDIRECT_URI ||
442
+ "http://localhost:3001/api/drive/export-callback",
443
+ );
444
+ }
445
+
446
+ export function getExportAuthUrl(): string {
447
+ const client = createExportOAuthClient();
448
+ return client.generateAuthUrl({
449
+ access_type: "offline",
450
+ scope: ["https://www.googleapis.com/auth/drive.file"],
451
+ prompt: "consent",
452
+ });
453
+ }
454
+
455
+ export async function handleExportCallback(code: string): Promise<void> {
456
+ const client = createExportOAuthClient();
457
+ const { tokens } = await client.getToken(code);
458
+ await fs.mkdir(path.dirname(EXPORT_TOKENS_FILE), { recursive: true });
459
+ await fs.writeFile(EXPORT_TOKENS_FILE, JSON.stringify(tokens, null, 2));
460
+ }
461
+
462
+ export async function isExportAuthed(): Promise<boolean> {
463
+ try {
464
+ await fs.access(EXPORT_TOKENS_FILE);
465
+ return true;
466
+ } catch {
467
+ return false;
468
+ }
469
+ }
470
+
471
+ async function getExportDriveClient(): Promise<drive_v3.Drive> {
472
+ const client = createExportOAuthClient();
473
+ const data = await fs.readFile(EXPORT_TOKENS_FILE, "utf-8");
474
+ const tokens = JSON.parse(data);
475
+ client.setCredentials(tokens);
476
+ client.on("tokens", async (newTokens) => {
477
+ try {
478
+ const existing = JSON.parse(
479
+ await fs.readFile(EXPORT_TOKENS_FILE, "utf-8"),
480
+ );
481
+ await fs.writeFile(
482
+ EXPORT_TOKENS_FILE,
483
+ JSON.stringify({ ...existing, ...newTokens }, null, 2),
484
+ );
485
+ } catch {
486
+ /* ok */
487
+ }
488
+ });
489
+ return google.drive({ version: "v3", auth: client });
490
+ }
491
+
492
+ export interface ExportResult {
493
+ topicsExported: number;
494
+ questionsExported: number;
495
+ filesExported: number;
496
+ errors: string[];
497
+ }
498
+
499
+ /**
500
+ * Walk every file and folder inside a Drive folder tree and grant
501
+ * "anyone reader" on anything that doesn't already have it.
502
+ * Used to repair exports that were created before the permission fix.
503
+ */
504
+ export async function repairDriveFolderPermissions(
505
+ folderId: string,
506
+ ): Promise<number> {
507
+ const drive = await getExportDriveClient();
508
+ let fixed = 0;
509
+
510
+ async function walk(id: string): Promise<void> {
511
+ await ensurePublicRead(drive, id);
512
+ fixed++;
513
+
514
+ // List all children (files + folders)
515
+ const res = await drive.files.list({
516
+ q: `'${id}' in parents and trashed = false`,
517
+ fields: "files(id,mimeType)",
518
+ pageSize: 200,
519
+ });
520
+ const children = res.data.files || [];
521
+ await Promise.all(
522
+ children.map(async (child) => {
523
+ if (child.mimeType === "application/vnd.google-apps.folder") {
524
+ await walk(child.id!);
525
+ } else {
526
+ await ensurePublicRead(drive, child.id!);
527
+ fixed++;
528
+ }
529
+ }),
530
+ );
531
+ }
532
+
533
+ await walk(folderId);
534
+ return fixed;
535
+ }
536
+
537
+ async function ensurePublicRead(
538
+ drive: drive_v3.Drive,
539
+ fileId: string,
540
+ ): Promise<void> {
541
+ try {
542
+ await drive.permissions.create({
543
+ fileId,
544
+ requestBody: { type: "anyone", role: "reader" },
545
+ });
546
+ } catch {
547
+ // Already has the permission or not applicable — safe to ignore
548
+ }
549
+ }
550
+
551
+ async function getOrCreateFolder(
552
+ drive: drive_v3.Drive,
553
+ parentId: string,
554
+ name: string,
555
+ ): Promise<string> {
556
+ const safeName = name.replace(/'/g, "\\'");
557
+ const res = await drive.files.list({
558
+ q: `'${parentId}' in parents and name = '${safeName}' and mimeType = 'application/vnd.google-apps.folder' and trashed = false`,
559
+ fields: "files(id)",
560
+ pageSize: 1,
561
+ });
562
+ const folderId = res.data.files?.length
563
+ ? res.data.files[0].id!
564
+ : (
565
+ await drive.files.create({
566
+ requestBody: {
567
+ name,
568
+ mimeType: "application/vnd.google-apps.folder",
569
+ parents: [parentId],
570
+ },
571
+ fields: "id",
572
+ })
573
+ ).data.id!;
574
+ // Always ensure public read — covers both newly created and pre-existing folders
575
+ await ensurePublicRead(drive, folderId);
576
+ return folderId;
577
+ }
578
+
579
+ async function uploadFileToFolder(
580
+ drive: drive_v3.Drive,
581
+ folderId: string,
582
+ name: string,
583
+ content: Buffer | string,
584
+ mimeType: string,
585
+ ): Promise<void> {
586
+ const buffer = Buffer.isBuffer(content)
587
+ ? content
588
+ : Buffer.from(content, "utf-8");
589
+ const created = await drive.files.create({
590
+ requestBody: { name, parents: [folderId] },
591
+ media: { mimeType, body: Readable.from(buffer) },
592
+ fields: "id",
593
+ });
594
+ // Always ensure public read — covers re-uploaded and brand-new files
595
+ if (created.data.id) {
596
+ await ensurePublicRead(drive, created.data.id);
597
+ }
598
+ }
599
+
600
+ export async function exportWorkspace(
601
+ workspaceId: string,
602
+ targetFolderId?: string,
603
+ ): Promise<ExportResult> {
604
+ const registry = await storage.getWorkspaces();
605
+ const ws = registry.workspaces.find((w) => w.id === workspaceId);
606
+ if (!ws?.driveConfig?.folderId) {
607
+ throw new Error("No Drive folder linked to this workspace");
608
+ }
609
+
610
+ const drive = await getExportDriveClient();
611
+ const { folderId } = ws.driveConfig;
612
+ const result: ExportResult = {
613
+ topicsExported: 0,
614
+ questionsExported: 0,
615
+ filesExported: 0,
616
+ errors: [],
617
+ };
618
+
619
+ // Use the chosen subfolder directly, or fall back to an "_export" subfolder
620
+ const exportFolderId =
621
+ targetFolderId ?? (await getOrCreateFolder(drive, folderId, "_export"));
622
+ const topics = await storage.getTopicsForWorkspace(workspaceId);
623
+
624
+ for (const topic of topics) {
625
+ try {
626
+ const topicFolderId = await getOrCreateFolder(
627
+ drive,
628
+ exportFolderId,
629
+ topic.name,
630
+ );
631
+ const questions = await storage.getQuestionsByTopicForWorkspace(
632
+ workspaceId,
633
+ topic.id,
634
+ );
635
+
636
+ // Upload questions into a questions/ subfolder (parallel uploads)
637
+ if (questions.length > 0) {
638
+ const questionsFolderId = await getOrCreateFolder(
639
+ drive,
640
+ topicFolderId,
641
+ "questions",
642
+ );
643
+ await Promise.all(
644
+ questions.map(async (q) => {
645
+ try {
646
+ const safeName =
647
+ q.title.replace(/[/\\:*?"<>|]/g, "-").trim() || q.id;
648
+ // Export full question as JSON so nothing is lost on sync-back
649
+ const payload = JSON.stringify(
650
+ {
651
+ title: q.title,
652
+ systemContext: q.systemContext || "",
653
+ messages: q.messages,
654
+ codeContextFiles: q.codeContextFiles,
655
+ },
656
+ null,
657
+ 2,
658
+ );
659
+ await uploadFileToFolder(
660
+ drive,
661
+ questionsFolderId,
662
+ `${safeName}.json`,
663
+ payload,
664
+ "application/json",
665
+ );
666
+ result.questionsExported++;
667
+ } catch (err: any) {
668
+ result.errors.push(
669
+ `Question "${q.title}": ${err?.message || "upload failed"}`,
670
+ );
671
+ }
672
+ }),
673
+ );
674
+ }
675
+
676
+ // Upload context files into a context-files/ subfolder (parallel uploads)
677
+ if (topic.contextFiles.length > 0) {
678
+ const ctxFolderId = await getOrCreateFolder(
679
+ drive,
680
+ topicFolderId,
681
+ "context-files",
682
+ );
683
+ const ctxDir = storage.getContextFilesDirForWorkspace(workspaceId);
684
+ await Promise.all(
685
+ topic.contextFiles.map(async (cf) => {
686
+ try {
687
+ const buffer = await fs.readFile(path.join(ctxDir, cf.id));
688
+ await uploadFileToFolder(
689
+ drive,
690
+ ctxFolderId,
691
+ cf.originalName,
692
+ buffer,
693
+ "application/octet-stream",
694
+ );
695
+ result.filesExported++;
696
+ } catch (err: any) {
697
+ result.errors.push(
698
+ `File "${cf.originalName}": ${err?.message || "upload failed"}`,
699
+ );
700
+ }
701
+ }),
702
+ );
703
+ }
704
+
705
+ result.topicsExported++;
706
+ } catch (err: any) {
707
+ result.errors.push(
708
+ `Topic "${topic.name}": ${err?.message || "export failed"}`,
709
+ );
710
+ }
711
+ }
712
+
713
+ return result;
714
+ }