create-interview-cockpit 0.18.0 → 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,
@@ -629,6 +787,168 @@ export async function syncWorkspace(
629
787
  return result;
630
788
  }
631
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
+
632
952
  /** List subfolders directly inside the workspace's linked Drive folder (public API key read). */
633
953
  export async function listDriveSubfolders(
634
954
  workspaceId: string,
@@ -889,6 +1209,128 @@ async function uploadFileToFolder(
889
1209
  }
890
1210
  }
891
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
+
892
1334
  export async function exportWorkspace(
893
1335
  workspaceId: string,
894
1336
  targetFolderId?: string,
@@ -910,7 +1352,8 @@ export async function exportWorkspace(
910
1352
 
911
1353
  // Use the chosen subfolder directly, or fall back to an "_export" subfolder
912
1354
  const exportFolderId =
913
- targetFolderId ?? (await getOrCreateFolder(drive, folderId, "_export"));
1355
+ targetFolderId ??
1356
+ (await getOrCreateFolder(drive, folderId, EXPORT_FOLDER_NAME));
914
1357
  const topics = await storage.getTopicsForWorkspace(workspaceId);
915
1358
  const ctxDir = storage.getContextFilesDirForWorkspace(workspaceId);
916
1359
 
@@ -947,117 +1390,56 @@ export async function exportWorkspace(
947
1390
  }
948
1391
 
949
1392
  for (const topic of topics) {
950
- try {
951
- const topicFolderId = await getOrCreateFolder(
952
- drive,
953
- exportFolderId,
954
- topic.name,
955
- );
956
- const questions = await storage.getQuestionsByTopicForWorkspace(
957
- workspaceId,
958
- topic.id,
959
- );
960
-
961
- // Upload questions into a questions/ subfolder (parallel uploads)
962
- if (questions.length > 0) {
963
- const questionsFolderId = await getOrCreateFolder(
964
- drive,
965
- topicFolderId,
966
- "questions",
967
- );
968
- await Promise.all(
969
- questions.map(async (q) => {
970
- try {
971
- const safeName =
972
- q.title.replace(/[/\\:*?"<>|]/g, "-").trim() || q.id;
973
- // Look up parent title so the relationship can be restored on import
974
- const parentTitle = q.parentQuestionId
975
- ? questions.find((p) => p.id === q.parentQuestionId)?.title
976
- : undefined;
977
-
978
- // Read every question-attached context file so uploaded docs,
979
- // images, and generated code snippets survive Drive sync.
980
- const contextFilesWithContent = await Promise.all(
981
- (q.contextFiles || []).map((cf) =>
982
- serializeContextFileForExport(cf, workspaceId, ctxDir),
983
- ),
984
- );
985
- const legacyCodeSnippets = contextFilesWithContent.filter(
986
- (cf) => cf.origin && cf.origin !== "upload",
987
- );
1393
+ await exportTopicToFolder({
1394
+ drive,
1395
+ workspaceId,
1396
+ exportFolderId,
1397
+ topic,
1398
+ ctxDir,
1399
+ result,
1400
+ });
1401
+ }
988
1402
 
989
- // Export full question as JSON so nothing is lost on sync-back
990
- const payload = JSON.stringify(
991
- {
992
- title: q.title,
993
- parentTitle: parentTitle ?? null,
994
- systemContext: q.systemContext || "",
995
- messages: q.messages,
996
- codeContextFiles: q.codeContextFiles,
997
- codeAnnotations: q.codeAnnotations ?? {},
998
- contextFiles: contextFilesWithContent,
999
- codeSnippets: legacyCodeSnippets,
1000
- createdAt: q.createdAt,
1001
- },
1002
- null,
1003
- 2,
1004
- );
1005
- await uploadFileToFolder(
1006
- drive,
1007
- questionsFolderId,
1008
- `${safeName}.json`,
1009
- payload,
1010
- "application/json",
1011
- );
1012
- result.questionsExported++;
1013
- } catch (err: any) {
1014
- result.errors.push(
1015
- `Question "${q.title}": ${err?.message || "upload failed"}`,
1016
- );
1017
- }
1018
- }),
1019
- );
1020
- }
1403
+ return result;
1404
+ }
1021
1405
 
1022
- // Upload context files into a context-files/ subfolder (parallel uploads)
1023
- if (topic.contextFiles.length > 0) {
1024
- const ctxFolderId = await getOrCreateFolder(
1025
- drive,
1026
- topicFolderId,
1027
- "context-files",
1028
- );
1029
- await Promise.all(
1030
- topic.contextFiles.map(async (cf) => {
1031
- try {
1032
- const buffer = await readContextFileExportBytes(
1033
- cf,
1034
- workspaceId,
1035
- ctxDir,
1036
- );
1037
- await uploadFileToFolder(
1038
- drive,
1039
- ctxFolderId,
1040
- cf.originalName,
1041
- buffer,
1042
- mimeForFilename(cf.originalName),
1043
- );
1044
- result.filesExported++;
1045
- } catch (err: any) {
1046
- result.errors.push(
1047
- `File "${cf.originalName}": ${err?.message || "upload failed"}`,
1048
- );
1049
- }
1050
- }),
1051
- );
1052
- }
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
+ }
1053
1416
 
1054
- result.topicsExported++;
1055
- } catch (err: any) {
1056
- result.errors.push(
1057
- `Topic "${topic.name}": ${err?.message || "export failed"}`,
1058
- );
1059
- }
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");
1060
1427
  }
1061
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
+ });
1062
1444
  return result;
1063
1445
  }