@learnpack/learnpack 5.0.323 → 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.
@@ -33,6 +33,7 @@ const sidebarGenerator_1 = require("../utils/sidebarGenerator");
33
33
  const publish_1 = require("./publish");
34
34
  const export_1 = require("../utils/export");
35
35
  const errorHandler_1 = require("../utils/errorHandler");
36
+ const jsdom_1 = require("jsdom");
36
37
  const frontMatter = require("front-matter");
37
38
  if (process.env.NEW_RELIC_ENABLED === "true") {
38
39
  require("newrelic");
@@ -265,6 +266,7 @@ async function startInteractivityGeneration(rigoToken, steps, packageContext, ex
265
266
  initial_lesson: exercise.initialContent + `-${randomCacheEvict}`,
266
267
  output_language: packageContext.language || "en",
267
268
  current_syllabus: JSON.stringify(fullSyllabus),
269
+ lesson_info: JSON.stringify(lessonCleaner(exercise)),
268
270
  }, webhookUrl);
269
271
  return res.id;
270
272
  }
@@ -357,6 +359,43 @@ async function updateLessonStatusToError(courseSlug, lessonUID, bucket) {
357
359
  console.error(`Error updating lesson ${lessonUID} status to ERROR:`, error);
358
360
  }
359
361
  }
362
+ async function updateLessonStatusToDone(courseSlug, exerciseSlug, bucket) {
363
+ try {
364
+ const syllabus = await getSyllabus(courseSlug, bucket);
365
+ const lessonIndex = syllabus.lessons.findIndex(lesson => (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title) === exerciseSlug);
366
+ if (lessonIndex === -1) {
367
+ throw new errorHandler_1.NotFoundError(`Lesson with slug "${exerciseSlug}" not found in course "${courseSlug}"`);
368
+ }
369
+ const lesson = syllabus.lessons[lessonIndex];
370
+ // Update lesson status to DONE
371
+ lesson.status = "DONE";
372
+ lesson.generated = true;
373
+ // Update translations to mark as completed
374
+ const currentTranslations = lesson.translations || {};
375
+ const language = syllabus.courseInfo.language || "en";
376
+ if (currentTranslations[language]) {
377
+ currentTranslations[language].completedAt = Date.now();
378
+ // Clear error flag if it existed
379
+ if (currentTranslations[language].error) {
380
+ delete currentTranslations[language].error;
381
+ }
382
+ }
383
+ else {
384
+ currentTranslations[language] = {
385
+ completionId: 0,
386
+ startedAt: Date.now(),
387
+ completedAt: Date.now(),
388
+ };
389
+ }
390
+ lesson.translations = currentTranslations;
391
+ await saveSyllabus(courseSlug, syllabus, bucket);
392
+ console.log(`Updated lesson ${exerciseSlug} status to DONE in course ${courseSlug}`);
393
+ }
394
+ catch (error) {
395
+ console.error(`Error updating lesson ${exerciseSlug} status to DONE:`, error);
396
+ throw error;
397
+ }
398
+ }
360
399
  async function continueWithNextLesson(courseSlug, currentExerciseIndex, rigoToken, finalContent, bucket) {
361
400
  const syllabus = await getSyllabus(courseSlug, bucket);
362
401
  const nextExercise = syllabus.lessons[currentExerciseIndex + 1] || null;
@@ -427,6 +466,125 @@ const getTitleFromHTML = (html) => {
427
466
  const titleMatch = html.match(titleRegex);
428
467
  return titleMatch ? titleMatch[1] : null;
429
468
  };
469
+ async function processSyncTranslationsSequentially(courseSlug, exerciseSlug, notificationId, sourceReadmeContent, targetLanguages, rigoToken, bucket) {
470
+ var _a, _b, _c, _d, _e, _f;
471
+ try {
472
+ // Process translations sequentially (no race conditions)
473
+ for (const targetLang of targetLanguages) {
474
+ try {
475
+ // Call Rigobot directly with synchronous execution (no webhook needed)
476
+ // eslint-disable-next-line no-await-in-loop
477
+ const response = await axios_1.default.post(`${api_1.RIGOBOT_HOST}/v1/prompting/completion/translate-asset-markdown/`, {
478
+ inputs: {
479
+ text_to_translate: sourceReadmeContent,
480
+ output_language: targetLang,
481
+ },
482
+ include_purpose_objective: false,
483
+ execute_async: false, // Synchronous execution
484
+ }, {
485
+ headers: {
486
+ "Content-Type": "application/json",
487
+ Authorization: "Token " + rigoToken,
488
+ },
489
+ });
490
+ const translationResult = response.data;
491
+ // Check if translation was successful
492
+ if (!((_a = translationResult.parsed) === null || _a === void 0 ? void 0 : _a.translation)) {
493
+ throw new Error("Translation result is empty");
494
+ }
495
+ // Save translated README
496
+ const readmePath = `courses/${courseSlug}/exercises/${exerciseSlug}/README${(0, creatorUtilities_1.getReadmeExtension)(targetLang)}`;
497
+ // eslint-disable-next-line no-await-in-loop
498
+ await bucket
499
+ .file(readmePath)
500
+ .save(translationResult.parsed.translation);
501
+ // Update progress in syllabus
502
+ // eslint-disable-next-line no-await-in-loop
503
+ const syllabus = await getSyllabus(courseSlug, bucket);
504
+ const lessonIndex = syllabus.lessons.findIndex(lesson => (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title) === exerciseSlug);
505
+ if (lessonIndex !== -1) {
506
+ const lesson = syllabus.lessons[lessonIndex];
507
+ const notification = (_b = lesson.syncNotifications) === null || _b === void 0 ? void 0 : _b.find(n => n.id === notificationId);
508
+ if (notification === null || notification === void 0 ? void 0 : notification.syncProgress) {
509
+ notification.syncProgress.completedLanguages.push(targetLang);
510
+ notification.processingLastUpdate = Date.now();
511
+ const progress = notification.syncProgress.completedLanguages.length;
512
+ const total = notification.syncProgress.totalLanguages;
513
+ console.log(`🔄 SYNC: Progress ${progress}/${total} - Completed: [${notification.syncProgress.completedLanguages.join(", ")}]`);
514
+ // eslint-disable-next-line no-await-in-loop
515
+ await saveSyllabus(courseSlug, syllabus, bucket);
516
+ // Emit progress event
517
+ (0, creatorSocket_1.emitToCourse)(courseSlug, "sync-notification-progress", {
518
+ exerciseSlug,
519
+ notificationId,
520
+ completed: progress,
521
+ total,
522
+ });
523
+ }
524
+ }
525
+ }
526
+ catch (langError) {
527
+ // Translation failed for this language
528
+ console.error(`🔄 SYNC ERROR: Translation failed for ${targetLang}:`, langError);
529
+ // Update syllabus with failed language
530
+ // eslint-disable-next-line no-await-in-loop
531
+ const syllabus = await getSyllabus(courseSlug, bucket);
532
+ const lessonIndex = syllabus.lessons.findIndex(lesson => (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title) === exerciseSlug);
533
+ if (lessonIndex !== -1) {
534
+ const lesson = syllabus.lessons[lessonIndex];
535
+ const notification = (_c = lesson.syncNotifications) === null || _c === void 0 ? void 0 : _c.find(n => n.id === notificationId);
536
+ if (notification === null || notification === void 0 ? void 0 : notification.syncProgress) {
537
+ if (!notification.syncProgress.failedLanguages) {
538
+ notification.syncProgress.failedLanguages = [];
539
+ }
540
+ notification.syncProgress.failedLanguages.push({
541
+ code: targetLang,
542
+ error: langError.message,
543
+ });
544
+ // eslint-disable-next-line no-await-in-loop
545
+ await saveSyllabus(courseSlug, syllabus, bucket);
546
+ (0, creatorSocket_1.emitToCourse)(courseSlug, "sync-notification-language-failed", {
547
+ exerciseSlug,
548
+ notificationId,
549
+ language: targetLang,
550
+ error: langError.message,
551
+ });
552
+ }
553
+ }
554
+ }
555
+ }
556
+ // All translations processed - check final status
557
+ const syllabus = await getSyllabus(courseSlug, bucket);
558
+ const lessonIndex = syllabus.lessons.findIndex(lesson => (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title) === exerciseSlug);
559
+ if (lessonIndex !== -1) {
560
+ const lesson = syllabus.lessons[lessonIndex];
561
+ const notification = (_d = lesson.syncNotifications) === null || _d === void 0 ? void 0 : _d.find(n => n.id === notificationId);
562
+ if (notification === null || notification === void 0 ? void 0 : notification.syncProgress) {
563
+ const totalProcessed = notification.syncProgress.completedLanguages.length +
564
+ (((_e = notification.syncProgress.failedLanguages) === null || _e === void 0 ? void 0 : _e.length) || 0);
565
+ if (totalProcessed === notification.syncProgress.totalLanguages) {
566
+ notification.status =
567
+ notification.syncProgress.completedLanguages.length === 0 ?
568
+ "error" :
569
+ "completed";
570
+ await saveSyllabus(courseSlug, syllabus, bucket);
571
+ console.log(`🔄 SYNC: ✅ All translations completed for ${exerciseSlug} - Status: ${notification.status}`);
572
+ (0, creatorSocket_1.emitToCourse)(courseSlug, "sync-notification-completed", {
573
+ exerciseSlug,
574
+ notificationId,
575
+ status: notification.status,
576
+ completed: notification.syncProgress.completedLanguages.length,
577
+ failed: ((_f = notification.syncProgress.failedLanguages) === null || _f === void 0 ? void 0 : _f.length) || 0,
578
+ });
579
+ }
580
+ }
581
+ }
582
+ }
583
+ catch (error) {
584
+ console.error("🔄 SYNC ERROR: Critical error in processSyncTranslationsSequentially:", error);
585
+ throw error;
586
+ }
587
+ }
430
588
  class ServeCommand extends SessionCommand_1.default {
431
589
  async init() {
432
590
  const { flags } = this.parse(ServeCommand);
@@ -501,6 +659,43 @@ class ServeCommand extends SessionCommand_1.default {
501
659
  stream.end(buffer);
502
660
  });
503
661
  const upload = (0, misc_1.createUploadMiddleware)();
662
+ // Initialize DOMPurify for SVG sanitization
663
+ // eslint-disable-next-line
664
+ const createDOMPurify = require("isomorphic-dompurify");
665
+ const window = new jsdom_1.JSDOM("").window;
666
+ const DOMPurify = createDOMPurify(window);
667
+ const sanitizeSVGBuffer = (buffer) => {
668
+ try {
669
+ const svgContent = buffer.toString("utf-8");
670
+ const config = {
671
+ USE_PROFILES: { svg: true, svgFilters: true },
672
+ FORBID_TAGS: ["script", "iframe", "embed", "object", "foreignObject"],
673
+ FORBID_ATTR: [
674
+ "onerror",
675
+ "onload",
676
+ "onclick",
677
+ "onmouseover",
678
+ "onmouseout",
679
+ "onanimationend",
680
+ "onanimationstart",
681
+ "ontransitionend",
682
+ "onfocus",
683
+ "onblur",
684
+ ],
685
+ ALLOW_DATA_ATTR: false, // Block data-* attributes that might contain code
686
+ };
687
+ const sanitized = DOMPurify.sanitize(svgContent, config);
688
+ // Validate that sanitization didn't remove all content
689
+ if (!sanitized || sanitized.trim().length === 0) {
690
+ throw new Error("SVG file is invalid or empty after sanitization");
691
+ }
692
+ return buffer_1.Buffer.from(sanitized, "utf-8");
693
+ }
694
+ catch (error) {
695
+ console.error("Error sanitizing SVG:", error);
696
+ throw new Error(`SVG sanitization failed: ${error.message}`);
697
+ }
698
+ };
504
699
  app.post("/upload-image-file", upload.single("file"), async (req, res) => {
505
700
  console.log("INFO: Uploading image file");
506
701
  const destination = req.body.destination;
@@ -514,13 +709,36 @@ class ServeCommand extends SessionCommand_1.default {
514
709
  try {
515
710
  // eslint-disable-next-line
516
711
  // @ts-ignore
517
- const fileData = req.file.buffer;
712
+ let fileData = req.file.buffer;
713
+ // Sanitize if it's an SVG file
714
+ // eslint-disable-next-line
715
+ // @ts-ignore
716
+ if (
717
+ // eslint-disable-next-line
718
+ // @ts-ignore
719
+ req.file.mimetype === "image/svg+xml" ||
720
+ destination.toLowerCase().endsWith(".svg")) {
721
+ try {
722
+ fileData = sanitizeSVGBuffer(fileData);
723
+ console.log("INFO: SVG file sanitized successfully");
724
+ }
725
+ catch (sanitizeError) {
726
+ console.error("Error sanitizing SVG:", sanitizeError);
727
+ return res.status(400).json({
728
+ error: "Invalid SVG file or sanitization failed",
729
+ });
730
+ }
731
+ }
518
732
  const file = bucket.file(destination);
519
733
  await file.save(fileData, {
520
734
  resumable: false,
521
735
  // eslint-disable-next-line
522
736
  // @ts-ignore
523
737
  contentType: req.file.mimetype,
738
+ metadata: {
739
+ cacheControl: "public, max-age=31536000",
740
+ contentDisposition: "inline",
741
+ },
524
742
  });
525
743
  console.log(`INFO: Image uploaded to ${file.name}`);
526
744
  res.json({ message: "Image uploaded successfully", path: file.name });
@@ -1285,18 +1503,129 @@ class ServeCommand extends SessionCommand_1.default {
1285
1503
  });
1286
1504
  // The following endpoint is used to store an incoming translation where it supposed to be
1287
1505
  app.post("/webhooks/:courseSlug/:exSlug/save-translation", async (req, res) => {
1506
+ var _a, _b, _c, _d;
1288
1507
  const { courseSlug, exSlug } = req.params;
1289
1508
  const body = req.body;
1290
1509
  console.log("RECEIVING TRANSLATION WEBHOOK", body);
1291
- const readmePath = `courses/${courseSlug}/exercises/${exSlug}/README${(0, creatorUtilities_1.getReadmeExtension)(body.parsed.output_language_code)}`;
1292
- await uploadFileToBucket(bucket, body.parsed.translation, readmePath);
1293
- (0, creatorSocket_1.emitToCourse)(courseSlug, "translation-completed", {
1294
- exercise: exSlug,
1295
- language: body.parsed.output_language_code,
1296
- status: "completed",
1297
- message: `Translation completed for ${exSlug} to ${body.parsed.output_language_code}`,
1298
- });
1299
- res.json({ status: "SUCCESS" });
1510
+ try {
1511
+ // Check if there's an error from Rigobot
1512
+ if (body.error || body.status === "ERROR") {
1513
+ console.error("Translation failed for", exSlug, body.error);
1514
+ const language = ((_a = body.parsed) === null || _a === void 0 ? void 0 : _a.output_language_code) || body.language;
1515
+ // Update syllabus with error status
1516
+ if (language) {
1517
+ try {
1518
+ const syllabus = await getSyllabus(courseSlug, bucket);
1519
+ const lessonIndex = syllabus.lessons.findIndex(lesson => (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title) === exSlug);
1520
+ if (lessonIndex !== -1) {
1521
+ const lesson = syllabus.lessons[lessonIndex];
1522
+ if (!lesson.translations) {
1523
+ lesson.translations = {};
1524
+ }
1525
+ if (lesson.translations[language]) {
1526
+ lesson.translations[language].completedAt = Date.now();
1527
+ lesson.translations[language].error = true;
1528
+ }
1529
+ else {
1530
+ // Create entry if it doesn't exist
1531
+ lesson.translations[language] = {
1532
+ completionId: 0,
1533
+ startedAt: Date.now(),
1534
+ completedAt: Date.now(),
1535
+ error: true,
1536
+ };
1537
+ }
1538
+ }
1539
+ await saveSyllabus(courseSlug, syllabus, bucket);
1540
+ }
1541
+ catch (syllabusError) {
1542
+ console.error("Error updating syllabus with error status:", syllabusError);
1543
+ }
1544
+ }
1545
+ // Notify frontend via WebSocket
1546
+ (0, creatorSocket_1.emitToCourse)(courseSlug, "translation-error", {
1547
+ exercise: exSlug,
1548
+ language: language,
1549
+ error: body.error || "Translation failed",
1550
+ });
1551
+ return res.status(500).json({ status: "ERROR", error: body.error });
1552
+ }
1553
+ // Validate required data
1554
+ if (!((_b = body.parsed) === null || _b === void 0 ? void 0 : _b.translation) || !((_c = body.parsed) === null || _c === void 0 ? void 0 : _c.output_language_code)) {
1555
+ console.error("Missing required translation data", body);
1556
+ return res.status(400).json({
1557
+ status: "ERROR",
1558
+ error: "Missing translation or language code",
1559
+ });
1560
+ }
1561
+ // Translation successful
1562
+ const readmePath = `courses/${courseSlug}/exercises/${exSlug}/README${(0, creatorUtilities_1.getReadmeExtension)(body.parsed.output_language_code)}`;
1563
+ await uploadFileToBucket(bucket, body.parsed.translation, readmePath);
1564
+ // Verify file exists before updating syllabus (resilience: ensure file was actually saved)
1565
+ const [fileExists] = await bucket.file(readmePath).exists();
1566
+ // Update syllabus with completed status only if file was successfully saved
1567
+ if (fileExists) {
1568
+ try {
1569
+ const syllabus = await getSyllabus(courseSlug, bucket);
1570
+ const lessonIndex = syllabus.lessons.findIndex(lesson => (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title) === exSlug);
1571
+ if (lessonIndex !== -1) {
1572
+ const lesson = syllabus.lessons[lessonIndex];
1573
+ const language = body.parsed.output_language_code;
1574
+ if (!lesson.translations) {
1575
+ lesson.translations = {};
1576
+ }
1577
+ if (lesson.translations[language]) {
1578
+ lesson.translations[language].completedAt = Date.now();
1579
+ // Clear error flag if it existed
1580
+ if (lesson.translations[language].error) {
1581
+ delete lesson.translations[language].error;
1582
+ }
1583
+ }
1584
+ else {
1585
+ // Create entry if it doesn't exist
1586
+ lesson.translations[language] = {
1587
+ completionId: 0,
1588
+ startedAt: Date.now(),
1589
+ completedAt: Date.now(),
1590
+ };
1591
+ }
1592
+ }
1593
+ await saveSyllabus(courseSlug, syllabus, bucket);
1594
+ }
1595
+ catch (syllabusError) {
1596
+ console.error("Error updating syllabus with completed status:", syllabusError);
1597
+ // File exists but syllabus update failed - frontend will detect file and mark as complete
1598
+ }
1599
+ // Notify frontend via WebSocket ONLY if file exists
1600
+ (0, creatorSocket_1.emitToCourse)(courseSlug, "translation-completed", {
1601
+ exercise: exSlug,
1602
+ language: body.parsed.output_language_code,
1603
+ });
1604
+ }
1605
+ else {
1606
+ console.error(`File ${readmePath} was not found after upload, skipping syllabus update`);
1607
+ // File upload failed - emit error event instead of success
1608
+ (0, creatorSocket_1.emitToCourse)(courseSlug, "translation-error", {
1609
+ exercise: exSlug,
1610
+ language: body.parsed.output_language_code,
1611
+ error: "File upload verification failed",
1612
+ });
1613
+ }
1614
+ res.json({ status: "SUCCESS" });
1615
+ }
1616
+ catch (error) {
1617
+ console.error("Error processing translation webhook:", error);
1618
+ // Notify frontend of error
1619
+ const language = ((_d = body.parsed) === null || _d === void 0 ? void 0 : _d.output_language_code) || body.language || "unknown";
1620
+ (0, creatorSocket_1.emitToCourse)(courseSlug, "translation-error", {
1621
+ exercise: exSlug,
1622
+ language: language,
1623
+ error: error.message,
1624
+ });
1625
+ res
1626
+ .status(500)
1627
+ .json({ status: "ERROR", error: error.message });
1628
+ }
1300
1629
  });
1301
1630
  app.get("/check-preview-image/:slug", async (req, res) => {
1302
1631
  const { slug } = req.params;
@@ -1339,6 +1668,10 @@ class ServeCommand extends SessionCommand_1.default {
1339
1668
  const file = path.resolve(__dirname, "../ui/_app/index.html");
1340
1669
  res.sendFile(file);
1341
1670
  });
1671
+ app.get("/preview/:slug/webview", async (req, res) => {
1672
+ const file = path.resolve(__dirname, "../ui/_app/index.html");
1673
+ res.sendFile(file);
1674
+ });
1342
1675
  app.get("/config", async (req, res) => {
1343
1676
  const courseSlug = req.query.slug;
1344
1677
  // GEt the x-rigo-token
@@ -1407,6 +1740,7 @@ class ServeCommand extends SessionCommand_1.default {
1407
1740
  }
1408
1741
  });
1409
1742
  app.get("/.learn/assets/:file", async (req, res) => {
1743
+ var _a;
1410
1744
  console.log("GET /.learn/assets/:file", req.params.file);
1411
1745
  const { file } = req.params;
1412
1746
  const courseSlug = req.query.slug;
@@ -1419,9 +1753,34 @@ class ServeCommand extends SessionCommand_1.default {
1419
1753
  if (!exists) {
1420
1754
  return res.status(404).send("File not found");
1421
1755
  }
1756
+ // Determine Content-Type based on file extension
1757
+ const ext = (_a = file.split(".").pop()) === null || _a === void 0 ? void 0 : _a.toLowerCase();
1758
+ let contentType = "application/octet-stream";
1759
+ let contentDisposition = `inline; filename="${file}"`; // inline for images
1760
+ switch (ext) {
1761
+ case "svg":
1762
+ contentType = "image/svg+xml";
1763
+ break;
1764
+ case "jpg":
1765
+ case "jpeg":
1766
+ contentType = "image/jpeg";
1767
+ break;
1768
+ case "png":
1769
+ contentType = "image/png";
1770
+ break;
1771
+ case "gif":
1772
+ contentType = "image/gif";
1773
+ break;
1774
+ case "webp":
1775
+ contentType = "image/webp";
1776
+ break;
1777
+ default:
1778
+ // For non-image files, use attachment to force download
1779
+ contentDisposition = `attachment; filename="${file}"`;
1780
+ }
1422
1781
  const fileStream = fileRef.createReadStream();
1423
- res.set("Content-Type", "application/octet-stream");
1424
- res.set("Content-Disposition", `attachment; filename="${file}"`);
1782
+ res.set("Content-Type", contentType);
1783
+ res.set("Content-Disposition", contentDisposition);
1425
1784
  fileStream.pipe(res);
1426
1785
  });
1427
1786
  app.put("/exercise/:slug/file/:fileName", express.text(), (0, errorHandler_1.asyncHandler)(async (req, res) => {
@@ -1539,6 +1898,17 @@ class ServeCommand extends SessionCommand_1.default {
1539
1898
  });
1540
1899
  async function processTranslationsAsync(courseSlug, exerciseSlugs, languageCodes, rigoToken, currentLanguage, bucket) {
1541
1900
  try {
1901
+ // Track which languages already exist vs which are being translated
1902
+ const existingLanguages = new Set();
1903
+ const translatingLanguages = new Set();
1904
+ // Get syllabus to track translation status
1905
+ let syllabus = null;
1906
+ try {
1907
+ syllabus = await getSyllabus(courseSlug, bucket);
1908
+ }
1909
+ catch (_a) {
1910
+ console.log("Syllabus not found, translations will not be tracked in syllabus");
1911
+ }
1542
1912
  (0, creatorSocket_1.emitToCourse)(courseSlug, "translation-started", {
1543
1913
  languages: languageCodes,
1544
1914
  exercises: exerciseSlugs,
@@ -1547,12 +1917,21 @@ class ServeCommand extends SessionCommand_1.default {
1547
1917
  });
1548
1918
  await Promise.all(exerciseSlugs.map(async (slug) => {
1549
1919
  const readmePath = `courses/${courseSlug}/exercises/${slug}/README${(0, creatorUtilities_1.getReadmeExtension)(currentLanguage)}`;
1550
- const readme = await bucket.file(readmePath).download();
1920
+ // Validate that README exists before attempting translation
1921
+ const readmeFile = bucket.file(readmePath);
1922
+ const [readmeExists] = await readmeFile.exists();
1923
+ if (!readmeExists) {
1924
+ console.error(`README not found for exercise ${slug} in language ${currentLanguage}`);
1925
+ return; // Skip this exercise
1926
+ }
1927
+ const [readmeBuffer] = await readmeFile.download();
1928
+ const readme = readmeBuffer.toString();
1551
1929
  await Promise.all(languageCodes.map(async (language) => {
1552
1930
  const translationPath = `courses/${courseSlug}/exercises/${slug}/README${(0, creatorUtilities_1.getReadmeExtension)(language)}`;
1553
1931
  const [exists] = await bucket.file(translationPath).exists();
1554
1932
  if (exists) {
1555
1933
  console.log(`Translation in ${language} already exists for exercise ${slug}`);
1934
+ existingLanguages.add(language);
1556
1935
  (0, creatorSocket_1.emitToCourse)(courseSlug, "translation-progress", {
1557
1936
  exercise: slug,
1558
1937
  language: language,
@@ -1561,10 +1940,29 @@ class ServeCommand extends SessionCommand_1.default {
1561
1940
  });
1562
1941
  return;
1563
1942
  }
1564
- await (0, rigoActions_1.translateExercise)(rigoToken, {
1565
- text_to_translate: readme.toString(),
1943
+ // Call translateExercise first to get the real completion ID
1944
+ const translateResponse = await (0, rigoActions_1.translateExercise)(rigoToken, {
1945
+ text_to_translate: readme,
1566
1946
  output_language: language,
1567
1947
  }, `${process.env.HOST}/webhooks/${courseSlug}/${slug}/save-translation`);
1948
+ // Mark this language as being translated
1949
+ translatingLanguages.add(language);
1950
+ // Update syllabus with translation status if available
1951
+ if (syllabus) {
1952
+ const lessonIndex = syllabus.lessons.findIndex(lesson => (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title) === slug);
1953
+ if (lessonIndex !== -1) {
1954
+ const lesson = syllabus.lessons[lessonIndex];
1955
+ if (!lesson.translations) {
1956
+ lesson.translations = {};
1957
+ }
1958
+ // Use the real completion ID from the response, fallback to Date.now() if not available
1959
+ lesson.translations[language] = {
1960
+ completionId: (translateResponse === null || translateResponse === void 0 ? void 0 : translateResponse.id) || Date.now(),
1961
+ startedAt: Date.now(),
1962
+ completedAt: undefined,
1963
+ };
1964
+ }
1965
+ }
1568
1966
  (0, creatorSocket_1.emitToCourse)(courseSlug, "translation-progress", {
1569
1967
  exercise: slug,
1570
1968
  language: language,
@@ -1573,6 +1971,10 @@ class ServeCommand extends SessionCommand_1.default {
1573
1971
  });
1574
1972
  }));
1575
1973
  }));
1974
+ // Save syllabus with translation status
1975
+ if (syllabus && courseSlug) {
1976
+ await saveSyllabus(courseSlug, syllabus, bucket);
1977
+ }
1576
1978
  const course = await bucket
1577
1979
  .file(`courses/${courseSlug}/learn.json`)
1578
1980
  .download();
@@ -1657,21 +2059,379 @@ class ServeCommand extends SessionCommand_1.default {
1657
2059
  raw_languages: languagesToTranslate.join(","),
1658
2060
  });
1659
2061
  const languageCodes = languageCodesRes.parsed.language_codes;
2062
+ // Pre-calculate which languages already exist vs which need translation
2063
+ // This provides immediate, accurate feedback to the frontend
2064
+ const existingLanguages = new Set();
2065
+ const translatingLanguages = new Set();
2066
+ // Quick check: for each language, check if ALL exercises already have that translation
2067
+ await Promise.all(languageCodes.map(async (language) => {
2068
+ const allExist = await Promise.all(exerciseSlugs.map(async (slug) => {
2069
+ const translationPath = `courses/${courseSlug}/exercises/${slug}/README${(0, creatorUtilities_1.getReadmeExtension)(language)}`;
2070
+ const [exists] = await bucket.file(translationPath).exists();
2071
+ return exists;
2072
+ }));
2073
+ // If all exercises have this language, mark as existing
2074
+ if (allExist.every(exists => exists)) {
2075
+ existingLanguages.add(language);
2076
+ }
2077
+ else {
2078
+ // Otherwise, it will be translated
2079
+ translatingLanguages.add(language);
2080
+ }
2081
+ }));
2082
+ // Convert Sets to Arrays for JSON response
2083
+ const translatingLanguagesList = [...translatingLanguages];
2084
+ const existingLanguagesList = [...existingLanguages];
1660
2085
  res.status(200).json({
1661
2086
  message: "Translation started",
1662
2087
  languages: languageCodes,
1663
2088
  exercises: exerciseSlugs,
1664
2089
  status: "processing",
2090
+ translatingLanguages: translatingLanguagesList,
2091
+ existingLanguages: existingLanguagesList,
1665
2092
  });
1666
- processTranslationsAsync(courseSlug, exerciseSlugs, languageCodes, rigoToken, currentLanguage, bucket).catch(error => {
1667
- console.error("Error in background translation processing:", error);
1668
- });
2093
+ // Only process translations if there are languages to translate
2094
+ if (translatingLanguagesList.length > 0) {
2095
+ processTranslationsAsync(courseSlug, exerciseSlugs, languageCodes, rigoToken, currentLanguage, bucket).catch(error => {
2096
+ console.error("Error in background translation processing:", error);
2097
+ });
2098
+ }
1669
2099
  }
1670
2100
  catch (error) {
1671
2101
  console.log(error, "ERROR");
1672
2102
  return res.status(400).json({ error: error.message });
1673
2103
  }
1674
2104
  });
2105
+ // ============================================
2106
+ // SYNC NOTIFICATIONS ENDPOINTS
2107
+ // ============================================
2108
+ // Create or update sync notification
2109
+ app.post("/courses/:courseSlug/lessons/:exerciseSlug/sync-notification", express.json(), async (req, res) => {
2110
+ console.log("POST /courses/:courseSlug/lessons/:exerciseSlug/sync-notification");
2111
+ const { courseSlug, exerciseSlug } = req.params;
2112
+ const { sourceLanguage } = req.body;
2113
+ try {
2114
+ if (!courseSlug || !exerciseSlug || !sourceLanguage) {
2115
+ return res.status(400).json({
2116
+ error: "Missing required parameters",
2117
+ code: "MISSING_PARAMETERS",
2118
+ });
2119
+ }
2120
+ const syllabus = await getSyllabus(courseSlug, bucket);
2121
+ if (!syllabus) {
2122
+ return res.status(404).json({
2123
+ error: "Syllabus not found",
2124
+ code: "SYLLABUS_NOT_FOUND",
2125
+ });
2126
+ }
2127
+ // Find lesson
2128
+ const lessonIndex = syllabus.lessons.findIndex(lesson => (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title) === exerciseSlug);
2129
+ if (lessonIndex === -1) {
2130
+ return res.status(404).json({
2131
+ error: "Lesson not found",
2132
+ code: "LESSON_NOT_FOUND",
2133
+ });
2134
+ }
2135
+ const lesson = syllabus.lessons[lessonIndex];
2136
+ // Verify source README exists
2137
+ const readmePath = `courses/${courseSlug}/exercises/${exerciseSlug}/README${(0, creatorUtilities_1.getReadmeExtension)(sourceLanguage)}`;
2138
+ const [readmeExists] = await bucket.file(readmePath).exists();
2139
+ if (!readmeExists) {
2140
+ return res.status(404).json({
2141
+ error: "Source README not found",
2142
+ code: "SOURCE_README_NOT_FOUND",
2143
+ details: { sourceLanguage, path: readmePath },
2144
+ });
2145
+ }
2146
+ if (!lesson.syncNotifications) {
2147
+ lesson.syncNotifications = [];
2148
+ }
2149
+ // Find existing notification for this language in pending status
2150
+ const existingNotification = lesson.syncNotifications.find(n => n.sourceLanguage === sourceLanguage && n.status === "pending");
2151
+ let notificationToReturn;
2152
+ if (existingNotification) {
2153
+ // Update timestamp of existing notification
2154
+ existingNotification.updatedAt = Date.now();
2155
+ notificationToReturn = existingNotification;
2156
+ }
2157
+ else {
2158
+ const newNotification = {
2159
+ id: String(Date.now()),
2160
+ sourceLanguage,
2161
+ createdAt: Date.now(),
2162
+ updatedAt: Date.now(),
2163
+ status: "pending",
2164
+ };
2165
+ lesson.syncNotifications.push(newNotification);
2166
+ notificationToReturn = newNotification;
2167
+ }
2168
+ await saveSyllabus(courseSlug, syllabus, bucket);
2169
+ (0, creatorSocket_1.emitToCourse)(courseSlug, "sync-notification-created", {
2170
+ exerciseSlug,
2171
+ sourceLanguage,
2172
+ notificationId: notificationToReturn.id,
2173
+ });
2174
+ res.json({
2175
+ status: "SUCCESS",
2176
+ notification: notificationToReturn,
2177
+ });
2178
+ }
2179
+ catch (error) {
2180
+ console.error("Error creating sync notification:", error);
2181
+ res.status(500).json({
2182
+ error: "Internal server error",
2183
+ code: "INTERNAL_ERROR",
2184
+ message: error.message,
2185
+ });
2186
+ }
2187
+ });
2188
+ // Get all sync notifications for a course
2189
+ app.get("/courses/:courseSlug/sync-notifications", async (req, res) => {
2190
+ console.log("GET /courses/:courseSlug/sync-notifications");
2191
+ try {
2192
+ const { courseSlug } = req.params;
2193
+ if (!courseSlug) {
2194
+ return res.status(400).json({ error: "Course slug is required" });
2195
+ }
2196
+ const syllabus = await getSyllabus(courseSlug, bucket);
2197
+ if (!syllabus) {
2198
+ return res.status(404).json({ error: "Syllabus not found" });
2199
+ }
2200
+ const PROCESSING_TIMEOUT = 3 * 60 * 1000; // 3 minutes
2201
+ let modified = false;
2202
+ // Collect active notifications (pending, processing, or error)
2203
+ const notifications = [];
2204
+ for (const lesson of syllabus.lessons) {
2205
+ if (lesson.syncNotifications && lesson.syncNotifications.length > 0) {
2206
+ for (const notification of lesson.syncNotifications) {
2207
+ // Check for timeout in processing notifications
2208
+ if (notification.status === "processing") {
2209
+ // Use processingLastUpdate if available, otherwise fallback to updatedAt
2210
+ const processingLastUpdateTime = notification.processingLastUpdate || notification.updatedAt;
2211
+ const timeSinceProcessingStarted = Date.now() - processingLastUpdateTime;
2212
+ if (timeSinceProcessingStarted > PROCESSING_TIMEOUT) {
2213
+ notification.status = "error";
2214
+ notification.error = {
2215
+ message: "Synchronization timeout - process took too long",
2216
+ code: "PROCESSING_TIMEOUT",
2217
+ timestamp: Date.now(),
2218
+ };
2219
+ modified = true;
2220
+ // Emit error event
2221
+ (0, creatorSocket_1.emitToCourse)(courseSlug, "sync-notification-error", {
2222
+ exerciseSlug: (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title),
2223
+ notificationId: notification.id,
2224
+ error: "Processing timeout",
2225
+ });
2226
+ }
2227
+ }
2228
+ // Include active notifications (pending, processing, or error)
2229
+ if (notification.status === "pending" ||
2230
+ notification.status === "processing" ||
2231
+ notification.status === "error") {
2232
+ notifications.push(Object.assign(Object.assign({}, notification), { lessonSlug: (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title), lessonTitle: lesson.title }));
2233
+ }
2234
+ }
2235
+ }
2236
+ }
2237
+ // Save syllabus if any notification was modified
2238
+ if (modified) {
2239
+ await saveSyllabus(courseSlug, syllabus, bucket);
2240
+ }
2241
+ res.json({ notifications });
2242
+ }
2243
+ catch (error) {
2244
+ console.error("Error fetching sync notifications:", error);
2245
+ res.status(500).json({
2246
+ error: "Error fetching sync notifications",
2247
+ message: error.message,
2248
+ });
2249
+ }
2250
+ });
2251
+ // Dismiss sync notification
2252
+ app.delete("/courses/:courseSlug/lessons/:lessonSlug/sync-notification/:notificationId", async (req, res) => {
2253
+ console.log("DELETE /courses/:courseSlug/lessons/:lessonSlug/sync-notification/:notificationId");
2254
+ try {
2255
+ const { courseSlug, lessonSlug, notificationId } = req.params;
2256
+ if (!courseSlug || !lessonSlug || !notificationId) {
2257
+ return res
2258
+ .status(400)
2259
+ .json({ error: "Missing required parameters" });
2260
+ }
2261
+ const syllabus = await getSyllabus(courseSlug, bucket);
2262
+ if (!syllabus) {
2263
+ return res.status(404).json({ error: "Syllabus not found" });
2264
+ }
2265
+ // Find lesson
2266
+ const lessonIndex = syllabus.lessons.findIndex(lesson => (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title) === lessonSlug);
2267
+ if (lessonIndex === -1) {
2268
+ return res.status(404).json({ error: "Lesson not found" });
2269
+ }
2270
+ const lesson = syllabus.lessons[lessonIndex];
2271
+ if (!lesson.syncNotifications ||
2272
+ lesson.syncNotifications.length === 0) {
2273
+ return res.status(404).json({ error: "Notification not found" });
2274
+ }
2275
+ const notificationExists = lesson.syncNotifications.some(n => n.id === notificationId);
2276
+ if (!notificationExists) {
2277
+ return res.status(404).json({ error: "Notification not found" });
2278
+ }
2279
+ // Filter out the notification
2280
+ lesson.syncNotifications = lesson.syncNotifications.filter(n => n.id !== notificationId);
2281
+ await saveSyllabus(courseSlug, syllabus, bucket);
2282
+ res.json({ status: "SUCCESS", message: "Notification dismissed" });
2283
+ }
2284
+ catch (error) {
2285
+ console.error("Error dismissing notification:", error);
2286
+ res.status(500).json({
2287
+ error: "Error dismissing notification",
2288
+ message: error.message,
2289
+ });
2290
+ }
2291
+ });
2292
+ // Accept sync notification and start synchronization
2293
+ app.post("/courses/:courseSlug/lessons/:exerciseSlug/sync-notification/:notificationId/accept", express.json(), async (req, res) => {
2294
+ var _a, _b;
2295
+ console.log("POST /courses/:courseSlug/lessons/:exerciseSlug/sync-notification/:notificationId/accept");
2296
+ const { courseSlug, exerciseSlug, notificationId } = req.params;
2297
+ const rigoToken = req.header("x-rigo-token");
2298
+ try {
2299
+ if (!rigoToken) {
2300
+ return res.status(400).json({ error: "Rigo token not found" });
2301
+ }
2302
+ if (!courseSlug || !exerciseSlug || !notificationId) {
2303
+ return res
2304
+ .status(400)
2305
+ .json({ error: "Missing required parameters" });
2306
+ }
2307
+ const syllabus = await getSyllabus(courseSlug, bucket);
2308
+ if (!syllabus) {
2309
+ return res.status(404).json({ error: "Syllabus not found" });
2310
+ }
2311
+ // Find lesson
2312
+ const lessonIndex = syllabus.lessons.findIndex(lesson => (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title) === exerciseSlug);
2313
+ if (lessonIndex === -1) {
2314
+ return res.status(404).json({ error: "Lesson not found" });
2315
+ }
2316
+ const lesson = syllabus.lessons[lessonIndex];
2317
+ // Find notification
2318
+ const notification = (_a = lesson.syncNotifications) === null || _a === void 0 ? void 0 : _a.find(n => n.id === notificationId);
2319
+ if (!notification) {
2320
+ return res.status(404).json({ error: "Notification not found" });
2321
+ }
2322
+ if (notification.status !== "pending") {
2323
+ return res.status(400).json({
2324
+ error: "Notification is not pending",
2325
+ currentStatus: notification.status,
2326
+ });
2327
+ }
2328
+ // Get source README
2329
+ const sourceReadmePath = `courses/${courseSlug}/exercises/${exerciseSlug}/README${(0, creatorUtilities_1.getReadmeExtension)(notification.sourceLanguage)}`;
2330
+ let sourceReadmeContent;
2331
+ try {
2332
+ const [content] = await bucket.file(sourceReadmePath).download();
2333
+ sourceReadmeContent = content.toString();
2334
+ }
2335
+ catch (error) {
2336
+ console.error("Source README not found:", error);
2337
+ return res.status(404).json({
2338
+ error: "Source README not found or inaccessible",
2339
+ code: "SOURCE_README_ERROR",
2340
+ });
2341
+ }
2342
+ // Determine target languages
2343
+ const availableLanguages = Object.keys(lesson.translations || {});
2344
+ const targetLanguages = availableLanguages.filter(lang => lang !== notification.sourceLanguage);
2345
+ if (targetLanguages.length === 0) {
2346
+ return res.status(400).json({
2347
+ error: "No target languages found",
2348
+ code: "NO_TARGET_LANGUAGES",
2349
+ });
2350
+ }
2351
+ // Remove all other notifications for this lesson
2352
+ lesson.syncNotifications =
2353
+ ((_b = lesson.syncNotifications) === null || _b === void 0 ? void 0 : _b.filter(n => n.id === notificationId)) ||
2354
+ [];
2355
+ notification.status = "processing";
2356
+ notification.processingLastUpdate = Date.now();
2357
+ notification.syncProgress = {
2358
+ totalLanguages: targetLanguages.length,
2359
+ completedLanguages: [], // Array of completed language codes
2360
+ failedLanguages: [],
2361
+ };
2362
+ await saveSyllabus(courseSlug, syllabus, bucket);
2363
+ (0, creatorSocket_1.emitToCourse)(courseSlug, "sync-notification-started", {
2364
+ exerciseSlug,
2365
+ notificationId,
2366
+ totalLanguages: targetLanguages.length,
2367
+ });
2368
+ // Respond immediately
2369
+ res.json({
2370
+ status: "PROCESSING",
2371
+ notificationId,
2372
+ targetLanguages: targetLanguages.length,
2373
+ });
2374
+ // Process translations sequentially (no race conditions)
2375
+ processSyncTranslationsSequentially(courseSlug, exerciseSlug, notificationId, sourceReadmeContent, targetLanguages, rigoToken, bucket).catch(error => {
2376
+ console.error("Error in sync translation processing:", error);
2377
+ // Update notification with critical error
2378
+ getSyllabus(courseSlug, bucket).then(syl => {
2379
+ var _a;
2380
+ const les = syl.lessons.find(l => (0, creatorUtilities_2.slugify)(l.id + "-" + l.title) === exerciseSlug);
2381
+ const notif = (_a = les === null || les === void 0 ? void 0 : les.syncNotifications) === null || _a === void 0 ? void 0 : _a.find(n => n.id === notificationId);
2382
+ if (notif) {
2383
+ notif.status = "error";
2384
+ notif.error = {
2385
+ message: error.message,
2386
+ code: "CRITICAL_ERROR",
2387
+ timestamp: Date.now(),
2388
+ };
2389
+ saveSyllabus(courseSlug, syl, bucket);
2390
+ (0, creatorSocket_1.emitToCourse)(courseSlug, "sync-notification-error", {
2391
+ exerciseSlug,
2392
+ notificationId,
2393
+ error: error.message,
2394
+ });
2395
+ }
2396
+ });
2397
+ });
2398
+ }
2399
+ catch (error) {
2400
+ console.error("Error accepting sync notification:", error);
2401
+ res.status(500).json({
2402
+ error: "Internal server error",
2403
+ message: error.message,
2404
+ });
2405
+ }
2406
+ });
2407
+ // Update lesson status to DONE
2408
+ app.put("/courses/:courseSlug/lessons/:exerciseSlug/status", express.json(), (0, errorHandler_1.asyncHandler)(async (req, res) => {
2409
+ console.log("PUT /courses/:courseSlug/lessons/:exerciseSlug/status");
2410
+ const { courseSlug, exerciseSlug } = req.params;
2411
+ const rigoToken = req.header("x-rigo-token");
2412
+ if (!rigoToken) {
2413
+ throw new errorHandler_1.ValidationError("Rigo token is required. x-rigo-token header is missing");
2414
+ }
2415
+ if (!courseSlug || !exerciseSlug) {
2416
+ throw new errorHandler_1.ValidationError("Course slug and exercise slug are required");
2417
+ }
2418
+ // Verify authorization
2419
+ const { isAuthor } = await (0, rigoActions_1.isPackageAuthor)(rigoToken, courseSlug);
2420
+ if (!isAuthor) {
2421
+ throw new errorHandler_1.ValidationError("You are not authorized to update lesson status for this course");
2422
+ }
2423
+ // Update lesson status to DONE
2424
+ await updateLessonStatusToDone(courseSlug, exerciseSlug, bucket);
2425
+ // Emit WebSocket event to notify frontend
2426
+ (0, creatorSocket_1.emitToCourse)(courseSlug, "lesson-status-updated", {
2427
+ exerciseSlug,
2428
+ status: "DONE",
2429
+ });
2430
+ res.json({
2431
+ status: "SUCCESS",
2432
+ message: `Lesson ${exerciseSlug} status updated to DONE`,
2433
+ });
2434
+ }));
1675
2435
  app.delete("/exercise/:slug/delete", async (req, res) => {
1676
2436
  console.log("DELETE /exercise/:slug/delete");
1677
2437
  const { slug } = req.params;