@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.
- package/bin/run +17 -17
- package/lib/commands/init.js +41 -41
- package/lib/commands/serve.js +645 -129
- 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 -5148
- 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}`);
|
|
@@ -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:
|
|
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:
|
|
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
|
|
1688
|
+
var _a, _b;
|
|
1592
1689
|
const { courseSlug, exSlug } = req.params;
|
|
1593
1690
|
const body = req.body;
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
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
|
-
//
|
|
1631
|
-
(
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
})
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
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
|
|
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
|
-
|
|
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[
|
|
1663
|
-
lesson.translations[
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
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
|
-
|
|
1671
|
-
|
|
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
|
-
|
|
1865
|
+
(0, creatorSocket_1.emitToCourse)(courseSlug, "translation-completed", {
|
|
1866
|
+
exercise: exSlug,
|
|
1867
|
+
language: body.parsed.output_language_code,
|
|
1868
|
+
});
|
|
1679
1869
|
}
|
|
1680
|
-
|
|
1681
|
-
console.error("
|
|
1682
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1691
|
-
console.error(
|
|
1692
|
-
|
|
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:
|
|
1696
|
-
error:
|
|
1893
|
+
language: language,
|
|
1894
|
+
error: error.message,
|
|
1697
1895
|
});
|
|
1896
|
+
res
|
|
1897
|
+
.status(500)
|
|
1898
|
+
.json({ status: "ERROR", error: error.message });
|
|
1698
1899
|
}
|
|
1699
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
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;
|