create-interview-cockpit 0.17.3 → 0.19.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.
@@ -138,7 +138,11 @@ async function downloadFileAuthed(
138
138
  }
139
139
 
140
140
  const WORKSPACE_FILES_FOLDER_NAME = "workspace-files";
141
- const RESERVED_EXPORT_FOLDER_NAMES = new Set([WORKSPACE_FILES_FOLDER_NAME]);
141
+ const EXPORT_FOLDER_NAME = "_export";
142
+ const RESERVED_EXPORT_FOLDER_NAMES = new Set([
143
+ WORKSPACE_FILES_FOLDER_NAME,
144
+ EXPORT_FOLDER_NAME,
145
+ ]);
142
146
 
143
147
  function extensionForFilename(filename: string): string {
144
148
  return filename.split(".").pop()?.toLowerCase() ?? "";
@@ -224,6 +228,173 @@ export interface SyncResult {
224
228
  errors: string[];
225
229
  }
226
230
 
231
+ function getWorkspaceSyncRoot(ws: storage.WorkspaceMeta): string {
232
+ if (!ws.driveConfig?.folderId) {
233
+ throw new Error("No Drive folder linked to this workspace");
234
+ }
235
+ return ws.driveConfig.subFolderId || ws.driveConfig.folderId;
236
+ }
237
+
238
+ async function getDriveReadAdapters(): Promise<{
239
+ doListFolders: (id: string) => Promise<DriveItem[]>;
240
+ doListFiles: (id: string) => Promise<DriveItem[]>;
241
+ doDownload: (id: string, mime: string) => Promise<Buffer>;
242
+ }> {
243
+ // Use OAuth client when available — it bypasses public sharing restrictions.
244
+ // Fall back to API-key functions for read-only public workspaces.
245
+ const authed = await isExportAuthed();
246
+ const drive = authed ? await getExportDriveClient() : null;
247
+
248
+ return {
249
+ doListFolders: drive
250
+ ? (id: string) => listFoldersAuthed(drive, id)
251
+ : listFolders,
252
+ doListFiles: drive ? (id: string) => listFilesAuthed(drive, id) : listFiles,
253
+ doDownload: drive
254
+ ? (id: string, mime: string) => downloadFileAuthed(drive, id, mime)
255
+ : downloadFile,
256
+ };
257
+ }
258
+
259
+ type ParentTitleLink = {
260
+ questionId: string;
261
+ topicId: string;
262
+ parentTitle: string;
263
+ };
264
+
265
+ async function importQuestionFromDriveBuffer(input: {
266
+ workspaceId: string;
267
+ topicId: string;
268
+ filename: string;
269
+ buffer: Buffer;
270
+ extractText: (buffer: Buffer, filename: string) => Promise<string>;
271
+ parentLinks: ParentTitleLink[];
272
+ result: SyncResult;
273
+ }): Promise<void> {
274
+ const { workspaceId, topicId, filename, buffer, extractText, parentLinks } =
275
+ input;
276
+ const { result } = input;
277
+
278
+ try {
279
+ let title = filename.replace(/\.[^/.]+$/, "") || filename;
280
+ let systemContext = "";
281
+ let messages: storage.Question["messages"] = [];
282
+ let codeContextFiles: string[] = [];
283
+ let codeAnnotations: storage.Question["codeAnnotations"] = {};
284
+ let parentTitle: string | null = null;
285
+ let restoredCreatedAt: string | null = null;
286
+ const restoredContextFiles: storage.ContextFile[] = [];
287
+
288
+ if (filename.endsWith(".json")) {
289
+ try {
290
+ const parsed = JSON.parse(buffer.toString("utf-8"));
291
+ title = parsed.title || title;
292
+ systemContext = parsed.systemContext || "";
293
+ messages = parsed.messages || [];
294
+ codeContextFiles = parsed.codeContextFiles || [];
295
+ codeAnnotations = parsed.codeAnnotations || {};
296
+ parentTitle = parsed.parentTitle || null;
297
+ if (parsed.createdAt) restoredCreatedAt = parsed.createdAt;
298
+
299
+ const parsedContextFiles: SerializedContextFile[] = Array.isArray(
300
+ parsed.contextFiles,
301
+ )
302
+ ? parsed.contextFiles
303
+ : Array.isArray(parsed.codeSnippets)
304
+ ? parsed.codeSnippets
305
+ : [];
306
+ if (parsedContextFiles.length > 0) {
307
+ const ctxDir = storage.getContextFilesDirForWorkspace(workspaceId);
308
+ await Promise.all(
309
+ parsedContextFiles.map(async (cs) => {
310
+ if (!cs.id) return;
311
+ const originalName = cs.originalName || cs.name || cs.id;
312
+ try {
313
+ const originalBuffer = cs.originalBase64
314
+ ? Buffer.from(cs.originalBase64, "base64")
315
+ : null;
316
+ const content =
317
+ typeof cs.content === "string"
318
+ ? cs.content
319
+ : originalBuffer
320
+ ? await extractText(originalBuffer, originalName)
321
+ : null;
322
+
323
+ if (content == null) return;
324
+ await fs.mkdir(ctxDir, { recursive: true });
325
+ await fs.writeFile(
326
+ path.join(ctxDir, cs.id),
327
+ Buffer.from(content, "utf-8"),
328
+ );
329
+ if (originalBuffer) {
330
+ await storage.writeOriginalBlob(
331
+ cs.id,
332
+ originalBuffer,
333
+ workspaceId,
334
+ );
335
+ }
336
+ restoredContextFiles.push(
337
+ contextFileMetadataForImport(cs, originalName),
338
+ );
339
+ } catch {
340
+ /* skip bad blobs */
341
+ }
342
+ }),
343
+ );
344
+ }
345
+ } catch {
346
+ systemContext = buffer.toString("utf-8");
347
+ }
348
+ } else {
349
+ systemContext = await extractText(buffer, filename);
350
+ }
351
+
352
+ const q: storage.Question = {
353
+ id: randomUUID(),
354
+ topicId,
355
+ title,
356
+ systemContext,
357
+ codeContextFiles,
358
+ contextFiles: restoredContextFiles,
359
+ messages,
360
+ codeAnnotations,
361
+ createdAt: restoredCreatedAt ?? new Date().toISOString(),
362
+ };
363
+ await storage.saveQuestion(q);
364
+ result.filesImported++;
365
+ if (parentTitle) {
366
+ parentLinks.push({ questionId: q.id, topicId, parentTitle });
367
+ }
368
+ } catch (err: any) {
369
+ result.errors.push(`${filename}: ${err?.message ?? "failed"}`);
370
+ }
371
+ }
372
+
373
+ async function relinkImportedParents(
374
+ parentLinks: ParentTitleLink[],
375
+ ): Promise<void> {
376
+ if (parentLinks.length === 0) return;
377
+ const byTopic = new Map<string, ParentTitleLink[]>();
378
+ for (const link of parentLinks) {
379
+ const arr = byTopic.get(link.topicId) ?? [];
380
+ arr.push(link);
381
+ byTopic.set(link.topicId, arr);
382
+ }
383
+ for (const [topicId, links] of byTopic) {
384
+ const allInTopic = await storage.getQuestionsByTopic(topicId);
385
+ for (const { questionId, parentTitle } of links) {
386
+ const parent = allInTopic.find(
387
+ (q) => q.title === parentTitle && q.id !== questionId,
388
+ );
389
+ const child = allInTopic.find((q) => q.id === questionId);
390
+ if (parent && child) {
391
+ child.parentQuestionId = parent.id;
392
+ await storage.saveQuestion(child);
393
+ }
394
+ }
395
+ }
396
+ }
397
+
227
398
  export async function syncWorkspace(
228
399
  workspaceId: string,
229
400
  extractText: (buffer: Buffer, filename: string) => Promise<string>,
@@ -234,25 +405,12 @@ export async function syncWorkspace(
234
405
  throw new Error("No Drive folder linked to this workspace");
235
406
  }
236
407
 
237
- const { folderId, subFolderId } = ws.driveConfig;
238
- const syncRoot = subFolderId || folderId;
408
+ const syncRoot = getWorkspaceSyncRoot(ws);
239
409
 
240
410
  storage.setActiveWorkspaceId(workspaceId);
241
411
 
242
- // Use OAuth client when available — it bypasses public sharing restrictions.
243
- // Fall back to API-key functions for read-only public workspaces.
244
- const authed = await isExportAuthed();
245
- const drive = authed ? await getExportDriveClient() : null;
246
-
247
- const doListFolders = drive
248
- ? (id: string) => listFoldersAuthed(drive, id)
249
- : listFolders;
250
- const doListFiles = drive
251
- ? (id: string) => listFilesAuthed(drive, id)
252
- : listFiles;
253
- const doDownload = drive
254
- ? (id: string, mime: string) => downloadFileAuthed(drive, id, mime)
255
- : downloadFile;
412
+ const { doListFolders, doListFiles, doDownload } =
413
+ await getDriveReadAdapters();
256
414
 
257
415
  const result: SyncResult = {
258
416
  topicsUpserted: 0,
@@ -261,9 +419,6 @@ export async function syncWorkspace(
261
419
  errors: [],
262
420
  };
263
421
 
264
- // ── Phase 0: fast wipe (parallel deletes instead of serial deleteTopic calls) ──
265
- await storage.clearWorkspaceData(workspaceId);
266
-
267
422
  const importedQuestionKeys = new Set<string>();
268
423
 
269
424
  // ── Phase 1: fetch all folder listings in parallel ──────────────────────────
@@ -301,15 +456,14 @@ export async function syncWorkspace(
301
456
  : Promise.resolve([]),
302
457
  ]);
303
458
 
304
- // ── Phase 2: write all topics in one batch (one topics.json write) ───────────
459
+ // ── Phase 2: build topic records in memory ─────────────────────────────────
305
460
  const topicRecords: storage.Topic[] = folderData.map(({ folder }) => ({
306
461
  id: randomUUID(),
307
462
  name: folder.name,
308
463
  contextFiles: [],
309
464
  createdAt: new Date().toISOString(),
310
465
  }));
311
- await storage.replaceAllTopics(topicRecords);
312
- result.topicsUpserted = topicRecords.length;
466
+ const extraTopicRecords: storage.Topic[] = [];
313
467
 
314
468
  const topicIdByFolderId = new Map(
315
469
  folderData.map(({ folder }, i) => [folder.id, topicRecords[i].id]),
@@ -394,8 +548,7 @@ export async function syncWorkspace(
394
548
  contextFiles: [],
395
549
  createdAt: new Date().toISOString(),
396
550
  };
397
- await storage.saveTopic(generalTopic);
398
- result.topicsUpserted++;
551
+ extraTopicRecords.push(generalTopic);
399
552
 
400
553
  await Promise.all(
401
554
  rootFiles.map(async (file) => {
@@ -420,6 +573,18 @@ export async function syncWorkspace(
420
573
  );
421
574
  }
422
575
 
576
+ // If downloads hit private Drive files or an old token lacks enough scope,
577
+ // stop before wiping the local workspace. The route will prompt re-auth.
578
+ if (result.errors.some((error) => /\b403\b/.test(error))) {
579
+ return result;
580
+ }
581
+
582
+ // ── Phase 3b: fast wipe + write all topics in one batch ────────────────────
583
+ await storage.clearWorkspaceData(workspaceId);
584
+ const allTopicRecords = [...topicRecords, ...extraTopicRecords];
585
+ await storage.replaceAllTopics(allTopicRecords);
586
+ result.topicsUpserted = allTopicRecords.length;
587
+
423
588
  // ── Phase 4: save questions in parallel (each is an individual file) ─────────
424
589
  const questions = pending.filter((p) => !p.isContextFile);
425
590
  const contextFiles = pending.filter((p) => p.isContextFile);
@@ -563,19 +728,19 @@ export async function syncWorkspace(
563
728
 
564
729
  // ── Phase 5: save workspace/topic context file blobs ───────────────────────
565
730
  if (pendingWorkspaceFiles.length > 0) {
566
- await Promise.all(
567
- pendingWorkspaceFiles.map(async ({ filename, buffer }) => {
568
- const fileId = randomUUID();
569
- const text = await extractText(buffer, filename);
570
- await storage.writeOriginalBlob(fileId, buffer, workspaceId);
571
- await storage.saveWorkspaceContextFile(
572
- fileId,
573
- filename,
574
- Buffer.from(text, "utf-8"),
575
- workspaceId,
576
- );
577
- }),
578
- );
731
+ // saveWorkspaceContextFile updates workspace-files.json; do this
732
+ // sequentially so concurrent reads/writes do not overwrite metadata.
733
+ for (const { filename, buffer } of pendingWorkspaceFiles) {
734
+ const fileId = randomUUID();
735
+ const text = await extractText(buffer, filename);
736
+ await storage.writeOriginalBlob(fileId, buffer, workspaceId);
737
+ await storage.saveWorkspaceContextFile(
738
+ fileId,
739
+ filename,
740
+ Buffer.from(text, "utf-8"),
741
+ workspaceId,
742
+ );
743
+ }
579
744
  result.filesImported += pendingWorkspaceFiles.length;
580
745
  }
581
746
 
@@ -622,6 +787,168 @@ export async function syncWorkspace(
622
787
  return result;
623
788
  }
624
789
 
790
+ export async function syncTopic(
791
+ workspaceId: string,
792
+ topicId: string,
793
+ extractText: (buffer: Buffer, filename: string) => Promise<string>,
794
+ ): Promise<SyncResult> {
795
+ const registry = await storage.getWorkspaces();
796
+ const ws = registry.workspaces.find((w) => w.id === workspaceId);
797
+ if (!ws?.driveConfig?.folderId) {
798
+ throw new Error("No Drive folder linked to this workspace");
799
+ }
800
+
801
+ storage.setActiveWorkspaceId(workspaceId);
802
+ const topics = await storage.getTopics();
803
+ const topic = topics.find((t) => t.id === topicId);
804
+ if (!topic) {
805
+ throw new Error("Topic not found");
806
+ }
807
+
808
+ const syncRoot = getWorkspaceSyncRoot(ws);
809
+ const { doListFolders, doListFiles, doDownload } =
810
+ await getDriveReadAdapters();
811
+ const subfolders = await doListFolders(syncRoot);
812
+ let topicFolder = subfolders.find((folder) => folder.name === topic.name);
813
+ if (!topicFolder && !ws.driveConfig.subFolderId) {
814
+ // Workspace-level push uses an _export folder when no Drive subfolder is
815
+ // selected. Check there too so "Push topic" then "Pull topic" round-trips.
816
+ const exportFolder = subfolders.find(
817
+ (folder) => folder.name === EXPORT_FOLDER_NAME,
818
+ );
819
+ if (exportFolder) {
820
+ const exportedTopicFolders = await doListFolders(exportFolder.id);
821
+ topicFolder = exportedTopicFolders.find(
822
+ (folder) => folder.name === topic.name,
823
+ );
824
+ }
825
+ }
826
+ if (!topicFolder) {
827
+ throw new Error(`No Drive topic folder named "${topic.name}" was found`);
828
+ }
829
+
830
+ const result: SyncResult = {
831
+ topicsUpserted: 0,
832
+ filesImported: 0,
833
+ filesSkipped: 0,
834
+ errors: [],
835
+ };
836
+
837
+ const innerFolders = await doListFolders(topicFolder.id);
838
+ const questionsFolder = innerFolders.find((f) => f.name === "questions");
839
+ const ctxFilesFolder = innerFolders.find((f) => f.name === "context-files");
840
+
841
+ const [qFiles, cfFiles] = await Promise.all([
842
+ questionsFolder
843
+ ? doListFiles(questionsFolder.id)
844
+ : doListFiles(topicFolder.id),
845
+ ctxFilesFolder ? doListFiles(ctxFilesFolder.id) : Promise.resolve([]),
846
+ ]);
847
+
848
+ type Pending = {
849
+ filename: string;
850
+ buffer: Buffer;
851
+ isContextFile: boolean;
852
+ };
853
+ const pending: Pending[] = [];
854
+ const importedQuestionTitles = new Set<string>();
855
+
856
+ await Promise.all([
857
+ ...qFiles.map(async (file) => {
858
+ const title = file.name.replace(/\.[^/.]+$/, "") || file.name;
859
+ if (importedQuestionTitles.has(title)) {
860
+ result.filesSkipped++;
861
+ return;
862
+ }
863
+ try {
864
+ const buffer = await doDownload(file.id, file.mimeType);
865
+ pending.push({ filename: file.name, buffer, isContextFile: false });
866
+ importedQuestionTitles.add(title);
867
+ } catch (err: any) {
868
+ result.errors.push(`${file.name}: ${err?.message ?? "failed"}`);
869
+ }
870
+ }),
871
+ ...cfFiles.map(async (cf) => {
872
+ try {
873
+ const buffer = await doDownload(cf.id, cf.mimeType);
874
+ pending.push({ filename: cf.name, buffer, isContextFile: true });
875
+ } catch (err: any) {
876
+ result.errors.push(
877
+ `Context file "${cf.name}": ${err?.message ?? "failed"}`,
878
+ );
879
+ }
880
+ }),
881
+ ]);
882
+
883
+ // If downloads hit private Drive files or an old token lacks enough scope,
884
+ // stop before replacing the local topic. The route will prompt re-auth.
885
+ if (result.errors.some((error) => /\b403\b/.test(error))) {
886
+ return result;
887
+ }
888
+
889
+ const ctxDir = storage.getContextFilesDirForWorkspace(workspaceId);
890
+ await Promise.all(
891
+ (topic.contextFiles || []).map((cf) =>
892
+ fs.unlink(path.join(ctxDir, cf.id)).catch(() => {}),
893
+ ),
894
+ );
895
+ const oldQuestions = await storage.getQuestionsByTopic(topicId);
896
+ await Promise.all(oldQuestions.map((q) => storage.deleteQuestion(q.id)));
897
+
898
+ const questions = pending.filter((p) => !p.isContextFile);
899
+ const contextFiles = pending.filter((p) => p.isContextFile);
900
+ const parentLinks: ParentTitleLink[] = [];
901
+
902
+ await Promise.all(
903
+ questions.map(({ filename, buffer }) =>
904
+ importQuestionFromDriveBuffer({
905
+ workspaceId,
906
+ topicId,
907
+ filename,
908
+ buffer,
909
+ extractText,
910
+ parentLinks,
911
+ result,
912
+ }),
913
+ ),
914
+ );
915
+ await relinkImportedParents(parentLinks);
916
+
917
+ const importedTopicContextFiles = await Promise.all(
918
+ contextFiles.map(async ({ filename, buffer }) => {
919
+ const fileId = randomUUID();
920
+ const text = await extractText(buffer, filename);
921
+ await storage.writeOriginalBlob(fileId, buffer, workspaceId);
922
+ await storage.writeContextFileBlob(
923
+ workspaceId,
924
+ fileId,
925
+ Buffer.from(text, "utf-8"),
926
+ );
927
+ result.filesImported++;
928
+ return {
929
+ id: fileId,
930
+ name: filename,
931
+ originalName: filename,
932
+ createdAt: new Date().toISOString(),
933
+ } satisfies storage.ContextFile;
934
+ }),
935
+ );
936
+
937
+ await storage.updateTopic(topicId, {
938
+ contextFiles: importedTopicContextFiles,
939
+ });
940
+ result.topicsUpserted = 1;
941
+
942
+ await storage.updateWorkspace(workspaceId, {
943
+ driveConfig: {
944
+ ...ws.driveConfig,
945
+ lastSyncedAt: new Date().toISOString(),
946
+ },
947
+ });
948
+
949
+ return result;
950
+ }
951
+
625
952
  /** List subfolders directly inside the workspace's linked Drive folder (public API key read). */
626
953
  export async function listDriveSubfolders(
627
954
  workspaceId: string,
@@ -631,6 +958,10 @@ export async function listDriveSubfolders(
631
958
  if (!ws?.driveConfig?.folderId) {
632
959
  throw new Error("No Drive folder linked to this workspace");
633
960
  }
961
+ if (await isExportAuthed()) {
962
+ const drive = await getExportDriveClient();
963
+ return listFoldersAuthed(drive, ws.driveConfig.folderId);
964
+ }
634
965
  return listFolders(ws.driveConfig.folderId);
635
966
  }
636
967
 
@@ -663,11 +994,11 @@ const EXPORT_TOKENS_FILE = path.resolve(
663
994
  );
664
995
 
665
996
  function createExportOAuthClient() {
997
+ const defaultRedirectUri = `http://localhost:${process.env.GOOGLE_EXPORT_REDIRECT_PORT || "3001"}/api/drive/export-callback`;
666
998
  return new google.auth.OAuth2(
667
999
  process.env.GOOGLE_CLIENT_ID,
668
1000
  process.env.GOOGLE_CLIENT_SECRET,
669
- process.env.GOOGLE_EXPORT_REDIRECT_URI ||
670
- "http://localhost:3001/api/drive/export-callback",
1001
+ process.env.GOOGLE_EXPORT_REDIRECT_URI || defaultRedirectUri,
671
1002
  );
672
1003
  }
673
1004
 
@@ -675,7 +1006,7 @@ export function getExportAuthUrl(): string {
675
1006
  const client = createExportOAuthClient();
676
1007
  return client.generateAuthUrl({
677
1008
  access_type: "offline",
678
- scope: ["https://www.googleapis.com/auth/drive.file"],
1009
+ scope: ["https://www.googleapis.com/auth/drive"],
679
1010
  prompt: "consent",
680
1011
  });
681
1012
  }
@@ -878,6 +1209,128 @@ async function uploadFileToFolder(
878
1209
  }
879
1210
  }
880
1211
 
1212
+ async function exportTopicToFolder(input: {
1213
+ drive: drive_v3.Drive;
1214
+ workspaceId: string;
1215
+ exportFolderId: string;
1216
+ topic: storage.Topic;
1217
+ ctxDir: string;
1218
+ result: ExportResult;
1219
+ }): Promise<void> {
1220
+ const { drive, workspaceId, exportFolderId, topic, ctxDir, result } = input;
1221
+
1222
+ try {
1223
+ const topicFolderId = await getOrCreateFolder(
1224
+ drive,
1225
+ exportFolderId,
1226
+ topic.name,
1227
+ );
1228
+ const questions = await storage.getQuestionsByTopicForWorkspace(
1229
+ workspaceId,
1230
+ topic.id,
1231
+ );
1232
+
1233
+ // Upload questions into a questions/ subfolder (parallel uploads)
1234
+ if (questions.length > 0) {
1235
+ const questionsFolderId = await getOrCreateFolder(
1236
+ drive,
1237
+ topicFolderId,
1238
+ "questions",
1239
+ );
1240
+ await Promise.all(
1241
+ questions.map(async (q) => {
1242
+ try {
1243
+ const safeName =
1244
+ q.title.replace(/[/\\:*?"<>|]/g, "-").trim() || q.id;
1245
+ // Look up parent title so the relationship can be restored on import
1246
+ const parentTitle = q.parentQuestionId
1247
+ ? questions.find((p) => p.id === q.parentQuestionId)?.title
1248
+ : undefined;
1249
+
1250
+ // Read every question-attached context file so uploaded docs,
1251
+ // images, and generated code snippets survive Drive sync.
1252
+ const contextFilesWithContent = await Promise.all(
1253
+ (q.contextFiles || []).map((cf) =>
1254
+ serializeContextFileForExport(cf, workspaceId, ctxDir),
1255
+ ),
1256
+ );
1257
+ const legacyCodeSnippets = contextFilesWithContent.filter(
1258
+ (cf) => cf.origin && cf.origin !== "upload",
1259
+ );
1260
+
1261
+ // Export full question as JSON so nothing is lost on sync-back
1262
+ const payload = JSON.stringify(
1263
+ {
1264
+ title: q.title,
1265
+ parentTitle: parentTitle ?? null,
1266
+ systemContext: q.systemContext || "",
1267
+ messages: q.messages,
1268
+ codeContextFiles: q.codeContextFiles,
1269
+ codeAnnotations: q.codeAnnotations ?? {},
1270
+ contextFiles: contextFilesWithContent,
1271
+ codeSnippets: legacyCodeSnippets,
1272
+ createdAt: q.createdAt,
1273
+ },
1274
+ null,
1275
+ 2,
1276
+ );
1277
+ await uploadFileToFolder(
1278
+ drive,
1279
+ questionsFolderId,
1280
+ `${safeName}.json`,
1281
+ payload,
1282
+ "application/json",
1283
+ );
1284
+ result.questionsExported++;
1285
+ } catch (err: any) {
1286
+ result.errors.push(
1287
+ `Question "${q.title}": ${err?.message || "upload failed"}`,
1288
+ );
1289
+ }
1290
+ }),
1291
+ );
1292
+ }
1293
+
1294
+ // Upload context files into a context-files/ subfolder (parallel uploads)
1295
+ if (topic.contextFiles.length > 0) {
1296
+ const ctxFolderId = await getOrCreateFolder(
1297
+ drive,
1298
+ topicFolderId,
1299
+ "context-files",
1300
+ );
1301
+ await Promise.all(
1302
+ topic.contextFiles.map(async (cf) => {
1303
+ try {
1304
+ const buffer = await readContextFileExportBytes(
1305
+ cf,
1306
+ workspaceId,
1307
+ ctxDir,
1308
+ );
1309
+ await uploadFileToFolder(
1310
+ drive,
1311
+ ctxFolderId,
1312
+ cf.originalName,
1313
+ buffer,
1314
+ mimeForFilename(cf.originalName),
1315
+ );
1316
+ result.filesExported++;
1317
+ } catch (err: any) {
1318
+ result.errors.push(
1319
+ `File "${cf.originalName}": ${err?.message || "upload failed"}`,
1320
+ );
1321
+ }
1322
+ }),
1323
+ );
1324
+ }
1325
+
1326
+ result.topicsExported++;
1327
+ } catch (err: any) {
1328
+ result.errors.push(
1329
+ `Topic "${topic.name}": ${err?.message || "export failed"}`,
1330
+ );
1331
+ }
1332
+ }
1333
+
881
1334
  export async function exportWorkspace(
882
1335
  workspaceId: string,
883
1336
  targetFolderId?: string,
@@ -899,7 +1352,8 @@ export async function exportWorkspace(
899
1352
 
900
1353
  // Use the chosen subfolder directly, or fall back to an "_export" subfolder
901
1354
  const exportFolderId =
902
- targetFolderId ?? (await getOrCreateFolder(drive, folderId, "_export"));
1355
+ targetFolderId ??
1356
+ (await getOrCreateFolder(drive, folderId, EXPORT_FOLDER_NAME));
903
1357
  const topics = await storage.getTopicsForWorkspace(workspaceId);
904
1358
  const ctxDir = storage.getContextFilesDirForWorkspace(workspaceId);
905
1359
 
@@ -936,117 +1390,56 @@ export async function exportWorkspace(
936
1390
  }
937
1391
 
938
1392
  for (const topic of topics) {
939
- try {
940
- const topicFolderId = await getOrCreateFolder(
941
- drive,
942
- exportFolderId,
943
- topic.name,
944
- );
945
- const questions = await storage.getQuestionsByTopicForWorkspace(
946
- workspaceId,
947
- topic.id,
948
- );
949
-
950
- // Upload questions into a questions/ subfolder (parallel uploads)
951
- if (questions.length > 0) {
952
- const questionsFolderId = await getOrCreateFolder(
953
- drive,
954
- topicFolderId,
955
- "questions",
956
- );
957
- await Promise.all(
958
- questions.map(async (q) => {
959
- try {
960
- const safeName =
961
- q.title.replace(/[/\\:*?"<>|]/g, "-").trim() || q.id;
962
- // Look up parent title so the relationship can be restored on import
963
- const parentTitle = q.parentQuestionId
964
- ? questions.find((p) => p.id === q.parentQuestionId)?.title
965
- : undefined;
966
-
967
- // Read every question-attached context file so uploaded docs,
968
- // images, and generated code snippets survive Drive sync.
969
- const contextFilesWithContent = await Promise.all(
970
- (q.contextFiles || []).map((cf) =>
971
- serializeContextFileForExport(cf, workspaceId, ctxDir),
972
- ),
973
- );
974
- const legacyCodeSnippets = contextFilesWithContent.filter(
975
- (cf) => cf.origin && cf.origin !== "upload",
976
- );
1393
+ await exportTopicToFolder({
1394
+ drive,
1395
+ workspaceId,
1396
+ exportFolderId,
1397
+ topic,
1398
+ ctxDir,
1399
+ result,
1400
+ });
1401
+ }
977
1402
 
978
- // Export full question as JSON so nothing is lost on sync-back
979
- const payload = JSON.stringify(
980
- {
981
- title: q.title,
982
- parentTitle: parentTitle ?? null,
983
- systemContext: q.systemContext || "",
984
- messages: q.messages,
985
- codeContextFiles: q.codeContextFiles,
986
- codeAnnotations: q.codeAnnotations ?? {},
987
- contextFiles: contextFilesWithContent,
988
- codeSnippets: legacyCodeSnippets,
989
- createdAt: q.createdAt,
990
- },
991
- null,
992
- 2,
993
- );
994
- await uploadFileToFolder(
995
- drive,
996
- questionsFolderId,
997
- `${safeName}.json`,
998
- payload,
999
- "application/json",
1000
- );
1001
- result.questionsExported++;
1002
- } catch (err: any) {
1003
- result.errors.push(
1004
- `Question "${q.title}": ${err?.message || "upload failed"}`,
1005
- );
1006
- }
1007
- }),
1008
- );
1009
- }
1403
+ return result;
1404
+ }
1010
1405
 
1011
- // Upload context files into a context-files/ subfolder (parallel uploads)
1012
- if (topic.contextFiles.length > 0) {
1013
- const ctxFolderId = await getOrCreateFolder(
1014
- drive,
1015
- topicFolderId,
1016
- "context-files",
1017
- );
1018
- await Promise.all(
1019
- topic.contextFiles.map(async (cf) => {
1020
- try {
1021
- const buffer = await readContextFileExportBytes(
1022
- cf,
1023
- workspaceId,
1024
- ctxDir,
1025
- );
1026
- await uploadFileToFolder(
1027
- drive,
1028
- ctxFolderId,
1029
- cf.originalName,
1030
- buffer,
1031
- mimeForFilename(cf.originalName),
1032
- );
1033
- result.filesExported++;
1034
- } catch (err: any) {
1035
- result.errors.push(
1036
- `File "${cf.originalName}": ${err?.message || "upload failed"}`,
1037
- );
1038
- }
1039
- }),
1040
- );
1041
- }
1406
+ export async function exportTopic(
1407
+ workspaceId: string,
1408
+ topicId: string,
1409
+ targetFolderId?: string,
1410
+ ): Promise<ExportResult> {
1411
+ const registry = await storage.getWorkspaces();
1412
+ const ws = registry.workspaces.find((w) => w.id === workspaceId);
1413
+ if (!ws?.driveConfig?.folderId) {
1414
+ throw new Error("No Drive folder linked to this workspace");
1415
+ }
1042
1416
 
1043
- result.topicsExported++;
1044
- } catch (err: any) {
1045
- result.errors.push(
1046
- `Topic "${topic.name}": ${err?.message || "export failed"}`,
1047
- );
1048
- }
1417
+ const drive = await getExportDriveClient();
1418
+ const { folderId } = ws.driveConfig;
1419
+ const exportFolderId =
1420
+ targetFolderId ??
1421
+ (await getOrCreateFolder(drive, folderId, EXPORT_FOLDER_NAME));
1422
+ const topic = (await storage.getTopicsForWorkspace(workspaceId)).find(
1423
+ (t) => t.id === topicId,
1424
+ );
1425
+ if (!topic) {
1426
+ throw new Error("Topic not found");
1049
1427
  }
1050
1428
 
1429
+ const result: ExportResult = {
1430
+ topicsExported: 0,
1431
+ questionsExported: 0,
1432
+ filesExported: 0,
1433
+ errors: [],
1434
+ };
1435
+ const ctxDir = storage.getContextFilesDirForWorkspace(workspaceId);
1436
+ await exportTopicToFolder({
1437
+ drive,
1438
+ workspaceId,
1439
+ exportFolderId,
1440
+ topic,
1441
+ ctxDir,
1442
+ result,
1443
+ });
1051
1444
  return result;
1052
1445
  }