@learnpack/learnpack 5.0.320 → 5.0.323

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.
@@ -60,6 +60,15 @@ import { checkAndFixSidebarPure } from "../utils/sidebarGenerator"
60
60
  import { handleAssetCreation } from "./publish"
61
61
  import { FormState, Lesson, Syllabus } from "../models/creator"
62
62
  import { exportToScorm, exportToEpub, exportToZip } from "../utils/export"
63
+ import {
64
+ errorHandler,
65
+ notFoundHandler,
66
+ asyncHandler,
67
+ ValidationError,
68
+ ConflictError,
69
+ InternalServerError,
70
+ NotFoundError,
71
+ } from "../utils/errorHandler"
63
72
 
64
73
  const frontMatter = require("front-matter")
65
74
 
@@ -326,19 +335,44 @@ async function startInitialContentGeneration(
326
335
  purposeSlug: string,
327
336
  lastLesson = ""
328
337
  ): Promise<number> {
338
+ // Defensive validation
339
+ if (!exercise) {
340
+ throw new ValidationError("Exercise is required but was not provided")
341
+ }
342
+
343
+ console.log("EXERCISE", exercise)
344
+
345
+ if (!exercise.id || !exercise.title) {
346
+ throw new ValidationError(
347
+ `Exercise is missing required properties: id=${exercise.id}, title=${exercise.title}`
348
+ )
349
+ }
350
+
329
351
  const exSlug = slugify(exercise.id + "-" + exercise.title)
330
352
  console.log("Starting initial content generation for", exSlug)
331
353
 
332
354
  const webhookUrl = `${process.env.HOST}/webhooks/${courseSlug}/initial-content-processor/${exercise.uid}/${rigoToken}`
333
355
 
334
- // Determine if this is the first lesson
335
356
  const exerciseIndex = steps.findIndex(
336
357
  lesson => lesson.uid === exercise.uid
337
358
  )
338
359
  const isFirstLesson = exerciseIndex === 0
339
- const endpointSlug = isFirstLesson ?
340
- "initial-step-content-generator" :
341
- "generate-step-initial-content"
360
+ const isLastLesson = exerciseIndex === steps.length - 1
361
+
362
+ let endpointSlug: string
363
+
364
+ let passTopicDescription = false
365
+ if (isFirstLesson) {
366
+ endpointSlug = "generate-learnpack-intro-content"
367
+ } else if (isLastLesson) {
368
+ endpointSlug = "generate-learnpack-outro-content"
369
+ } else if (exercise.id.endsWith(".0")) {
370
+ passTopicDescription = true
371
+ endpointSlug = "generate-learnpack-topic-intro-content"
372
+ } else {
373
+ passTopicDescription = true
374
+ endpointSlug = "generate-learnpack-subtopic-content"
375
+ }
342
376
 
343
377
  // Emit notification that initial content generation is starting
344
378
  emitToCourse(courseSlug, "course-creation", {
@@ -356,15 +390,20 @@ async function startInitialContentGeneration(
356
390
  // eslint-disable-next-line no-mixed-operators
357
391
  const randomCacheEvict = Math.floor(100_000 + Math.random() * 900_000)
358
392
 
393
+ const inputs = {
394
+ output_language: packageContext.language || "en",
395
+ current_syllabus: JSON.stringify(fullSyllabus),
396
+ lesson_description:
397
+ JSON.stringify(lessonCleaner(exercise)) + `-${randomCacheEvict}`,
398
+ target_word_count: String(PARAMS.max_words),
399
+ topic_description: passTopicDescription ?
400
+ String(exercise.description) :
401
+ undefined,
402
+ }
403
+
359
404
  const res = await initialContentGenerator(
360
405
  rigoToken.trim(),
361
- {
362
- // prev_lesson: lastLesson,
363
- output_language: packageContext.language || "en",
364
- current_syllabus: JSON.stringify(fullSyllabus),
365
- lesson_description:
366
- JSON.stringify(lessonCleaner(exercise)) + `-${randomCacheEvict}`,
367
- },
406
+ inputs,
368
407
  webhookUrl,
369
408
  endpointSlug
370
409
  )
@@ -1038,9 +1077,8 @@ export default class ServeCommand extends SessionCommand {
1038
1077
 
1039
1078
  const exerciseDir = `courses/${courseSlug}/exercises/${exercise.slug}`
1040
1079
 
1041
- for (const fileStr of files) {
1080
+ for (const fileObj of files) {
1042
1081
  try {
1043
- const fileObj = JSON.parse(fileStr)
1044
1082
  console.log(`📄 Processing file: ${fileObj.name}`)
1045
1083
 
1046
1084
  // Save the main file with content
@@ -1102,7 +1140,7 @@ export default class ServeCommand extends SessionCommand {
1102
1140
 
1103
1141
  app.post(
1104
1142
  "/actions/continue-generating/:courseSlug/:lessonUid",
1105
- async (req, res) => {
1143
+ asyncHandler(async (req, res) => {
1106
1144
  const { courseSlug, lessonUid } = req.params
1107
1145
  const { feedback, mode } = req.body
1108
1146
  const rigoToken = req.header("x-rigo-token")
@@ -1114,9 +1152,9 @@ export default class ServeCommand extends SessionCommand {
1114
1152
  // console.log("MODE", mode);
1115
1153
 
1116
1154
  if (!rigoToken) {
1117
- return res.status(400).json({
1118
- error: "Rigo token is required. x-rigo-token header is missing",
1119
- })
1155
+ throw new ValidationError(
1156
+ "Rigo token is required. x-rigo-token header is missing"
1157
+ )
1120
1158
  }
1121
1159
 
1122
1160
  const syllabus = await getSyllabus(courseSlug, bucket)
@@ -1128,6 +1166,27 @@ export default class ServeCommand extends SessionCommand {
1128
1166
  lesson => lesson.uid === lessonUid
1129
1167
  )
1130
1168
 
1169
+ // Validate that exercise exists
1170
+ if (!exercise) {
1171
+ throw new NotFoundError(
1172
+ `Lesson with UID "${lessonUid}" not found in course "${courseSlug}"`
1173
+ )
1174
+ }
1175
+
1176
+ // Validate that exercise index is valid (defensive check)
1177
+ if (exerciseIndex === -1) {
1178
+ throw new NotFoundError(
1179
+ `Lesson with UID "${lessonUid}" not found in course "${courseSlug}"`
1180
+ )
1181
+ }
1182
+
1183
+ // Validate that exercise has required properties
1184
+ if (!exercise.id || !exercise.title) {
1185
+ throw new ValidationError(
1186
+ `Lesson "${lessonUid}" is missing required properties (id or title)`
1187
+ )
1188
+ }
1189
+
1131
1190
  // previous exercise
1132
1191
  let previousReadme = "---"
1133
1192
  const previousExercise = syllabus.lessons[exerciseIndex - 1]
@@ -1154,7 +1213,7 @@ export default class ServeCommand extends SessionCommand {
1154
1213
  rigoToken,
1155
1214
  syllabus.lessons,
1156
1215
  syllabus.courseInfo,
1157
- exercise!,
1216
+ exercise,
1158
1217
  courseSlug,
1159
1218
  syllabus.courseInfo.purpose,
1160
1219
  previousReadme +
@@ -1182,7 +1241,7 @@ export default class ServeCommand extends SessionCommand {
1182
1241
  await saveSyllabus(courseSlug, syllabus, bucket)
1183
1242
 
1184
1243
  res.json({ status: "SUCCESS" })
1185
- }
1244
+ })
1186
1245
  )
1187
1246
 
1188
1247
  // TODO: Check if this command is being used
@@ -1857,6 +1916,13 @@ export default class ServeCommand extends SessionCommand {
1857
1916
 
1858
1917
  await uploadFileToBucket(bucket, body.parsed.translation, readmePath)
1859
1918
 
1919
+ emitToCourse(courseSlug, "translation-completed", {
1920
+ exercise: exSlug,
1921
+ language: body.parsed.output_language_code,
1922
+ status: "completed",
1923
+ message: `Translation completed for ${exSlug} to ${body.parsed.output_language_code}`,
1924
+ })
1925
+
1860
1926
  res.json({ status: "SUCCESS" })
1861
1927
  }
1862
1928
  )
@@ -2032,26 +2098,68 @@ export default class ServeCommand extends SessionCommand {
2032
2098
  app.put(
2033
2099
  "/exercise/:slug/file/:fileName",
2034
2100
  express.text(),
2035
- async (req, res) => {
2101
+ asyncHandler(async (req, res) => {
2036
2102
  const { slug, fileName } = req.params
2037
2103
  const query = req.query
2104
+ const courseSlug = query.slug as string
2105
+
2038
2106
  console.log(`PUT /exercise/${slug}/file/${fileName}`)
2039
2107
 
2040
- const courseSlug = query.slug
2041
- // console.log("COURSE SLUG", courseSlug)
2108
+ // Validate required parameters
2109
+ if (!courseSlug) {
2110
+ throw new ValidationError(
2111
+ "Course slug is required in query parameters"
2112
+ )
2113
+ }
2042
2114
 
2043
- // Update the file in the bucket
2044
- const file = await bucket.file(
2045
- `courses/${courseSlug}/exercises/${slug}/${fileName}`
2046
- )
2047
- await file.save(req.body)
2048
- const created = await file.exists()
2049
- // console.log("File updated", created)
2050
- res.send({
2051
- message: "File updated",
2052
- created,
2053
- })
2054
- }
2115
+ if (!fileName || !slug) {
2116
+ throw new ValidationError("File name and exercise slug are required")
2117
+ }
2118
+
2119
+ try {
2120
+ // Update the file in the bucket
2121
+ const file = bucket.file(
2122
+ `courses/${courseSlug}/exercises/${slug}/${fileName}`
2123
+ )
2124
+
2125
+ await file.save(req.body, {
2126
+ resumable: false,
2127
+ })
2128
+
2129
+ const created = await file.exists()
2130
+
2131
+ res.send({
2132
+ message: "File updated",
2133
+ created,
2134
+ })
2135
+ } catch (error: any) {
2136
+ // Handle Google Cloud Storage rate limit errors (429)
2137
+ if (
2138
+ error.code === 429 ||
2139
+ error.message?.includes("rate limit") ||
2140
+ error.message?.includes("rateLimitExceeded")
2141
+ ) {
2142
+ throw new ConflictError(
2143
+ "Storage rate limit exceeded. Please try again in a few moments.",
2144
+ {
2145
+ code: "STORAGE_RATE_LIMIT",
2146
+ retryAfter: 60, // seconds
2147
+ }
2148
+ )
2149
+ }
2150
+
2151
+ // Handle other GCS errors
2152
+ if (error.code) {
2153
+ throw new InternalServerError(
2154
+ `Storage error: ${error.message || "Failed to update file"}`,
2155
+ { code: error.code }
2156
+ )
2157
+ }
2158
+
2159
+ // Re-throw if it's already an operational error
2160
+ throw error
2161
+ }
2162
+ })
2055
2163
  )
2056
2164
  // Create a new step for a course
2057
2165
  app.post("/course/:slug/create-step", async (req, res) => {
@@ -2163,23 +2271,22 @@ export default class ServeCommand extends SessionCommand {
2163
2271
  res.send({ message: "Files renamed" })
2164
2272
  })
2165
2273
 
2166
- app.post("/actions/translate", express.json(), async (req, res) => {
2167
- console.log("POST /actions/translate")
2168
- const { exerciseSlugs, languages, rigoToken, currentLanguage } = req.body
2169
- const query = req.query
2170
- const courseSlug = query.slug
2171
-
2172
- if (!rigoToken) {
2173
- return res.status(400).json({ error: "RigoToken not found" })
2174
- }
2175
-
2176
- const languagesToTranslate: string[] = languages.split(",")
2177
- const languageCodesRes = await getLanguageCodes(rigoToken, {
2178
- raw_languages: languagesToTranslate.join(","),
2179
- })
2180
- const languageCodes = languageCodesRes.parsed.language_codes
2181
-
2274
+ async function processTranslationsAsync(
2275
+ courseSlug: string,
2276
+ exerciseSlugs: string[],
2277
+ languageCodes: string[],
2278
+ rigoToken: string,
2279
+ currentLanguage: string,
2280
+ bucket: Bucket
2281
+ ) {
2182
2282
  try {
2283
+ emitToCourse(courseSlug, "translation-started", {
2284
+ languages: languageCodes,
2285
+ exercises: exerciseSlugs,
2286
+ status: "started",
2287
+ message: "Translation process started",
2288
+ })
2289
+
2183
2290
  await Promise.all(
2184
2291
  exerciseSlugs.map(async (slug: string) => {
2185
2292
  const readmePath = `courses/${courseSlug}/exercises/${slug}/README${getReadmeExtension(
@@ -2189,7 +2296,6 @@ export default class ServeCommand extends SessionCommand {
2189
2296
 
2190
2297
  await Promise.all(
2191
2298
  languageCodes.map(async (language: string) => {
2192
- // verify if the translation already exists
2193
2299
  const translationPath = `courses/${courseSlug}/exercises/${slug}/README${getReadmeExtension(
2194
2300
  language
2195
2301
  )}`
@@ -2199,6 +2305,12 @@ export default class ServeCommand extends SessionCommand {
2199
2305
  console.log(
2200
2306
  `Translation in ${language} already exists for exercise ${slug}`
2201
2307
  )
2308
+ emitToCourse(courseSlug, "translation-progress", {
2309
+ exercise: slug,
2310
+ language: language,
2311
+ status: "skipped",
2312
+ message: `Translation in ${language} already exists`,
2313
+ })
2202
2314
  return
2203
2315
  }
2204
2316
 
@@ -2210,6 +2322,13 @@ export default class ServeCommand extends SessionCommand {
2210
2322
  },
2211
2323
  `${process.env.HOST}/webhooks/${courseSlug}/${slug}/save-translation`
2212
2324
  )
2325
+
2326
+ emitToCourse(courseSlug, "translation-progress", {
2327
+ exercise: slug,
2328
+ language: language,
2329
+ status: "initiated",
2330
+ message: `Translation job initiated for ${slug} to ${language}`,
2331
+ })
2213
2332
  })
2214
2333
  )
2215
2334
  })
@@ -2257,8 +2376,6 @@ export default class ServeCommand extends SessionCommand {
2257
2376
  currentLanguages = Object.keys(courseJson.title)
2258
2377
  }
2259
2378
 
2260
- // Check that all the READMEs exists
2261
-
2262
2379
  const missingReadmeTranslations = []
2263
2380
  let firstAvailable = ""
2264
2381
  for (const languageCode of currentLanguages) {
@@ -2281,6 +2398,7 @@ export default class ServeCommand extends SessionCommand {
2281
2398
 
2282
2399
  await Promise.all(
2283
2400
  missingReadmeTranslations.map(async languageCode => {
2401
+
2284
2402
  await translateExercise(
2285
2403
  rigoToken,
2286
2404
  {
@@ -2292,7 +2410,63 @@ export default class ServeCommand extends SessionCommand {
2292
2410
  })
2293
2411
  )
2294
2412
 
2295
- return res.status(200).json({ message: "Translated exercises" })
2413
+ emitToCourse(courseSlug, "translation-initiated", {
2414
+ status: "completed",
2415
+ message: "All translation jobs have been initiated",
2416
+ languages: languageCodes,
2417
+ exercises: exerciseSlugs,
2418
+ })
2419
+ } catch (error) {
2420
+ console.error("Error processing translations:", error)
2421
+ emitToCourse(courseSlug, "translation-error", {
2422
+ status: "error",
2423
+ error: (error as Error).message,
2424
+ message: "Error occurred during translation processing",
2425
+ })
2426
+ throw error
2427
+ }
2428
+ }
2429
+
2430
+ app.post("/actions/translate", express.json(), async (req, res) => {
2431
+ console.log("POST /actions/translate")
2432
+ const { exerciseSlugs, languages, rigoToken, currentLanguage } = req.body
2433
+ const query = req.query
2434
+ const courseSlug =
2435
+ typeof query.slug === "string" ? query.slug : undefined
2436
+
2437
+ if (!rigoToken) {
2438
+ return res.status(400).json({ error: "RigoToken not found" })
2439
+ }
2440
+
2441
+ if (!courseSlug) {
2442
+ return res.status(400).json({ error: "Course slug not found" })
2443
+ }
2444
+
2445
+ const languagesToTranslate: string[] = languages.split(",")
2446
+
2447
+ try {
2448
+ const languageCodesRes = await getLanguageCodes(rigoToken, {
2449
+ raw_languages: languagesToTranslate.join(","),
2450
+ })
2451
+ const languageCodes = languageCodesRes.parsed.language_codes
2452
+
2453
+ res.status(200).json({
2454
+ message: "Translation started",
2455
+ languages: languageCodes,
2456
+ exercises: exerciseSlugs,
2457
+ status: "processing",
2458
+ })
2459
+
2460
+ processTranslationsAsync(
2461
+ courseSlug,
2462
+ exerciseSlugs,
2463
+ languageCodes,
2464
+ rigoToken,
2465
+ currentLanguage,
2466
+ bucket
2467
+ ).catch(error => {
2468
+ console.error("Error in background translation processing:", error)
2469
+ })
2296
2470
  } catch (error) {
2297
2471
  console.log(error, "ERROR")
2298
2472
  return res.status(400).json({ error: (error as Error).message })
@@ -3453,6 +3627,12 @@ export default class ServeCommand extends SessionCommand {
3453
3627
  }
3454
3628
  })
3455
3629
 
3630
+ // 404 error handler
3631
+ app.use(notFoundHandler)
3632
+
3633
+ // Global error handler
3634
+ app.use(errorHandler)
3635
+
3456
3636
  server.listen(PORT, () => {
3457
3637
  console.log(
3458
3638
  `🚀 Creator UI server running at http://localhost:${PORT}/creator`