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