@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.
- package/bin/run +17 -17
- package/lib/commands/init.js +41 -41
- package/lib/commands/serve.js +589 -126
- package/lib/creatorDist/assets/index-BhqDgBS9.js +8448 -78631
- package/lib/creatorDist/assets/index-CjddKHB_.css +1 -1688
- package/lib/managers/config/exercise.js +2 -14
- package/lib/managers/readmeHistoryService.js +3 -1
- package/lib/managers/server/routes.js +2 -1
- package/lib/utils/configBuilder.js +2 -1
- package/lib/utils/creatorUtilities.js +14 -14
- package/lib/utils/exerciseFileOrder.d.ts +20 -0
- package/lib/utils/exerciseFileOrder.js +49 -0
- package/lib/utils/export/epub.js +26 -26
- package/lib/utils/readmeSanitizer.d.ts +8 -0
- package/lib/utils/readmeSanitizer.js +13 -0
- package/lib/utils/templates/epub/epub.css +146 -146
- package/lib/utils/templates/scorm/config/api.js +175 -175
- package/package.json +1 -1
- package/src/commands/init.ts +655 -655
- package/src/commands/publish.ts +670 -670
- package/src/commands/serve.ts +5853 -5216
- package/src/creator/eslint.config.js +28 -28
- package/src/creator/src/index.css +227 -227
- package/src/creator/src/utils/lib.ts +471 -471
- package/src/creatorDist/assets/index-BhqDgBS9.js +8448 -78631
- package/src/creatorDist/assets/index-CjddKHB_.css +1 -1688
- package/src/managers/config/exercise.ts +3 -15
- package/src/managers/readmeHistoryService.ts +3 -1
- package/src/managers/server/routes.ts +15 -6
- package/src/managers/session.ts +184 -184
- package/src/ui/_app/app.css +1 -1
- package/src/ui/_app/app.js +1950 -1878
- package/src/ui/app.tar.gz +0 -0
- package/src/utils/api.ts +675 -675
- package/src/utils/configBuilder.ts +102 -100
- package/src/utils/creatorUtilities.ts +536 -536
- package/src/utils/errors.ts +108 -108
- package/src/utils/exerciseFileOrder.ts +50 -0
- package/src/utils/export/epub.ts +553 -553
- package/src/utils/export/index.ts +4 -4
- package/src/utils/export/scorm.ts +121 -121
- package/src/utils/export/shared.ts +61 -61
- package/src/utils/export/types.ts +25 -25
- package/src/utils/export/zip.ts +55 -55
- package/src/utils/readmeSanitizer.ts +10 -0
- package/src/utils/rigoActions.ts +642 -642
- package/src/utils/templates/epub/epub.css +146 -146
- package/src/utils/templates/scorm/config/api.js +175 -175
package/lib/commands/serve.js
CHANGED
|
@@ -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(
|
|
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:
|
|
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:
|
|
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
|
|
1688
|
+
var _a, _b;
|
|
1607
1689
|
const { courseSlug, exSlug } = req.params;
|
|
1608
1690
|
const body = req.body;
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
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
|
-
//
|
|
1646
|
-
(
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
})
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
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
|
|
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
|
-
|
|
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[
|
|
1678
|
-
lesson.translations[
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
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
|
-
|
|
1686
|
-
|
|
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
|
-
|
|
1865
|
+
(0, creatorSocket_1.emitToCourse)(courseSlug, "translation-completed", {
|
|
1866
|
+
exercise: exSlug,
|
|
1867
|
+
language: body.parsed.output_language_code,
|
|
1868
|
+
});
|
|
1694
1869
|
}
|
|
1695
|
-
|
|
1696
|
-
console.error("
|
|
1697
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1706
|
-
console.error(
|
|
1707
|
-
|
|
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:
|
|
1711
|
-
error:
|
|
1893
|
+
language: language,
|
|
1894
|
+
error: error.message,
|
|
1712
1895
|
});
|
|
1896
|
+
res
|
|
1897
|
+
.status(500)
|
|
1898
|
+
.json({ status: "ERROR", error: error.message });
|
|
1713
1899
|
}
|
|
1714
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
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) {
|