@learnpack/learnpack 5.0.334 → 5.0.339

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 +645 -129
  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 -5148
  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}`);
@@ -155,6 +194,21 @@ const cleanFormState = (formState) => {
155
194
  const cleanFormStateForSyllabus = (formState) => {
156
195
  return Object.assign(Object.assign({}, formState), { description: formState.description, technologies: formState.technologies, contentIndex: formState.contentIndex, purposse: undefined, duration: undefined, hasContentIndex: undefined, variables: undefined, currentStep: undefined, language: undefined, isCompleted: undefined });
157
196
  };
197
+ const getLocalizedValue = (translations, lang, fallbackLangs = ["en", "us"]) => {
198
+ if (!translations || typeof translations !== "object")
199
+ return "";
200
+ const direct = translations[lang];
201
+ if (typeof direct === "string" && direct.trim().length > 0)
202
+ return direct;
203
+ for (const fb of fallbackLangs) {
204
+ const v = translations[fb];
205
+ if (typeof v === "string" && v.trim().length > 0)
206
+ return v;
207
+ }
208
+ const firstKey = Object.keys(translations)[0];
209
+ const first = firstKey ? translations[firstKey] : "";
210
+ return typeof first === "string" ? first : "";
211
+ };
158
212
  const createMultiLangAsset = async (bucket, rigoToken, bcToken, courseSlug, courseJson, deployUrl, academyId) => {
159
213
  var _a;
160
214
  const availableLangs = Object.keys(courseJson.title);
@@ -312,9 +366,51 @@ async function getSyllabus(courseSlug, bucket) {
312
366
  const [content] = await syllabus.download();
313
367
  return JSON.parse(content.toString());
314
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
+ }
315
377
  async function saveSyllabus(courseSlug, syllabus, bucket) {
378
+ sanitizeSyllabusFromUnknown(syllabus);
316
379
  await uploadFileToBucket(bucket, JSON.stringify(syllabus), `courses/${courseSlug}/.learn/initialSyllabus.json`);
317
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
+ }
318
414
  async function updateUsedComponents(courseSlug, usedComponents, bucket) {
319
415
  const syllabus = await getSyllabus(courseSlug, bucket);
320
416
  // Initialize used_components if undefined
@@ -488,6 +584,7 @@ const getTitleFromHTML = (html) => {
488
584
  async function processSyncTranslationsSequentially(courseSlug, exerciseSlug, notificationId, sourceReadmeContent, targetLanguages, rigoToken, bucket, historyManager) {
489
585
  var _a, _b, _c, _d, _e, _f;
490
586
  try {
587
+ const sanitizedSource = (0, readmeSanitizer_1.sanitizeReadmeNewlines)(sourceReadmeContent);
491
588
  // Process translations sequentially (no race conditions)
492
589
  for (const targetLang of targetLanguages) {
493
590
  try {
@@ -495,7 +592,7 @@ async function processSyncTranslationsSequentially(courseSlug, exerciseSlug, not
495
592
  // eslint-disable-next-line no-await-in-loop
496
593
  const response = await axios_1.default.post(`${api_1.RIGOBOT_HOST}/v1/prompting/completion/translate-asset-markdown/`, {
497
594
  inputs: {
498
- text_to_translate: sourceReadmeContent,
595
+ text_to_translate: sanitizedSource,
499
596
  output_language: targetLang,
500
597
  },
501
598
  include_purpose_objective: false,
@@ -659,7 +756,7 @@ class ServeCommand extends SessionCommand_1.default {
659
756
  url: redisUrl,
660
757
  socket: {
661
758
  tls: useTLS,
662
- rejectUnauthorized: useTLS,
759
+ rejectUnauthorized: false,
663
760
  reconnectStrategy: (retries) => {
664
761
  if (retries > 10) {
665
762
  console.error("❌ Too many Redis reconnection attempts");
@@ -1588,129 +1685,219 @@ class ServeCommand extends SessionCommand_1.default {
1588
1685
  });
1589
1686
  // The following endpoint is used to store an incoming translation where it supposed to be
1590
1687
  app.post("/webhooks/:courseSlug/:exSlug/save-translation", async (req, res) => {
1591
- var _a, _b, _c, _d;
1688
+ var _a, _b;
1592
1689
  const { courseSlug, exSlug } = req.params;
1593
1690
  const body = req.body;
1594
- console.log("RECEIVING TRANSLATION WEBHOOK", body);
1595
- try {
1596
- // Check if there's an error from Rigobot
1597
- if (body.error || body.status === "ERROR") {
1598
- console.error("Translation failed for", exSlug, body.error);
1599
- const language = ((_a = body.parsed) === null || _a === void 0 ? void 0 : _a.output_language_code) || body.language;
1600
- // Update syllabus with error status
1601
- if (language) {
1602
- try {
1603
- const syllabus = await getSyllabus(courseSlug, bucket);
1604
- const lessonIndex = syllabus.lessons.findIndex(lesson => (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title) === exSlug);
1605
- if (lessonIndex !== -1) {
1606
- const lesson = syllabus.lessons[lessonIndex];
1607
- if (!lesson.translations) {
1608
- lesson.translations = {};
1609
- }
1610
- if (lesson.translations[language]) {
1611
- lesson.translations[language].completedAt = Date.now();
1612
- lesson.translations[language].error = true;
1613
- }
1614
- else {
1615
- // Create entry if it doesn't exist
1616
- lesson.translations[language] = {
1617
- completionId: 0,
1618
- startedAt: Date.now(),
1619
- completedAt: Date.now(),
1620
- error: true,
1621
- };
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
+ }
1622
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 });
1623
1751
  }
1624
- await saveSyllabus(courseSlug, syllabus, bucket);
1625
- }
1626
- catch (syllabusError) {
1627
- console.error("Error updating syllabus with error status:", syllabusError);
1628
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 });
1629
1761
  }
1630
- // Notify frontend via WebSocket
1631
- (0, creatorSocket_1.emitToCourse)(courseSlug, "translation-error", {
1632
- exercise: exSlug,
1633
- language: language,
1634
- error: body.error || "Translation failed",
1635
- });
1636
- return res.status(500).json({ status: "ERROR", error: body.error });
1637
- }
1638
- // Validate required data
1639
- 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)) {
1640
- console.error("Missing required translation data", body);
1641
- return res.status(400).json({
1642
- status: "ERROR",
1643
- error: "Missing translation or language code",
1644
- });
1645
- }
1646
- // Translation successful
1647
- const readmePath = `courses/${courseSlug}/exercises/${exSlug}/README${(0, creatorUtilities_1.getReadmeExtension)(body.parsed.output_language_code)}`;
1648
- await uploadFileToBucket(bucket, body.parsed.translation, readmePath);
1649
- // Verify file exists before updating syllabus (resilience: ensure file was actually saved)
1650
- const [fileExists] = await bucket.file(readmePath).exists();
1651
- // Update syllabus with completed status only if file was successfully saved
1652
- if (fileExists) {
1653
- try {
1654
- 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
+ });
1655
1787
  const lessonIndex = syllabus.lessons.findIndex(lesson => (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title) === exSlug);
1656
- 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 {
1657
1798
  const lesson = syllabus.lessons[lessonIndex];
1658
- 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
+ });
1659
1805
  if (!lesson.translations) {
1660
1806
  lesson.translations = {};
1661
1807
  }
1662
- if (lesson.translations[language]) {
1663
- lesson.translations[language].completedAt = Date.now();
1664
- // Clear error flag if it existed
1665
- if (lesson.translations[language].error) {
1666
- 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;
1667
1813
  }
1668
1814
  }
1669
1815
  else {
1670
- // Create entry if it doesn't exist
1671
- lesson.translations[language] = {
1672
- completionId: 0,
1816
+ lesson.translations[body.parsed.output_language_code] = {
1817
+ completionId: body.id || 0,
1673
1818
  startedAt: Date.now(),
1674
1819
  completedAt: Date.now(),
1675
1820
  };
1676
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
+ }
1677
1864
  }
1678
- await saveSyllabus(courseSlug, syllabus, bucket);
1865
+ (0, creatorSocket_1.emitToCourse)(courseSlug, "translation-completed", {
1866
+ exercise: exSlug,
1867
+ language: body.parsed.output_language_code,
1868
+ });
1679
1869
  }
1680
- catch (syllabusError) {
1681
- console.error("Error updating syllabus with completed status:", syllabusError);
1682
- // 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
+ });
1683
1880
  }
1684
- // Notify frontend via WebSocket ONLY if file exists
1685
- (0, creatorSocket_1.emitToCourse)(courseSlug, "translation-completed", {
1686
- exercise: exSlug,
1687
- language: body.parsed.output_language_code,
1688
- });
1881
+ res.json({ status: "SUCCESS" });
1689
1882
  }
1690
- else {
1691
- console.error(`File ${readmePath} was not found after upload, skipping syllabus update`);
1692
- // 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
+ });
1693
1891
  (0, creatorSocket_1.emitToCourse)(courseSlug, "translation-error", {
1694
1892
  exercise: exSlug,
1695
- language: body.parsed.output_language_code,
1696
- error: "File upload verification failed",
1893
+ language: language,
1894
+ error: error.message,
1697
1895
  });
1896
+ res
1897
+ .status(500)
1898
+ .json({ status: "ERROR", error: error.message });
1698
1899
  }
1699
- res.json({ status: "SUCCESS" });
1700
- }
1701
- catch (error) {
1702
- console.error("Error processing translation webhook:", error);
1703
- // Notify frontend of error
1704
- const language = ((_d = body.parsed) === null || _d === void 0 ? void 0 : _d.output_language_code) || body.language || "unknown";
1705
- (0, creatorSocket_1.emitToCourse)(courseSlug, "translation-error", {
1706
- exercise: exSlug,
1707
- language: language,
1708
- error: error.message,
1709
- });
1710
- res
1711
- .status(500)
1712
- .json({ status: "ERROR", error: error.message });
1713
- }
1900
+ });
1714
1901
  });
1715
1902
  app.get("/check-preview-image/:slug", async (req, res) => {
1716
1903
  const { slug } = req.params;
@@ -1807,14 +1994,16 @@ class ServeCommand extends SessionCommand_1.default {
1807
1994
  foundLang = match[1].toLowerCase();
1808
1995
  }
1809
1996
  const [contentBuffer] = await bucket.file(selectedFile).download();
1997
+ const rawContent = contentBuffer.toString();
1998
+ const content = (0, readmeSanitizer_1.sanitizeReadmeNewlines)(rawContent);
1810
1999
  try {
1811
- const { attributes, body } = frontMatter(contentBuffer.toString());
2000
+ const { attributes, body } = frontMatter(content);
1812
2001
  res.send({ attributes, body, lang: foundLang });
1813
2002
  }
1814
2003
  catch (_a) {
1815
2004
  res.status(200).json({
1816
2005
  attributes: {},
1817
- body: contentBuffer.toString(),
2006
+ body: content,
1818
2007
  lang: foundLang,
1819
2008
  });
1820
2009
  }
@@ -2203,6 +2392,85 @@ class ServeCommand extends SessionCommand_1.default {
2203
2392
  }
2204
2393
  res.send({ message: "Files renamed" });
2205
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
+ });
2206
2474
  async function processTranslationsAsync(courseSlug, exerciseSlugs, languageCodes, rigoToken, currentLanguage, bucket) {
2207
2475
  try {
2208
2476
  // Track which languages already exist vs which are being translated
@@ -2249,7 +2517,7 @@ class ServeCommand extends SessionCommand_1.default {
2249
2517
  }
2250
2518
  // Call translateExercise first to get the real completion ID
2251
2519
  const translateResponse = await (0, rigoActions_1.translateExercise)(rigoToken, {
2252
- text_to_translate: readme,
2520
+ text_to_translate: (0, readmeSanitizer_1.sanitizeReadmeNewlines)(readme),
2253
2521
  output_language: language,
2254
2522
  }, `${process.env.HOST}/webhooks/${courseSlug}/${slug}/save-translation`);
2255
2523
  // Mark this language as being translated
@@ -2328,7 +2596,7 @@ class ServeCommand extends SessionCommand_1.default {
2328
2596
  }
2329
2597
  await Promise.all(missingReadmeTranslations.map(async (languageCode) => {
2330
2598
  await (0, rigoActions_1.translateExercise)(rigoToken, {
2331
- text_to_translate: firstAvailable,
2599
+ text_to_translate: (0, readmeSanitizer_1.sanitizeReadmeNewlines)(firstAvailable),
2332
2600
  output_language: languageCode,
2333
2601
  }, `${process.env.HOST}/webhooks/${courseSlug}/initial-readme-processor`);
2334
2602
  }));
@@ -2744,13 +3012,222 @@ class ServeCommand extends SessionCommand_1.default {
2744
3012
  const { slug } = req.params;
2745
3013
  const query = req.query;
2746
3014
  const courseSlug = query.slug;
2747
- const filePrefix = `courses/${courseSlug}/exercises/${slug}/`;
2748
- const [files] = await bucket.getFiles({ prefix: filePrefix });
2749
- for (const file of files) {
2750
- // eslint-disable-next-line no-await-in-loop
2751
- 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
+ });
2752
3230
  }
2753
- res.send({ message: "Files deleted" });
2754
3231
  });
2755
3232
  app.get("/translations/sidebar", async (req, res) => {
2756
3233
  const { slug } = req.query;
@@ -2885,6 +3362,7 @@ class ServeCommand extends SessionCommand_1.default {
2885
3362
  const syllabus = await bucket.file(`courses/${courseSlug}/.learn/initialSyllabus.json`);
2886
3363
  const [content] = await syllabus.download();
2887
3364
  const syllabusJson = JSON.parse(content.toString());
3365
+ sanitizeSyllabusFromUnknown(syllabusJson);
2888
3366
  res.json(syllabusJson);
2889
3367
  }
2890
3368
  catch (error) {
@@ -2927,9 +3405,7 @@ class ServeCommand extends SessionCommand_1.default {
2927
3405
  try {
2928
3406
  const bcToken = req.header("x-breathecode-token");
2929
3407
  if (!bcToken) {
2930
- return res
2931
- .status(400)
2932
- .json({
3408
+ return res.status(400).json({
2933
3409
  error: "Authentication failed, missing breathecode token",
2934
3410
  });
2935
3411
  }
@@ -2941,6 +3417,46 @@ class ServeCommand extends SessionCommand_1.default {
2941
3417
  return res.status(500).json({ error: error.message });
2942
3418
  }
2943
3419
  });
3420
+ app.get("/actions/package-academy/:slug", async (req, res) => {
3421
+ try {
3422
+ const { slug } = req.params;
3423
+ const bcToken = req.header("x-breathecode-token");
3424
+ if (!bcToken) {
3425
+ return res.status(400).json({
3426
+ error: "Authentication failed, missing breathecode token",
3427
+ });
3428
+ }
3429
+ const configFile = await bucket.file(`courses/${slug}/.learn/config.json`);
3430
+ const [configContent] = await configFile.download();
3431
+ const configJson = JSON.parse(configContent.toString());
3432
+ const { config } = configJson;
3433
+ const availableLangs = Object.keys(config.title || {});
3434
+ let academyId = null;
3435
+ let isPublished = false;
3436
+ for (const lang of availableLangs) {
3437
+ const assetTitle = getLocalizedValue(config.title, lang);
3438
+ if (!assetTitle)
3439
+ continue;
3440
+ let assetSlug = (0, creatorUtilities_2.slugify)(assetTitle).slice(0, 47);
3441
+ assetSlug = `${assetSlug}-${lang}`;
3442
+ const { exists, academyId: existingAcademyId } =
3443
+ // eslint-disable-next-line no-await-in-loop
3444
+ await (0, api_1.doesAssetExists)(bcToken, assetSlug);
3445
+ if (exists) {
3446
+ isPublished = true;
3447
+ if (existingAcademyId !== undefined) {
3448
+ academyId = existingAcademyId;
3449
+ break;
3450
+ }
3451
+ }
3452
+ }
3453
+ return res.json({ academyId, isPublished });
3454
+ }
3455
+ catch (error) {
3456
+ console.error("Error fetching package academy:", error);
3457
+ return res.status(500).json({ error: error.message });
3458
+ }
3459
+ });
2944
3460
  app.post("/actions/publish/:slug", async (req, res) => {
2945
3461
  try {
2946
3462
  const { slug } = req.params;