@learnpack/learnpack 5.0.323 → 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 +778 -18
- package/lib/models/creator.d.ts +24 -0
- package/lib/utils/rigoActions.d.ts +1 -0
- package/lib/utils/rigoActions.js +1 -1
- package/package.json +4 -1
- package/src/commands/serve.ts +1058 -29
- 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/src/utils/rigoActions.ts +2 -1
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");
|
|
@@ -265,6 +266,7 @@ async function startInteractivityGeneration(rigoToken, steps, packageContext, ex
|
|
|
265
266
|
initial_lesson: exercise.initialContent + `-${randomCacheEvict}`,
|
|
266
267
|
output_language: packageContext.language || "en",
|
|
267
268
|
current_syllabus: JSON.stringify(fullSyllabus),
|
|
269
|
+
lesson_info: JSON.stringify(lessonCleaner(exercise)),
|
|
268
270
|
}, webhookUrl);
|
|
269
271
|
return res.id;
|
|
270
272
|
}
|
|
@@ -357,6 +359,43 @@ async function updateLessonStatusToError(courseSlug, lessonUID, bucket) {
|
|
|
357
359
|
console.error(`Error updating lesson ${lessonUID} status to ERROR:`, error);
|
|
358
360
|
}
|
|
359
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
|
+
}
|
|
360
399
|
async function continueWithNextLesson(courseSlug, currentExerciseIndex, rigoToken, finalContent, bucket) {
|
|
361
400
|
const syllabus = await getSyllabus(courseSlug, bucket);
|
|
362
401
|
const nextExercise = syllabus.lessons[currentExerciseIndex + 1] || null;
|
|
@@ -427,6 +466,125 @@ const getTitleFromHTML = (html) => {
|
|
|
427
466
|
const titleMatch = html.match(titleRegex);
|
|
428
467
|
return titleMatch ? titleMatch[1] : null;
|
|
429
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
|
+
}
|
|
430
588
|
class ServeCommand extends SessionCommand_1.default {
|
|
431
589
|
async init() {
|
|
432
590
|
const { flags } = this.parse(ServeCommand);
|
|
@@ -501,6 +659,43 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
501
659
|
stream.end(buffer);
|
|
502
660
|
});
|
|
503
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
|
+
};
|
|
504
699
|
app.post("/upload-image-file", upload.single("file"), async (req, res) => {
|
|
505
700
|
console.log("INFO: Uploading image file");
|
|
506
701
|
const destination = req.body.destination;
|
|
@@ -514,13 +709,36 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
514
709
|
try {
|
|
515
710
|
// eslint-disable-next-line
|
|
516
711
|
// @ts-ignore
|
|
517
|
-
|
|
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
|
+
}
|
|
518
732
|
const file = bucket.file(destination);
|
|
519
733
|
await file.save(fileData, {
|
|
520
734
|
resumable: false,
|
|
521
735
|
// eslint-disable-next-line
|
|
522
736
|
// @ts-ignore
|
|
523
737
|
contentType: req.file.mimetype,
|
|
738
|
+
metadata: {
|
|
739
|
+
cacheControl: "public, max-age=31536000",
|
|
740
|
+
contentDisposition: "inline",
|
|
741
|
+
},
|
|
524
742
|
});
|
|
525
743
|
console.log(`INFO: Image uploaded to ${file.name}`);
|
|
526
744
|
res.json({ message: "Image uploaded successfully", path: file.name });
|
|
@@ -1285,18 +1503,129 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
1285
1503
|
});
|
|
1286
1504
|
// The following endpoint is used to store an incoming translation where it supposed to be
|
|
1287
1505
|
app.post("/webhooks/:courseSlug/:exSlug/save-translation", async (req, res) => {
|
|
1506
|
+
var _a, _b, _c, _d;
|
|
1288
1507
|
const { courseSlug, exSlug } = req.params;
|
|
1289
1508
|
const body = req.body;
|
|
1290
1509
|
console.log("RECEIVING TRANSLATION WEBHOOK", body);
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
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
|
+
}
|
|
1300
1629
|
});
|
|
1301
1630
|
app.get("/check-preview-image/:slug", async (req, res) => {
|
|
1302
1631
|
const { slug } = req.params;
|
|
@@ -1339,6 +1668,10 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
1339
1668
|
const file = path.resolve(__dirname, "../ui/_app/index.html");
|
|
1340
1669
|
res.sendFile(file);
|
|
1341
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
|
+
});
|
|
1342
1675
|
app.get("/config", async (req, res) => {
|
|
1343
1676
|
const courseSlug = req.query.slug;
|
|
1344
1677
|
// GEt the x-rigo-token
|
|
@@ -1407,6 +1740,7 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
1407
1740
|
}
|
|
1408
1741
|
});
|
|
1409
1742
|
app.get("/.learn/assets/:file", async (req, res) => {
|
|
1743
|
+
var _a;
|
|
1410
1744
|
console.log("GET /.learn/assets/:file", req.params.file);
|
|
1411
1745
|
const { file } = req.params;
|
|
1412
1746
|
const courseSlug = req.query.slug;
|
|
@@ -1419,9 +1753,34 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
1419
1753
|
if (!exists) {
|
|
1420
1754
|
return res.status(404).send("File not found");
|
|
1421
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
|
+
}
|
|
1422
1781
|
const fileStream = fileRef.createReadStream();
|
|
1423
|
-
res.set("Content-Type",
|
|
1424
|
-
res.set("Content-Disposition",
|
|
1782
|
+
res.set("Content-Type", contentType);
|
|
1783
|
+
res.set("Content-Disposition", contentDisposition);
|
|
1425
1784
|
fileStream.pipe(res);
|
|
1426
1785
|
});
|
|
1427
1786
|
app.put("/exercise/:slug/file/:fileName", express.text(), (0, errorHandler_1.asyncHandler)(async (req, res) => {
|
|
@@ -1539,6 +1898,17 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
1539
1898
|
});
|
|
1540
1899
|
async function processTranslationsAsync(courseSlug, exerciseSlugs, languageCodes, rigoToken, currentLanguage, bucket) {
|
|
1541
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
|
+
}
|
|
1542
1912
|
(0, creatorSocket_1.emitToCourse)(courseSlug, "translation-started", {
|
|
1543
1913
|
languages: languageCodes,
|
|
1544
1914
|
exercises: exerciseSlugs,
|
|
@@ -1547,12 +1917,21 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
1547
1917
|
});
|
|
1548
1918
|
await Promise.all(exerciseSlugs.map(async (slug) => {
|
|
1549
1919
|
const readmePath = `courses/${courseSlug}/exercises/${slug}/README${(0, creatorUtilities_1.getReadmeExtension)(currentLanguage)}`;
|
|
1550
|
-
|
|
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();
|
|
1551
1929
|
await Promise.all(languageCodes.map(async (language) => {
|
|
1552
1930
|
const translationPath = `courses/${courseSlug}/exercises/${slug}/README${(0, creatorUtilities_1.getReadmeExtension)(language)}`;
|
|
1553
1931
|
const [exists] = await bucket.file(translationPath).exists();
|
|
1554
1932
|
if (exists) {
|
|
1555
1933
|
console.log(`Translation in ${language} already exists for exercise ${slug}`);
|
|
1934
|
+
existingLanguages.add(language);
|
|
1556
1935
|
(0, creatorSocket_1.emitToCourse)(courseSlug, "translation-progress", {
|
|
1557
1936
|
exercise: slug,
|
|
1558
1937
|
language: language,
|
|
@@ -1561,10 +1940,29 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
1561
1940
|
});
|
|
1562
1941
|
return;
|
|
1563
1942
|
}
|
|
1564
|
-
|
|
1565
|
-
|
|
1943
|
+
// Call translateExercise first to get the real completion ID
|
|
1944
|
+
const translateResponse = await (0, rigoActions_1.translateExercise)(rigoToken, {
|
|
1945
|
+
text_to_translate: readme,
|
|
1566
1946
|
output_language: language,
|
|
1567
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
|
+
}
|
|
1568
1966
|
(0, creatorSocket_1.emitToCourse)(courseSlug, "translation-progress", {
|
|
1569
1967
|
exercise: slug,
|
|
1570
1968
|
language: language,
|
|
@@ -1573,6 +1971,10 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
1573
1971
|
});
|
|
1574
1972
|
}));
|
|
1575
1973
|
}));
|
|
1974
|
+
// Save syllabus with translation status
|
|
1975
|
+
if (syllabus && courseSlug) {
|
|
1976
|
+
await saveSyllabus(courseSlug, syllabus, bucket);
|
|
1977
|
+
}
|
|
1576
1978
|
const course = await bucket
|
|
1577
1979
|
.file(`courses/${courseSlug}/learn.json`)
|
|
1578
1980
|
.download();
|
|
@@ -1657,21 +2059,379 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
1657
2059
|
raw_languages: languagesToTranslate.join(","),
|
|
1658
2060
|
});
|
|
1659
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];
|
|
1660
2085
|
res.status(200).json({
|
|
1661
2086
|
message: "Translation started",
|
|
1662
2087
|
languages: languageCodes,
|
|
1663
2088
|
exercises: exerciseSlugs,
|
|
1664
2089
|
status: "processing",
|
|
2090
|
+
translatingLanguages: translatingLanguagesList,
|
|
2091
|
+
existingLanguages: existingLanguagesList,
|
|
1665
2092
|
});
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
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
|
+
}
|
|
1669
2099
|
}
|
|
1670
2100
|
catch (error) {
|
|
1671
2101
|
console.log(error, "ERROR");
|
|
1672
2102
|
return res.status(400).json({ error: error.message });
|
|
1673
2103
|
}
|
|
1674
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
|
+
}));
|
|
1675
2435
|
app.delete("/exercise/:slug/delete", async (req, res) => {
|
|
1676
2436
|
console.log("DELETE /exercise/:slug/delete");
|
|
1677
2437
|
const { slug } = req.params;
|