@chalksurf/cli 0.1.0 → 0.2.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.
@@ -1,163 +1,226 @@
1
- import { openAsBlob } from 'node:fs';
2
- import { stat } from 'node:fs/promises';
3
- import { extname, resolve } from 'node:path';
4
1
  import { createApiClient } from '../lib/api-client.js';
5
2
  import { CliCommandError } from '../lib/cli-error.js';
6
- import { loadSheetImportManifest } from '../lib/manifest.js';
3
+ 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';
7
6
  import { mapApiErrorToCliError, requireResolvedBaseUrl, requireResolvedToken, resolveRequestedOrganizationId, } from '../lib/session.js';
8
- import { cleanupResolvedSources, resolveSources, } from '../lib/source-resolver.js';
9
- import { formatCliJobSummary, getWaitExitCode, throwSilentExitCode, waitForCliJobs } from '../lib/user-jobs.js';
10
- const mimeTypesByExtension = {
11
- '.avif': 'image/avif',
12
- '.doc': 'application/msword',
13
- '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
14
- '.gif': 'image/gif',
15
- '.jpeg': 'image/jpeg',
16
- '.jpg': 'image/jpeg',
17
- '.md': 'text/markdown',
18
- '.pdf': 'application/pdf',
19
- '.png': 'image/png',
20
- '.svg': 'image/svg+xml',
21
- '.tex': 'text/plain',
22
- '.tif': 'image/tiff',
23
- '.tiff': 'image/tiff',
24
- '.txt': 'text/plain',
25
- '.webp': 'image/webp',
7
+ import { cleanupResolvedSources, resolveSources } from '../lib/source-resolver.js';
8
+ import { translationLanguages } from '../lib/translation-languages.js';
9
+ import { formatCliJobSummary, getWaitError, getWaitExitCode, throwSilentExitCode, waitForCliJobs, } from '../lib/user-jobs.js';
10
+ const hasUniqueItems = (values) => new Set(values).size === values.length;
11
+ const isValidTargetFolderPath = (value) => {
12
+ const normalizedSegments = value
13
+ .replaceAll('\\', '/')
14
+ .split('/')
15
+ .filter((segment) => segment.length > 0);
16
+ return normalizedSegments.length > 0 && normalizedSegments.every((segment) => segment !== '.' && segment !== '..');
26
17
  };
27
- const normalizeTimeoutMs = (timeoutMs) => {
28
- const normalizedTimeoutMs = timeoutMs ?? 300000;
29
- if (!Number.isFinite(normalizedTimeoutMs) || normalizedTimeoutMs <= 0) {
30
- throw new CliCommandError('--timeout-ms must be a positive number.', 2);
18
+ const assertValidSheetImportPlan = (plan) => {
19
+ if (plan.sheets.length === 0) {
20
+ throw new CliCommandError('Invalid sheet import plan: sheets must include at least one sheet.', 2);
31
21
  }
32
- return normalizedTimeoutMs;
22
+ plan.sheets.forEach((sheet) => {
23
+ if (sheet.sourceIndexes.length === 0) {
24
+ throw new CliCommandError('Invalid sheet import plan: sourceIndexes must include at least one source.', 2);
25
+ }
26
+ if (!sheet.sourceIndexes.every((sourceIndex) => Number.isInteger(sourceIndex) && sourceIndex >= 0)) {
27
+ throw new CliCommandError('Invalid sheet import plan: sourceIndexes must contain only non-negative integers.', 2);
28
+ }
29
+ if (!hasUniqueItems(sheet.sourceIndexes)) {
30
+ throw new CliCommandError('Invalid sheet import plan: sourceIndexes must be unique within a sheet.', 2);
31
+ }
32
+ if (sheet.targetFolderPath !== null && !isValidTargetFolderPath(sheet.targetFolderPath)) {
33
+ throw new CliCommandError('Invalid sheet import plan: targetFolderPath must be a normalized folder path without "." or ".." segments.', 2);
34
+ }
35
+ if (sheet.translateToLanguages && !hasUniqueItems(sheet.translateToLanguages)) {
36
+ throw new CliCommandError('Invalid sheet import plan: translateToLanguages must be unique.', 2);
37
+ }
38
+ });
33
39
  };
34
- const guessMimeType = (fileName) => {
35
- return mimeTypesByExtension[extname(fileName).toLowerCase()] ?? 'application/octet-stream';
40
+ const normalizeOptionalTitle = (value) => {
41
+ const normalizedValue = value?.trim();
42
+ if (!normalizedValue) {
43
+ return undefined;
44
+ }
45
+ return normalizedValue;
36
46
  };
37
- const isHttpUrl = (value) => {
38
- try {
39
- const parsedUrl = new URL(value);
40
- return ['http:', 'https:'].includes(parsedUrl.protocol);
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);
41
58
  }
42
- catch {
43
- return false;
59
+ return languages;
60
+ };
61
+ const normalizeOptionalTargetFolderPath = (value) => {
62
+ const normalizedValue = value?.trim();
63
+ if (!normalizedValue) {
64
+ return null;
44
65
  }
66
+ return normalizedValue;
45
67
  };
46
- const buildFormData = async (resolvedSources) => {
47
- const formData = new FormData();
48
- await Promise.all(resolvedSources.map(async (resolvedSource, index) => {
49
- const mimeType = guessMimeType(resolvedSource.fileName);
50
- const fileBlob = await openAsBlob(resolvedSource.filePath, { type: mimeType });
51
- const file = new File([fileBlob], resolvedSource.fileName, { type: mimeType });
52
- formData.set(`file-${index}`, file);
53
- formData.set(`path-${index}`, resolvedSource.relativePath);
54
- }));
55
- return formData;
68
+ const getFolderPathFromRelativePath = (relativePath) => {
69
+ const normalizedSegments = relativePath
70
+ .replaceAll('\\', '/')
71
+ .split('/')
72
+ .filter((segment) => segment.length > 0);
73
+ if (normalizedSegments.length <= 1) {
74
+ return null;
75
+ }
76
+ return normalizedSegments.slice(0, -1).join('/');
56
77
  };
57
- const buildCommandSources = async ({ cwd, manifestPath, rawSources, relativePath, stdin, wait, }) => {
58
- if (manifestPath && rawSources.length > 0) {
59
- throw new CliCommandError('Pass positional sources or --manifest, not both.', 2);
78
+ const assertSheetImportFlagUsage = ({ manifestPath, singleSheet, sources, targetFolderPath, title, translateTo, }) => {
79
+ if (manifestPath && singleSheet) {
80
+ throw new CliCommandError('--single-sheet cannot be used with --manifest. Define grouped sheet imports in the manifest.', 2);
81
+ }
82
+ if (manifestPath && targetFolderPath !== null) {
83
+ throw new CliCommandError('--target-folder cannot be used with --manifest. Set targetFolderPath in the manifest.', 2);
60
84
  }
61
- if (manifestPath && relativePath) {
62
- throw new CliCommandError('--relative-path can only be used with a single positional source.', 2);
85
+ if (targetFolderPath !== null && !singleSheet) {
86
+ throw new CliCommandError('--target-folder can only be used with --single-sheet.', 2);
87
+ }
88
+ if (!title && !translateTo) {
89
+ return;
63
90
  }
64
91
  if (manifestPath) {
65
- const manifest = await loadSheetImportManifest({
66
- cwd,
67
- manifestPath,
68
- stdin,
69
- });
70
- return {
71
- organizationId: manifest.organizationId,
72
- sources: manifest.sources,
73
- wait: wait || manifest.wait === true,
74
- };
92
+ throw new CliCommandError('--title and --translate-to cannot be used with --manifest. Set the import metadata in the manifest instead.', 2);
75
93
  }
76
- if (rawSources.length === 0) {
77
- throw new CliCommandError('At least one source or --manifest is required.', 2);
94
+ if (!singleSheet && (sources.length !== 1 || sources[0]?.kind === 'directory')) {
95
+ throw new CliCommandError('--title and --translate-to can only be used with a single non-directory source. Use --manifest for multi-source imports.', 2);
78
96
  }
79
- if (relativePath && rawSources.length !== 1) {
80
- throw new CliCommandError('--relative-path can only be used with a single positional source.', 2);
97
+ };
98
+ const buildSheetImportSources = ({ sources, title, translateTo, }) => {
99
+ if (!title && !translateTo) {
100
+ return sources;
81
101
  }
82
- const sources = [];
83
- for (const rawSource of rawSources) {
84
- if (isHttpUrl(rawSource)) {
85
- sources.push({
86
- kind: 'url',
87
- url: rawSource,
88
- relativePath,
89
- });
90
- continue;
102
+ return sources.map((source, index) => {
103
+ if (index !== 0) {
104
+ return source;
91
105
  }
92
- try {
93
- const pathStats = await stat(resolve(cwd, rawSource));
94
- if (pathStats.isDirectory()) {
95
- if (relativePath) {
96
- throw new CliCommandError('--relative-path cannot be used with directory sources.', 2);
97
- }
98
- sources.push({
99
- kind: 'directory',
100
- path: rawSource,
101
- });
102
- continue;
106
+ return {
107
+ ...source,
108
+ title,
109
+ translateTo,
110
+ };
111
+ });
112
+ };
113
+ const buildSourceIndexesByInputIndex = (sources) => {
114
+ const sourceIndexesByInputIndex = new Map();
115
+ sources.forEach((source) => {
116
+ const sourceIndexes = sourceIndexesByInputIndex.get(source.sourceInputIndex) ?? [];
117
+ sourceIndexes.push(source.sourceIndex);
118
+ sourceIndexesByInputIndex.set(source.sourceInputIndex, sourceIndexes);
119
+ });
120
+ return sourceIndexesByInputIndex;
121
+ };
122
+ const resolveGroupedSourceIndexes = ({ groups, sourceIndexesByInputIndex, }) => {
123
+ return groups.map((group, groupIndex) => {
124
+ const sourceIndexes = group.sourceInputIndexes.flatMap((sourceInputIndex) => {
125
+ const resolvedSourceIndexes = sourceIndexesByInputIndex.get(sourceInputIndex);
126
+ if (!resolvedSourceIndexes || resolvedSourceIndexes.length === 0) {
127
+ throw new CliCommandError(`Manifest sheet at index ${groupIndex} references source index ${sourceInputIndex}, but it resolved to no files.`, 2);
103
128
  }
129
+ return resolvedSourceIndexes;
130
+ });
131
+ return {
132
+ sourceIndexes,
133
+ targetFolderPath: group.targetFolderPath,
134
+ titleOverride: group.title,
135
+ translateToLanguages: group.translateTo,
136
+ };
137
+ });
138
+ };
139
+ const buildSheetImportPlan = ({ groupedManifestSheets, requestedTargetFolderPath, requestedTitle, requestedTranslateTo, singleSheet, sources, }) => {
140
+ const sourceIndexesByInputIndex = buildSourceIndexesByInputIndex(sources);
141
+ const rawPlan = groupedManifestSheets
142
+ ? {
143
+ sheets: resolveGroupedSourceIndexes({
144
+ groups: groupedManifestSheets,
145
+ sourceIndexesByInputIndex,
146
+ }),
104
147
  }
105
- catch (error) {
106
- if (error.code !== 'ENOENT') {
107
- throw error;
148
+ : singleSheet
149
+ ? {
150
+ sheets: [
151
+ {
152
+ sourceIndexes: sources.map((source) => source.sourceIndex),
153
+ targetFolderPath: requestedTargetFolderPath,
154
+ titleOverride: requestedTitle,
155
+ translateToLanguages: requestedTranslateTo,
156
+ },
157
+ ],
108
158
  }
109
- }
110
- sources.push({
111
- kind: 'local',
112
- path: rawSource,
113
- relativePath,
114
- });
115
- }
116
- return {
117
- organizationId: undefined,
118
- sources,
119
- wait: wait === true,
120
- };
159
+ : {
160
+ sheets: sources.map((source) => ({
161
+ sourceIndexes: [source.sourceIndex],
162
+ targetFolderPath: getFolderPathFromRelativePath(source.relativePath),
163
+ titleOverride: source.title,
164
+ translateToLanguages: source.translateTo,
165
+ })),
166
+ };
167
+ assertValidSheetImportPlan(rawPlan);
168
+ return rawPlan;
121
169
  };
122
- const buildSheetImportJobs = ({ importResultJobs, resolvedSources, waitedJobsById, }) => {
170
+ const buildSheetImportJobs = ({ importResultJobs, sources, waitedJobsById, }) => {
123
171
  return importResultJobs.map((job, index) => {
124
- const source = resolvedSources[index];
172
+ const sourceIndexes = job.sourceIndexes ?? (sources[index] ? [sources[index].sourceIndex] : []);
125
173
  const waitedJob = waitedJobsById?.get(job.jobId);
126
174
  return {
127
175
  jobId: job.jobId,
128
- fileName: job.fileName,
129
- relativePath: job.relativePath,
130
- source: {
131
- kind: source?.kind ?? 'local',
132
- input: source?.input ?? job.relativePath,
133
- relativePath: source?.relativePath ?? job.relativePath,
134
- },
135
- status: waitedJob?.status,
176
+ sourceIndexes,
177
+ sourceIds: Array.from(new Set(sourceIndexes
178
+ .map((sourceIndex) => sources[sourceIndex]?.sourceId)
179
+ .filter((sourceId) => sourceId != null))),
180
+ status: waitedJob?.status ?? 'queued',
136
181
  error: waitedJob?.error,
137
182
  exerciseSheetId: waitedJob?.exerciseSheetId,
138
183
  };
139
184
  });
140
185
  };
141
- const formatQueuedImportOutput = (jobs) => {
186
+ const buildSheetImportRequest = ({ inputMode, organizationId, timeoutMs, wait, }) => {
187
+ return {
188
+ organizationId,
189
+ inputMode,
190
+ wait,
191
+ timeoutMs,
192
+ target: {},
193
+ };
194
+ };
195
+ const formatSheetJobSourceLabel = ({ job, sources, }) => {
196
+ if (job.sourceIndexes.length !== 1) {
197
+ return `${job.sourceIndexes.length} source${job.sourceIndexes.length === 1 ? '' : 's'}`;
198
+ }
199
+ const firstSource = sources[job.sourceIndexes[0]];
200
+ return firstSource?.relativePath ?? 'unknown source';
201
+ };
202
+ const formatQueuedImportOutput = ({ jobs, summary, sources, }) => {
142
203
  return [
143
204
  `Queued ${jobs.length} import${jobs.length === 1 ? '' : 's'}.`,
144
- ...jobs.map((job) => `${job.jobId} ${job.relativePath}`),
205
+ ...jobs.map((job) => `${job.jobId} ${formatSheetJobSourceLabel({ job, sources })}`),
206
+ jobs.length > 1 ? formatCliImportSummary(summary) : null,
145
207
  ]
146
208
  .filter(Boolean)
147
209
  .join('\n');
148
210
  };
149
- const formatWaitedImportOutput = (jobs) => {
211
+ const formatWaitedImportOutput = ({ jobs, summary, }) => {
150
212
  return [
151
213
  `${jobs.length} job${jobs.length === 1 ? '' : 's'} queued.`,
152
214
  ...jobs.map((job) => formatCliJobSummary({
153
215
  id: job.jobId,
154
- status: job.status ?? 'pending',
216
+ status: job.status === 'queued' ? 'pending' : job.status,
155
217
  type: 'exercise_sheet_import',
156
218
  createdAt: '',
157
219
  updatedAt: '',
158
220
  error: job.error,
159
221
  exerciseSheetId: job.exerciseSheetId,
160
222
  })),
223
+ jobs.length > 1 ? formatCliImportSummary(summary) : null,
161
224
  ]
162
225
  .filter(Boolean)
163
226
  .join('\n');
@@ -177,6 +240,24 @@ export const registerSheetCommands = (sheetYargs, context) => {
177
240
  .option('relative-path', {
178
241
  type: 'string',
179
242
  describe: 'Override the relative path for a single positional source',
243
+ })
244
+ .option('single-sheet', {
245
+ type: 'boolean',
246
+ default: false,
247
+ describe: 'Import all positional sources into one sheet',
248
+ })
249
+ .option('target-folder', {
250
+ type: 'string',
251
+ describe: 'Place a --single-sheet import into this destination folder (defaults to root)',
252
+ })
253
+ .option('title', {
254
+ type: 'string',
255
+ describe: 'Override the imported title for a single-sheet import',
256
+ })
257
+ .option('translate-to', {
258
+ array: true,
259
+ type: 'string',
260
+ describe: `Generate translated sheet content for these languages (${translationLanguages.join(', ')})`,
180
261
  })
181
262
  .option('timeout-ms', {
182
263
  type: 'number',
@@ -187,7 +268,14 @@ export const registerSheetCommands = (sheetYargs, context) => {
187
268
  type: 'boolean',
188
269
  default: false,
189
270
  describe: 'Wait for the queued jobs to finish',
190
- }), async (argv) => {
271
+ })
272
+ .example('chalksurf sheet import ./fixtures/algebra.pdf --title "OKTV 2014 Round 1" --translate-to english --wait', 'Import a sheet, override its title, and request an English translation')
273
+ .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
+ .example('cat sheet-import.json | chalksurf sheet import --manifest - --wait --json', 'Drive multi-source sheet imports from a manifest in an agent or CI flow')
275
+ .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.',
278
+ ].join('\n')), async (argv) => {
191
279
  const config = await context.configStore.load();
192
280
  const rawSources = (argv.sources ?? []).map(String);
193
281
  const manifestPath = argv.manifest === '' ? '-' : argv.manifest;
@@ -201,14 +289,32 @@ export const registerSheetCommands = (sheetYargs, context) => {
201
289
  env: context.env,
202
290
  config,
203
291
  });
204
- const commandSources = await buildCommandSources({
292
+ const commandSources = await buildFileImportCommandSources({
205
293
  cwd: context.cwd,
294
+ loadManifest: loadSheetImportManifest,
206
295
  manifestPath,
207
296
  rawSources: normalizedRawSources,
208
297
  relativePath: argv.relativePath,
209
298
  stdin: context.stdin,
210
299
  wait: argv.wait,
211
300
  });
301
+ const requestedTitle = normalizeOptionalTitle(argv.title);
302
+ const requestedTranslateTo = resolveRequestedTranslateToLanguages(argv.translateTo);
303
+ const requestedTargetFolderPath = normalizeOptionalTargetFolderPath(argv.targetFolder);
304
+ const singleSheet = argv.singleSheet === true;
305
+ assertSheetImportFlagUsage({
306
+ manifestPath,
307
+ singleSheet,
308
+ sources: commandSources.sources,
309
+ targetFolderPath: requestedTargetFolderPath,
310
+ title: requestedTitle,
311
+ translateTo: requestedTranslateTo,
312
+ });
313
+ const sheetSources = buildSheetImportSources({
314
+ sources: commandSources.sources,
315
+ title: requestedTitle,
316
+ translateTo: requestedTranslateTo,
317
+ });
212
318
  const organizationId = resolveRequestedOrganizationId({
213
319
  flagValue: argv.organization,
214
320
  fallbackValue: commandSources.organizationId,
@@ -219,16 +325,36 @@ export const registerSheetCommands = (sheetYargs, context) => {
219
325
  baseUrl: resolvedBaseUrl.value,
220
326
  token: resolvedToken.value,
221
327
  organizationId,
222
- fetchImpl: context.fetchImpl,
223
328
  });
224
329
  let resolvedSources = [];
225
330
  try {
226
331
  resolvedSources = await resolveSources({
227
332
  cwd: context.cwd,
228
- fetchImpl: context.fetchImpl,
229
- sources: commandSources.sources,
333
+ sources: sheetSources,
334
+ });
335
+ const sources = buildCliImportSources({
336
+ resolvedSources,
337
+ getExtra: (resolvedSource) => ({
338
+ title: resolvedSource.sourceInput.title,
339
+ translateTo: resolvedSource.sourceInput.translateTo,
340
+ }),
341
+ });
342
+ const formData = await buildFileImportFormData(resolvedSources);
343
+ const sheetImportPlan = buildSheetImportPlan({
344
+ groupedManifestSheets: commandSources.sheetGroups,
345
+ requestedTargetFolderPath,
346
+ requestedTitle,
347
+ requestedTranslateTo,
348
+ singleSheet,
349
+ sources,
350
+ });
351
+ formData.set('sheetImportPlan', JSON.stringify(sheetImportPlan));
352
+ const request = buildSheetImportRequest({
353
+ inputMode: manifestPath ? 'manifest' : 'arguments',
354
+ organizationId: organizationId ?? null,
355
+ timeoutMs: commandSources.wait ? normalizeWaitTimeoutMs(argv.timeoutMs) : null,
356
+ wait: commandSources.wait,
230
357
  });
231
- const formData = await buildFormData(resolvedSources);
232
358
  let importResult;
233
359
  try {
234
360
  importResult = await apiClient.importExerciseSheet(formData);
@@ -239,34 +365,65 @@ export const registerSheetCommands = (sheetYargs, context) => {
239
365
  if (!commandSources.wait) {
240
366
  const jobs = buildSheetImportJobs({
241
367
  importResultJobs: importResult.jobs,
242
- resolvedSources,
368
+ sources,
369
+ });
370
+ const summary = summarizeCliImportJobs({
371
+ jobs,
372
+ sourceCount: sources.length,
373
+ sourceInputCount: sheetSources.length,
374
+ timedOut: false,
243
375
  });
244
376
  context.output.print({
245
- organizationId: organizationId ?? null,
377
+ request,
378
+ sources,
246
379
  jobs,
247
- waited: false,
248
- }, (result) => formatQueuedImportOutput(result.jobs));
380
+ summary,
381
+ }, (result) => formatQueuedImportOutput({
382
+ jobs: result.jobs,
383
+ summary: result.summary,
384
+ sources: result.sources,
385
+ }), {
386
+ command: 'sheet import',
387
+ });
249
388
  return;
250
389
  }
390
+ const waitTimeoutMs = normalizeWaitTimeoutMs(argv.timeoutMs);
251
391
  const waitResult = await waitForCliJobs({
252
392
  getUserJob: async (jobId) => await apiClient.getUserJob(jobId),
253
393
  jobIds: importResult.jobs.map((job) => job.jobId),
254
394
  now: context.now,
255
395
  sleep: context.sleep,
256
- timeoutMs: normalizeTimeoutMs(argv.timeoutMs),
396
+ timeoutMs: waitTimeoutMs,
257
397
  });
258
398
  const waitedJobsById = new Map(waitResult.jobs.map((job) => [job.id, job]));
259
399
  const jobs = buildSheetImportJobs({
260
400
  importResultJobs: importResult.jobs,
261
- resolvedSources,
401
+ sources,
262
402
  waitedJobsById,
263
403
  });
264
- context.output.print({
265
- organizationId: organizationId ?? null,
404
+ const summary = summarizeCliImportJobs({
266
405
  jobs,
406
+ sourceCount: sources.length,
407
+ sourceInputCount: sheetSources.length,
267
408
  timedOut: waitResult.timedOut,
268
- waited: true,
269
- }, (result) => formatWaitedImportOutput(result.jobs));
409
+ });
410
+ const waitError = getWaitError(waitResult);
411
+ context.output.print({
412
+ request: {
413
+ ...request,
414
+ timeoutMs: waitTimeoutMs,
415
+ },
416
+ sources,
417
+ jobs,
418
+ summary,
419
+ }, (result) => formatWaitedImportOutput({
420
+ jobs: result.jobs,
421
+ summary: result.summary,
422
+ }), {
423
+ command: 'sheet import',
424
+ ok: waitError == null,
425
+ error: waitError,
426
+ });
270
427
  throwSilentExitCode(getWaitExitCode(waitResult));
271
428
  }
272
429
  finally {
@@ -18,7 +18,7 @@ const getResponseData = async ({ response, batched }) => {
18
18
  }
19
19
  return payload.result.data;
20
20
  };
21
- export const createApiClient = ({ baseUrl, token, organizationId, fetchImpl = fetch, }) => {
21
+ export const createApiClient = ({ baseUrl, token, organizationId, }) => {
22
22
  const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
23
23
  const createHeaders = ({ includeJsonContentType }) => {
24
24
  const headers = new Headers();
@@ -37,7 +37,7 @@ export const createApiClient = ({ baseUrl, token, organizationId, fetchImpl = fe
37
37
  const url = new URL(`trpc/${procedureName}`, normalizedBaseUrl);
38
38
  url.searchParams.set('batch', '1');
39
39
  url.searchParams.set('input', JSON.stringify({ 0: input ?? null }));
40
- const response = await fetchImpl(url.toString(), {
40
+ const response = await fetch(url.toString(), {
41
41
  method: 'GET',
42
42
  headers: createHeaders({ includeJsonContentType: false }),
43
43
  });
@@ -45,7 +45,7 @@ export const createApiClient = ({ baseUrl, token, organizationId, fetchImpl = fe
45
45
  };
46
46
  const mutation = async (procedureName, input) => {
47
47
  const isFormDataInput = input instanceof FormData;
48
- const response = await fetchImpl(new URL(`trpc/${procedureName}`, normalizedBaseUrl).toString(), {
48
+ const response = await fetch(new URL(`trpc/${procedureName}`, normalizedBaseUrl).toString(), {
49
49
  method: 'POST',
50
50
  headers: createHeaders({ includeJsonContentType: !isFormDataInput }),
51
51
  body: isFormDataInput ? input : JSON.stringify(input ?? {}),
@@ -67,5 +67,11 @@ export const createApiClient = ({ baseUrl, token, organizationId, fetchImpl = fe
67
67
  importExerciseSheet: async (formData) => {
68
68
  return await mutation('importExerciseSheet', formData);
69
69
  },
70
+ importExercise: async (formData) => {
71
+ return await mutation('importExercise', formData);
72
+ },
73
+ importExerciseSolution: async (formData) => {
74
+ return await mutation('importExerciseSolution', formData);
75
+ },
70
76
  };
71
77
  };
@@ -1,10 +1,46 @@
1
+ const cliErrorCodeByExitCode = {
2
+ 1: 'unexpected_error',
3
+ 2: 'usage_error',
4
+ 3: 'not_authenticated',
5
+ 4: 'source_resolution_failed',
6
+ 5: 'api_error',
7
+ 6: 'wait_timed_out',
8
+ 7: 'job_failed',
9
+ };
10
+ const isRetryableCliErrorCode = (code) => {
11
+ return ['api_error', 'wait_timed_out'].includes(code);
12
+ };
13
+ export const getCliErrorCode = (exitCode) => {
14
+ return cliErrorCodeByExitCode[exitCode] ?? 'unexpected_error';
15
+ };
16
+ export const createSerializableCliError = ({ code, exitCode, message, retryable, }) => {
17
+ const resolvedCode = code ?? getCliErrorCode(exitCode);
18
+ return {
19
+ code: resolvedCode,
20
+ exitCode,
21
+ message,
22
+ retryable: retryable ?? isRetryableCliErrorCode(resolvedCode),
23
+ };
24
+ };
1
25
  export class CliCommandError extends Error {
26
+ code;
2
27
  exitCode;
28
+ retryable;
3
29
  shouldReport;
4
- constructor(message, exitCode, shouldReport = true) {
30
+ constructor(message, exitCode, shouldReport = true, options = {}) {
5
31
  super(message);
6
32
  this.name = 'CliCommandError';
33
+ this.code = options.code ?? getCliErrorCode(exitCode);
7
34
  this.exitCode = exitCode;
35
+ this.retryable = options.retryable ?? isRetryableCliErrorCode(this.code);
8
36
  this.shouldReport = shouldReport;
9
37
  }
10
38
  }
39
+ export const serializeCliError = (error) => {
40
+ return createSerializableCliError({
41
+ code: error.code,
42
+ exitCode: error.exitCode,
43
+ message: error.message,
44
+ retryable: error.retryable,
45
+ });
46
+ };