create-interview-cockpit 0.17.2 → 0.17.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-interview-cockpit",
3
- "version": "0.17.2",
3
+ "version": "0.17.3",
4
4
  "description": "Scaffold a personal AI-powered interview prep cockpit",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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;
@@ -188,25 +268,38 @@ export async function syncWorkspace(
188
268
 
189
269
  // ── Phase 1: fetch all folder listings in parallel ──────────────────────────
190
270
  const subfolders = await doListFolders(syncRoot);
271
+ const workspaceFilesFolder = subfolders.find(
272
+ (folder) => folder.name === WORKSPACE_FILES_FOLDER_NAME,
273
+ );
274
+ const topicFolders = subfolders.filter(
275
+ (folder) => !RESERVED_EXPORT_FOLDER_NAMES.has(folder.name),
276
+ );
191
277
 
192
- const folderData = await Promise.all(
193
- subfolders.map(async (folder) => {
194
- const innerFolders = await doListFolders(folder.id);
195
- const questionsFolder = innerFolders.find((f) => f.name === "questions");
196
- const ctxFilesFolder = innerFolders.find(
197
- (f) => f.name === "context-files",
198
- );
278
+ const [folderData, workspaceFileItems] = await Promise.all([
279
+ Promise.all(
280
+ topicFolders.map(async (folder) => {
281
+ const innerFolders = await doListFolders(folder.id);
282
+ const questionsFolder = innerFolders.find(
283
+ (f) => f.name === "questions",
284
+ );
285
+ const ctxFilesFolder = innerFolders.find(
286
+ (f) => f.name === "context-files",
287
+ );
199
288
 
200
- const [qFiles, cfFiles] = await Promise.all([
201
- questionsFolder
202
- ? doListFiles(questionsFolder.id)
203
- : doListFiles(folder.id),
204
- ctxFilesFolder ? doListFiles(ctxFilesFolder.id) : Promise.resolve([]),
205
- ]);
289
+ const [qFiles, cfFiles] = await Promise.all([
290
+ questionsFolder
291
+ ? doListFiles(questionsFolder.id)
292
+ : doListFiles(folder.id),
293
+ ctxFilesFolder ? doListFiles(ctxFilesFolder.id) : Promise.resolve([]),
294
+ ]);
206
295
 
207
- return { folder, qFiles, cfFiles };
208
- }),
209
- );
296
+ return { folder, qFiles, cfFiles };
297
+ }),
298
+ ),
299
+ workspaceFilesFolder
300
+ ? doListFiles(workspaceFilesFolder.id)
301
+ : Promise.resolve([]),
302
+ ]);
210
303
 
211
304
  // ── Phase 2: write all topics in one batch (one topics.json write) ───────────
212
305
  const topicRecords: storage.Topic[] = folderData.map(({ folder }) => ({
@@ -229,8 +322,13 @@ export async function syncWorkspace(
229
322
  buffer: Buffer;
230
323
  isContextFile: boolean;
231
324
  };
325
+ type PendingWorkspaceFile = {
326
+ filename: string;
327
+ buffer: Buffer;
328
+ };
232
329
 
233
330
  const pending: Pending[] = [];
331
+ const pendingWorkspaceFiles: PendingWorkspaceFile[] = [];
234
332
 
235
333
  await Promise.all(
236
334
  folderData.flatMap(({ folder, qFiles, cfFiles }) => {
@@ -274,6 +372,19 @@ export async function syncWorkspace(
274
372
  }),
275
373
  );
276
374
 
375
+ await Promise.all(
376
+ workspaceFileItems.map(async (file) => {
377
+ try {
378
+ const buffer = await doDownload(file.id, file.mimeType);
379
+ pendingWorkspaceFiles.push({ filename: file.name, buffer });
380
+ } catch (err: any) {
381
+ result.errors.push(
382
+ `Workspace file "${file.name}": ${err?.message ?? "failed"}`,
383
+ );
384
+ }
385
+ }),
386
+ );
387
+
277
388
  // Root-level files in syncRoot → "General" topic
278
389
  const rootFiles = await doListFiles(syncRoot);
279
390
  if (rootFiles.length > 0) {
@@ -345,42 +456,53 @@ export async function syncWorkspace(
345
456
  parentTitle = parsed.parentTitle || null;
346
457
  if (parsed.createdAt) restoredCreatedAt = parsed.createdAt;
347
458
 
348
- // Restore code snippet context files (user/ai origin)
349
- if (
350
- Array.isArray(parsed.codeSnippets) &&
351
- parsed.codeSnippets.length > 0
352
- ) {
459
+ // Restore all question-attached context files. New exports use
460
+ // contextFiles; older exports used codeSnippets for lab/code blobs.
461
+ const parsedContextFiles: SerializedContextFile[] = Array.isArray(
462
+ parsed.contextFiles,
463
+ )
464
+ ? parsed.contextFiles
465
+ : Array.isArray(parsed.codeSnippets)
466
+ ? parsed.codeSnippets
467
+ : [];
468
+ if (parsedContextFiles.length > 0) {
353
469
  const ctxDir =
354
470
  storage.getContextFilesDirForWorkspace(workspaceId);
355
471
  await Promise.all(
356
- parsed.codeSnippets.map(
357
- async (cs: storage.ContextFile & { content?: string }) => {
358
- if (
359
- cs.content &&
360
- (cs.origin === "user" ||
361
- cs.origin === "ai" ||
362
- cs.origin === "sandbox" ||
363
- cs.origin === "browser-security" ||
364
- cs.origin === "react" ||
365
- cs.origin === "nextjs" ||
366
- cs.origin === "module-federation" ||
367
- cs.origin === "infra")
368
- ) {
369
- try {
370
- await fs.mkdir(ctxDir, { recursive: true });
371
- await fs.writeFile(
372
- path.join(ctxDir, cs.id),
373
- Buffer.from(cs.content, "utf-8"),
374
- );
375
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
376
- const { content: _content, ...cfMeta } = cs;
377
- restoredContextFiles.push(cfMeta);
378
- } catch {
379
- /* skip bad blobs */
380
- }
472
+ parsedContextFiles.map(async (cs) => {
473
+ if (!cs.id) return;
474
+ const originalName = cs.originalName || cs.name || cs.id;
475
+ try {
476
+ const originalBuffer = cs.originalBase64
477
+ ? Buffer.from(cs.originalBase64, "base64")
478
+ : null;
479
+ const content =
480
+ typeof cs.content === "string"
481
+ ? cs.content
482
+ : originalBuffer
483
+ ? await extractText(originalBuffer, originalName)
484
+ : null;
485
+
486
+ if (content == null) return;
487
+ await fs.mkdir(ctxDir, { recursive: true });
488
+ await fs.writeFile(
489
+ path.join(ctxDir, cs.id),
490
+ Buffer.from(content, "utf-8"),
491
+ );
492
+ if (originalBuffer) {
493
+ await storage.writeOriginalBlob(
494
+ cs.id,
495
+ originalBuffer,
496
+ workspaceId,
497
+ );
381
498
  }
382
- },
383
- ),
499
+ restoredContextFiles.push(
500
+ contextFileMetadataForImport(cs, originalName),
501
+ );
502
+ } catch {
503
+ /* skip bad blobs */
504
+ }
505
+ }),
384
506
  );
385
507
  }
386
508
  } catch {
@@ -439,13 +561,36 @@ export async function syncWorkspace(
439
561
  }
440
562
  }
441
563
 
442
- // ── Phase 5: save context file blobs in parallel, then update topics.json once ─
564
+ // ── Phase 5: save workspace/topic context file blobs ───────────────────────
565
+ if (pendingWorkspaceFiles.length > 0) {
566
+ await Promise.all(
567
+ pendingWorkspaceFiles.map(async ({ filename, buffer }) => {
568
+ const fileId = randomUUID();
569
+ const text = await extractText(buffer, filename);
570
+ await storage.writeOriginalBlob(fileId, buffer, workspaceId);
571
+ await storage.saveWorkspaceContextFile(
572
+ fileId,
573
+ filename,
574
+ Buffer.from(text, "utf-8"),
575
+ workspaceId,
576
+ );
577
+ }),
578
+ );
579
+ result.filesImported += pendingWorkspaceFiles.length;
580
+ }
581
+
582
+ // Save topic context file blobs in parallel, then update topics.json once.
443
583
  if (contextFiles.length > 0) {
444
- // Write binary files in parallel
445
584
  const cfRecords = await Promise.all(
446
585
  contextFiles.map(async ({ topicId, filename, buffer }) => {
447
586
  const fileId = randomUUID();
448
- await storage.writeContextFileBlob(workspaceId, fileId, buffer);
587
+ const text = await extractText(buffer, filename);
588
+ await storage.writeOriginalBlob(fileId, buffer, workspaceId);
589
+ await storage.writeContextFileBlob(
590
+ workspaceId,
591
+ fileId,
592
+ Buffer.from(text, "utf-8"),
593
+ );
449
594
  return { topicId, fileId, filename };
450
595
  }),
451
596
  );
@@ -697,6 +842,31 @@ async function uploadFileToFolder(
697
842
  const buffer = Buffer.isBuffer(content)
698
843
  ? content
699
844
  : Buffer.from(content, "utf-8");
845
+ const safeName = name.replace(/'/g, "\\'");
846
+ const existing = await drive.files.list({
847
+ q: `'${folderId}' in parents and name = '${safeName}' and trashed = false`,
848
+ fields: "files(id)",
849
+ pageSize: 10,
850
+ });
851
+ const [first, ...duplicates] = existing.data.files || [];
852
+ if (first?.id) {
853
+ await drive.files.update({
854
+ fileId: first.id,
855
+ requestBody: { name },
856
+ media: { mimeType, body: Readable.from(buffer) },
857
+ fields: "id",
858
+ });
859
+ await Promise.all(
860
+ duplicates.map((file) =>
861
+ file.id
862
+ ? drive.files.delete({ fileId: file.id }).catch(() => {})
863
+ : null,
864
+ ),
865
+ );
866
+ await ensurePublicRead(drive, first.id);
867
+ return;
868
+ }
869
+
700
870
  const created = await drive.files.create({
701
871
  requestBody: { name, parents: [folderId] },
702
872
  media: { mimeType, body: Readable.from(buffer) },
@@ -731,6 +901,39 @@ export async function exportWorkspace(
731
901
  const exportFolderId =
732
902
  targetFolderId ?? (await getOrCreateFolder(drive, folderId, "_export"));
733
903
  const topics = await storage.getTopicsForWorkspace(workspaceId);
904
+ const ctxDir = storage.getContextFilesDirForWorkspace(workspaceId);
905
+
906
+ const workspaceFiles = await storage.getWorkspaceContextFiles(workspaceId);
907
+ if (workspaceFiles.length > 0) {
908
+ const workspaceFilesFolderId = await getOrCreateFolder(
909
+ drive,
910
+ exportFolderId,
911
+ WORKSPACE_FILES_FOLDER_NAME,
912
+ );
913
+ await Promise.all(
914
+ workspaceFiles.map(async (cf) => {
915
+ try {
916
+ const buffer = await readContextFileExportBytes(
917
+ cf,
918
+ workspaceId,
919
+ ctxDir,
920
+ );
921
+ await uploadFileToFolder(
922
+ drive,
923
+ workspaceFilesFolderId,
924
+ cf.originalName,
925
+ buffer,
926
+ mimeForFilename(cf.originalName),
927
+ );
928
+ result.filesExported++;
929
+ } catch (err: any) {
930
+ result.errors.push(
931
+ `Workspace file "${cf.originalName}": ${err?.message || "upload failed"}`,
932
+ );
933
+ }
934
+ }),
935
+ );
936
+ }
734
937
 
735
938
  for (const topic of topics) {
736
939
  try {
@@ -761,34 +964,16 @@ export async function exportWorkspace(
761
964
  ? questions.find((p) => p.id === q.parentQuestionId)?.title
762
965
  : undefined;
763
966
 
764
- // Read code snippet blobs so they survive drive sync
765
- const ctxDir =
766
- storage.getContextFilesDirForWorkspace(workspaceId);
767
- const contextFilesWithContent: Array<
768
- storage.ContextFile & { content?: string }
769
- > = [];
770
- for (const cf of q.contextFiles || []) {
771
- if (
772
- cf.origin === "user" ||
773
- cf.origin === "ai" ||
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
- }
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
+ );
792
977
 
793
978
  // Export full question as JSON so nothing is lost on sync-back
794
979
  const payload = JSON.stringify(
@@ -799,7 +984,8 @@ export async function exportWorkspace(
799
984
  messages: q.messages,
800
985
  codeContextFiles: q.codeContextFiles,
801
986
  codeAnnotations: q.codeAnnotations ?? {},
802
- codeSnippets: contextFilesWithContent,
987
+ contextFiles: contextFilesWithContent,
988
+ codeSnippets: legacyCodeSnippets,
803
989
  createdAt: q.createdAt,
804
990
  },
805
991
  null,
@@ -829,17 +1015,20 @@ export async function exportWorkspace(
829
1015
  topicFolderId,
830
1016
  "context-files",
831
1017
  );
832
- const ctxDir = storage.getContextFilesDirForWorkspace(workspaceId);
833
1018
  await Promise.all(
834
1019
  topic.contextFiles.map(async (cf) => {
835
1020
  try {
836
- const buffer = await fs.readFile(path.join(ctxDir, cf.id));
1021
+ const buffer = await readContextFileExportBytes(
1022
+ cf,
1023
+ workspaceId,
1024
+ ctxDir,
1025
+ );
837
1026
  await uploadFileToFolder(
838
1027
  drive,
839
1028
  ctxFolderId,
840
1029
  cf.originalName,
841
1030
  buffer,
842
- "application/octet-stream",
1031
+ mimeForFilename(cf.originalName),
843
1032
  );
844
1033
  result.filesExported++;
845
1034
  } catch (err: any) {
@@ -434,6 +434,7 @@ export async function clearWorkspaceData(workspaceId: string): Promise<void> {
434
434
  await ensureWorkspaceDirs(workspaceId);
435
435
  const wid = workspaceId;
436
436
  await fs.writeFile(topicsFilePath(wid), JSON.stringify([], null, 2));
437
+ await fs.writeFile(workspaceFilesFilePath(wid), JSON.stringify([], null, 2));
437
438
  await Promise.all([
438
439
  fs
439
440
  .readdir(questionsDirPath(wid))