create-interview-cockpit 0.1.1 → 0.3.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.
@@ -4,14 +4,38 @@ import { fileURLToPath } from "url";
4
4
 
5
5
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
6
  const DATA_DIR = path.join(__dirname, "../../data");
7
- const TOPICS_FILE = path.join(DATA_DIR, "topics.json");
8
- const QUESTIONS_DIR = path.join(DATA_DIR, "questions");
9
- const CONTEXT_FILES_DIR = path.join(DATA_DIR, "context-files");
7
+ const WORKSPACES_DIR = path.join(DATA_DIR, "workspaces");
8
+ const WORKSPACES_FILE = path.join(DATA_DIR, "workspaces.json");
10
9
 
11
- async function ensureDirs() {
12
- await fs.mkdir(DATA_DIR, { recursive: true });
13
- await fs.mkdir(QUESTIONS_DIR, { recursive: true });
14
- await fs.mkdir(CONTEXT_FILES_DIR, { recursive: true });
10
+ // ── Active workspace ──────────────────────────────────────────
11
+ let _activeWorkspaceId = "default";
12
+
13
+ export function getActiveWorkspaceId(): string {
14
+ return _activeWorkspaceId;
15
+ }
16
+
17
+ /** Directly set the active workspace without side effects (no disk write, no dir creation). */
18
+ export function setActiveWorkspaceId(id: string): void {
19
+ _activeWorkspaceId = id;
20
+ }
21
+
22
+ // ── Workspace-scoped path helpers ─────────────────────────────
23
+ function workspaceDir(id = _activeWorkspaceId) {
24
+ return path.join(WORKSPACES_DIR, id);
25
+ }
26
+ function topicsFilePath(id = _activeWorkspaceId) {
27
+ return path.join(workspaceDir(id), "topics.json");
28
+ }
29
+ function questionsDirPath(id = _activeWorkspaceId) {
30
+ return path.join(workspaceDir(id), "questions");
31
+ }
32
+ function contextFilesDirPath(id = _activeWorkspaceId) {
33
+ return path.join(workspaceDir(id), "context-files");
34
+ }
35
+ async function ensureWorkspaceDirs(id = _activeWorkspaceId) {
36
+ await fs.mkdir(workspaceDir(id), { recursive: true });
37
+ await fs.mkdir(questionsDirPath(id), { recursive: true });
38
+ await fs.mkdir(contextFilesDirPath(id), { recursive: true });
15
39
  }
16
40
 
17
41
  export interface Topic {
@@ -25,6 +49,7 @@ export interface ContextFile {
25
49
  id: string;
26
50
  name: string;
27
51
  originalName: string;
52
+ driveFileId?: string;
28
53
  createdAt: string;
29
54
  }
30
55
 
@@ -71,12 +96,226 @@ export interface Question {
71
96
  createdAt: string;
72
97
  }
73
98
 
99
+ // ── Workspace registry types ──────────────────────────────────────────
100
+ export interface DriveConfig {
101
+ folderId: string;
102
+ folderName: string;
103
+ lastSyncedAt?: string;
104
+ subFolderId?: string;
105
+ subFolderName?: string;
106
+ }
107
+
108
+ export interface WorkspaceMeta {
109
+ id: string;
110
+ name: string;
111
+ type: "local" | "google_drive";
112
+ driveConfig?: DriveConfig;
113
+ createdAt: string;
114
+ }
115
+
116
+ interface WorkspacesRegistry {
117
+ activeWorkspaceId: string;
118
+ workspaces: WorkspaceMeta[];
119
+ }
120
+
121
+ // ── Migration: flat data/ → data/workspaces/default/ ────────────────────
122
+ // Call once on server startup. No-op if already migrated.
123
+ export async function migrateToWorkspaces(): Promise<void> {
124
+ try {
125
+ await fs.access(WORKSPACES_FILE);
126
+ // Already migrated — load the persisted active workspace ID
127
+ const data = await fs.readFile(WORKSPACES_FILE, "utf-8");
128
+ const registry: WorkspacesRegistry = JSON.parse(data);
129
+ _activeWorkspaceId = registry.activeWorkspaceId || "default";
130
+ return;
131
+ } catch {
132
+ // No registry yet — perform first-time migration
133
+ }
134
+
135
+ const defaultId = "default";
136
+ await ensureWorkspaceDirs(defaultId);
137
+
138
+ // Copy existing flat topics.json
139
+ try {
140
+ await fs.copyFile(
141
+ path.join(DATA_DIR, "topics.json"),
142
+ topicsFilePath(defaultId),
143
+ );
144
+ } catch {
145
+ await fs.writeFile(topicsFilePath(defaultId), JSON.stringify([], null, 2));
146
+ }
147
+
148
+ // Copy existing questions/*.json
149
+ try {
150
+ const files = await fs.readdir(path.join(DATA_DIR, "questions"));
151
+ for (const file of files) {
152
+ if (!file.endsWith(".json")) continue;
153
+ await fs.copyFile(
154
+ path.join(DATA_DIR, "questions", file),
155
+ path.join(questionsDirPath(defaultId), file),
156
+ );
157
+ }
158
+ } catch {
159
+ /* ok if not present */
160
+ }
161
+
162
+ // Copy existing context-files/*
163
+ try {
164
+ const files = await fs.readdir(path.join(DATA_DIR, "context-files"));
165
+ for (const file of files) {
166
+ await fs.copyFile(
167
+ path.join(DATA_DIR, "context-files", file),
168
+ path.join(contextFilesDirPath(defaultId), file),
169
+ );
170
+ }
171
+ } catch {
172
+ /* ok if not present */
173
+ }
174
+
175
+ const registry: WorkspacesRegistry = {
176
+ activeWorkspaceId: defaultId,
177
+ workspaces: [
178
+ {
179
+ id: defaultId,
180
+ name: "Local",
181
+ type: "local",
182
+ createdAt: new Date().toISOString(),
183
+ },
184
+ ],
185
+ };
186
+ await fs.writeFile(WORKSPACES_FILE, JSON.stringify(registry, null, 2));
187
+ _activeWorkspaceId = defaultId;
188
+ }
189
+
190
+ // ── Workspace CRUD ───────────────────────────────────────────────
191
+ export async function getWorkspaces(): Promise<WorkspacesRegistry> {
192
+ try {
193
+ const data = await fs.readFile(WORKSPACES_FILE, "utf-8");
194
+ const registry = JSON.parse(data) as WorkspacesRegistry;
195
+ // Keep the in-memory active ID in sync with what's persisted
196
+ if (registry.activeWorkspaceId) {
197
+ _activeWorkspaceId = registry.activeWorkspaceId;
198
+ }
199
+ return registry;
200
+ } catch {
201
+ return { activeWorkspaceId: _activeWorkspaceId, workspaces: [] };
202
+ }
203
+ }
204
+
205
+ export async function saveWorkspace(ws: WorkspaceMeta): Promise<WorkspaceMeta> {
206
+ const registry = await getWorkspaces();
207
+ const idx = registry.workspaces.findIndex((w) => w.id === ws.id);
208
+ if (idx !== -1) {
209
+ registry.workspaces[idx] = ws;
210
+ } else {
211
+ registry.workspaces.push(ws);
212
+ await ensureWorkspaceDirs(ws.id);
213
+ }
214
+ await fs.writeFile(WORKSPACES_FILE, JSON.stringify(registry, null, 2));
215
+ return ws;
216
+ }
217
+
218
+ export async function updateWorkspace(
219
+ id: string,
220
+ patch: Partial<WorkspaceMeta>,
221
+ ): Promise<WorkspaceMeta | null> {
222
+ const registry = await getWorkspaces();
223
+ const idx = registry.workspaces.findIndex((w) => w.id === id);
224
+ if (idx === -1) return null;
225
+ // Deep-merge driveConfig if provided
226
+ if (patch.driveConfig && registry.workspaces[idx].driveConfig) {
227
+ patch.driveConfig = {
228
+ ...registry.workspaces[idx].driveConfig,
229
+ ...patch.driveConfig,
230
+ };
231
+ }
232
+ Object.assign(registry.workspaces[idx], patch);
233
+ await fs.writeFile(WORKSPACES_FILE, JSON.stringify(registry, null, 2));
234
+ return registry.workspaces[idx];
235
+ }
236
+
237
+ export async function deleteWorkspace(id: string): Promise<void> {
238
+ const registry = await getWorkspaces();
239
+ if (registry.workspaces.length <= 1) {
240
+ throw new Error("Cannot delete the last workspace");
241
+ }
242
+ registry.workspaces = registry.workspaces.filter((w) => w.id !== id);
243
+ if (registry.activeWorkspaceId === id) {
244
+ registry.activeWorkspaceId = registry.workspaces[0].id;
245
+ _activeWorkspaceId = registry.activeWorkspaceId;
246
+ }
247
+ await fs.writeFile(WORKSPACES_FILE, JSON.stringify(registry, null, 2));
248
+ try {
249
+ await fs.rm(workspaceDir(id), { recursive: true });
250
+ } catch {
251
+ /* ok */
252
+ }
253
+ }
254
+
255
+ export async function activateWorkspace(
256
+ id: string,
257
+ ): Promise<WorkspacesRegistry> {
258
+ const registry = await getWorkspaces();
259
+ const ws = registry.workspaces.find((w) => w.id === id);
260
+ if (!ws) throw new Error(`Workspace "${id}" not found`);
261
+ registry.activeWorkspaceId = id;
262
+ _activeWorkspaceId = id;
263
+ await ensureWorkspaceDirs(id);
264
+ await fs.writeFile(WORKSPACES_FILE, JSON.stringify(registry, null, 2));
265
+ return registry;
266
+ }
267
+
268
+ // Workspace-scoped read helpers used by the Drive export path
269
+ // (avoids mutating the module-level active workspace)
270
+ export async function getTopicsForWorkspace(
271
+ workspaceId: string,
272
+ ): Promise<Topic[]> {
273
+ await ensureWorkspaceDirs(workspaceId);
274
+ try {
275
+ const data = await fs.readFile(topicsFilePath(workspaceId), "utf-8");
276
+ const topics: Topic[] = JSON.parse(data);
277
+ for (const t of topics) {
278
+ if (!t.contextFiles) t.contextFiles = [];
279
+ }
280
+ return topics;
281
+ } catch {
282
+ return [];
283
+ }
284
+ }
285
+
286
+ export async function getQuestionsByTopicForWorkspace(
287
+ workspaceId: string,
288
+ topicId: string,
289
+ ): Promise<Question[]> {
290
+ await ensureWorkspaceDirs(workspaceId);
291
+ try {
292
+ const files = await fs.readdir(questionsDirPath(workspaceId));
293
+ const questions: Question[] = [];
294
+ for (const file of files) {
295
+ if (!file.endsWith(".json")) continue;
296
+ const data = await fs.readFile(
297
+ path.join(questionsDirPath(workspaceId), file),
298
+ "utf-8",
299
+ );
300
+ const q: Question = JSON.parse(data);
301
+ if (q.topicId === topicId) questions.push(q);
302
+ }
303
+ return questions.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
304
+ } catch {
305
+ return [];
306
+ }
307
+ }
308
+
309
+ export function getContextFilesDirForWorkspace(workspaceId: string): string {
310
+ return contextFilesDirPath(workspaceId);
311
+ }
312
+
74
313
  // --- Topics ---
75
314
 
76
315
  export async function getTopics(): Promise<Topic[]> {
77
- await ensureDirs();
316
+ await ensureWorkspaceDirs();
78
317
  try {
79
- const data = await fs.readFile(TOPICS_FILE, "utf-8");
318
+ const data = await fs.readFile(topicsFilePath(), "utf-8");
80
319
  const topics: Topic[] = JSON.parse(data);
81
320
  // Backfill missing fields from older data
82
321
  for (const t of topics) {
@@ -96,7 +335,7 @@ export async function saveTopic(topic: Topic): Promise<Topic> {
96
335
  } else {
97
336
  topics.push(topic);
98
337
  }
99
- await fs.writeFile(TOPICS_FILE, JSON.stringify(topics, null, 2));
338
+ await fs.writeFile(topicsFilePath(), JSON.stringify(topics, null, 2));
100
339
  return topic;
101
340
  }
102
341
 
@@ -107,14 +346,14 @@ export async function deleteTopic(id: string): Promise<void> {
107
346
  if (topic?.contextFiles) {
108
347
  for (const cf of topic.contextFiles) {
109
348
  try {
110
- await fs.unlink(path.join(CONTEXT_FILES_DIR, cf.id));
349
+ await fs.unlink(path.join(contextFilesDirPath(), cf.id));
111
350
  } catch {
112
351
  /* already gone */
113
352
  }
114
353
  }
115
354
  }
116
355
  const remaining = topics.filter((t) => t.id !== id);
117
- await fs.writeFile(TOPICS_FILE, JSON.stringify(remaining, null, 2));
356
+ await fs.writeFile(topicsFilePath(), JSON.stringify(remaining, null, 2));
118
357
  const questions = await getQuestionsByTopic(id);
119
358
  for (const q of questions) {
120
359
  await deleteQuestion(q.id);
@@ -129,14 +368,54 @@ export async function updateTopic(
129
368
  const idx = topics.findIndex((t) => t.id === id);
130
369
  if (idx === -1) return null;
131
370
  Object.assign(topics[idx], patch);
132
- await fs.writeFile(TOPICS_FILE, JSON.stringify(topics, null, 2));
371
+ await fs.writeFile(topicsFilePath(), JSON.stringify(topics, null, 2));
133
372
  return topics[idx];
134
373
  }
135
374
 
375
+ /**
376
+ * Replace the entire topic list in one write (used by sync to avoid N read+write cycles).
377
+ */
378
+ export async function replaceAllTopics(topics: Topic[]): Promise<void> {
379
+ await ensureWorkspaceDirs();
380
+ await fs.writeFile(topicsFilePath(), JSON.stringify(topics, null, 2));
381
+ }
382
+
383
+ /**
384
+ * Wipe all topics, questions, and context-file blobs for a workspace in parallel.
385
+ * Much faster than calling deleteTopic() in a loop.
386
+ */
387
+ export async function clearWorkspaceData(workspaceId: string): Promise<void> {
388
+ await ensureWorkspaceDirs(workspaceId);
389
+ const wid = workspaceId;
390
+ await fs.writeFile(topicsFilePath(wid), JSON.stringify([], null, 2));
391
+ await Promise.all([
392
+ fs
393
+ .readdir(questionsDirPath(wid))
394
+ .then((files) =>
395
+ Promise.all(
396
+ files.map((f) =>
397
+ fs.unlink(path.join(questionsDirPath(wid), f)).catch(() => {}),
398
+ ),
399
+ ),
400
+ )
401
+ .catch(() => {}),
402
+ fs
403
+ .readdir(contextFilesDirPath(wid))
404
+ .then((files) =>
405
+ Promise.all(
406
+ files.map((f) =>
407
+ fs.unlink(path.join(contextFilesDirPath(wid), f)).catch(() => {}),
408
+ ),
409
+ ),
410
+ )
411
+ .catch(() => {}),
412
+ ]);
413
+ }
414
+
136
415
  // --- Context Files ---
137
416
 
138
417
  export function getContextFilesDir(): string {
139
- return CONTEXT_FILES_DIR;
418
+ return contextFilesDirPath();
140
419
  }
141
420
 
142
421
  export async function saveContextFile(
@@ -144,13 +423,15 @@ export async function saveContextFile(
144
423
  fileId: string,
145
424
  originalName: string,
146
425
  buffer: Buffer,
426
+ driveFileId?: string,
147
427
  ): Promise<ContextFile> {
148
- await ensureDirs();
149
- await fs.writeFile(path.join(CONTEXT_FILES_DIR, fileId), buffer);
428
+ await ensureWorkspaceDirs();
429
+ await fs.writeFile(path.join(contextFilesDirPath(), fileId), buffer);
150
430
  const cf: ContextFile = {
151
431
  id: fileId,
152
432
  name: originalName,
153
433
  originalName,
434
+ ...(driveFileId ? { driveFileId } : {}),
154
435
  createdAt: new Date().toISOString(),
155
436
  };
156
437
  const topics = await getTopics();
@@ -158,17 +439,33 @@ export async function saveContextFile(
158
439
  if (topic) {
159
440
  if (!topic.contextFiles) topic.contextFiles = [];
160
441
  topic.contextFiles.push(cf);
161
- await fs.writeFile(TOPICS_FILE, JSON.stringify(topics, null, 2));
442
+ await fs.writeFile(topicsFilePath(), JSON.stringify(topics, null, 2));
162
443
  }
163
444
  return cf;
164
445
  }
165
446
 
447
+ /**
448
+ * Write only the binary blob for a context file — does NOT update topics.json.
449
+ * Use this when you want to batch-update topics.json separately (e.g. during sync).
450
+ */
451
+ export async function writeContextFileBlob(
452
+ workspaceId: string,
453
+ fileId: string,
454
+ buffer: Buffer,
455
+ ): Promise<void> {
456
+ await ensureWorkspaceDirs(workspaceId);
457
+ await fs.writeFile(
458
+ path.join(contextFilesDirPath(workspaceId), fileId),
459
+ buffer,
460
+ );
461
+ }
462
+
166
463
  export async function deleteContextFile(
167
464
  topicId: string,
168
465
  fileId: string,
169
466
  ): Promise<void> {
170
467
  try {
171
- await fs.unlink(path.join(CONTEXT_FILES_DIR, fileId));
468
+ await fs.unlink(path.join(contextFilesDirPath(), fileId));
172
469
  } catch {
173
470
  /* already gone */
174
471
  }
@@ -178,21 +475,21 @@ export async function deleteContextFile(
178
475
  topic.contextFiles = (topic.contextFiles || []).filter(
179
476
  (f) => f.id !== fileId,
180
477
  );
181
- await fs.writeFile(TOPICS_FILE, JSON.stringify(topics, null, 2));
478
+ await fs.writeFile(topicsFilePath(), JSON.stringify(topics, null, 2));
182
479
  }
183
480
  }
184
481
 
185
482
  export async function readContextFileContent(fileId: string): Promise<string> {
186
- return fs.readFile(path.join(CONTEXT_FILES_DIR, fileId), "utf-8");
483
+ return fs.readFile(path.join(contextFilesDirPath(), fileId), "utf-8");
187
484
  }
188
485
 
189
486
  // --- Questions ---
190
487
 
191
488
  export async function getQuestion(id: string): Promise<Question | null> {
192
- await ensureDirs();
489
+ await ensureWorkspaceDirs();
193
490
  try {
194
491
  const data = await fs.readFile(
195
- path.join(QUESTIONS_DIR, `${id}.json`),
492
+ path.join(questionsDirPath(), `${id}.json`),
196
493
  "utf-8",
197
494
  );
198
495
  const q: Question = JSON.parse(data);
@@ -207,13 +504,16 @@ export async function getQuestion(id: string): Promise<Question | null> {
207
504
  export async function getQuestionsByTopic(
208
505
  topicId: string,
209
506
  ): Promise<Question[]> {
210
- await ensureDirs();
507
+ await ensureWorkspaceDirs();
211
508
  try {
212
- const files = await fs.readdir(QUESTIONS_DIR);
509
+ const files = await fs.readdir(questionsDirPath());
213
510
  const questions: Question[] = [];
214
511
  for (const file of files) {
215
512
  if (!file.endsWith(".json")) continue;
216
- const data = await fs.readFile(path.join(QUESTIONS_DIR, file), "utf-8");
513
+ const data = await fs.readFile(
514
+ path.join(questionsDirPath(), file),
515
+ "utf-8",
516
+ );
217
517
  const q: Question = JSON.parse(data);
218
518
  if (q.topicId === topicId) {
219
519
  questions.push(q);
@@ -226,9 +526,9 @@ export async function getQuestionsByTopic(
226
526
  }
227
527
 
228
528
  export async function saveQuestion(question: Question): Promise<Question> {
229
- await ensureDirs();
529
+ await ensureWorkspaceDirs();
230
530
  await fs.writeFile(
231
- path.join(QUESTIONS_DIR, `${question.id}.json`),
531
+ path.join(questionsDirPath(), `${question.id}.json`),
232
532
  JSON.stringify(question, null, 2),
233
533
  );
234
534
  return question;
@@ -240,14 +540,14 @@ export async function deleteQuestion(id: string): Promise<void> {
240
540
  if (q?.contextFiles) {
241
541
  for (const cf of q.contextFiles) {
242
542
  try {
243
- await fs.unlink(path.join(CONTEXT_FILES_DIR, cf.id));
543
+ await fs.unlink(path.join(contextFilesDirPath(), cf.id));
244
544
  } catch {
245
545
  /* ok */
246
546
  }
247
547
  }
248
548
  }
249
549
  try {
250
- await fs.unlink(path.join(QUESTIONS_DIR, `${id}.json`));
550
+ await fs.unlink(path.join(questionsDirPath(), `${id}.json`));
251
551
  } catch {
252
552
  // ignore if already deleted
253
553
  }
@@ -259,8 +559,8 @@ export async function saveQuestionContextFile(
259
559
  originalName: string,
260
560
  buffer: Buffer,
261
561
  ): Promise<ContextFile> {
262
- await ensureDirs();
263
- await fs.writeFile(path.join(CONTEXT_FILES_DIR, fileId), buffer);
562
+ await ensureWorkspaceDirs();
563
+ await fs.writeFile(path.join(contextFilesDirPath(), fileId), buffer);
264
564
  const cf: ContextFile = {
265
565
  id: fileId,
266
566
  name: originalName,
@@ -281,7 +581,7 @@ export async function deleteQuestionContextFile(
281
581
  fileId: string,
282
582
  ): Promise<void> {
283
583
  try {
284
- await fs.unlink(path.join(CONTEXT_FILES_DIR, fileId));
584
+ await fs.unlink(path.join(contextFilesDirPath(), fileId));
285
585
  } catch {
286
586
  /* ok */
287
587
  }