create-interview-cockpit 0.18.0 → 0.20.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.
- package/package.json +1 -1
- package/template/client/src/api.ts +101 -0
- package/template/client/src/components/GhaHistoryPanel.tsx +194 -0
- package/template/client/src/components/GhaJobsPanel.tsx +432 -0
- package/template/client/src/components/GithubActionsLabModal.tsx +583 -76
- package/template/client/src/components/LabsPanel.tsx +11 -1
- package/template/client/src/components/Sidebar.tsx +216 -59
- package/template/client/src/githubActionsLab.ts +239 -2
- package/template/client/src/store.ts +47 -0
- package/template/client/src/types.ts +6 -0
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/server/src/gha-runner.ts +327 -1
- package/template/server/src/google-drive.ts +507 -125
- package/template/server/src/index.ts +87 -1
|
@@ -138,7 +138,11 @@ async function downloadFileAuthed(
|
|
|
138
138
|
}
|
|
139
139
|
|
|
140
140
|
const WORKSPACE_FILES_FOLDER_NAME = "workspace-files";
|
|
141
|
-
const
|
|
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
|
|
238
|
-
const syncRoot = subFolderId || folderId;
|
|
408
|
+
const syncRoot = getWorkspaceSyncRoot(ws);
|
|
239
409
|
|
|
240
410
|
storage.setActiveWorkspaceId(workspaceId);
|
|
241
411
|
|
|
242
|
-
|
|
243
|
-
|
|
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 ??
|
|
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
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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
|
-
|
|
990
|
-
|
|
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
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
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
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
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
|
}
|