@chalksurf/cli 0.2.1 → 0.2.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.
@@ -1,13 +1,16 @@
1
+ import { z } from 'zod';
1
2
  import { createApiClient } from '../lib/api-client.js';
2
3
  import { CliCommandError } from '../lib/cli-error.js';
4
+ import { normalizeLimit, normalizeOffset, normalizeOptionalText, normalizeOwnership, toApiOwnership, } from '../lib/command-options.js';
3
5
  import { buildFileImportCommandSources, buildFileImportFormData, normalizeWaitTimeoutMs } from '../lib/import-files.js';
4
- import { buildCliImportSources, formatCliImportSummary, summarizeCliImportJobs, } from '../lib/import-output.js';
5
- import { loadSheetImportManifest, } from '../lib/manifest.js';
6
- import { mapApiErrorToCliError, requireResolvedBaseUrl, requireResolvedToken, resolveRequestedOrganizationId, } from '../lib/session.js';
6
+ import { buildCliImportSources, formatCliImportSummary, formatCliImportTranslationJobs, summarizeCliImportJobs, } from '../lib/import-output.js';
7
+ import { loadExerciseSheetSolutionImportManifest, loadSheetImportManifest, } from '../lib/manifest.js';
8
+ import { createResolvedApiClient, mapApiErrorToCliError, requireResolvedBaseUrl, requireResolvedToken, resolveRequestedOrganizationId, } from '../lib/session.js';
7
9
  import { cleanupResolvedSources, resolveSources } from '../lib/source-resolver.js';
8
- import { translationLanguages } from '../lib/translation-languages.js';
10
+ import { resolveRequestedTranslateToLanguages, translationLanguages, } from '../lib/translation-languages.js';
9
11
  import { formatCliJobSummary, getWaitError, getWaitExitCode, throwSilentExitCode, waitForCliJobs, } from '../lib/user-jobs.js';
10
12
  const hasUniqueItems = (values) => new Set(values).size === values.length;
13
+ const sheetImportComponentIdPattern = /^[a-zA-Z0-9_-]{1,80}$/;
11
14
  const isValidTargetFolderPath = (value) => {
12
15
  const normalizedSegments = value
13
16
  .replaceAll('\\', '/')
@@ -29,6 +32,32 @@ const assertValidSheetImportPlan = (plan) => {
29
32
  if (!hasUniqueItems(sheet.sourceIndexes)) {
30
33
  throw new CliCommandError('Invalid sheet import plan: sourceIndexes must be unique within a sheet.', 2);
31
34
  }
35
+ if (sheet.components) {
36
+ if (sheet.targetFolderPath !== undefined || sheet.titleOverride !== undefined || sheet.translateToLanguages) {
37
+ throw new CliCommandError('Invalid sheet import plan: targetFolderPath, titleOverride, and translateToLanguages must be set on components when components are provided.', 2);
38
+ }
39
+ if (!hasUniqueItems(sheet.components.map((component) => component.componentId))) {
40
+ throw new CliCommandError('Invalid sheet import plan: componentId values must be unique within a sheet.', 2);
41
+ }
42
+ sheet.components.forEach((component) => {
43
+ if (!sheetImportComponentIdPattern.test(component.componentId)) {
44
+ throw new CliCommandError('Invalid sheet import plan: componentId must be 1-80 letters, numbers, underscores, or hyphens.', 2);
45
+ }
46
+ if (!component.description.trim()) {
47
+ throw new CliCommandError('Invalid sheet import plan: component descriptions must be non-empty.', 2);
48
+ }
49
+ if (component.targetFolderPath != null && !isValidTargetFolderPath(component.targetFolderPath)) {
50
+ throw new CliCommandError('Invalid sheet import plan: component targetFolderPath must be a normalized folder path without "." or ".." segments.', 2);
51
+ }
52
+ if (component.translateToLanguages && !hasUniqueItems(component.translateToLanguages)) {
53
+ throw new CliCommandError('Invalid sheet import plan: component translateToLanguages must be unique.', 2);
54
+ }
55
+ });
56
+ return;
57
+ }
58
+ if (sheet.targetFolderPath === undefined) {
59
+ throw new CliCommandError('Invalid sheet import plan: targetFolderPath is required.', 2);
60
+ }
32
61
  if (sheet.targetFolderPath !== null && !isValidTargetFolderPath(sheet.targetFolderPath)) {
33
62
  throw new CliCommandError('Invalid sheet import plan: targetFolderPath must be a normalized folder path without "." or ".." segments.', 2);
34
63
  }
@@ -44,20 +73,6 @@ const normalizeOptionalTitle = (value) => {
44
73
  }
45
74
  return normalizedValue;
46
75
  };
47
- const resolveRequestedTranslateToLanguages = (rawValues) => {
48
- if (!rawValues || rawValues.length === 0) {
49
- return undefined;
50
- }
51
- const invalidLanguage = rawValues.find((value) => !translationLanguages.includes(value));
52
- if (invalidLanguage) {
53
- throw new CliCommandError(`--translate-to must be one of: ${translationLanguages.join(', ')}`, 2);
54
- }
55
- const languages = rawValues;
56
- if (new Set(languages).size !== languages.length) {
57
- throw new CliCommandError('--translate-to languages must be unique.', 2);
58
- }
59
- return languages;
60
- };
61
76
  const normalizeOptionalTargetFolderPath = (value) => {
62
77
  const normalizedValue = value?.trim();
63
78
  if (!normalizedValue) {
@@ -65,6 +80,20 @@ const normalizeOptionalTargetFolderPath = (value) => {
65
80
  }
66
81
  return normalizedValue;
67
82
  };
83
+ const resolveRequestedUuid = ({ flagValue, fallbackValue, label, required = false, }) => {
84
+ const resolvedValue = flagValue?.trim() || fallbackValue?.trim();
85
+ if (!resolvedValue) {
86
+ if (required) {
87
+ throw new CliCommandError(`${label} is required.`, 2);
88
+ }
89
+ return undefined;
90
+ }
91
+ const parsedValue = z.uuid().safeParse(resolvedValue);
92
+ if (!parsedValue.success) {
93
+ throw new CliCommandError(`${label} must be a valid UUID.`, 2);
94
+ }
95
+ return parsedValue.data;
96
+ };
68
97
  const getFolderPathFromRelativePath = (relativePath) => {
69
98
  const normalizedSegments = relativePath
70
99
  .replaceAll('\\', '/')
@@ -130,9 +159,21 @@ const resolveGroupedSourceIndexes = ({ groups, sourceIndexesByInputIndex, }) =>
130
159
  });
131
160
  return {
132
161
  sourceIndexes,
133
- targetFolderPath: group.targetFolderPath,
134
- titleOverride: group.title,
135
- translateToLanguages: group.translateTo,
162
+ ...(group.components
163
+ ? {
164
+ components: group.components.map((component) => ({
165
+ componentId: component.componentId,
166
+ description: component.description,
167
+ targetFolderPath: component.targetFolderPath,
168
+ titleOverride: component.title,
169
+ translateToLanguages: component.translateTo,
170
+ })),
171
+ }
172
+ : {
173
+ targetFolderPath: group.targetFolderPath,
174
+ titleOverride: group.title,
175
+ translateToLanguages: group.translateTo,
176
+ }),
136
177
  };
137
178
  });
138
179
  };
@@ -171,6 +212,7 @@ const buildSheetImportJobs = ({ importResultJobs, sources, waitedJobsById, }) =>
171
212
  return importResultJobs.map((job, index) => {
172
213
  const sourceIndexes = job.sourceIndexes ?? (sources[index] ? [sources[index].sourceIndex] : []);
173
214
  const waitedJob = waitedJobsById?.get(job.jobId);
215
+ const importedSheetTranslationJobs = waitedJob?.sheets?.flatMap((sheet) => (sheet.status === 'imported' ? (sheet.translationJobs ?? []) : [])) ?? [];
174
216
  return {
175
217
  jobId: job.jobId,
176
218
  sourceIndexes,
@@ -179,7 +221,10 @@ const buildSheetImportJobs = ({ importResultJobs, sources, waitedJobsById, }) =>
179
221
  .filter((sourceId) => sourceId != null))),
180
222
  status: waitedJob?.status ?? 'queued',
181
223
  error: waitedJob?.error,
224
+ componentIds: job.componentIds,
182
225
  exerciseSheetId: waitedJob?.exerciseSheetId,
226
+ sheets: waitedJob?.sheets,
227
+ translationJobs: waitedJob?.translationJobs ?? importedSheetTranslationJobs,
183
228
  };
184
229
  });
185
230
  };
@@ -192,6 +237,38 @@ const buildSheetImportRequest = ({ inputMode, organizationId, timeoutMs, wait, }
192
237
  target: {},
193
238
  };
194
239
  };
240
+ const buildSheetSolutionImportRequest = ({ exerciseSheetId, inputMode, organizationId, timeoutMs, wait, }) => {
241
+ return {
242
+ organizationId,
243
+ inputMode,
244
+ wait,
245
+ timeoutMs,
246
+ target: { exerciseSheetId },
247
+ };
248
+ };
249
+ const buildQueuedSheetSolutionImportJob = ({ jobId, sources, }) => {
250
+ return {
251
+ jobId,
252
+ sourceIndexes: sources.map((source) => source.sourceIndex),
253
+ sourceIds: sources.map((source) => source.sourceId).filter((sourceId) => sourceId !== null),
254
+ status: 'queued',
255
+ };
256
+ };
257
+ const buildWaitedSheetSolutionImportJob = ({ jobId, sources, waitedJob, }) => {
258
+ return {
259
+ jobId,
260
+ sourceIndexes: sources.map((source) => source.sourceIndex),
261
+ sourceIds: sources.map((source) => source.sourceId).filter((sourceId) => sourceId !== null),
262
+ status: waitedJob?.status ?? 'pending',
263
+ error: waitedJob?.error,
264
+ eligibleExerciseCount: waitedJob?.eligibleExerciseCount,
265
+ exerciseSheetId: waitedJob?.exerciseSheetId,
266
+ nonUpdatableExerciseCount: waitedJob?.nonUpdatableExerciseCount,
267
+ skippedExerciseCount: waitedJob?.skippedExerciseCount,
268
+ unmatchedImportedSolutionCount: waitedJob?.unmatchedImportedSolutionCount,
269
+ updatedExerciseCount: waitedJob?.updatedExerciseCount,
270
+ };
271
+ };
195
272
  const formatSheetJobSourceLabel = ({ job, sources, }) => {
196
273
  if (job.sourceIndexes.length !== 1) {
197
274
  return `${job.sourceIndexes.length} source${job.sourceIndexes.length === 1 ? '' : 's'}`;
@@ -211,22 +288,113 @@ const formatQueuedImportOutput = ({ jobs, summary, sources, }) => {
211
288
  const formatWaitedImportOutput = ({ jobs, summary, }) => {
212
289
  return [
213
290
  `${jobs.length} job${jobs.length === 1 ? '' : 's'} queued.`,
214
- ...jobs.map((job) => formatCliJobSummary({
215
- id: job.jobId,
216
- status: job.status === 'queued' ? 'pending' : job.status,
217
- type: 'exercise_sheet_import',
218
- createdAt: '',
219
- updatedAt: '',
220
- error: job.error,
221
- exerciseSheetId: job.exerciseSheetId,
222
- })),
291
+ ...jobs.flatMap((job) => [
292
+ formatCliJobSummary({
293
+ id: job.jobId,
294
+ status: job.status === 'queued' ? 'pending' : job.status,
295
+ type: 'exercise_sheet_import',
296
+ createdAt: '',
297
+ updatedAt: '',
298
+ error: job.error,
299
+ exerciseSheetId: job.exerciseSheetId,
300
+ sheets: job.sheets,
301
+ }),
302
+ ...formatCliImportTranslationJobs(job.translationJobs),
303
+ ]),
223
304
  jobs.length > 1 ? formatCliImportSummary(summary) : null,
224
305
  ]
225
306
  .filter(Boolean)
226
307
  .join('\n');
227
308
  };
309
+ const formatQueuedSheetSolutionImportOutput = (job) => {
310
+ return [
311
+ 'Queued exercise sheet solution import.',
312
+ `${job.jobId} ${job.sourceIndexes.length} source${job.sourceIndexes.length === 1 ? '' : 's'}`,
313
+ ].join('\n');
314
+ };
315
+ const formatWaitedSheetSolutionImportOutput = (job) => {
316
+ return formatCliJobSummary({
317
+ id: job.jobId,
318
+ status: job.status === 'queued' ? 'pending' : job.status,
319
+ type: 'exercise_sheet_solution_import',
320
+ createdAt: '',
321
+ updatedAt: '',
322
+ error: job.error,
323
+ exerciseSheetId: job.exerciseSheetId,
324
+ });
325
+ };
326
+ const formatSheetSearchOutput = ({ exerciseSheets, totalCount, }) => {
327
+ if (exerciseSheets.length === 0) {
328
+ return 'No sheets found.';
329
+ }
330
+ return [
331
+ `${exerciseSheets.length} of ${totalCount} sheet${totalCount === 1 ? '' : 's'} returned.`,
332
+ ...exerciseSheets.map((sheet) => `${sheet.id} ${sheet.name} ${sheet.exerciseCount} exercises`),
333
+ ].join('\n');
334
+ };
228
335
  export const registerSheetCommands = (sheetYargs, context) => {
229
336
  return sheetYargs
337
+ .command('search', 'Search exercise sheets visible to the selected organization', (searchYargs) => searchYargs
338
+ .option('text', {
339
+ type: 'string',
340
+ describe: 'Search sheet names',
341
+ })
342
+ .option('ownership', {
343
+ type: 'string',
344
+ default: 'own',
345
+ describe: 'Visibility scope: own, public, or all',
346
+ })
347
+ .option('limit', {
348
+ type: 'number',
349
+ default: 20,
350
+ describe: 'Maximum number of sheets to return',
351
+ })
352
+ .option('offset', {
353
+ type: 'number',
354
+ default: 0,
355
+ describe: 'Number of matching sheets to skip',
356
+ })
357
+ .example('chalksurf sheet search --text "OKTV 2014" --ownership own --json', 'Verify an imported private sheet in the selected organization')
358
+ .example('chalksurf --profile prod-codex sheet search --ownership own --limit 10 --json', 'List recent sheets visible to the agent profile'), async (argv) => {
359
+ const text = normalizeOptionalText(argv.text);
360
+ const ownership = normalizeOwnership(argv.ownership);
361
+ const limit = normalizeLimit(argv.limit);
362
+ const offset = normalizeOffset(argv.offset);
363
+ const { apiClient, organizationId } = await createResolvedApiClient({
364
+ context,
365
+ baseUrlFlagValue: argv.baseUrl,
366
+ organizationFlagValue: argv.organization,
367
+ profileName: argv.profile,
368
+ });
369
+ let searchResult;
370
+ try {
371
+ searchResult = await apiClient.searchExerciseSheets({
372
+ text,
373
+ ownership: toApiOwnership(ownership),
374
+ limit,
375
+ offset,
376
+ });
377
+ }
378
+ catch (error) {
379
+ throw mapApiErrorToCliError(error);
380
+ }
381
+ context.output.print({
382
+ request: {
383
+ text,
384
+ ownership,
385
+ limit,
386
+ offset,
387
+ organizationId: organizationId ?? null,
388
+ },
389
+ exerciseSheets: searchResult.exerciseSheets,
390
+ totalCount: searchResult.totalCount,
391
+ }, (result) => formatSheetSearchOutput({
392
+ exerciseSheets: result.exerciseSheets,
393
+ totalCount: result.totalCount,
394
+ }), {
395
+ command: 'sheet search',
396
+ });
397
+ })
230
398
  .command('import [sources..]', 'Import one or more exercise sheet sources', (importYargs) => importYargs
231
399
  .positional('sources', {
232
400
  array: true,
@@ -273,8 +441,8 @@ export const registerSheetCommands = (sheetYargs, context) => {
273
441
  .example('chalksurf sheet import ./fixtures/round-1-a.pdf ./fixtures/round-1-b.pdf --single-sheet --target-folder Contests --wait', 'Import multiple source files into one sheet and place the result into a target folder')
274
442
  .example('cat sheet-import.json | chalksurf --profile prod-codex sheet import --manifest - --wait --json', 'Drive multi-source sheet imports from a manifest in an agent or CI flow')
275
443
  .epilogue([
276
- 'When --wait is set, the command exits only after the full requested import is complete.',
277
- 'For translated sheet imports, that includes generating and storing the requested translations.',
444
+ 'When --wait is set, the command exits after the queued import job reaches a terminal state.',
445
+ 'For translated sheet imports, requested translations continue as separate jobs listed in the import result.',
278
446
  ].join('\n')), async (argv) => {
279
447
  const config = await context.configStore.loadProfile({ profileName: argv.profile });
280
448
  const rawSources = (argv.sources ?? []).map(String);
@@ -429,6 +597,175 @@ export const registerSheetCommands = (sheetYargs, context) => {
429
597
  finally {
430
598
  await cleanupResolvedSources(resolvedSources);
431
599
  }
600
+ })
601
+ .command('import-solutions [exerciseSheetId] [sources..]', 'Import one or more solution files into an existing exercise sheet', (importSolutionsYargs) => importSolutionsYargs
602
+ .positional('exerciseSheetId', {
603
+ type: 'string',
604
+ describe: 'Exercise sheet ID to reconcile imported solutions into',
605
+ })
606
+ .positional('sources', {
607
+ array: true,
608
+ type: 'string',
609
+ describe: 'Local file paths, directories, or HTTP(S) URLs',
610
+ })
611
+ .option('manifest', {
612
+ type: 'string',
613
+ describe: 'Load sources from a manifest file, or "-" for stdin',
614
+ })
615
+ .option('relative-path', {
616
+ type: 'string',
617
+ describe: 'Override the relative path for a single positional source',
618
+ })
619
+ .option('timeout-ms', {
620
+ type: 'number',
621
+ default: 300000,
622
+ describe: 'Maximum time to wait before timing out',
623
+ })
624
+ .option('wait', {
625
+ type: 'boolean',
626
+ default: false,
627
+ describe: 'Wait for the queued job to finish',
628
+ })
629
+ .example('chalksurf sheet import-solutions 00000000-0000-4000-8000-000000000001 ./fixtures/solutions.pdf --wait', 'Attach a separate solution file to an existing exercise sheet')
630
+ .example('cat sheet-solution-import.json | chalksurf --profile prod-codex sheet import-solutions --manifest - --wait --json', 'Drive sheet solution imports from a manifest in an agent or CI flow')
631
+ .epilogue('When --wait is set, the command exits after the queued import job reaches a terminal state.'), async (argv) => {
632
+ const config = await context.configStore.loadProfile({ profileName: argv.profile });
633
+ const rawSources = (argv.sources ?? []).map(String);
634
+ const manifestPath = argv.manifest === '' ? '-' : argv.manifest;
635
+ const normalizedRawSources = manifestPath === '-' && rawSources[0] === '-' ? rawSources.slice(1) : rawSources;
636
+ const resolvedBaseUrl = requireResolvedBaseUrl({
637
+ flagValue: argv.baseUrl,
638
+ env: context.env,
639
+ config,
640
+ });
641
+ const resolvedToken = requireResolvedToken({
642
+ env: context.env,
643
+ config,
644
+ });
645
+ const commandSources = await buildFileImportCommandSources({
646
+ cwd: context.cwd,
647
+ loadManifest: loadExerciseSheetSolutionImportManifest,
648
+ manifestPath,
649
+ rawSources: normalizedRawSources,
650
+ relativePath: argv.relativePath,
651
+ stdin: context.stdin,
652
+ wait: argv.wait,
653
+ });
654
+ const organizationId = resolveRequestedOrganizationId({
655
+ flagValue: argv.organization,
656
+ fallbackValue: commandSources.organizationId,
657
+ env: context.env,
658
+ config,
659
+ });
660
+ const exerciseSheetId = resolveRequestedUuid({
661
+ flagValue: argv.exerciseSheetId,
662
+ fallbackValue: commandSources.exerciseSheetId,
663
+ label: 'exerciseSheetId',
664
+ required: true,
665
+ });
666
+ const apiClient = createApiClient({
667
+ baseUrl: resolvedBaseUrl.value,
668
+ token: resolvedToken.value,
669
+ organizationId,
670
+ });
671
+ let resolvedSources = [];
672
+ try {
673
+ resolvedSources = await resolveSources({
674
+ cwd: context.cwd,
675
+ sources: commandSources.sources,
676
+ });
677
+ const sources = buildCliImportSources({
678
+ resolvedSources,
679
+ });
680
+ const formData = await buildFileImportFormData(resolvedSources);
681
+ formData.set('exerciseSheetId', exerciseSheetId);
682
+ const request = buildSheetSolutionImportRequest({
683
+ exerciseSheetId,
684
+ inputMode: manifestPath ? 'manifest' : 'arguments',
685
+ organizationId: organizationId ?? null,
686
+ timeoutMs: commandSources.wait ? normalizeWaitTimeoutMs(argv.timeoutMs) : null,
687
+ wait: commandSources.wait,
688
+ });
689
+ let importResult;
690
+ try {
691
+ importResult = await apiClient.importExerciseSheetSolutions(formData);
692
+ }
693
+ catch (error) {
694
+ throw mapApiErrorToCliError(error);
695
+ }
696
+ if (!commandSources.wait) {
697
+ const jobs = [
698
+ buildQueuedSheetSolutionImportJob({
699
+ jobId: importResult.jobId,
700
+ sources,
701
+ }),
702
+ ];
703
+ const summary = summarizeCliImportJobs({
704
+ jobs,
705
+ sourceCount: sources.length,
706
+ sourceInputCount: commandSources.sources.length,
707
+ timedOut: false,
708
+ });
709
+ context.output.print({
710
+ request,
711
+ sources,
712
+ jobs,
713
+ summary,
714
+ }, (result) => formatQueuedSheetSolutionImportOutput(result.jobs[0]), {
715
+ command: 'sheet import-solutions',
716
+ });
717
+ return;
718
+ }
719
+ const waitTimeoutMs = normalizeWaitTimeoutMs(argv.timeoutMs);
720
+ const waitResult = await waitForCliJobs({
721
+ getUserJob: async (jobId) => await apiClient.getUserJob(jobId),
722
+ jobIds: [importResult.jobId],
723
+ now: context.now,
724
+ sleep: context.sleep,
725
+ timeoutMs: waitTimeoutMs,
726
+ });
727
+ const waitedJob = waitResult.jobs[0];
728
+ const jobs = [
729
+ buildWaitedSheetSolutionImportJob({
730
+ jobId: importResult.jobId,
731
+ sources,
732
+ waitedJob: waitedJob == null
733
+ ? undefined
734
+ : {
735
+ ...waitedJob,
736
+ id: waitedJob.id,
737
+ status: waitedJob.status,
738
+ },
739
+ }),
740
+ ];
741
+ const summary = summarizeCliImportJobs({
742
+ jobs,
743
+ sourceCount: sources.length,
744
+ sourceInputCount: commandSources.sources.length,
745
+ timedOut: waitResult.timedOut,
746
+ });
747
+ const waitError = getWaitError({
748
+ jobs: waitResult.jobs,
749
+ timedOut: waitResult.timedOut,
750
+ });
751
+ context.output.print({
752
+ request: {
753
+ ...request,
754
+ timeoutMs: waitTimeoutMs,
755
+ },
756
+ sources,
757
+ jobs,
758
+ summary,
759
+ }, (result) => formatWaitedSheetSolutionImportOutput(result.jobs[0]), {
760
+ command: 'sheet import-solutions',
761
+ ok: waitError == null,
762
+ error: waitError,
763
+ });
764
+ throwSilentExitCode(getWaitExitCode({ jobs: waitResult.jobs, timedOut: waitResult.timedOut }));
765
+ }
766
+ finally {
767
+ await cleanupResolvedSources(resolvedSources);
768
+ }
432
769
  })
433
770
  .demandCommand(1)
434
771
  .strict();
@@ -58,12 +58,18 @@ export const createApiClient = ({ baseUrl, token, organizationId, }) => {
58
58
  getUserProfile: async () => {
59
59
  return await query('getUserProfile', null);
60
60
  },
61
- listUserJobs: async () => {
62
- return await query('listUserJobs', null);
61
+ listUserJobs: async (input) => {
62
+ return await query('listUserJobs', input ?? null);
63
63
  },
64
64
  getUserJob: async (id) => {
65
65
  return await query('getUserJob', { id });
66
66
  },
67
+ searchExercises: async (input) => {
68
+ return await mutation('searchExercises', input);
69
+ },
70
+ searchExerciseSheets: async (input) => {
71
+ return await mutation('searchExerciseSheets', input);
72
+ },
67
73
  importExerciseSheet: async (formData) => {
68
74
  return await mutation('importExerciseSheet', formData);
69
75
  },
@@ -73,5 +79,8 @@ export const createApiClient = ({ baseUrl, token, organizationId, }) => {
73
79
  importExerciseSolution: async (formData) => {
74
80
  return await mutation('importExerciseSolution', formData);
75
81
  },
82
+ importExerciseSheetSolutions: async (formData) => {
83
+ return await mutation('importExerciseSheetSolutions', formData);
84
+ },
76
85
  };
77
86
  };
@@ -0,0 +1,61 @@
1
+ import { CliCommandError } from './cli-error.js';
2
+ export const cliDefaultLimit = 20;
3
+ export const cliMaxLimit = 100;
4
+ const normalizeOptionalString = (value) => {
5
+ const normalizedValue = value?.trim();
6
+ return normalizedValue ? normalizedValue : undefined;
7
+ };
8
+ export const normalizeOptionalText = (value) => {
9
+ return normalizeOptionalString(value) ?? null;
10
+ };
11
+ export const normalizeLimit = (value) => {
12
+ const normalizedValue = value ?? cliDefaultLimit;
13
+ if (!Number.isInteger(normalizedValue) || normalizedValue < 1 || normalizedValue > cliMaxLimit) {
14
+ throw new CliCommandError(`--limit must be an integer between 1 and ${cliMaxLimit}.`, 2);
15
+ }
16
+ return normalizedValue;
17
+ };
18
+ export const normalizeOffset = (value) => {
19
+ const normalizedValue = value ?? 0;
20
+ if (!Number.isInteger(normalizedValue) || normalizedValue < 0) {
21
+ throw new CliCommandError('--offset must be a non-negative integer.', 2);
22
+ }
23
+ return normalizedValue;
24
+ };
25
+ export const normalizeOptionalInteger = ({ label, value }) => {
26
+ if (value === undefined) {
27
+ return null;
28
+ }
29
+ if (!Number.isInteger(value)) {
30
+ throw new CliCommandError(`${label} must be an integer.`, 2);
31
+ }
32
+ return value;
33
+ };
34
+ export const normalizeChoiceValues = ({ allowedValues, label, values, }) => {
35
+ if (!values || values.length === 0) {
36
+ return undefined;
37
+ }
38
+ const normalizedValues = values.map((value) => value.trim()).filter((value) => value.length > 0);
39
+ const invalidValue = normalizedValues.find((value) => !allowedValues.includes(value));
40
+ if (invalidValue) {
41
+ throw new CliCommandError(`${label} must be one of: ${allowedValues.join(', ')}`, 2);
42
+ }
43
+ return Array.from(new Set(normalizedValues));
44
+ };
45
+ export const normalizeStringValues = (values) => {
46
+ if (!values || values.length === 0) {
47
+ return [];
48
+ }
49
+ return Array.from(new Set(values.map((value) => value.trim()).filter((value) => value.length > 0)));
50
+ };
51
+ export const ownershipOptions = ['own', 'public', 'all'];
52
+ export const normalizeOwnership = (value) => {
53
+ const normalizedValue = normalizeOptionalString(value) ?? 'own';
54
+ if (!ownershipOptions.includes(normalizedValue)) {
55
+ throw new CliCommandError(`--ownership must be one of: ${ownershipOptions.join(', ')}`, 2);
56
+ }
57
+ return normalizedValue;
58
+ };
59
+ export const toApiOwnership = (ownership) => {
60
+ return ownership === 'all' ? null : ownership;
61
+ };
@@ -65,3 +65,17 @@ export const formatCliImportSummary = (summary) => {
65
65
  }
66
66
  return `Summary: ${parts.join(', ') || '0 jobs'}.`;
67
67
  };
68
+ export const formatCliImportTranslationJobs = (translationJobs) => {
69
+ if (!translationJobs || translationJobs.length === 0) {
70
+ return [];
71
+ }
72
+ return [
73
+ `Queued ${translationJobs.length} translation job${translationJobs.length === 1 ? '' : 's'}:`,
74
+ ...translationJobs.map((job) => {
75
+ if (job.type === 'exercise_translation_generation') {
76
+ return `${job.jobId} exercise ${job.exerciseId} ${job.languages.join(', ')}`;
77
+ }
78
+ return `${job.jobId} sheet ${job.exerciseSheetId} ${job.language}`;
79
+ }),
80
+ ];
81
+ };