@learnpack/learnpack 5.0.335 → 5.0.340

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.
Files changed (48) hide show
  1. package/bin/run +17 -17
  2. package/lib/commands/init.js +41 -41
  3. package/lib/commands/serve.js +589 -126
  4. package/lib/creatorDist/assets/index-BhqDgBS9.js +8448 -78631
  5. package/lib/creatorDist/assets/index-CjddKHB_.css +1 -1688
  6. package/lib/managers/config/exercise.js +2 -14
  7. package/lib/managers/readmeHistoryService.js +3 -1
  8. package/lib/managers/server/routes.js +2 -1
  9. package/lib/utils/configBuilder.js +2 -1
  10. package/lib/utils/creatorUtilities.js +14 -14
  11. package/lib/utils/exerciseFileOrder.d.ts +20 -0
  12. package/lib/utils/exerciseFileOrder.js +49 -0
  13. package/lib/utils/export/epub.js +26 -26
  14. package/lib/utils/readmeSanitizer.d.ts +8 -0
  15. package/lib/utils/readmeSanitizer.js +13 -0
  16. package/lib/utils/templates/epub/epub.css +146 -146
  17. package/lib/utils/templates/scorm/config/api.js +175 -175
  18. package/package.json +1 -1
  19. package/src/commands/init.ts +655 -655
  20. package/src/commands/publish.ts +670 -670
  21. package/src/commands/serve.ts +5853 -5216
  22. package/src/creator/eslint.config.js +28 -28
  23. package/src/creator/src/index.css +227 -227
  24. package/src/creator/src/utils/lib.ts +471 -471
  25. package/src/creatorDist/assets/index-BhqDgBS9.js +8448 -78631
  26. package/src/creatorDist/assets/index-CjddKHB_.css +1 -1688
  27. package/src/managers/config/exercise.ts +3 -15
  28. package/src/managers/readmeHistoryService.ts +3 -1
  29. package/src/managers/server/routes.ts +15 -6
  30. package/src/managers/session.ts +184 -184
  31. package/src/ui/_app/app.css +1 -1
  32. package/src/ui/_app/app.js +1950 -1878
  33. package/src/ui/app.tar.gz +0 -0
  34. package/src/utils/api.ts +675 -675
  35. package/src/utils/configBuilder.ts +102 -100
  36. package/src/utils/creatorUtilities.ts +536 -536
  37. package/src/utils/errors.ts +108 -108
  38. package/src/utils/exerciseFileOrder.ts +50 -0
  39. package/src/utils/export/epub.ts +553 -553
  40. package/src/utils/export/index.ts +4 -4
  41. package/src/utils/export/scorm.ts +121 -121
  42. package/src/utils/export/shared.ts +61 -61
  43. package/src/utils/export/types.ts +25 -25
  44. package/src/utils/export/zip.ts +55 -55
  45. package/src/utils/readmeSanitizer.ts +10 -0
  46. package/src/utils/rigoActions.ts +642 -642
  47. package/src/utils/templates/epub/epub.css +146 -146
  48. package/src/utils/templates/scorm/config/api.js +175 -175
@@ -10,6 +10,7 @@ const path = require("path");
10
10
  const http = require("http");
11
11
  const creatorSocket_1 = require("../utils/creatorSocket");
12
12
  const os = require("os");
13
+ const crypto = require("crypto");
13
14
  const archiver = require("archiver");
14
15
  const mkdirp = require("mkdirp");
15
16
  const html_to_text_1 = require("html-to-text");
@@ -37,11 +38,47 @@ const jsdom_1 = require("jsdom");
37
38
  const redis_1 = require("redis");
38
39
  const historyManager_1 = require("../managers/historyManager");
39
40
  const readmeHistoryService_1 = require("../managers/readmeHistoryService");
41
+ const readmeSanitizer_1 = require("../utils/readmeSanitizer");
40
42
  const frontMatter = require("front-matter");
41
43
  if (process.env.NEW_RELIC_ENABLED === "true") {
42
44
  require("newrelic");
43
45
  }
44
46
  dotenv.config();
47
+ // Observability utilities for syllabus race condition detection
48
+ function syllabusHash(syllabus) {
49
+ const content = JSON.stringify(syllabus);
50
+ return crypto.createHash("md5").update(content).digest("hex").slice(0, 8);
51
+ }
52
+ function translationsSummary(lesson) {
53
+ if (!(lesson === null || lesson === void 0 ? void 0 : lesson.translations))
54
+ return "{}";
55
+ const summary = {};
56
+ for (const [lang, data] of Object.entries(lesson.translations)) {
57
+ summary[lang] = data.completedAt ? "done" : "pending";
58
+ }
59
+ return JSON.stringify(summary);
60
+ }
61
+ function opId() {
62
+ return crypto.randomBytes(4).toString("hex");
63
+ }
64
+ // Serialization per course to prevent syllabus race conditions
65
+ const courseQueues = new Map();
66
+ async function serializeByCourse(courseSlug, fn) {
67
+ var _a;
68
+ const prev = (_a = courseQueues.get(courseSlug)) !== null && _a !== void 0 ? _a : Promise.resolve();
69
+ const next = prev
70
+ .then(() => fn())
71
+ .catch(error => {
72
+ throw error;
73
+ })
74
+ .finally(() => {
75
+ if (courseQueues.get(courseSlug) === next) {
76
+ courseQueues.delete(courseSlug);
77
+ }
78
+ });
79
+ courseQueues.set(courseSlug, next);
80
+ return next;
81
+ }
45
82
  const createLearnJson = (courseInfo) => {
46
83
  // console.log("courseInfo to create learn json", courseInfo)
47
84
  const expectedPreviewUrl = `https://${courseInfo.slug}.learn-pack.com/preview.png`;
@@ -66,8 +103,10 @@ const createLearnJson = (courseInfo) => {
66
103
  };
67
104
  exports.createLearnJson = createLearnJson;
68
105
  const uploadFileToBucket = async (bucket, file, path) => {
106
+ const isReadme = /readme(\.\w+)?\.md$/i.test(path);
107
+ const content = isReadme ? (0, readmeSanitizer_1.sanitizeReadmeNewlines)(file) : file;
69
108
  const fileRef = bucket.file(path);
70
- await fileRef.save(buffer_1.Buffer.from(file, "utf8"));
109
+ await fileRef.save(buffer_1.Buffer.from(content, "utf8"));
71
110
  };
72
111
  const PARAMS = {
73
112
  expected_grade_level: "8",
@@ -95,18 +134,18 @@ async function fetchComponentsYml() {
95
134
  axios_1.default.get("https://raw.githubusercontent.com/learnpack/ide/refs/heads/master/docs/assessment_components.yml"),
96
135
  axios_1.default.get("https://raw.githubusercontent.com/learnpack/ide/refs/heads/master/docs/explanatory_components.yml"),
97
136
  ]);
98
- const combinedContent = `
99
- # ASSESSMENT COMPONENTS
100
- These components are designed for evaluation and knowledge assessment:
101
-
102
- ${assessmentResponse.data}
103
-
104
- ---
105
-
106
- # EXPLANATORY COMPONENTS
107
- These components are designed for explanation and learning support:
108
-
109
- ${explanatoryResponse.data}
137
+ const combinedContent = `
138
+ # ASSESSMENT COMPONENTS
139
+ These components are designed for evaluation and knowledge assessment:
140
+
141
+ ${assessmentResponse.data}
142
+
143
+ ---
144
+
145
+ # EXPLANATORY COMPONENTS
146
+ These components are designed for explanation and learning support:
147
+
148
+ ${explanatoryResponse.data}
110
149
  `;
111
150
  return combinedContent;
112
151
  }
@@ -140,10 +179,10 @@ const createInitialSidebar = async (slugs, initialLanguage = "en") => {
140
179
  return sidebar;
141
180
  };
142
181
  const uploadInitialReadme = async (bucket, exSlug, targetDir, packageContext) => {
143
- const isGeneratingText = `
144
- \`\`\`loader slug="${exSlug}"
145
- :rigo
146
- \`\`\`
182
+ const isGeneratingText = `
183
+ \`\`\`loader slug="${exSlug}"
184
+ :rigo
185
+ \`\`\`
147
186
  `;
148
187
  const readmeFilename = `README${(0, creatorUtilities_1.getReadmeExtension)(packageContext.language || "en")}`;
149
188
  await uploadFileToBucket(bucket, isGeneratingText, `${targetDir}/${readmeFilename}`);
@@ -327,9 +366,51 @@ async function getSyllabus(courseSlug, bucket) {
327
366
  const [content] = await syllabus.download();
328
367
  return JSON.parse(content.toString());
329
368
  }
369
+ function sanitizeSyllabusFromUnknown(syllabus) {
370
+ var _a;
371
+ for (const lesson of (_a = syllabus.lessons) !== null && _a !== void 0 ? _a : []) {
372
+ if (lesson.translations && "unknown" in lesson.translations) {
373
+ delete lesson.translations.unknown;
374
+ }
375
+ }
376
+ }
330
377
  async function saveSyllabus(courseSlug, syllabus, bucket) {
378
+ sanitizeSyllabusFromUnknown(syllabus);
331
379
  await uploadFileToBucket(bucket, JSON.stringify(syllabus), `courses/${courseSlug}/.learn/initialSyllabus.json`);
332
380
  }
381
+ // Instrumented syllabus operations for race condition observability
382
+ async function getSyllabusWithLog(courseSlug, bucket, context) {
383
+ const readAt = Date.now();
384
+ const syllabus = await getSyllabus(courseSlug, bucket);
385
+ const hash = syllabusHash(syllabus);
386
+ console.log("[SYLLABUS_READ]", {
387
+ op: context.op,
388
+ courseSlug,
389
+ exSlug: context.exSlug,
390
+ lang: context.lang,
391
+ hash,
392
+ readAt,
393
+ lessonsCount: syllabus.lessons.length,
394
+ });
395
+ return { syllabus, hash, readAt };
396
+ }
397
+ async function saveSyllabusWithLog(courseSlug, syllabus, bucket, context) {
398
+ const writeAt = Date.now();
399
+ const newHash = syllabusHash(syllabus);
400
+ const elapsedMs = writeAt - context.readAt;
401
+ await saveSyllabus(courseSlug, syllabus, bucket);
402
+ console.log("[SYLLABUS_WRITE]", {
403
+ op: context.op,
404
+ courseSlug,
405
+ exSlug: context.exSlug,
406
+ lang: context.lang,
407
+ readHash: context.readHash,
408
+ newHash,
409
+ writeAt,
410
+ elapsedMs,
411
+ });
412
+ return { success: true, newHash, writeAt };
413
+ }
333
414
  async function updateUsedComponents(courseSlug, usedComponents, bucket) {
334
415
  const syllabus = await getSyllabus(courseSlug, bucket);
335
416
  // Initialize used_components if undefined
@@ -503,6 +584,7 @@ const getTitleFromHTML = (html) => {
503
584
  async function processSyncTranslationsSequentially(courseSlug, exerciseSlug, notificationId, sourceReadmeContent, targetLanguages, rigoToken, bucket, historyManager) {
504
585
  var _a, _b, _c, _d, _e, _f;
505
586
  try {
587
+ const sanitizedSource = (0, readmeSanitizer_1.sanitizeReadmeNewlines)(sourceReadmeContent);
506
588
  // Process translations sequentially (no race conditions)
507
589
  for (const targetLang of targetLanguages) {
508
590
  try {
@@ -510,7 +592,7 @@ async function processSyncTranslationsSequentially(courseSlug, exerciseSlug, not
510
592
  // eslint-disable-next-line no-await-in-loop
511
593
  const response = await axios_1.default.post(`${api_1.RIGOBOT_HOST}/v1/prompting/completion/translate-asset-markdown/`, {
512
594
  inputs: {
513
- text_to_translate: sourceReadmeContent,
595
+ text_to_translate: sanitizedSource,
514
596
  output_language: targetLang,
515
597
  },
516
598
  include_purpose_objective: false,
@@ -674,7 +756,7 @@ class ServeCommand extends SessionCommand_1.default {
674
756
  url: redisUrl,
675
757
  socket: {
676
758
  tls: useTLS,
677
- rejectUnauthorized: useTLS,
759
+ rejectUnauthorized: false,
678
760
  reconnectStrategy: (retries) => {
679
761
  if (retries > 10) {
680
762
  console.error("❌ Too many Redis reconnection attempts");
@@ -1603,129 +1685,219 @@ class ServeCommand extends SessionCommand_1.default {
1603
1685
  });
1604
1686
  // The following endpoint is used to store an incoming translation where it supposed to be
1605
1687
  app.post("/webhooks/:courseSlug/:exSlug/save-translation", async (req, res) => {
1606
- var _a, _b, _c, _d;
1688
+ var _a, _b;
1607
1689
  const { courseSlug, exSlug } = req.params;
1608
1690
  const body = req.body;
1609
- console.log("RECEIVING TRANSLATION WEBHOOK", body);
1610
- try {
1611
- // Check if there's an error from Rigobot
1612
- if (body.error || body.status === "ERROR") {
1613
- console.error("Translation failed for", exSlug, body.error);
1614
- const language = ((_a = body.parsed) === null || _a === void 0 ? void 0 : _a.output_language_code) || body.language;
1615
- // Update syllabus with error status
1616
- if (language) {
1617
- try {
1618
- const syllabus = await getSyllabus(courseSlug, bucket);
1619
- const lessonIndex = syllabus.lessons.findIndex(lesson => (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title) === exSlug);
1620
- if (lessonIndex !== -1) {
1621
- const lesson = syllabus.lessons[lessonIndex];
1622
- if (!lesson.translations) {
1623
- lesson.translations = {};
1624
- }
1625
- if (lesson.translations[language]) {
1626
- lesson.translations[language].completedAt = Date.now();
1627
- lesson.translations[language].error = true;
1628
- }
1629
- else {
1630
- // Create entry if it doesn't exist
1631
- lesson.translations[language] = {
1632
- completionId: 0,
1633
- startedAt: Date.now(),
1634
- completedAt: Date.now(),
1635
- error: true,
1636
- };
1691
+ const op = opId();
1692
+ const language = ((_a = body.parsed) === null || _a === void 0 ? void 0 : _a.output_language_code) || body.language || "unknown";
1693
+ console.log("[TRANSLATION_WEBHOOK] RECEIVED", {
1694
+ op,
1695
+ courseSlug,
1696
+ exSlug,
1697
+ language,
1698
+ completionId: body.id,
1699
+ hasTranslation: !!((_b = body.parsed) === null || _b === void 0 ? void 0 : _b.translation),
1700
+ status: body.status,
1701
+ });
1702
+ const queuedAt = Date.now();
1703
+ await serializeByCourse(courseSlug, async () => {
1704
+ var _a, _b, _c, _d;
1705
+ const waitedMs = Date.now() - queuedAt;
1706
+ if (waitedMs > 0) {
1707
+ console.log("[TRANSLATION_WEBHOOK] QUEUE_WAIT", {
1708
+ op,
1709
+ courseSlug,
1710
+ exSlug,
1711
+ language,
1712
+ waitedMs,
1713
+ });
1714
+ }
1715
+ try {
1716
+ // Check if there's an error from Rigobot
1717
+ if (body.error || body.status === "ERROR") {
1718
+ console.error("[TRANSLATION_WEBHOOK] Translation failed", {
1719
+ op,
1720
+ exSlug,
1721
+ error: body.error,
1722
+ });
1723
+ // Update syllabus with error status (skip when language is invalid fallback "unknown")
1724
+ if (language && language !== "unknown") {
1725
+ try {
1726
+ const syllabus = await getSyllabus(courseSlug, bucket);
1727
+ const lessonIndex = syllabus.lessons.findIndex(lesson => (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title) === exSlug);
1728
+ if (lessonIndex !== -1) {
1729
+ const lesson = syllabus.lessons[lessonIndex];
1730
+ if (!lesson.translations) {
1731
+ lesson.translations = {};
1732
+ }
1733
+ if (lesson.translations[language]) {
1734
+ lesson.translations[language].completedAt = Date.now();
1735
+ lesson.translations[language].error = true;
1736
+ }
1737
+ else {
1738
+ // Create entry if it doesn't exist
1739
+ lesson.translations[language] = {
1740
+ completionId: 0,
1741
+ startedAt: Date.now(),
1742
+ completedAt: Date.now(),
1743
+ error: true,
1744
+ };
1745
+ }
1637
1746
  }
1747
+ await saveSyllabus(courseSlug, syllabus, bucket);
1748
+ }
1749
+ catch (syllabusError) {
1750
+ console.error("[TRANSLATION_WEBHOOK] Error updating syllabus with error status", { op, exSlug, language, error: syllabusError });
1638
1751
  }
1639
- await saveSyllabus(courseSlug, syllabus, bucket);
1640
- }
1641
- catch (syllabusError) {
1642
- console.error("Error updating syllabus with error status:", syllabusError);
1643
1752
  }
1753
+ (0, creatorSocket_1.emitToCourse)(courseSlug, "translation-error", {
1754
+ exercise: exSlug,
1755
+ language: language,
1756
+ error: body.error || "Translation failed",
1757
+ });
1758
+ return res
1759
+ .status(500)
1760
+ .json({ status: "ERROR", error: body.error });
1644
1761
  }
1645
- // Notify frontend via WebSocket
1646
- (0, creatorSocket_1.emitToCourse)(courseSlug, "translation-error", {
1647
- exercise: exSlug,
1648
- language: language,
1649
- error: body.error || "Translation failed",
1650
- });
1651
- return res.status(500).json({ status: "ERROR", error: body.error });
1652
- }
1653
- // Validate required data
1654
- 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)) {
1655
- console.error("Missing required translation data", body);
1656
- return res.status(400).json({
1657
- status: "ERROR",
1658
- error: "Missing translation or language code",
1659
- });
1660
- }
1661
- // Translation successful
1662
- const readmePath = `courses/${courseSlug}/exercises/${exSlug}/README${(0, creatorUtilities_1.getReadmeExtension)(body.parsed.output_language_code)}`;
1663
- await uploadFileToBucket(bucket, body.parsed.translation, readmePath);
1664
- // Verify file exists before updating syllabus (resilience: ensure file was actually saved)
1665
- const [fileExists] = await bucket.file(readmePath).exists();
1666
- // Update syllabus with completed status only if file was successfully saved
1667
- if (fileExists) {
1668
- try {
1669
- const syllabus = await getSyllabus(courseSlug, bucket);
1762
+ // Validate required data
1763
+ if (!((_a = body.parsed) === null || _a === void 0 ? void 0 : _a.translation) ||
1764
+ !((_b = body.parsed) === null || _b === void 0 ? void 0 : _b.output_language_code)) {
1765
+ console.error("[TRANSLATION_WEBHOOK] Missing required data", {
1766
+ op,
1767
+ courseSlug,
1768
+ exSlug,
1769
+ });
1770
+ return res.status(400).json({
1771
+ status: "ERROR",
1772
+ error: "Missing translation or language code",
1773
+ });
1774
+ }
1775
+ // Translation successful
1776
+ const readmePath = `courses/${courseSlug}/exercises/${exSlug}/README${(0, creatorUtilities_1.getReadmeExtension)(body.parsed.output_language_code)}`;
1777
+ await uploadFileToBucket(bucket, body.parsed.translation, readmePath);
1778
+ // Verify file exists before updating syllabus (resilience: ensure file was actually saved)
1779
+ const [fileExists] = await bucket.file(readmePath).exists();
1780
+ // Update syllabus with completed status only if file was successfully saved
1781
+ if (fileExists) {
1782
+ const { syllabus, hash: readHash, readAt, } = await getSyllabusWithLog(courseSlug, bucket, {
1783
+ op,
1784
+ exSlug,
1785
+ lang: body.parsed.output_language_code,
1786
+ });
1670
1787
  const lessonIndex = syllabus.lessons.findIndex(lesson => (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title) === exSlug);
1671
- if (lessonIndex !== -1) {
1788
+ if (lessonIndex === -1) {
1789
+ console.warn("[TRANSLATION_WEBHOOK] LESSON_NOT_FOUND", {
1790
+ op,
1791
+ courseSlug,
1792
+ exSlug,
1793
+ language: body.parsed.output_language_code,
1794
+ availableSlugs: syllabus.lessons.map(l => (0, creatorUtilities_2.slugify)(l.id + "-" + l.title)),
1795
+ });
1796
+ }
1797
+ else {
1672
1798
  const lesson = syllabus.lessons[lessonIndex];
1673
- const language = body.parsed.output_language_code;
1799
+ console.log("[TRANSLATION_WEBHOOK] BEFORE_MODIFY", {
1800
+ op,
1801
+ exSlug,
1802
+ language: body.parsed.output_language_code,
1803
+ translationsBefore: translationsSummary(lesson),
1804
+ });
1674
1805
  if (!lesson.translations) {
1675
1806
  lesson.translations = {};
1676
1807
  }
1677
- if (lesson.translations[language]) {
1678
- lesson.translations[language].completedAt = Date.now();
1679
- // Clear error flag if it existed
1680
- if (lesson.translations[language].error) {
1681
- delete lesson.translations[language].error;
1808
+ if (lesson.translations[body.parsed.output_language_code]) {
1809
+ lesson.translations[body.parsed.output_language_code].completedAt = Date.now();
1810
+ if (lesson.translations[body.parsed.output_language_code].error) {
1811
+ delete lesson.translations[body.parsed.output_language_code]
1812
+ .error;
1682
1813
  }
1683
1814
  }
1684
1815
  else {
1685
- // Create entry if it doesn't exist
1686
- lesson.translations[language] = {
1687
- completionId: 0,
1816
+ lesson.translations[body.parsed.output_language_code] = {
1817
+ completionId: body.id || 0,
1688
1818
  startedAt: Date.now(),
1689
1819
  completedAt: Date.now(),
1690
1820
  };
1691
1821
  }
1822
+ console.log("[TRANSLATION_WEBHOOK] AFTER_MODIFY", {
1823
+ op,
1824
+ exSlug,
1825
+ language: body.parsed.output_language_code,
1826
+ translationsAfter: translationsSummary(lesson),
1827
+ });
1828
+ try {
1829
+ await saveSyllabusWithLog(courseSlug, syllabus, bucket, {
1830
+ op,
1831
+ exSlug,
1832
+ lang: body.parsed.output_language_code,
1833
+ readHash,
1834
+ readAt,
1835
+ });
1836
+ // Verify: re-read to confirm changes persisted
1837
+ const verification = await getSyllabus(courseSlug, bucket);
1838
+ const verifyLesson = verification.lessons[lessonIndex];
1839
+ const persisted = (_d = (_c = verifyLesson === null || verifyLesson === void 0 ? void 0 : verifyLesson.translations) === null || _c === void 0 ? void 0 : _c[body.parsed.output_language_code]) === null || _d === void 0 ? void 0 : _d.completedAt;
1840
+ console.log("[TRANSLATION_WEBHOOK] VERIFY", {
1841
+ op,
1842
+ exSlug,
1843
+ language: body.parsed.output_language_code,
1844
+ persisted: !!persisted,
1845
+ finalHash: syllabusHash(verification),
1846
+ });
1847
+ if (!persisted) {
1848
+ console.error("[TRANSLATION_WEBHOOK] RACE_CONDITION_DETECTED", {
1849
+ op,
1850
+ exSlug,
1851
+ language: body.parsed.output_language_code,
1852
+ message: "Translation was saved but not persisted - likely overwritten by concurrent operation",
1853
+ });
1854
+ }
1855
+ }
1856
+ catch (syllabusError) {
1857
+ console.error("[TRANSLATION_WEBHOOK] Error updating syllabus with completed status", {
1858
+ op,
1859
+ exSlug,
1860
+ language: body.parsed.output_language_code,
1861
+ error: syllabusError,
1862
+ });
1863
+ }
1692
1864
  }
1693
- await saveSyllabus(courseSlug, syllabus, bucket);
1865
+ (0, creatorSocket_1.emitToCourse)(courseSlug, "translation-completed", {
1866
+ exercise: exSlug,
1867
+ language: body.parsed.output_language_code,
1868
+ });
1694
1869
  }
1695
- catch (syllabusError) {
1696
- console.error("Error updating syllabus with completed status:", syllabusError);
1697
- // File exists but syllabus update failed - frontend will detect file and mark as complete
1870
+ else {
1871
+ console.error("[TRANSLATION_WEBHOOK] File not found after upload", {
1872
+ op,
1873
+ readmePath,
1874
+ });
1875
+ (0, creatorSocket_1.emitToCourse)(courseSlug, "translation-error", {
1876
+ exercise: exSlug,
1877
+ language: body.parsed.output_language_code,
1878
+ error: "File upload verification failed",
1879
+ });
1698
1880
  }
1699
- // Notify frontend via WebSocket ONLY if file exists
1700
- (0, creatorSocket_1.emitToCourse)(courseSlug, "translation-completed", {
1701
- exercise: exSlug,
1702
- language: body.parsed.output_language_code,
1703
- });
1881
+ res.json({ status: "SUCCESS" });
1704
1882
  }
1705
- else {
1706
- console.error(`File ${readmePath} was not found after upload, skipping syllabus update`);
1707
- // File upload failed - emit error event instead of success
1883
+ catch (error) {
1884
+ console.error("[TRANSLATION_WEBHOOK] ERROR", {
1885
+ op,
1886
+ courseSlug,
1887
+ exSlug,
1888
+ language,
1889
+ error: error.message,
1890
+ });
1708
1891
  (0, creatorSocket_1.emitToCourse)(courseSlug, "translation-error", {
1709
1892
  exercise: exSlug,
1710
- language: body.parsed.output_language_code,
1711
- error: "File upload verification failed",
1893
+ language: language,
1894
+ error: error.message,
1712
1895
  });
1896
+ res
1897
+ .status(500)
1898
+ .json({ status: "ERROR", error: error.message });
1713
1899
  }
1714
- res.json({ status: "SUCCESS" });
1715
- }
1716
- catch (error) {
1717
- console.error("Error processing translation webhook:", error);
1718
- // Notify frontend of error
1719
- const language = ((_d = body.parsed) === null || _d === void 0 ? void 0 : _d.output_language_code) || body.language || "unknown";
1720
- (0, creatorSocket_1.emitToCourse)(courseSlug, "translation-error", {
1721
- exercise: exSlug,
1722
- language: language,
1723
- error: error.message,
1724
- });
1725
- res
1726
- .status(500)
1727
- .json({ status: "ERROR", error: error.message });
1728
- }
1900
+ });
1729
1901
  });
1730
1902
  app.get("/check-preview-image/:slug", async (req, res) => {
1731
1903
  const { slug } = req.params;
@@ -1822,14 +1994,16 @@ class ServeCommand extends SessionCommand_1.default {
1822
1994
  foundLang = match[1].toLowerCase();
1823
1995
  }
1824
1996
  const [contentBuffer] = await bucket.file(selectedFile).download();
1997
+ const rawContent = contentBuffer.toString();
1998
+ const content = (0, readmeSanitizer_1.sanitizeReadmeNewlines)(rawContent);
1825
1999
  try {
1826
- const { attributes, body } = frontMatter(contentBuffer.toString());
2000
+ const { attributes, body } = frontMatter(content);
1827
2001
  res.send({ attributes, body, lang: foundLang });
1828
2002
  }
1829
2003
  catch (_a) {
1830
2004
  res.status(200).json({
1831
2005
  attributes: {},
1832
- body: contentBuffer.toString(),
2006
+ body: content,
1833
2007
  lang: foundLang,
1834
2008
  });
1835
2009
  }
@@ -2218,6 +2392,85 @@ class ServeCommand extends SessionCommand_1.default {
2218
2392
  }
2219
2393
  res.send({ message: "Files renamed" });
2220
2394
  });
2395
+ app.post("/actions/update-title", express.json(), async (req, res) => {
2396
+ var _a;
2397
+ console.log("POST /actions/update-title");
2398
+ const { language, title } = req.body;
2399
+ const courseSlug = typeof req.query.slug === "string" ? req.query.slug : undefined;
2400
+ const rigoToken = req.header("x-rigo-token");
2401
+ if (!courseSlug) {
2402
+ return res.status(400).json({ error: "Course slug not found" });
2403
+ }
2404
+ if (!rigoToken) {
2405
+ return res.status(400).json({ error: "Rigo token not found" });
2406
+ }
2407
+ if (!language || !title) {
2408
+ return res
2409
+ .status(400)
2410
+ .json({ error: "Language and title are required" });
2411
+ }
2412
+ try {
2413
+ const learnJsonFile = bucket.file(`courses/${courseSlug}/learn.json`);
2414
+ const [learnJsonContent] = await learnJsonFile.download();
2415
+ const learnJson = JSON.parse(learnJsonContent.toString());
2416
+ if (!learnJson.title)
2417
+ learnJson.title = {};
2418
+ learnJson.title[language] = title;
2419
+ await uploadFileToBucket(bucket, JSON.stringify(learnJson), `courses/${courseSlug}/learn.json`);
2420
+ const configFile = bucket.file(`courses/${courseSlug}/.learn/config.json`);
2421
+ const [configContent] = await configFile.download();
2422
+ const configJson = JSON.parse(configContent.toString());
2423
+ configJson.config = configJson.config || {};
2424
+ configJson.config.title = Object.assign(Object.assign({}, configJson.config.title), learnJson.title);
2425
+ await uploadFileToBucket(bucket, JSON.stringify(configJson), `courses/${courseSlug}/.learn/config.json`);
2426
+ try {
2427
+ const syllabusFile = bucket.file(`courses/${courseSlug}/.learn/initialSyllabus.json`);
2428
+ const [syllabusContent] = await syllabusFile.download();
2429
+ const syllabusJson = JSON.parse(syllabusContent.toString());
2430
+ if (syllabusJson === null || syllabusJson === void 0 ? void 0 : syllabusJson.courseInfo) {
2431
+ syllabusJson.courseInfo.title = title;
2432
+ await uploadFileToBucket(bucket, JSON.stringify(syllabusJson), `courses/${courseSlug}/.learn/initialSyllabus.json`);
2433
+ }
2434
+ }
2435
+ catch (error) {
2436
+ console.warn("Skipping syllabus title update:", error);
2437
+ }
2438
+ const existingDescription = learnJson.description || {};
2439
+ const targetLanguages = Object.keys(learnJson.title).filter(lang => lang !== language);
2440
+ if (targetLanguages.length > 0) {
2441
+ try {
2442
+ const result = await (0, rigoActions_1.translateCourseMetadata)(rigoToken, {
2443
+ title: JSON.stringify(learnJson.title),
2444
+ description: JSON.stringify(existingDescription),
2445
+ new_languages: targetLanguages.join(","),
2446
+ });
2447
+ if ((_a = result === null || result === void 0 ? void 0 : result.parsed) === null || _a === void 0 ? void 0 : _a.title) {
2448
+ const translatedTitle = JSON.parse(result.parsed.title);
2449
+ learnJson.title = Object.assign(Object.assign(Object.assign({}, learnJson.title), translatedTitle), { [language]: title });
2450
+ await uploadFileToBucket(bucket, JSON.stringify(learnJson), `courses/${courseSlug}/learn.json`);
2451
+ configJson.config.title = Object.assign(Object.assign({}, configJson.config.title), learnJson.title);
2452
+ await uploadFileToBucket(bucket, JSON.stringify(configJson), `courses/${courseSlug}/.learn/config.json`);
2453
+ }
2454
+ }
2455
+ catch (translationError) {
2456
+ console.error("Title translation failed:", translationError);
2457
+ return res.json({
2458
+ status: "PARTIAL_SUCCESS",
2459
+ title: learnJson.title,
2460
+ message: "Title updated for current language, translation failed for others",
2461
+ });
2462
+ }
2463
+ }
2464
+ return res.json({
2465
+ status: "SUCCESS",
2466
+ title: learnJson.title,
2467
+ });
2468
+ }
2469
+ catch (error) {
2470
+ console.error("Error updating course title:", error);
2471
+ return res.status(500).json({ error: error.message });
2472
+ }
2473
+ });
2221
2474
  async function processTranslationsAsync(courseSlug, exerciseSlugs, languageCodes, rigoToken, currentLanguage, bucket) {
2222
2475
  try {
2223
2476
  // Track which languages already exist vs which are being translated
@@ -2264,7 +2517,7 @@ class ServeCommand extends SessionCommand_1.default {
2264
2517
  }
2265
2518
  // Call translateExercise first to get the real completion ID
2266
2519
  const translateResponse = await (0, rigoActions_1.translateExercise)(rigoToken, {
2267
- text_to_translate: readme,
2520
+ text_to_translate: (0, readmeSanitizer_1.sanitizeReadmeNewlines)(readme),
2268
2521
  output_language: language,
2269
2522
  }, `${process.env.HOST}/webhooks/${courseSlug}/${slug}/save-translation`);
2270
2523
  // Mark this language as being translated
@@ -2343,7 +2596,7 @@ class ServeCommand extends SessionCommand_1.default {
2343
2596
  }
2344
2597
  await Promise.all(missingReadmeTranslations.map(async (languageCode) => {
2345
2598
  await (0, rigoActions_1.translateExercise)(rigoToken, {
2346
- text_to_translate: firstAvailable,
2599
+ text_to_translate: (0, readmeSanitizer_1.sanitizeReadmeNewlines)(firstAvailable),
2347
2600
  output_language: languageCode,
2348
2601
  }, `${process.env.HOST}/webhooks/${courseSlug}/initial-readme-processor`);
2349
2602
  }));
@@ -2759,13 +3012,222 @@ class ServeCommand extends SessionCommand_1.default {
2759
3012
  const { slug } = req.params;
2760
3013
  const query = req.query;
2761
3014
  const courseSlug = query.slug;
2762
- const filePrefix = `courses/${courseSlug}/exercises/${slug}/`;
2763
- const [files] = await bucket.getFiles({ prefix: filePrefix });
2764
- for (const file of files) {
2765
- // eslint-disable-next-line no-await-in-loop
2766
- await file.delete();
3015
+ try {
3016
+ // Delete exercise files from bucket
3017
+ const filePrefix = `courses/${courseSlug}/exercises/${slug}/`;
3018
+ const [files] = await bucket.getFiles({ prefix: filePrefix });
3019
+ for (const file of files) {
3020
+ // eslint-disable-next-line no-await-in-loop
3021
+ await file.delete();
3022
+ }
3023
+ // Update syllabus to remove the lesson
3024
+ const syllabus = await getSyllabus(courseSlug, bucket);
3025
+ const lessonIndex = syllabus.lessons.findIndex(lesson => (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title) === slug);
3026
+ if (lessonIndex !== -1) {
3027
+ syllabus.lessons.splice(lessonIndex, 1);
3028
+ await saveSyllabus(courseSlug, syllabus, bucket);
3029
+ console.log(`✅ Removed lesson ${slug} from syllabus`);
3030
+ }
3031
+ else {
3032
+ console.warn(`⚠️ Lesson ${slug} not found in syllabus`);
3033
+ }
3034
+ res.send({
3035
+ message: "Exercise deleted successfully",
3036
+ removedFromSyllabus: lessonIndex !== -1,
3037
+ });
3038
+ }
3039
+ catch (error) {
3040
+ console.error("❌ Error deleting exercise:", error);
3041
+ res.status(500).send({
3042
+ error: "Error deleting exercise",
3043
+ details: error.message,
3044
+ });
3045
+ }
3046
+ });
3047
+ app.post("/actions/synchronize-syllabus", async (req, res) => {
3048
+ console.log("POST /actions/synchronize-syllabus");
3049
+ const { slug } = req.query;
3050
+ const courseSlug = slug;
3051
+ if (!courseSlug) {
3052
+ return res.status(400).json({ error: "Course slug is required" });
3053
+ }
3054
+ try {
3055
+ // Get the current syllabus
3056
+ const syllabus = await getSyllabus(courseSlug, bucket);
3057
+ const removedLessons = [];
3058
+ const keptLessons = [];
3059
+ const duplicatesRemoved = [];
3060
+ const addedLessons = [];
3061
+ console.log(`📋 Checking ${syllabus.lessons.length} lessons in syllabus...`);
3062
+ // First pass: Check each lesson to see if it exists in the bucket and count files.
3063
+ // We try two possible folder names because they can differ by source:
3064
+ // - Initial upload / most flows: folder = slugify(lesson.id + "-" + lesson.title)
3065
+ // - create-step flow: folder = lesson.uid (Rigo's stepSlug), which may not match slugify()
3066
+ // (e.g. casing or character handling). GCS prefixes are case-sensitive.
3067
+ const existingLessons = [];
3068
+ for (const lesson of syllabus.lessons) {
3069
+ const lessonSlug = (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title);
3070
+ let filePrefix = `courses/${courseSlug}/exercises/${lessonSlug}/`;
3071
+ // eslint-disable-next-line no-await-in-loop
3072
+ let [files] = await bucket.getFiles({
3073
+ prefix: filePrefix,
3074
+ });
3075
+ if (files.length === 0 && lesson.uid) {
3076
+ filePrefix = `courses/${courseSlug}/exercises/${lesson.uid}/`;
3077
+ // eslint-disable-next-line no-await-in-loop
3078
+ [files] = await bucket.getFiles({ prefix: filePrefix });
3079
+ }
3080
+ const resolvedSlug = files.length > 0 ?
3081
+ filePrefix
3082
+ .replace(`courses/${courseSlug}/exercises/`, "")
3083
+ .replace(/\/$/, "") :
3084
+ lessonSlug;
3085
+ if (files.length === 0) {
3086
+ removedLessons.push({
3087
+ id: lesson.id,
3088
+ title: lesson.title,
3089
+ slug: lessonSlug,
3090
+ });
3091
+ console.log(`❌ Lesson not found: ${lessonSlug}${lesson.uid ? ` (also tried uid: ${lesson.uid})` : ""}`);
3092
+ }
3093
+ else {
3094
+ existingLessons.push({
3095
+ lesson,
3096
+ slug: resolvedSlug,
3097
+ fileCount: files.length,
3098
+ });
3099
+ console.log(`✅ Lesson exists: ${resolvedSlug} (${files.length} files)`);
3100
+ }
3101
+ }
3102
+ // Second pass: Detect and resolve duplicates
3103
+ const slugMap = new Map();
3104
+ // Group by slug
3105
+ for (const item of existingLessons) {
3106
+ if (!slugMap.has(item.slug)) {
3107
+ slugMap.set(item.slug, []);
3108
+ }
3109
+ slugMap.get(item.slug).push(item);
3110
+ }
3111
+ // Process each group
3112
+ for (const [slug, items] of slugMap.entries()) {
3113
+ if (items.length > 1) {
3114
+ // Duplicates found! Sort by file count (descending) and keep the most complete
3115
+ items.sort((a, b) => b.fileCount - a.fileCount);
3116
+ const winner = items[0];
3117
+ const losers = items.slice(1);
3118
+ console.log(`🔍 Duplicate slug detected: ${slug}`);
3119
+ console.log(` ✅ Keeping: ${winner.lesson.id} - ${winner.lesson.title} (${winner.fileCount} files)`);
3120
+ // Keep the winner
3121
+ keptLessons.push({
3122
+ id: winner.lesson.id,
3123
+ title: winner.lesson.title,
3124
+ slug: winner.slug,
3125
+ fileCount: winner.fileCount,
3126
+ });
3127
+ // Mark losers for removal
3128
+ for (const loser of losers) {
3129
+ console.log(` ❌ Removing duplicate: ${loser.lesson.id} - ${loser.lesson.title} (${loser.fileCount} files)`);
3130
+ duplicatesRemoved.push({
3131
+ id: loser.lesson.id,
3132
+ title: loser.lesson.title,
3133
+ slug: loser.slug,
3134
+ fileCount: loser.fileCount,
3135
+ });
3136
+ }
3137
+ }
3138
+ else {
3139
+ // No duplicates, keep as is
3140
+ keptLessons.push({
3141
+ id: items[0].lesson.id,
3142
+ title: items[0].lesson.title,
3143
+ slug: items[0].slug,
3144
+ fileCount: items[0].fileCount,
3145
+ });
3146
+ }
3147
+ }
3148
+ // Update syllabus: keep only the winners and remove non-existent + duplicates
3149
+ const totalRemoved = removedLessons.length + duplicatesRemoved.length;
3150
+ if (totalRemoved > 0) {
3151
+ syllabus.lessons = syllabus.lessons.filter(lesson => {
3152
+ const lessonSlug = (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title);
3153
+ const isRemoved = removedLessons.some(removed => removed.slug === lessonSlug);
3154
+ const isDuplicate = duplicatesRemoved.some(dup => dup.id === lesson.id && dup.title === lesson.title);
3155
+ return !isRemoved && !isDuplicate;
3156
+ });
3157
+ }
3158
+ // Third pass: add lessons that exist in the bucket but are missing from the syllabus
3159
+ const existingSlugs = new Set(existingLessons.map(e => e.slug));
3160
+ const exercisesPrefix = `courses/${courseSlug}/exercises/`;
3161
+ const [allExerciseFiles] = await bucket.getFiles({
3162
+ prefix: exercisesPrefix,
3163
+ });
3164
+ const bucketFolderSlugs = new Set();
3165
+ for (const file of allExerciseFiles) {
3166
+ if (!file.name.startsWith(exercisesPrefix))
3167
+ continue;
3168
+ const afterPrefix = file.name.slice(exercisesPrefix.length);
3169
+ const segment = afterPrefix.split("/")[0];
3170
+ if (segment)
3171
+ bucketFolderSlugs.add(segment);
3172
+ }
3173
+ for (const folderSlug of bucketFolderSlugs) {
3174
+ if (existingSlugs.has(folderSlug))
3175
+ continue;
3176
+ const idMatch = folderSlug.match(/^(\d+(?:\.\d+)?)(?:-(.*))?$/);
3177
+ const id = idMatch ? idMatch[1] : folderSlug;
3178
+ const titlePart = idMatch && idMatch[2] ? idMatch[2] : folderSlug;
3179
+ const title = titlePart.replace(/-/g, " ").trim() || folderSlug;
3180
+ const newLesson = {
3181
+ id,
3182
+ uid: folderSlug,
3183
+ title,
3184
+ type: "READ",
3185
+ description: title,
3186
+ duration: 2,
3187
+ generated: true,
3188
+ status: "DONE",
3189
+ };
3190
+ syllabus.lessons.push(newLesson);
3191
+ addedLessons.push({ id, title, slug: folderSlug });
3192
+ console.log(`➕ Added missing lesson from bucket: ${folderSlug}`);
3193
+ }
3194
+ if (addedLessons.length > 0) {
3195
+ syllabus.lessons.sort((a, b) => {
3196
+ const na = parseFloat(a.id);
3197
+ const nb = parseFloat(b.id);
3198
+ if (!Number.isNaN(na) && !Number.isNaN(nb))
3199
+ return na - nb;
3200
+ return String(a.id).localeCompare(String(b.id));
3201
+ });
3202
+ }
3203
+ if (totalRemoved > 0 || addedLessons.length > 0) {
3204
+ await saveSyllabus(courseSlug, syllabus, bucket);
3205
+ console.log(`✅ Syllabus synchronized. Removed ${removedLessons.length} non-existent, ${duplicatesRemoved.length} duplicate(s); added ${addedLessons.length} from bucket.`);
3206
+ }
3207
+ else {
3208
+ console.log(`✅ Syllabus is already in sync. No changes.`);
3209
+ }
3210
+ res.json({
3211
+ status: "SUCCESS",
3212
+ message: `Syllabus synchronized successfully`,
3213
+ totalLessons: syllabus.lessons.length,
3214
+ keptLessons: keptLessons.length,
3215
+ removedLessons: removedLessons.length,
3216
+ duplicatesResolved: duplicatesRemoved.length,
3217
+ addedLessons: addedLessons.length,
3218
+ removed: removedLessons,
3219
+ duplicates: duplicatesRemoved,
3220
+ kept: keptLessons,
3221
+ added: addedLessons,
3222
+ });
3223
+ }
3224
+ catch (error) {
3225
+ console.error("❌ Error synchronizing syllabus:", error);
3226
+ res.status(500).json({
3227
+ error: "Error synchronizing syllabus",
3228
+ details: error.message,
3229
+ });
2767
3230
  }
2768
- res.send({ message: "Files deleted" });
2769
3231
  });
2770
3232
  app.get("/translations/sidebar", async (req, res) => {
2771
3233
  const { slug } = req.query;
@@ -2900,6 +3362,7 @@ class ServeCommand extends SessionCommand_1.default {
2900
3362
  const syllabus = await bucket.file(`courses/${courseSlug}/.learn/initialSyllabus.json`);
2901
3363
  const [content] = await syllabus.download();
2902
3364
  const syllabusJson = JSON.parse(content.toString());
3365
+ sanitizeSyllabusFromUnknown(syllabusJson);
2903
3366
  res.json(syllabusJson);
2904
3367
  }
2905
3368
  catch (error) {