create-interview-cockpit 0.17.2 → 0.18.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/App.tsx +3 -0
- package/template/client/src/api.ts +83 -8
- package/template/client/src/components/GithubActionsLabModal.tsx +746 -0
- package/template/client/src/components/InfraLabModal.tsx +993 -262
- package/template/client/src/components/LabsPanel.tsx +71 -5
- package/template/client/src/components/Sidebar.tsx +400 -14
- package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
- package/template/client/src/enterpriseLocalLab.ts +921 -0
- package/template/client/src/githubActionsLab.ts +287 -0
- package/template/client/src/infraLab.ts +378 -6
- package/template/client/src/reactLab.ts +409 -0
- package/template/client/src/store.ts +83 -10
- package/template/client/src/types.ts +27 -3
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/server/src/gha-runner.ts +468 -0
- package/template/server/src/google-drive.ts +294 -94
- package/template/server/src/index.ts +241 -10
- package/template/server/src/infra-runner.ts +321 -30
- package/template/server/src/storage.ts +4 -1
|
@@ -137,6 +137,86 @@ async function downloadFileAuthed(
|
|
|
137
137
|
return Buffer.from(res.data as ArrayBuffer);
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
const WORKSPACE_FILES_FOLDER_NAME = "workspace-files";
|
|
141
|
+
const RESERVED_EXPORT_FOLDER_NAMES = new Set([WORKSPACE_FILES_FOLDER_NAME]);
|
|
142
|
+
|
|
143
|
+
function extensionForFilename(filename: string): string {
|
|
144
|
+
return filename.split(".").pop()?.toLowerCase() ?? "";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function mimeForFilename(filename: string): string {
|
|
148
|
+
const ext = extensionForFilename(filename);
|
|
149
|
+
const map: Record<string, string> = {
|
|
150
|
+
pdf: "application/pdf",
|
|
151
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
152
|
+
doc: "application/msword",
|
|
153
|
+
json: "application/json",
|
|
154
|
+
csv: "text/csv",
|
|
155
|
+
html: "text/html; charset=utf-8",
|
|
156
|
+
xml: "application/xml",
|
|
157
|
+
txt: "text/plain; charset=utf-8",
|
|
158
|
+
md: "text/markdown; charset=utf-8",
|
|
159
|
+
png: "image/png",
|
|
160
|
+
jpg: "image/jpeg",
|
|
161
|
+
jpeg: "image/jpeg",
|
|
162
|
+
gif: "image/gif",
|
|
163
|
+
webp: "image/webp",
|
|
164
|
+
svg: "image/svg+xml",
|
|
165
|
+
};
|
|
166
|
+
return map[ext] ?? "application/octet-stream";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
type SerializedContextFile = storage.ContextFile & {
|
|
170
|
+
content?: string;
|
|
171
|
+
originalBase64?: string;
|
|
172
|
+
originalMimeType?: string;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
async function readContextFileExportBytes(
|
|
176
|
+
cf: storage.ContextFile,
|
|
177
|
+
workspaceId: string,
|
|
178
|
+
ctxDir: string,
|
|
179
|
+
): Promise<Buffer> {
|
|
180
|
+
const original = await storage.readOriginalBlob(cf.id, workspaceId);
|
|
181
|
+
return original ?? fs.readFile(path.join(ctxDir, cf.id));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function serializeContextFileForExport(
|
|
185
|
+
cf: storage.ContextFile,
|
|
186
|
+
workspaceId: string,
|
|
187
|
+
ctxDir: string,
|
|
188
|
+
): Promise<SerializedContextFile> {
|
|
189
|
+
const exported: SerializedContextFile = { ...cf };
|
|
190
|
+
try {
|
|
191
|
+
exported.content = await fs.readFile(path.join(ctxDir, cf.id), "utf-8");
|
|
192
|
+
} catch {
|
|
193
|
+
/* Keep metadata even if the extracted-text blob is missing. */
|
|
194
|
+
}
|
|
195
|
+
const original = await storage.readOriginalBlob(cf.id, workspaceId);
|
|
196
|
+
if (original) {
|
|
197
|
+
exported.originalBase64 = original.toString("base64");
|
|
198
|
+
exported.originalMimeType = mimeForFilename(cf.originalName);
|
|
199
|
+
}
|
|
200
|
+
return exported;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function contextFileMetadataForImport(
|
|
204
|
+
cf: SerializedContextFile,
|
|
205
|
+
fallbackName: string,
|
|
206
|
+
): storage.ContextFile {
|
|
207
|
+
return {
|
|
208
|
+
id: cf.id,
|
|
209
|
+
name: cf.name || fallbackName,
|
|
210
|
+
originalName: cf.originalName || fallbackName,
|
|
211
|
+
...(cf.driveFileId ? { driveFileId: cf.driveFileId } : {}),
|
|
212
|
+
createdAt: cf.createdAt || new Date().toISOString(),
|
|
213
|
+
...(cf.origin ? { origin: cf.origin } : {}),
|
|
214
|
+
...(cf.language ? { language: cf.language } : {}),
|
|
215
|
+
...(cf.label ? { label: cf.label } : {}),
|
|
216
|
+
...(typeof cf.inContext === "boolean" ? { inContext: cf.inContext } : {}),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
140
220
|
export interface SyncResult {
|
|
141
221
|
topicsUpserted: number;
|
|
142
222
|
filesImported: number;
|
|
@@ -181,42 +261,51 @@ export async function syncWorkspace(
|
|
|
181
261
|
errors: [],
|
|
182
262
|
};
|
|
183
263
|
|
|
184
|
-
// ── Phase 0: fast wipe (parallel deletes instead of serial deleteTopic calls) ──
|
|
185
|
-
await storage.clearWorkspaceData(workspaceId);
|
|
186
|
-
|
|
187
264
|
const importedQuestionKeys = new Set<string>();
|
|
188
265
|
|
|
189
266
|
// ── Phase 1: fetch all folder listings in parallel ──────────────────────────
|
|
190
267
|
const subfolders = await doListFolders(syncRoot);
|
|
268
|
+
const workspaceFilesFolder = subfolders.find(
|
|
269
|
+
(folder) => folder.name === WORKSPACE_FILES_FOLDER_NAME,
|
|
270
|
+
);
|
|
271
|
+
const topicFolders = subfolders.filter(
|
|
272
|
+
(folder) => !RESERVED_EXPORT_FOLDER_NAMES.has(folder.name),
|
|
273
|
+
);
|
|
191
274
|
|
|
192
|
-
const folderData = await Promise.all(
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
275
|
+
const [folderData, workspaceFileItems] = await Promise.all([
|
|
276
|
+
Promise.all(
|
|
277
|
+
topicFolders.map(async (folder) => {
|
|
278
|
+
const innerFolders = await doListFolders(folder.id);
|
|
279
|
+
const questionsFolder = innerFolders.find(
|
|
280
|
+
(f) => f.name === "questions",
|
|
281
|
+
);
|
|
282
|
+
const ctxFilesFolder = innerFolders.find(
|
|
283
|
+
(f) => f.name === "context-files",
|
|
284
|
+
);
|
|
199
285
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
286
|
+
const [qFiles, cfFiles] = await Promise.all([
|
|
287
|
+
questionsFolder
|
|
288
|
+
? doListFiles(questionsFolder.id)
|
|
289
|
+
: doListFiles(folder.id),
|
|
290
|
+
ctxFilesFolder ? doListFiles(ctxFilesFolder.id) : Promise.resolve([]),
|
|
291
|
+
]);
|
|
206
292
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
293
|
+
return { folder, qFiles, cfFiles };
|
|
294
|
+
}),
|
|
295
|
+
),
|
|
296
|
+
workspaceFilesFolder
|
|
297
|
+
? doListFiles(workspaceFilesFolder.id)
|
|
298
|
+
: Promise.resolve([]),
|
|
299
|
+
]);
|
|
210
300
|
|
|
211
|
-
// ── Phase 2:
|
|
301
|
+
// ── Phase 2: build topic records in memory ─────────────────────────────────
|
|
212
302
|
const topicRecords: storage.Topic[] = folderData.map(({ folder }) => ({
|
|
213
303
|
id: randomUUID(),
|
|
214
304
|
name: folder.name,
|
|
215
305
|
contextFiles: [],
|
|
216
306
|
createdAt: new Date().toISOString(),
|
|
217
307
|
}));
|
|
218
|
-
|
|
219
|
-
result.topicsUpserted = topicRecords.length;
|
|
308
|
+
const extraTopicRecords: storage.Topic[] = [];
|
|
220
309
|
|
|
221
310
|
const topicIdByFolderId = new Map(
|
|
222
311
|
folderData.map(({ folder }, i) => [folder.id, topicRecords[i].id]),
|
|
@@ -229,8 +318,13 @@ export async function syncWorkspace(
|
|
|
229
318
|
buffer: Buffer;
|
|
230
319
|
isContextFile: boolean;
|
|
231
320
|
};
|
|
321
|
+
type PendingWorkspaceFile = {
|
|
322
|
+
filename: string;
|
|
323
|
+
buffer: Buffer;
|
|
324
|
+
};
|
|
232
325
|
|
|
233
326
|
const pending: Pending[] = [];
|
|
327
|
+
const pendingWorkspaceFiles: PendingWorkspaceFile[] = [];
|
|
234
328
|
|
|
235
329
|
await Promise.all(
|
|
236
330
|
folderData.flatMap(({ folder, qFiles, cfFiles }) => {
|
|
@@ -274,6 +368,19 @@ export async function syncWorkspace(
|
|
|
274
368
|
}),
|
|
275
369
|
);
|
|
276
370
|
|
|
371
|
+
await Promise.all(
|
|
372
|
+
workspaceFileItems.map(async (file) => {
|
|
373
|
+
try {
|
|
374
|
+
const buffer = await doDownload(file.id, file.mimeType);
|
|
375
|
+
pendingWorkspaceFiles.push({ filename: file.name, buffer });
|
|
376
|
+
} catch (err: any) {
|
|
377
|
+
result.errors.push(
|
|
378
|
+
`Workspace file "${file.name}": ${err?.message ?? "failed"}`,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
}),
|
|
382
|
+
);
|
|
383
|
+
|
|
277
384
|
// Root-level files in syncRoot → "General" topic
|
|
278
385
|
const rootFiles = await doListFiles(syncRoot);
|
|
279
386
|
if (rootFiles.length > 0) {
|
|
@@ -283,8 +390,7 @@ export async function syncWorkspace(
|
|
|
283
390
|
contextFiles: [],
|
|
284
391
|
createdAt: new Date().toISOString(),
|
|
285
392
|
};
|
|
286
|
-
|
|
287
|
-
result.topicsUpserted++;
|
|
393
|
+
extraTopicRecords.push(generalTopic);
|
|
288
394
|
|
|
289
395
|
await Promise.all(
|
|
290
396
|
rootFiles.map(async (file) => {
|
|
@@ -309,6 +415,18 @@ export async function syncWorkspace(
|
|
|
309
415
|
);
|
|
310
416
|
}
|
|
311
417
|
|
|
418
|
+
// If downloads hit private Drive files or an old token lacks enough scope,
|
|
419
|
+
// stop before wiping the local workspace. The route will prompt re-auth.
|
|
420
|
+
if (result.errors.some((error) => /\b403\b/.test(error))) {
|
|
421
|
+
return result;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ── Phase 3b: fast wipe + write all topics in one batch ────────────────────
|
|
425
|
+
await storage.clearWorkspaceData(workspaceId);
|
|
426
|
+
const allTopicRecords = [...topicRecords, ...extraTopicRecords];
|
|
427
|
+
await storage.replaceAllTopics(allTopicRecords);
|
|
428
|
+
result.topicsUpserted = allTopicRecords.length;
|
|
429
|
+
|
|
312
430
|
// ── Phase 4: save questions in parallel (each is an individual file) ─────────
|
|
313
431
|
const questions = pending.filter((p) => !p.isContextFile);
|
|
314
432
|
const contextFiles = pending.filter((p) => p.isContextFile);
|
|
@@ -345,42 +463,53 @@ export async function syncWorkspace(
|
|
|
345
463
|
parentTitle = parsed.parentTitle || null;
|
|
346
464
|
if (parsed.createdAt) restoredCreatedAt = parsed.createdAt;
|
|
347
465
|
|
|
348
|
-
// Restore
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
parsed.
|
|
352
|
-
)
|
|
466
|
+
// Restore all question-attached context files. New exports use
|
|
467
|
+
// contextFiles; older exports used codeSnippets for lab/code blobs.
|
|
468
|
+
const parsedContextFiles: SerializedContextFile[] = Array.isArray(
|
|
469
|
+
parsed.contextFiles,
|
|
470
|
+
)
|
|
471
|
+
? parsed.contextFiles
|
|
472
|
+
: Array.isArray(parsed.codeSnippets)
|
|
473
|
+
? parsed.codeSnippets
|
|
474
|
+
: [];
|
|
475
|
+
if (parsedContextFiles.length > 0) {
|
|
353
476
|
const ctxDir =
|
|
354
477
|
storage.getContextFilesDirForWorkspace(workspaceId);
|
|
355
478
|
await Promise.all(
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
cs.
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
479
|
+
parsedContextFiles.map(async (cs) => {
|
|
480
|
+
if (!cs.id) return;
|
|
481
|
+
const originalName = cs.originalName || cs.name || cs.id;
|
|
482
|
+
try {
|
|
483
|
+
const originalBuffer = cs.originalBase64
|
|
484
|
+
? Buffer.from(cs.originalBase64, "base64")
|
|
485
|
+
: null;
|
|
486
|
+
const content =
|
|
487
|
+
typeof cs.content === "string"
|
|
488
|
+
? cs.content
|
|
489
|
+
: originalBuffer
|
|
490
|
+
? await extractText(originalBuffer, originalName)
|
|
491
|
+
: null;
|
|
492
|
+
|
|
493
|
+
if (content == null) return;
|
|
494
|
+
await fs.mkdir(ctxDir, { recursive: true });
|
|
495
|
+
await fs.writeFile(
|
|
496
|
+
path.join(ctxDir, cs.id),
|
|
497
|
+
Buffer.from(content, "utf-8"),
|
|
498
|
+
);
|
|
499
|
+
if (originalBuffer) {
|
|
500
|
+
await storage.writeOriginalBlob(
|
|
501
|
+
cs.id,
|
|
502
|
+
originalBuffer,
|
|
503
|
+
workspaceId,
|
|
504
|
+
);
|
|
381
505
|
}
|
|
382
|
-
|
|
383
|
-
|
|
506
|
+
restoredContextFiles.push(
|
|
507
|
+
contextFileMetadataForImport(cs, originalName),
|
|
508
|
+
);
|
|
509
|
+
} catch {
|
|
510
|
+
/* skip bad blobs */
|
|
511
|
+
}
|
|
512
|
+
}),
|
|
384
513
|
);
|
|
385
514
|
}
|
|
386
515
|
} catch {
|
|
@@ -439,13 +568,36 @@ export async function syncWorkspace(
|
|
|
439
568
|
}
|
|
440
569
|
}
|
|
441
570
|
|
|
442
|
-
// ── Phase 5: save context file blobs
|
|
571
|
+
// ── Phase 5: save workspace/topic context file blobs ───────────────────────
|
|
572
|
+
if (pendingWorkspaceFiles.length > 0) {
|
|
573
|
+
// saveWorkspaceContextFile updates workspace-files.json; do this
|
|
574
|
+
// sequentially so concurrent reads/writes do not overwrite metadata.
|
|
575
|
+
for (const { filename, buffer } of pendingWorkspaceFiles) {
|
|
576
|
+
const fileId = randomUUID();
|
|
577
|
+
const text = await extractText(buffer, filename);
|
|
578
|
+
await storage.writeOriginalBlob(fileId, buffer, workspaceId);
|
|
579
|
+
await storage.saveWorkspaceContextFile(
|
|
580
|
+
fileId,
|
|
581
|
+
filename,
|
|
582
|
+
Buffer.from(text, "utf-8"),
|
|
583
|
+
workspaceId,
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
result.filesImported += pendingWorkspaceFiles.length;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Save topic context file blobs in parallel, then update topics.json once.
|
|
443
590
|
if (contextFiles.length > 0) {
|
|
444
|
-
// Write binary files in parallel
|
|
445
591
|
const cfRecords = await Promise.all(
|
|
446
592
|
contextFiles.map(async ({ topicId, filename, buffer }) => {
|
|
447
593
|
const fileId = randomUUID();
|
|
448
|
-
await
|
|
594
|
+
const text = await extractText(buffer, filename);
|
|
595
|
+
await storage.writeOriginalBlob(fileId, buffer, workspaceId);
|
|
596
|
+
await storage.writeContextFileBlob(
|
|
597
|
+
workspaceId,
|
|
598
|
+
fileId,
|
|
599
|
+
Buffer.from(text, "utf-8"),
|
|
600
|
+
);
|
|
449
601
|
return { topicId, fileId, filename };
|
|
450
602
|
}),
|
|
451
603
|
);
|
|
@@ -486,6 +638,10 @@ export async function listDriveSubfolders(
|
|
|
486
638
|
if (!ws?.driveConfig?.folderId) {
|
|
487
639
|
throw new Error("No Drive folder linked to this workspace");
|
|
488
640
|
}
|
|
641
|
+
if (await isExportAuthed()) {
|
|
642
|
+
const drive = await getExportDriveClient();
|
|
643
|
+
return listFoldersAuthed(drive, ws.driveConfig.folderId);
|
|
644
|
+
}
|
|
489
645
|
return listFolders(ws.driveConfig.folderId);
|
|
490
646
|
}
|
|
491
647
|
|
|
@@ -518,11 +674,11 @@ const EXPORT_TOKENS_FILE = path.resolve(
|
|
|
518
674
|
);
|
|
519
675
|
|
|
520
676
|
function createExportOAuthClient() {
|
|
677
|
+
const defaultRedirectUri = `http://localhost:${process.env.GOOGLE_EXPORT_REDIRECT_PORT || "3001"}/api/drive/export-callback`;
|
|
521
678
|
return new google.auth.OAuth2(
|
|
522
679
|
process.env.GOOGLE_CLIENT_ID,
|
|
523
680
|
process.env.GOOGLE_CLIENT_SECRET,
|
|
524
|
-
process.env.GOOGLE_EXPORT_REDIRECT_URI ||
|
|
525
|
-
"http://localhost:3001/api/drive/export-callback",
|
|
681
|
+
process.env.GOOGLE_EXPORT_REDIRECT_URI || defaultRedirectUri,
|
|
526
682
|
);
|
|
527
683
|
}
|
|
528
684
|
|
|
@@ -530,7 +686,7 @@ export function getExportAuthUrl(): string {
|
|
|
530
686
|
const client = createExportOAuthClient();
|
|
531
687
|
return client.generateAuthUrl({
|
|
532
688
|
access_type: "offline",
|
|
533
|
-
scope: ["https://www.googleapis.com/auth/drive
|
|
689
|
+
scope: ["https://www.googleapis.com/auth/drive"],
|
|
534
690
|
prompt: "consent",
|
|
535
691
|
});
|
|
536
692
|
}
|
|
@@ -697,6 +853,31 @@ async function uploadFileToFolder(
|
|
|
697
853
|
const buffer = Buffer.isBuffer(content)
|
|
698
854
|
? content
|
|
699
855
|
: Buffer.from(content, "utf-8");
|
|
856
|
+
const safeName = name.replace(/'/g, "\\'");
|
|
857
|
+
const existing = await drive.files.list({
|
|
858
|
+
q: `'${folderId}' in parents and name = '${safeName}' and trashed = false`,
|
|
859
|
+
fields: "files(id)",
|
|
860
|
+
pageSize: 10,
|
|
861
|
+
});
|
|
862
|
+
const [first, ...duplicates] = existing.data.files || [];
|
|
863
|
+
if (first?.id) {
|
|
864
|
+
await drive.files.update({
|
|
865
|
+
fileId: first.id,
|
|
866
|
+
requestBody: { name },
|
|
867
|
+
media: { mimeType, body: Readable.from(buffer) },
|
|
868
|
+
fields: "id",
|
|
869
|
+
});
|
|
870
|
+
await Promise.all(
|
|
871
|
+
duplicates.map((file) =>
|
|
872
|
+
file.id
|
|
873
|
+
? drive.files.delete({ fileId: file.id }).catch(() => {})
|
|
874
|
+
: null,
|
|
875
|
+
),
|
|
876
|
+
);
|
|
877
|
+
await ensurePublicRead(drive, first.id);
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
|
|
700
881
|
const created = await drive.files.create({
|
|
701
882
|
requestBody: { name, parents: [folderId] },
|
|
702
883
|
media: { mimeType, body: Readable.from(buffer) },
|
|
@@ -731,6 +912,39 @@ export async function exportWorkspace(
|
|
|
731
912
|
const exportFolderId =
|
|
732
913
|
targetFolderId ?? (await getOrCreateFolder(drive, folderId, "_export"));
|
|
733
914
|
const topics = await storage.getTopicsForWorkspace(workspaceId);
|
|
915
|
+
const ctxDir = storage.getContextFilesDirForWorkspace(workspaceId);
|
|
916
|
+
|
|
917
|
+
const workspaceFiles = await storage.getWorkspaceContextFiles(workspaceId);
|
|
918
|
+
if (workspaceFiles.length > 0) {
|
|
919
|
+
const workspaceFilesFolderId = await getOrCreateFolder(
|
|
920
|
+
drive,
|
|
921
|
+
exportFolderId,
|
|
922
|
+
WORKSPACE_FILES_FOLDER_NAME,
|
|
923
|
+
);
|
|
924
|
+
await Promise.all(
|
|
925
|
+
workspaceFiles.map(async (cf) => {
|
|
926
|
+
try {
|
|
927
|
+
const buffer = await readContextFileExportBytes(
|
|
928
|
+
cf,
|
|
929
|
+
workspaceId,
|
|
930
|
+
ctxDir,
|
|
931
|
+
);
|
|
932
|
+
await uploadFileToFolder(
|
|
933
|
+
drive,
|
|
934
|
+
workspaceFilesFolderId,
|
|
935
|
+
cf.originalName,
|
|
936
|
+
buffer,
|
|
937
|
+
mimeForFilename(cf.originalName),
|
|
938
|
+
);
|
|
939
|
+
result.filesExported++;
|
|
940
|
+
} catch (err: any) {
|
|
941
|
+
result.errors.push(
|
|
942
|
+
`Workspace file "${cf.originalName}": ${err?.message || "upload failed"}`,
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
}),
|
|
946
|
+
);
|
|
947
|
+
}
|
|
734
948
|
|
|
735
949
|
for (const topic of topics) {
|
|
736
950
|
try {
|
|
@@ -761,34 +975,16 @@ export async function exportWorkspace(
|
|
|
761
975
|
? questions.find((p) => p.id === q.parentQuestionId)?.title
|
|
762
976
|
: undefined;
|
|
763
977
|
|
|
764
|
-
// Read
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
cf.origin === "sandbox" ||
|
|
775
|
-
cf.origin === "browser-security" ||
|
|
776
|
-
cf.origin === "react" ||
|
|
777
|
-
cf.origin === "nextjs" ||
|
|
778
|
-
cf.origin === "module-federation" ||
|
|
779
|
-
cf.origin === "infra"
|
|
780
|
-
) {
|
|
781
|
-
try {
|
|
782
|
-
const content = await fs.readFile(
|
|
783
|
-
path.join(ctxDir, cf.id),
|
|
784
|
-
"utf-8",
|
|
785
|
-
);
|
|
786
|
-
contextFilesWithContent.push({ ...cf, content });
|
|
787
|
-
} catch {
|
|
788
|
-
contextFilesWithContent.push(cf);
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
}
|
|
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
|
+
);
|
|
792
988
|
|
|
793
989
|
// Export full question as JSON so nothing is lost on sync-back
|
|
794
990
|
const payload = JSON.stringify(
|
|
@@ -799,7 +995,8 @@ export async function exportWorkspace(
|
|
|
799
995
|
messages: q.messages,
|
|
800
996
|
codeContextFiles: q.codeContextFiles,
|
|
801
997
|
codeAnnotations: q.codeAnnotations ?? {},
|
|
802
|
-
|
|
998
|
+
contextFiles: contextFilesWithContent,
|
|
999
|
+
codeSnippets: legacyCodeSnippets,
|
|
803
1000
|
createdAt: q.createdAt,
|
|
804
1001
|
},
|
|
805
1002
|
null,
|
|
@@ -829,17 +1026,20 @@ export async function exportWorkspace(
|
|
|
829
1026
|
topicFolderId,
|
|
830
1027
|
"context-files",
|
|
831
1028
|
);
|
|
832
|
-
const ctxDir = storage.getContextFilesDirForWorkspace(workspaceId);
|
|
833
1029
|
await Promise.all(
|
|
834
1030
|
topic.contextFiles.map(async (cf) => {
|
|
835
1031
|
try {
|
|
836
|
-
const buffer = await
|
|
1032
|
+
const buffer = await readContextFileExportBytes(
|
|
1033
|
+
cf,
|
|
1034
|
+
workspaceId,
|
|
1035
|
+
ctxDir,
|
|
1036
|
+
);
|
|
837
1037
|
await uploadFileToFolder(
|
|
838
1038
|
drive,
|
|
839
1039
|
ctxFolderId,
|
|
840
1040
|
cf.originalName,
|
|
841
1041
|
buffer,
|
|
842
|
-
|
|
1042
|
+
mimeForFilename(cf.originalName),
|
|
843
1043
|
);
|
|
844
1044
|
result.filesExported++;
|
|
845
1045
|
} catch (err: any) {
|