@learnpack/learnpack 5.0.324 → 5.0.327

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.
@@ -58,7 +58,12 @@ import { buildConfig } from "../utils/configBuilder"
58
58
  import { checkReadability, slugify } from "../utils/creatorUtilities"
59
59
  import { checkAndFixSidebarPure } from "../utils/sidebarGenerator"
60
60
  import { handleAssetCreation } from "./publish"
61
- import { FormState, Lesson, Syllabus } from "../models/creator"
61
+ import {
62
+ FormState,
63
+ Lesson,
64
+ Syllabus,
65
+ SyncNotification,
66
+ } from "../models/creator"
62
67
  import { exportToScorm, exportToEpub, exportToZip } from "../utils/export"
63
68
  import {
64
69
  errorHandler,
@@ -69,6 +74,7 @@ import {
69
74
  InternalServerError,
70
75
  NotFoundError,
71
76
  } from "../utils/errorHandler"
77
+ import { JSDOM } from "jsdom"
72
78
 
73
79
  const frontMatter = require("front-matter")
74
80
 
@@ -612,6 +618,62 @@ async function updateLessonStatusToError(
612
618
  }
613
619
  }
614
620
 
621
+ async function updateLessonStatusToDone(
622
+ courseSlug: string,
623
+ exerciseSlug: string,
624
+ bucket: Bucket
625
+ ): Promise<void> {
626
+ try {
627
+ const syllabus = await getSyllabus(courseSlug, bucket)
628
+ const lessonIndex = syllabus.lessons.findIndex(
629
+ lesson => slugify(lesson.id + "-" + lesson.title) === exerciseSlug
630
+ )
631
+
632
+ if (lessonIndex === -1) {
633
+ throw new NotFoundError(
634
+ `Lesson with slug "${exerciseSlug}" not found in course "${courseSlug}"`
635
+ )
636
+ }
637
+
638
+ const lesson = syllabus.lessons[lessonIndex]
639
+
640
+ // Update lesson status to DONE
641
+ lesson.status = "DONE"
642
+ lesson.generated = true
643
+
644
+ // Update translations to mark as completed
645
+ const currentTranslations = lesson.translations || {}
646
+ const language = syllabus.courseInfo.language || "en"
647
+
648
+ if (currentTranslations[language]) {
649
+ currentTranslations[language].completedAt = Date.now()
650
+ // Clear error flag if it existed
651
+ if (currentTranslations[language].error) {
652
+ delete currentTranslations[language].error
653
+ }
654
+ } else {
655
+ currentTranslations[language] = {
656
+ completionId: 0,
657
+ startedAt: Date.now(),
658
+ completedAt: Date.now(),
659
+ }
660
+ }
661
+
662
+ lesson.translations = currentTranslations
663
+
664
+ await saveSyllabus(courseSlug, syllabus, bucket)
665
+ console.log(
666
+ `Updated lesson ${exerciseSlug} status to DONE in course ${courseSlug}`
667
+ )
668
+ } catch (error) {
669
+ console.error(
670
+ `Error updating lesson ${exerciseSlug} status to DONE:`,
671
+ error
672
+ )
673
+ throw error
674
+ }
675
+ }
676
+
615
677
  async function continueWithNextLesson(
616
678
  courseSlug: string,
617
679
  currentExerciseIndex: number,
@@ -727,6 +789,186 @@ const getTitleFromHTML = (html: string) => {
727
789
  return titleMatch ? titleMatch[1] : null
728
790
  }
729
791
 
792
+ async function processSyncTranslationsSequentially(
793
+ courseSlug: string,
794
+ exerciseSlug: string,
795
+ notificationId: string,
796
+ sourceReadmeContent: string,
797
+ targetLanguages: string[],
798
+ rigoToken: string,
799
+ bucket: Bucket
800
+ ) {
801
+ try {
802
+ // Process translations sequentially (no race conditions)
803
+ for (const targetLang of targetLanguages) {
804
+ try {
805
+ // Call Rigobot directly with synchronous execution (no webhook needed)
806
+ // eslint-disable-next-line no-await-in-loop
807
+ const response = await axios.post(
808
+ `${RIGOBOT_HOST}/v1/prompting/completion/translate-asset-markdown/`,
809
+ {
810
+ inputs: {
811
+ text_to_translate: sourceReadmeContent,
812
+ output_language: targetLang,
813
+ },
814
+ include_purpose_objective: false,
815
+ execute_async: false, // Synchronous execution
816
+ },
817
+ {
818
+ headers: {
819
+ "Content-Type": "application/json",
820
+ Authorization: "Token " + rigoToken,
821
+ },
822
+ }
823
+ )
824
+
825
+ const translationResult = response.data
826
+
827
+ // Check if translation was successful
828
+ if (!translationResult.parsed?.translation) {
829
+ throw new Error("Translation result is empty")
830
+ }
831
+
832
+ // Save translated README
833
+ const readmePath = `courses/${courseSlug}/exercises/${exerciseSlug}/README${getReadmeExtension(
834
+ targetLang
835
+ )}`
836
+ // eslint-disable-next-line no-await-in-loop
837
+ await bucket
838
+ .file(readmePath)
839
+ .save(translationResult.parsed.translation)
840
+
841
+ // Update progress in syllabus
842
+ // eslint-disable-next-line no-await-in-loop
843
+ const syllabus = await getSyllabus(courseSlug, bucket)
844
+ const lessonIndex = syllabus.lessons.findIndex(
845
+ lesson => slugify(lesson.id + "-" + lesson.title) === exerciseSlug
846
+ )
847
+
848
+ if (lessonIndex !== -1) {
849
+ const lesson = syllabus.lessons[lessonIndex]
850
+ const notification = lesson.syncNotifications?.find(
851
+ n => n.id === notificationId
852
+ )
853
+
854
+ if (notification?.syncProgress) {
855
+ notification.syncProgress.completedLanguages.push(targetLang)
856
+ notification.processingLastUpdate = Date.now()
857
+
858
+ const progress =
859
+ notification.syncProgress.completedLanguages.length
860
+ const total = notification.syncProgress.totalLanguages
861
+
862
+ console.log(
863
+ `🔄 SYNC: Progress ${progress}/${total} - Completed: [${notification.syncProgress.completedLanguages.join(
864
+ ", "
865
+ )}]`
866
+ )
867
+
868
+ // eslint-disable-next-line no-await-in-loop
869
+ await saveSyllabus(courseSlug, syllabus, bucket)
870
+
871
+ // Emit progress event
872
+ emitToCourse(courseSlug, "sync-notification-progress", {
873
+ exerciseSlug,
874
+ notificationId,
875
+ completed: progress,
876
+ total,
877
+ })
878
+ }
879
+ }
880
+ } catch (langError) {
881
+ // Translation failed for this language
882
+ console.error(
883
+ `🔄 SYNC ERROR: Translation failed for ${targetLang}:`,
884
+ langError
885
+ )
886
+
887
+ // Update syllabus with failed language
888
+ // eslint-disable-next-line no-await-in-loop
889
+ const syllabus = await getSyllabus(courseSlug, bucket)
890
+ const lessonIndex = syllabus.lessons.findIndex(
891
+ lesson => slugify(lesson.id + "-" + lesson.title) === exerciseSlug
892
+ )
893
+
894
+ if (lessonIndex !== -1) {
895
+ const lesson = syllabus.lessons[lessonIndex]
896
+ const notification = lesson.syncNotifications?.find(
897
+ n => n.id === notificationId
898
+ )
899
+
900
+ if (notification?.syncProgress) {
901
+ if (!notification.syncProgress.failedLanguages) {
902
+ notification.syncProgress.failedLanguages = []
903
+ }
904
+
905
+ notification.syncProgress.failedLanguages.push({
906
+ code: targetLang,
907
+ error: (langError as Error).message,
908
+ })
909
+
910
+ // eslint-disable-next-line no-await-in-loop
911
+ await saveSyllabus(courseSlug, syllabus, bucket)
912
+
913
+ emitToCourse(courseSlug, "sync-notification-language-failed", {
914
+ exerciseSlug,
915
+ notificationId,
916
+ language: targetLang,
917
+ error: (langError as Error).message,
918
+ })
919
+ }
920
+ }
921
+ }
922
+ }
923
+
924
+ // All translations processed - check final status
925
+ const syllabus = await getSyllabus(courseSlug, bucket)
926
+ const lessonIndex = syllabus.lessons.findIndex(
927
+ lesson => slugify(lesson.id + "-" + lesson.title) === exerciseSlug
928
+ )
929
+
930
+ if (lessonIndex !== -1) {
931
+ const lesson = syllabus.lessons[lessonIndex]
932
+ const notification = lesson.syncNotifications?.find(
933
+ n => n.id === notificationId
934
+ )
935
+
936
+ if (notification?.syncProgress) {
937
+ const totalProcessed =
938
+ notification.syncProgress.completedLanguages.length +
939
+ (notification.syncProgress.failedLanguages?.length || 0)
940
+
941
+ if (totalProcessed === notification.syncProgress.totalLanguages) {
942
+ notification.status =
943
+ notification.syncProgress.completedLanguages.length === 0 ?
944
+ "error" :
945
+ "completed"
946
+
947
+ await saveSyllabus(courseSlug, syllabus, bucket)
948
+
949
+ console.log(
950
+ `🔄 SYNC: ✅ All translations completed for ${exerciseSlug} - Status: ${notification.status}`
951
+ )
952
+
953
+ emitToCourse(courseSlug, "sync-notification-completed", {
954
+ exerciseSlug,
955
+ notificationId,
956
+ status: notification.status,
957
+ completed: notification.syncProgress.completedLanguages.length,
958
+ failed: notification.syncProgress.failedLanguages?.length || 0,
959
+ })
960
+ }
961
+ }
962
+ }
963
+ } catch (error) {
964
+ console.error(
965
+ "🔄 SYNC ERROR: Critical error in processSyncTranslationsSequentially:",
966
+ error
967
+ )
968
+ throw error
969
+ }
970
+ }
971
+
730
972
  export default class ServeCommand extends SessionCommand {
731
973
  static description = "Runs a small server to build tutorials"
732
974
 
@@ -846,6 +1088,48 @@ export default class ServeCommand extends SessionCommand {
846
1088
 
847
1089
  const upload = createUploadMiddleware()
848
1090
 
1091
+ // Initialize DOMPurify for SVG sanitization
1092
+ // eslint-disable-next-line
1093
+ const createDOMPurify = require("isomorphic-dompurify");
1094
+ const window = new JSDOM("").window
1095
+ const DOMPurify = createDOMPurify(window)
1096
+
1097
+ const sanitizeSVGBuffer = (buffer: Buffer): Buffer => {
1098
+ try {
1099
+ const svgContent = buffer.toString("utf-8")
1100
+
1101
+ const config = {
1102
+ USE_PROFILES: { svg: true, svgFilters: true },
1103
+ FORBID_TAGS: ["script", "iframe", "embed", "object", "foreignObject"],
1104
+ FORBID_ATTR: [
1105
+ "onerror",
1106
+ "onload",
1107
+ "onclick",
1108
+ "onmouseover",
1109
+ "onmouseout",
1110
+ "onanimationend",
1111
+ "onanimationstart",
1112
+ "ontransitionend",
1113
+ "onfocus",
1114
+ "onblur",
1115
+ ],
1116
+ ALLOW_DATA_ATTR: false, // Block data-* attributes that might contain code
1117
+ }
1118
+
1119
+ const sanitized = DOMPurify.sanitize(svgContent, config)
1120
+
1121
+ // Validate that sanitization didn't remove all content
1122
+ if (!sanitized || sanitized.trim().length === 0) {
1123
+ throw new Error("SVG file is invalid or empty after sanitization")
1124
+ }
1125
+
1126
+ return Buffer.from(sanitized, "utf-8")
1127
+ } catch (error) {
1128
+ console.error("Error sanitizing SVG:", error)
1129
+ throw new Error(`SVG sanitization failed: ${(error as Error).message}`)
1130
+ }
1131
+ }
1132
+
849
1133
  app.post("/upload-image-file", upload.single("file"), async (req, res) => {
850
1134
  console.log("INFO: Uploading image file")
851
1135
 
@@ -862,7 +1146,27 @@ export default class ServeCommand extends SessionCommand {
862
1146
  try {
863
1147
  // eslint-disable-next-line
864
1148
  // @ts-ignore
865
- const fileData = req.file.buffer
1149
+ let fileData = req.file.buffer
1150
+
1151
+ // Sanitize if it's an SVG file
1152
+ // eslint-disable-next-line
1153
+ // @ts-ignore
1154
+ if (
1155
+ // eslint-disable-next-line
1156
+ // @ts-ignore
1157
+ req.file.mimetype === "image/svg+xml" ||
1158
+ destination.toLowerCase().endsWith(".svg")
1159
+ ) {
1160
+ try {
1161
+ fileData = sanitizeSVGBuffer(fileData)
1162
+ console.log("INFO: SVG file sanitized successfully")
1163
+ } catch (sanitizeError) {
1164
+ console.error("Error sanitizing SVG:", sanitizeError)
1165
+ return res.status(400).json({
1166
+ error: "Invalid SVG file or sanitization failed",
1167
+ })
1168
+ }
1169
+ }
866
1170
 
867
1171
  const file = bucket.file(destination)
868
1172
  await file.save(fileData, {
@@ -870,6 +1174,10 @@ export default class ServeCommand extends SessionCommand {
870
1174
  // eslint-disable-next-line
871
1175
  // @ts-ignore
872
1176
  contentType: req.file.mimetype,
1177
+ metadata: {
1178
+ cacheControl: "public, max-age=31536000",
1179
+ contentDisposition: "inline",
1180
+ },
873
1181
  })
874
1182
 
875
1183
  console.log(`INFO: Image uploaded to ${file.name}`)
@@ -1911,20 +2219,155 @@ export default class ServeCommand extends SessionCommand {
1911
2219
 
1912
2220
  console.log("RECEIVING TRANSLATION WEBHOOK", body)
1913
2221
 
1914
- const readmePath = `courses/${courseSlug}/exercises/${exSlug}/README${getReadmeExtension(
1915
- body.parsed.output_language_code
1916
- )}`
2222
+ try {
2223
+ // Check if there's an error from Rigobot
2224
+ if (body.error || body.status === "ERROR") {
2225
+ console.error("Translation failed for", exSlug, body.error)
2226
+
2227
+ const language = body.parsed?.output_language_code || body.language
2228
+
2229
+ // Update syllabus with error status
2230
+ if (language) {
2231
+ try {
2232
+ const syllabus = await getSyllabus(courseSlug, bucket)
2233
+ const lessonIndex = syllabus.lessons.findIndex(
2234
+ lesson => slugify(lesson.id + "-" + lesson.title) === exSlug
2235
+ )
1917
2236
 
1918
- await uploadFileToBucket(bucket, body.parsed.translation, readmePath)
2237
+ if (lessonIndex !== -1) {
2238
+ const lesson = syllabus.lessons[lessonIndex]
1919
2239
 
1920
- emitToCourse(courseSlug, "translation-completed", {
1921
- exercise: exSlug,
1922
- language: body.parsed.output_language_code,
1923
- status: "completed",
1924
- message: `Translation completed for ${exSlug} to ${body.parsed.output_language_code}`,
1925
- })
2240
+ if (!lesson.translations) {
2241
+ lesson.translations = {}
2242
+ }
1926
2243
 
1927
- res.json({ status: "SUCCESS" })
2244
+ if (lesson.translations[language]) {
2245
+ lesson.translations[language].completedAt = Date.now()
2246
+ lesson.translations[language].error = true
2247
+ } else {
2248
+ // Create entry if it doesn't exist
2249
+ lesson.translations[language] = {
2250
+ completionId: 0,
2251
+ startedAt: Date.now(),
2252
+ completedAt: Date.now(),
2253
+ error: true,
2254
+ }
2255
+ }
2256
+ }
2257
+
2258
+ await saveSyllabus(courseSlug, syllabus, bucket)
2259
+ } catch (syllabusError) {
2260
+ console.error(
2261
+ "Error updating syllabus with error status:",
2262
+ syllabusError
2263
+ )
2264
+ }
2265
+ }
2266
+
2267
+ // Notify frontend via WebSocket
2268
+ emitToCourse(courseSlug, "translation-error", {
2269
+ exercise: exSlug,
2270
+ language: language,
2271
+ error: body.error || "Translation failed",
2272
+ })
2273
+
2274
+ return res.status(500).json({ status: "ERROR", error: body.error })
2275
+ }
2276
+
2277
+ // Validate required data
2278
+ if (!body.parsed?.translation || !body.parsed?.output_language_code) {
2279
+ console.error("Missing required translation data", body)
2280
+ return res.status(400).json({
2281
+ status: "ERROR",
2282
+ error: "Missing translation or language code",
2283
+ })
2284
+ }
2285
+
2286
+ // Translation successful
2287
+ const readmePath = `courses/${courseSlug}/exercises/${exSlug}/README${getReadmeExtension(
2288
+ body.parsed.output_language_code
2289
+ )}`
2290
+
2291
+ await uploadFileToBucket(bucket, body.parsed.translation, readmePath)
2292
+
2293
+ // Verify file exists before updating syllabus (resilience: ensure file was actually saved)
2294
+ const [fileExists] = await bucket.file(readmePath).exists()
2295
+
2296
+ // Update syllabus with completed status only if file was successfully saved
2297
+ if (fileExists) {
2298
+ try {
2299
+ const syllabus = await getSyllabus(courseSlug, bucket)
2300
+ const lessonIndex = syllabus.lessons.findIndex(
2301
+ lesson => slugify(lesson.id + "-" + lesson.title) === exSlug
2302
+ )
2303
+
2304
+ if (lessonIndex !== -1) {
2305
+ const lesson = syllabus.lessons[lessonIndex]
2306
+ const language = body.parsed.output_language_code
2307
+
2308
+ if (!lesson.translations) {
2309
+ lesson.translations = {}
2310
+ }
2311
+
2312
+ if (lesson.translations[language]) {
2313
+ lesson.translations[language].completedAt = Date.now()
2314
+ // Clear error flag if it existed
2315
+ if (lesson.translations[language].error) {
2316
+ delete lesson.translations[language].error
2317
+ }
2318
+ } else {
2319
+ // Create entry if it doesn't exist
2320
+ lesson.translations[language] = {
2321
+ completionId: 0,
2322
+ startedAt: Date.now(),
2323
+ completedAt: Date.now(),
2324
+ }
2325
+ }
2326
+ }
2327
+
2328
+ await saveSyllabus(courseSlug, syllabus, bucket)
2329
+ } catch (syllabusError) {
2330
+ console.error(
2331
+ "Error updating syllabus with completed status:",
2332
+ syllabusError
2333
+ )
2334
+ // File exists but syllabus update failed - frontend will detect file and mark as complete
2335
+ }
2336
+
2337
+ // Notify frontend via WebSocket ONLY if file exists
2338
+ emitToCourse(courseSlug, "translation-completed", {
2339
+ exercise: exSlug,
2340
+ language: body.parsed.output_language_code,
2341
+ })
2342
+ } else {
2343
+ console.error(
2344
+ `File ${readmePath} was not found after upload, skipping syllabus update`
2345
+ )
2346
+ // File upload failed - emit error event instead of success
2347
+ emitToCourse(courseSlug, "translation-error", {
2348
+ exercise: exSlug,
2349
+ language: body.parsed.output_language_code,
2350
+ error: "File upload verification failed",
2351
+ })
2352
+ }
2353
+
2354
+ res.json({ status: "SUCCESS" })
2355
+ } catch (error) {
2356
+ console.error("Error processing translation webhook:", error)
2357
+
2358
+ // Notify frontend of error
2359
+ const language =
2360
+ body.parsed?.output_language_code || body.language || "unknown"
2361
+ emitToCourse(courseSlug, "translation-error", {
2362
+ exercise: exSlug,
2363
+ language: language,
2364
+ error: (error as Error).message,
2365
+ })
2366
+
2367
+ res
2368
+ .status(500)
2369
+ .json({ status: "ERROR", error: (error as Error).message })
2370
+ }
1928
2371
  }
1929
2372
  )
1930
2373
 
@@ -1986,6 +2429,11 @@ export default class ServeCommand extends SessionCommand {
1986
2429
  res.sendFile(file)
1987
2430
  })
1988
2431
 
2432
+ app.get("/preview/:slug/webview", async (req, res) => {
2433
+ const file = path.resolve(__dirname, "../ui/_app/index.html")
2434
+ res.sendFile(file)
2435
+ })
2436
+
1989
2437
  app.get("/config", async (req, res) => {
1990
2438
  const courseSlug = req.query.slug as string
1991
2439
  // GEt the x-rigo-token
@@ -2090,9 +2538,36 @@ export default class ServeCommand extends SessionCommand {
2090
2538
  return res.status(404).send("File not found")
2091
2539
  }
2092
2540
 
2541
+ // Determine Content-Type based on file extension
2542
+ const ext = file.split(".").pop()?.toLowerCase()
2543
+ let contentType = "application/octet-stream"
2544
+ let contentDisposition = `inline; filename="${file}"` // inline for images
2545
+
2546
+ switch (ext) {
2547
+ case "svg":
2548
+ contentType = "image/svg+xml"
2549
+ break
2550
+ case "jpg":
2551
+ case "jpeg":
2552
+ contentType = "image/jpeg"
2553
+ break
2554
+ case "png":
2555
+ contentType = "image/png"
2556
+ break
2557
+ case "gif":
2558
+ contentType = "image/gif"
2559
+ break
2560
+ case "webp":
2561
+ contentType = "image/webp"
2562
+ break
2563
+ default:
2564
+ // For non-image files, use attachment to force download
2565
+ contentDisposition = `attachment; filename="${file}"`
2566
+ }
2567
+
2093
2568
  const fileStream = fileRef.createReadStream()
2094
- res.set("Content-Type", "application/octet-stream")
2095
- res.set("Content-Disposition", `attachment; filename="${file}"`)
2569
+ res.set("Content-Type", contentType)
2570
+ res.set("Content-Disposition", contentDisposition)
2096
2571
  fileStream.pipe(res)
2097
2572
  })
2098
2573
 
@@ -2281,6 +2756,20 @@ export default class ServeCommand extends SessionCommand {
2281
2756
  bucket: Bucket
2282
2757
  ) {
2283
2758
  try {
2759
+ // Track which languages already exist vs which are being translated
2760
+ const existingLanguages = new Set<string>()
2761
+ const translatingLanguages = new Set<string>()
2762
+
2763
+ // Get syllabus to track translation status
2764
+ let syllabus: Syllabus | null = null
2765
+ try {
2766
+ syllabus = await getSyllabus(courseSlug, bucket)
2767
+ } catch {
2768
+ console.log(
2769
+ "Syllabus not found, translations will not be tracked in syllabus"
2770
+ )
2771
+ }
2772
+
2284
2773
  emitToCourse(courseSlug, "translation-started", {
2285
2774
  languages: languageCodes,
2286
2775
  exercises: exerciseSlugs,
@@ -2293,7 +2782,19 @@ export default class ServeCommand extends SessionCommand {
2293
2782
  const readmePath = `courses/${courseSlug}/exercises/${slug}/README${getReadmeExtension(
2294
2783
  currentLanguage
2295
2784
  )}`
2296
- const readme = await bucket.file(readmePath).download()
2785
+
2786
+ // Validate that README exists before attempting translation
2787
+ const readmeFile = bucket.file(readmePath)
2788
+ const [readmeExists] = await readmeFile.exists()
2789
+ if (!readmeExists) {
2790
+ console.error(
2791
+ `README not found for exercise ${slug} in language ${currentLanguage}`
2792
+ )
2793
+ return // Skip this exercise
2794
+ }
2795
+
2796
+ const [readmeBuffer] = await readmeFile.download()
2797
+ const readme = readmeBuffer.toString()
2297
2798
 
2298
2799
  await Promise.all(
2299
2800
  languageCodes.map(async (language: string) => {
@@ -2306,6 +2807,7 @@ export default class ServeCommand extends SessionCommand {
2306
2807
  console.log(
2307
2808
  `Translation in ${language} already exists for exercise ${slug}`
2308
2809
  )
2810
+ existingLanguages.add(language)
2309
2811
  emitToCourse(courseSlug, "translation-progress", {
2310
2812
  exercise: slug,
2311
2813
  language: language,
@@ -2315,15 +2817,40 @@ export default class ServeCommand extends SessionCommand {
2315
2817
  return
2316
2818
  }
2317
2819
 
2318
- await translateExercise(
2820
+ // Call translateExercise first to get the real completion ID
2821
+ const translateResponse = await translateExercise(
2319
2822
  rigoToken,
2320
2823
  {
2321
- text_to_translate: readme.toString(),
2824
+ text_to_translate: readme,
2322
2825
  output_language: language,
2323
2826
  },
2324
2827
  `${process.env.HOST}/webhooks/${courseSlug}/${slug}/save-translation`
2325
2828
  )
2326
2829
 
2830
+ // Mark this language as being translated
2831
+ translatingLanguages.add(language)
2832
+
2833
+ // Update syllabus with translation status if available
2834
+ if (syllabus) {
2835
+ const lessonIndex = syllabus.lessons.findIndex(
2836
+ lesson => slugify(lesson.id + "-" + lesson.title) === slug
2837
+ )
2838
+
2839
+ if (lessonIndex !== -1) {
2840
+ const lesson = syllabus.lessons[lessonIndex]
2841
+ if (!lesson.translations) {
2842
+ lesson.translations = {}
2843
+ }
2844
+
2845
+ // Use the real completion ID from the response, fallback to Date.now() if not available
2846
+ lesson.translations[language] = {
2847
+ completionId: translateResponse?.id || Date.now(),
2848
+ startedAt: Date.now(),
2849
+ completedAt: undefined,
2850
+ }
2851
+ }
2852
+ }
2853
+
2327
2854
  emitToCourse(courseSlug, "translation-progress", {
2328
2855
  exercise: slug,
2329
2856
  language: language,
@@ -2335,6 +2862,11 @@ export default class ServeCommand extends SessionCommand {
2335
2862
  })
2336
2863
  )
2337
2864
 
2865
+ // Save syllabus with translation status
2866
+ if (syllabus && courseSlug) {
2867
+ await saveSyllabus(courseSlug, syllabus, bucket)
2868
+ }
2869
+
2338
2870
  const course = await bucket
2339
2871
  .file(`courses/${courseSlug}/learn.json`)
2340
2872
  .download()
@@ -2450,29 +2982,526 @@ export default class ServeCommand extends SessionCommand {
2450
2982
  })
2451
2983
  const languageCodes = languageCodesRes.parsed.language_codes
2452
2984
 
2985
+ // Pre-calculate which languages already exist vs which need translation
2986
+ // This provides immediate, accurate feedback to the frontend
2987
+ const existingLanguages = new Set<string>()
2988
+ const translatingLanguages = new Set<string>()
2989
+
2990
+ // Quick check: for each language, check if ALL exercises already have that translation
2991
+ await Promise.all(
2992
+ languageCodes.map(async (language: string) => {
2993
+ const allExist = await Promise.all(
2994
+ exerciseSlugs.map(async (slug: string) => {
2995
+ const translationPath = `courses/${courseSlug}/exercises/${slug}/README${getReadmeExtension(
2996
+ language
2997
+ )}`
2998
+ const [exists] = await bucket.file(translationPath).exists()
2999
+ return exists
3000
+ })
3001
+ )
3002
+
3003
+ // If all exercises have this language, mark as existing
3004
+ if (allExist.every(exists => exists)) {
3005
+ existingLanguages.add(language)
3006
+ } else {
3007
+ // Otherwise, it will be translated
3008
+ translatingLanguages.add(language)
3009
+ }
3010
+ })
3011
+ )
3012
+
3013
+ // Convert Sets to Arrays for JSON response
3014
+ const translatingLanguagesList = [...translatingLanguages]
3015
+ const existingLanguagesList = [...existingLanguages]
3016
+
2453
3017
  res.status(200).json({
2454
3018
  message: "Translation started",
2455
3019
  languages: languageCodes,
2456
3020
  exercises: exerciseSlugs,
2457
3021
  status: "processing",
3022
+ translatingLanguages: translatingLanguagesList,
3023
+ existingLanguages: existingLanguagesList,
2458
3024
  })
2459
3025
 
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
- })
3026
+ // Only process translations if there are languages to translate
3027
+ if (translatingLanguagesList.length > 0) {
3028
+ processTranslationsAsync(
3029
+ courseSlug,
3030
+ exerciseSlugs,
3031
+ languageCodes,
3032
+ rigoToken,
3033
+ currentLanguage,
3034
+ bucket
3035
+ ).catch(error => {
3036
+ console.error("Error in background translation processing:", error)
3037
+ })
3038
+ }
2470
3039
  } catch (error) {
2471
3040
  console.log(error, "ERROR")
2472
3041
  return res.status(400).json({ error: (error as Error).message })
2473
3042
  }
2474
3043
  })
2475
3044
 
3045
+ // ============================================
3046
+ // SYNC NOTIFICATIONS ENDPOINTS
3047
+ // ============================================
3048
+
3049
+ // Create or update sync notification
3050
+ app.post(
3051
+ "/courses/:courseSlug/lessons/:exerciseSlug/sync-notification",
3052
+ express.json(),
3053
+ async (req, res) => {
3054
+ console.log(
3055
+ "POST /courses/:courseSlug/lessons/:exerciseSlug/sync-notification"
3056
+ )
3057
+ const { courseSlug, exerciseSlug } = req.params
3058
+ const { sourceLanguage } = req.body
3059
+
3060
+ try {
3061
+ if (!courseSlug || !exerciseSlug || !sourceLanguage) {
3062
+ return res.status(400).json({
3063
+ error: "Missing required parameters",
3064
+ code: "MISSING_PARAMETERS",
3065
+ })
3066
+ }
3067
+
3068
+ const syllabus = await getSyllabus(courseSlug, bucket)
3069
+ if (!syllabus) {
3070
+ return res.status(404).json({
3071
+ error: "Syllabus not found",
3072
+ code: "SYLLABUS_NOT_FOUND",
3073
+ })
3074
+ }
3075
+
3076
+ // Find lesson
3077
+ const lessonIndex = syllabus.lessons.findIndex(
3078
+ lesson => slugify(lesson.id + "-" + lesson.title) === exerciseSlug
3079
+ )
3080
+
3081
+ if (lessonIndex === -1) {
3082
+ return res.status(404).json({
3083
+ error: "Lesson not found",
3084
+ code: "LESSON_NOT_FOUND",
3085
+ })
3086
+ }
3087
+
3088
+ const lesson = syllabus.lessons[lessonIndex]
3089
+
3090
+ // Verify source README exists
3091
+ const readmePath = `courses/${courseSlug}/exercises/${exerciseSlug}/README${getReadmeExtension(
3092
+ sourceLanguage
3093
+ )}`
3094
+ const [readmeExists] = await bucket.file(readmePath).exists()
3095
+
3096
+ if (!readmeExists) {
3097
+ return res.status(404).json({
3098
+ error: "Source README not found",
3099
+ code: "SOURCE_README_NOT_FOUND",
3100
+ details: { sourceLanguage, path: readmePath },
3101
+ })
3102
+ }
3103
+
3104
+ if (!lesson.syncNotifications) {
3105
+ lesson.syncNotifications = []
3106
+ }
3107
+
3108
+ // Find existing notification for this language in pending status
3109
+ const existingNotification = lesson.syncNotifications.find(
3110
+ n => n.sourceLanguage === sourceLanguage && n.status === "pending"
3111
+ )
3112
+
3113
+ let notificationToReturn
3114
+
3115
+ if (existingNotification) {
3116
+ // Update timestamp of existing notification
3117
+ existingNotification.updatedAt = Date.now()
3118
+ notificationToReturn = existingNotification
3119
+ } else {
3120
+ const newNotification: SyncNotification = {
3121
+ id: String(Date.now()),
3122
+ sourceLanguage,
3123
+ createdAt: Date.now(),
3124
+ updatedAt: Date.now(),
3125
+ status: "pending",
3126
+ }
3127
+ lesson.syncNotifications.push(newNotification)
3128
+ notificationToReturn = newNotification
3129
+ }
3130
+
3131
+ await saveSyllabus(courseSlug, syllabus, bucket)
3132
+
3133
+ emitToCourse(courseSlug, "sync-notification-created", {
3134
+ exerciseSlug,
3135
+ sourceLanguage,
3136
+ notificationId: notificationToReturn.id,
3137
+ })
3138
+
3139
+ res.json({
3140
+ status: "SUCCESS",
3141
+ notification: notificationToReturn,
3142
+ })
3143
+ } catch (error) {
3144
+ console.error("Error creating sync notification:", error)
3145
+ res.status(500).json({
3146
+ error: "Internal server error",
3147
+ code: "INTERNAL_ERROR",
3148
+ message: (error as Error).message,
3149
+ })
3150
+ }
3151
+ }
3152
+ )
3153
+
3154
+ // Get all sync notifications for a course
3155
+ app.get("/courses/:courseSlug/sync-notifications", async (req, res) => {
3156
+ console.log("GET /courses/:courseSlug/sync-notifications")
3157
+ try {
3158
+ const { courseSlug } = req.params
3159
+
3160
+ if (!courseSlug) {
3161
+ return res.status(400).json({ error: "Course slug is required" })
3162
+ }
3163
+
3164
+ const syllabus = await getSyllabus(courseSlug, bucket)
3165
+ if (!syllabus) {
3166
+ return res.status(404).json({ error: "Syllabus not found" })
3167
+ }
3168
+
3169
+ const PROCESSING_TIMEOUT = 3 * 60 * 1000 // 3 minutes
3170
+ let modified = false
3171
+
3172
+ // Collect active notifications (pending, processing, or error)
3173
+ const notifications: any[] = []
3174
+
3175
+ for (const lesson of syllabus.lessons) {
3176
+ if (lesson.syncNotifications && lesson.syncNotifications.length > 0) {
3177
+ for (const notification of lesson.syncNotifications) {
3178
+ // Check for timeout in processing notifications
3179
+ if (notification.status === "processing") {
3180
+ // Use processingLastUpdate if available, otherwise fallback to updatedAt
3181
+ const processingLastUpdateTime =
3182
+ notification.processingLastUpdate || notification.updatedAt
3183
+ const timeSinceProcessingStarted =
3184
+ Date.now() - processingLastUpdateTime
3185
+
3186
+ if (timeSinceProcessingStarted > PROCESSING_TIMEOUT) {
3187
+ notification.status = "error"
3188
+ notification.error = {
3189
+ message: "Synchronization timeout - process took too long",
3190
+ code: "PROCESSING_TIMEOUT",
3191
+ timestamp: Date.now(),
3192
+ }
3193
+ modified = true
3194
+
3195
+ // Emit error event
3196
+ emitToCourse(courseSlug, "sync-notification-error", {
3197
+ exerciseSlug: slugify(lesson.id + "-" + lesson.title),
3198
+ notificationId: notification.id,
3199
+ error: "Processing timeout",
3200
+ })
3201
+ }
3202
+ }
3203
+
3204
+ // Include active notifications (pending, processing, or error)
3205
+ if (
3206
+ notification.status === "pending" ||
3207
+ notification.status === "processing" ||
3208
+ notification.status === "error"
3209
+ ) {
3210
+ notifications.push({
3211
+ ...notification,
3212
+ lessonSlug: slugify(lesson.id + "-" + lesson.title),
3213
+ lessonTitle: lesson.title,
3214
+ })
3215
+ }
3216
+ }
3217
+ }
3218
+ }
3219
+
3220
+ // Save syllabus if any notification was modified
3221
+ if (modified) {
3222
+ await saveSyllabus(courseSlug, syllabus, bucket)
3223
+ }
3224
+
3225
+ res.json({ notifications })
3226
+ } catch (error) {
3227
+ console.error("Error fetching sync notifications:", error)
3228
+ res.status(500).json({
3229
+ error: "Error fetching sync notifications",
3230
+ message: (error as Error).message,
3231
+ })
3232
+ }
3233
+ })
3234
+
3235
+ // Dismiss sync notification
3236
+ app.delete(
3237
+ "/courses/:courseSlug/lessons/:lessonSlug/sync-notification/:notificationId",
3238
+ async (req, res) => {
3239
+ console.log(
3240
+ "DELETE /courses/:courseSlug/lessons/:lessonSlug/sync-notification/:notificationId"
3241
+ )
3242
+ try {
3243
+ const { courseSlug, lessonSlug, notificationId } = req.params
3244
+
3245
+ if (!courseSlug || !lessonSlug || !notificationId) {
3246
+ return res
3247
+ .status(400)
3248
+ .json({ error: "Missing required parameters" })
3249
+ }
3250
+
3251
+ const syllabus = await getSyllabus(courseSlug, bucket)
3252
+ if (!syllabus) {
3253
+ return res.status(404).json({ error: "Syllabus not found" })
3254
+ }
3255
+
3256
+ // Find lesson
3257
+ const lessonIndex = syllabus.lessons.findIndex(
3258
+ lesson => slugify(lesson.id + "-" + lesson.title) === lessonSlug
3259
+ )
3260
+
3261
+ if (lessonIndex === -1) {
3262
+ return res.status(404).json({ error: "Lesson not found" })
3263
+ }
3264
+
3265
+ const lesson = syllabus.lessons[lessonIndex]
3266
+
3267
+ if (
3268
+ !lesson.syncNotifications ||
3269
+ lesson.syncNotifications.length === 0
3270
+ ) {
3271
+ return res.status(404).json({ error: "Notification not found" })
3272
+ }
3273
+
3274
+ const notificationExists = lesson.syncNotifications.some(
3275
+ n => n.id === notificationId
3276
+ )
3277
+
3278
+ if (!notificationExists) {
3279
+ return res.status(404).json({ error: "Notification not found" })
3280
+ }
3281
+
3282
+ // Filter out the notification
3283
+ lesson.syncNotifications = lesson.syncNotifications.filter(
3284
+ n => n.id !== notificationId
3285
+ )
3286
+
3287
+ await saveSyllabus(courseSlug, syllabus, bucket)
3288
+
3289
+ res.json({ status: "SUCCESS", message: "Notification dismissed" })
3290
+ } catch (error) {
3291
+ console.error("Error dismissing notification:", error)
3292
+ res.status(500).json({
3293
+ error: "Error dismissing notification",
3294
+ message: (error as Error).message,
3295
+ })
3296
+ }
3297
+ }
3298
+ )
3299
+
3300
+ // Accept sync notification and start synchronization
3301
+ app.post(
3302
+ "/courses/:courseSlug/lessons/:exerciseSlug/sync-notification/:notificationId/accept",
3303
+ express.json(),
3304
+ async (req, res) => {
3305
+ console.log(
3306
+ "POST /courses/:courseSlug/lessons/:exerciseSlug/sync-notification/:notificationId/accept"
3307
+ )
3308
+ const { courseSlug, exerciseSlug, notificationId } = req.params
3309
+ const rigoToken = req.header("x-rigo-token")
3310
+
3311
+ try {
3312
+ if (!rigoToken) {
3313
+ return res.status(400).json({ error: "Rigo token not found" })
3314
+ }
3315
+
3316
+ if (!courseSlug || !exerciseSlug || !notificationId) {
3317
+ return res
3318
+ .status(400)
3319
+ .json({ error: "Missing required parameters" })
3320
+ }
3321
+
3322
+ const syllabus = await getSyllabus(courseSlug, bucket)
3323
+ if (!syllabus) {
3324
+ return res.status(404).json({ error: "Syllabus not found" })
3325
+ }
3326
+
3327
+ // Find lesson
3328
+ const lessonIndex = syllabus.lessons.findIndex(
3329
+ lesson => slugify(lesson.id + "-" + lesson.title) === exerciseSlug
3330
+ )
3331
+
3332
+ if (lessonIndex === -1) {
3333
+ return res.status(404).json({ error: "Lesson not found" })
3334
+ }
3335
+
3336
+ const lesson = syllabus.lessons[lessonIndex]
3337
+
3338
+ // Find notification
3339
+ const notification = lesson.syncNotifications?.find(
3340
+ n => n.id === notificationId
3341
+ )
3342
+
3343
+ if (!notification) {
3344
+ return res.status(404).json({ error: "Notification not found" })
3345
+ }
3346
+
3347
+ if (notification.status !== "pending") {
3348
+ return res.status(400).json({
3349
+ error: "Notification is not pending",
3350
+ currentStatus: notification.status,
3351
+ })
3352
+ }
3353
+
3354
+ // Get source README
3355
+ const sourceReadmePath = `courses/${courseSlug}/exercises/${exerciseSlug}/README${getReadmeExtension(
3356
+ notification.sourceLanguage
3357
+ )}`
3358
+ let sourceReadmeContent: string
3359
+
3360
+ try {
3361
+ const [content] = await bucket.file(sourceReadmePath).download()
3362
+ sourceReadmeContent = content.toString()
3363
+ } catch (error) {
3364
+ console.error("Source README not found:", error)
3365
+ return res.status(404).json({
3366
+ error: "Source README not found or inaccessible",
3367
+ code: "SOURCE_README_ERROR",
3368
+ })
3369
+ }
3370
+
3371
+ // Determine target languages
3372
+ const availableLanguages = Object.keys(lesson.translations || {})
3373
+ const targetLanguages = availableLanguages.filter(
3374
+ lang => lang !== notification.sourceLanguage
3375
+ )
3376
+
3377
+ if (targetLanguages.length === 0) {
3378
+ return res.status(400).json({
3379
+ error: "No target languages found",
3380
+ code: "NO_TARGET_LANGUAGES",
3381
+ })
3382
+ }
3383
+
3384
+ // Remove all other notifications for this lesson
3385
+ lesson.syncNotifications =
3386
+ lesson.syncNotifications?.filter(n => n.id === notificationId) ||
3387
+ []
3388
+
3389
+ notification.status = "processing"
3390
+ notification.processingLastUpdate = Date.now()
3391
+ notification.syncProgress = {
3392
+ totalLanguages: targetLanguages.length,
3393
+ completedLanguages: [], // Array of completed language codes
3394
+ failedLanguages: [],
3395
+ }
3396
+
3397
+ await saveSyllabus(courseSlug, syllabus, bucket)
3398
+
3399
+ emitToCourse(courseSlug, "sync-notification-started", {
3400
+ exerciseSlug,
3401
+ notificationId,
3402
+ totalLanguages: targetLanguages.length,
3403
+ })
3404
+
3405
+ // Respond immediately
3406
+ res.json({
3407
+ status: "PROCESSING",
3408
+ notificationId,
3409
+ targetLanguages: targetLanguages.length,
3410
+ })
3411
+
3412
+ // Process translations sequentially (no race conditions)
3413
+ processSyncTranslationsSequentially(
3414
+ courseSlug,
3415
+ exerciseSlug,
3416
+ notificationId,
3417
+ sourceReadmeContent,
3418
+ targetLanguages,
3419
+ rigoToken,
3420
+ bucket
3421
+ ).catch(error => {
3422
+ console.error("Error in sync translation processing:", error)
3423
+
3424
+ // Update notification with critical error
3425
+ getSyllabus(courseSlug, bucket).then(syl => {
3426
+ const les = syl.lessons.find(
3427
+ l => slugify(l.id + "-" + l.title) === exerciseSlug
3428
+ )
3429
+ const notif = les?.syncNotifications?.find(
3430
+ n => n.id === notificationId
3431
+ )
3432
+
3433
+ if (notif) {
3434
+ notif.status = "error"
3435
+ notif.error = {
3436
+ message: (error as Error).message,
3437
+ code: "CRITICAL_ERROR",
3438
+ timestamp: Date.now(),
3439
+ }
3440
+ saveSyllabus(courseSlug, syl, bucket)
3441
+
3442
+ emitToCourse(courseSlug, "sync-notification-error", {
3443
+ exerciseSlug,
3444
+ notificationId,
3445
+ error: (error as Error).message,
3446
+ })
3447
+ }
3448
+ })
3449
+ })
3450
+ } catch (error) {
3451
+ console.error("Error accepting sync notification:", error)
3452
+ res.status(500).json({
3453
+ error: "Internal server error",
3454
+ message: (error as Error).message,
3455
+ })
3456
+ }
3457
+ }
3458
+ )
3459
+
3460
+ // Update lesson status to DONE
3461
+ app.put(
3462
+ "/courses/:courseSlug/lessons/:exerciseSlug/status",
3463
+ express.json(),
3464
+ asyncHandler(async (req, res) => {
3465
+ console.log("PUT /courses/:courseSlug/lessons/:exerciseSlug/status")
3466
+ const { courseSlug, exerciseSlug } = req.params
3467
+ const rigoToken = req.header("x-rigo-token")
3468
+
3469
+ if (!rigoToken) {
3470
+ throw new ValidationError(
3471
+ "Rigo token is required. x-rigo-token header is missing"
3472
+ )
3473
+ }
3474
+
3475
+ if (!courseSlug || !exerciseSlug) {
3476
+ throw new ValidationError(
3477
+ "Course slug and exercise slug are required"
3478
+ )
3479
+ }
3480
+
3481
+ // Verify authorization
3482
+ const { isAuthor } = await isPackageAuthor(rigoToken, courseSlug)
3483
+ if (!isAuthor) {
3484
+ throw new ValidationError(
3485
+ "You are not authorized to update lesson status for this course"
3486
+ )
3487
+ }
3488
+
3489
+ // Update lesson status to DONE
3490
+ await updateLessonStatusToDone(courseSlug, exerciseSlug, bucket)
3491
+
3492
+ // Emit WebSocket event to notify frontend
3493
+ emitToCourse(courseSlug, "lesson-status-updated", {
3494
+ exerciseSlug,
3495
+ status: "DONE",
3496
+ })
3497
+
3498
+ res.json({
3499
+ status: "SUCCESS",
3500
+ message: `Lesson ${exerciseSlug} status updated to DONE`,
3501
+ })
3502
+ })
3503
+ )
3504
+
2476
3505
  app.delete("/exercise/:slug/delete", async (req, res) => {
2477
3506
  console.log("DELETE /exercise/:slug/delete")
2478
3507