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.
- package/package.json +1 -1
- package/template/client/src/App.tsx +3 -0
- package/template/client/src/api.ts +184 -8
- 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 +1048 -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 +603 -60
- package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
- package/template/client/src/enterpriseLocalLab.ts +921 -0
- package/template/client/src/githubActionsLab.ts +294 -0
- package/template/client/src/infraLab.ts +378 -6
- package/template/client/src/reactLab.ts +409 -0
- package/template/client/src/store.ts +130 -10
- package/template/client/src/types.ts +33 -3
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/server/src/gha-runner.ts +793 -0
- package/template/server/src/google-drive.ts +542 -149
- package/template/server/src/index.ts +327 -10
- package/template/server/src/infra-runner.ts +321 -30
- package/template/server/src/storage.ts +3 -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,
|
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
|
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 ??
|
|
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
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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
|
-
|
|
979
|
-
|
|
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
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
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
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
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
|
}
|