@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/src/commands/serve.ts
CHANGED
|
@@ -58,7 +58,12 @@ import { buildConfig } from "../utils/configBuilder"
|
|
|
58
58
|
import { checkReadability, slugify } from "../utils/creatorUtilities"
|
|
59
59
|
import { checkAndFixSidebarPure } from "../utils/sidebarGenerator"
|
|
60
60
|
import { handleAssetCreation } from "./publish"
|
|
61
|
-
import {
|
|
61
|
+
import {
|
|
62
|
+
FormState,
|
|
63
|
+
Lesson,
|
|
64
|
+
Syllabus,
|
|
65
|
+
SyncNotification,
|
|
66
|
+
} from "../models/creator"
|
|
62
67
|
import { exportToScorm, exportToEpub, exportToZip } from "../utils/export"
|
|
63
68
|
import {
|
|
64
69
|
errorHandler,
|
|
@@ -69,6 +74,7 @@ import {
|
|
|
69
74
|
InternalServerError,
|
|
70
75
|
NotFoundError,
|
|
71
76
|
} from "../utils/errorHandler"
|
|
77
|
+
import { JSDOM } from "jsdom"
|
|
72
78
|
|
|
73
79
|
const frontMatter = require("front-matter")
|
|
74
80
|
|
|
@@ -456,6 +462,7 @@ async function startInteractivityGeneration(
|
|
|
456
462
|
initial_lesson: exercise.initialContent + `-${randomCacheEvict}`,
|
|
457
463
|
output_language: packageContext.language || "en",
|
|
458
464
|
current_syllabus: JSON.stringify(fullSyllabus),
|
|
465
|
+
lesson_info: JSON.stringify(lessonCleaner(exercise)),
|
|
459
466
|
},
|
|
460
467
|
webhookUrl
|
|
461
468
|
)
|
|
@@ -611,6 +618,62 @@ async function updateLessonStatusToError(
|
|
|
611
618
|
}
|
|
612
619
|
}
|
|
613
620
|
|
|
621
|
+
async function updateLessonStatusToDone(
|
|
622
|
+
courseSlug: string,
|
|
623
|
+
exerciseSlug: string,
|
|
624
|
+
bucket: Bucket
|
|
625
|
+
): Promise<void> {
|
|
626
|
+
try {
|
|
627
|
+
const syllabus = await getSyllabus(courseSlug, bucket)
|
|
628
|
+
const lessonIndex = syllabus.lessons.findIndex(
|
|
629
|
+
lesson => slugify(lesson.id + "-" + lesson.title) === exerciseSlug
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
if (lessonIndex === -1) {
|
|
633
|
+
throw new NotFoundError(
|
|
634
|
+
`Lesson with slug "${exerciseSlug}" not found in course "${courseSlug}"`
|
|
635
|
+
)
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const lesson = syllabus.lessons[lessonIndex]
|
|
639
|
+
|
|
640
|
+
// Update lesson status to DONE
|
|
641
|
+
lesson.status = "DONE"
|
|
642
|
+
lesson.generated = true
|
|
643
|
+
|
|
644
|
+
// Update translations to mark as completed
|
|
645
|
+
const currentTranslations = lesson.translations || {}
|
|
646
|
+
const language = syllabus.courseInfo.language || "en"
|
|
647
|
+
|
|
648
|
+
if (currentTranslations[language]) {
|
|
649
|
+
currentTranslations[language].completedAt = Date.now()
|
|
650
|
+
// Clear error flag if it existed
|
|
651
|
+
if (currentTranslations[language].error) {
|
|
652
|
+
delete currentTranslations[language].error
|
|
653
|
+
}
|
|
654
|
+
} else {
|
|
655
|
+
currentTranslations[language] = {
|
|
656
|
+
completionId: 0,
|
|
657
|
+
startedAt: Date.now(),
|
|
658
|
+
completedAt: Date.now(),
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
lesson.translations = currentTranslations
|
|
663
|
+
|
|
664
|
+
await saveSyllabus(courseSlug, syllabus, bucket)
|
|
665
|
+
console.log(
|
|
666
|
+
`Updated lesson ${exerciseSlug} status to DONE in course ${courseSlug}`
|
|
667
|
+
)
|
|
668
|
+
} catch (error) {
|
|
669
|
+
console.error(
|
|
670
|
+
`Error updating lesson ${exerciseSlug} status to DONE:`,
|
|
671
|
+
error
|
|
672
|
+
)
|
|
673
|
+
throw error
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
614
677
|
async function continueWithNextLesson(
|
|
615
678
|
courseSlug: string,
|
|
616
679
|
currentExerciseIndex: number,
|
|
@@ -726,6 +789,186 @@ const getTitleFromHTML = (html: string) => {
|
|
|
726
789
|
return titleMatch ? titleMatch[1] : null
|
|
727
790
|
}
|
|
728
791
|
|
|
792
|
+
async function processSyncTranslationsSequentially(
|
|
793
|
+
courseSlug: string,
|
|
794
|
+
exerciseSlug: string,
|
|
795
|
+
notificationId: string,
|
|
796
|
+
sourceReadmeContent: string,
|
|
797
|
+
targetLanguages: string[],
|
|
798
|
+
rigoToken: string,
|
|
799
|
+
bucket: Bucket
|
|
800
|
+
) {
|
|
801
|
+
try {
|
|
802
|
+
// Process translations sequentially (no race conditions)
|
|
803
|
+
for (const targetLang of targetLanguages) {
|
|
804
|
+
try {
|
|
805
|
+
// Call Rigobot directly with synchronous execution (no webhook needed)
|
|
806
|
+
// eslint-disable-next-line no-await-in-loop
|
|
807
|
+
const response = await axios.post(
|
|
808
|
+
`${RIGOBOT_HOST}/v1/prompting/completion/translate-asset-markdown/`,
|
|
809
|
+
{
|
|
810
|
+
inputs: {
|
|
811
|
+
text_to_translate: sourceReadmeContent,
|
|
812
|
+
output_language: targetLang,
|
|
813
|
+
},
|
|
814
|
+
include_purpose_objective: false,
|
|
815
|
+
execute_async: false, // Synchronous execution
|
|
816
|
+
},
|
|
817
|
+
{
|
|
818
|
+
headers: {
|
|
819
|
+
"Content-Type": "application/json",
|
|
820
|
+
Authorization: "Token " + rigoToken,
|
|
821
|
+
},
|
|
822
|
+
}
|
|
823
|
+
)
|
|
824
|
+
|
|
825
|
+
const translationResult = response.data
|
|
826
|
+
|
|
827
|
+
// Check if translation was successful
|
|
828
|
+
if (!translationResult.parsed?.translation) {
|
|
829
|
+
throw new Error("Translation result is empty")
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// Save translated README
|
|
833
|
+
const readmePath = `courses/${courseSlug}/exercises/${exerciseSlug}/README${getReadmeExtension(
|
|
834
|
+
targetLang
|
|
835
|
+
)}`
|
|
836
|
+
// eslint-disable-next-line no-await-in-loop
|
|
837
|
+
await bucket
|
|
838
|
+
.file(readmePath)
|
|
839
|
+
.save(translationResult.parsed.translation)
|
|
840
|
+
|
|
841
|
+
// Update progress in syllabus
|
|
842
|
+
// eslint-disable-next-line no-await-in-loop
|
|
843
|
+
const syllabus = await getSyllabus(courseSlug, bucket)
|
|
844
|
+
const lessonIndex = syllabus.lessons.findIndex(
|
|
845
|
+
lesson => slugify(lesson.id + "-" + lesson.title) === exerciseSlug
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
if (lessonIndex !== -1) {
|
|
849
|
+
const lesson = syllabus.lessons[lessonIndex]
|
|
850
|
+
const notification = lesson.syncNotifications?.find(
|
|
851
|
+
n => n.id === notificationId
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
if (notification?.syncProgress) {
|
|
855
|
+
notification.syncProgress.completedLanguages.push(targetLang)
|
|
856
|
+
notification.processingLastUpdate = Date.now()
|
|
857
|
+
|
|
858
|
+
const progress =
|
|
859
|
+
notification.syncProgress.completedLanguages.length
|
|
860
|
+
const total = notification.syncProgress.totalLanguages
|
|
861
|
+
|
|
862
|
+
console.log(
|
|
863
|
+
`🔄 SYNC: Progress ${progress}/${total} - Completed: [${notification.syncProgress.completedLanguages.join(
|
|
864
|
+
", "
|
|
865
|
+
)}]`
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
// eslint-disable-next-line no-await-in-loop
|
|
869
|
+
await saveSyllabus(courseSlug, syllabus, bucket)
|
|
870
|
+
|
|
871
|
+
// Emit progress event
|
|
872
|
+
emitToCourse(courseSlug, "sync-notification-progress", {
|
|
873
|
+
exerciseSlug,
|
|
874
|
+
notificationId,
|
|
875
|
+
completed: progress,
|
|
876
|
+
total,
|
|
877
|
+
})
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
} catch (langError) {
|
|
881
|
+
// Translation failed for this language
|
|
882
|
+
console.error(
|
|
883
|
+
`🔄 SYNC ERROR: Translation failed for ${targetLang}:`,
|
|
884
|
+
langError
|
|
885
|
+
)
|
|
886
|
+
|
|
887
|
+
// Update syllabus with failed language
|
|
888
|
+
// eslint-disable-next-line no-await-in-loop
|
|
889
|
+
const syllabus = await getSyllabus(courseSlug, bucket)
|
|
890
|
+
const lessonIndex = syllabus.lessons.findIndex(
|
|
891
|
+
lesson => slugify(lesson.id + "-" + lesson.title) === exerciseSlug
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
if (lessonIndex !== -1) {
|
|
895
|
+
const lesson = syllabus.lessons[lessonIndex]
|
|
896
|
+
const notification = lesson.syncNotifications?.find(
|
|
897
|
+
n => n.id === notificationId
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
if (notification?.syncProgress) {
|
|
901
|
+
if (!notification.syncProgress.failedLanguages) {
|
|
902
|
+
notification.syncProgress.failedLanguages = []
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
notification.syncProgress.failedLanguages.push({
|
|
906
|
+
code: targetLang,
|
|
907
|
+
error: (langError as Error).message,
|
|
908
|
+
})
|
|
909
|
+
|
|
910
|
+
// eslint-disable-next-line no-await-in-loop
|
|
911
|
+
await saveSyllabus(courseSlug, syllabus, bucket)
|
|
912
|
+
|
|
913
|
+
emitToCourse(courseSlug, "sync-notification-language-failed", {
|
|
914
|
+
exerciseSlug,
|
|
915
|
+
notificationId,
|
|
916
|
+
language: targetLang,
|
|
917
|
+
error: (langError as Error).message,
|
|
918
|
+
})
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// All translations processed - check final status
|
|
925
|
+
const syllabus = await getSyllabus(courseSlug, bucket)
|
|
926
|
+
const lessonIndex = syllabus.lessons.findIndex(
|
|
927
|
+
lesson => slugify(lesson.id + "-" + lesson.title) === exerciseSlug
|
|
928
|
+
)
|
|
929
|
+
|
|
930
|
+
if (lessonIndex !== -1) {
|
|
931
|
+
const lesson = syllabus.lessons[lessonIndex]
|
|
932
|
+
const notification = lesson.syncNotifications?.find(
|
|
933
|
+
n => n.id === notificationId
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
if (notification?.syncProgress) {
|
|
937
|
+
const totalProcessed =
|
|
938
|
+
notification.syncProgress.completedLanguages.length +
|
|
939
|
+
(notification.syncProgress.failedLanguages?.length || 0)
|
|
940
|
+
|
|
941
|
+
if (totalProcessed === notification.syncProgress.totalLanguages) {
|
|
942
|
+
notification.status =
|
|
943
|
+
notification.syncProgress.completedLanguages.length === 0 ?
|
|
944
|
+
"error" :
|
|
945
|
+
"completed"
|
|
946
|
+
|
|
947
|
+
await saveSyllabus(courseSlug, syllabus, bucket)
|
|
948
|
+
|
|
949
|
+
console.log(
|
|
950
|
+
`🔄 SYNC: ✅ All translations completed for ${exerciseSlug} - Status: ${notification.status}`
|
|
951
|
+
)
|
|
952
|
+
|
|
953
|
+
emitToCourse(courseSlug, "sync-notification-completed", {
|
|
954
|
+
exerciseSlug,
|
|
955
|
+
notificationId,
|
|
956
|
+
status: notification.status,
|
|
957
|
+
completed: notification.syncProgress.completedLanguages.length,
|
|
958
|
+
failed: notification.syncProgress.failedLanguages?.length || 0,
|
|
959
|
+
})
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
} catch (error) {
|
|
964
|
+
console.error(
|
|
965
|
+
"🔄 SYNC ERROR: Critical error in processSyncTranslationsSequentially:",
|
|
966
|
+
error
|
|
967
|
+
)
|
|
968
|
+
throw error
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
729
972
|
export default class ServeCommand extends SessionCommand {
|
|
730
973
|
static description = "Runs a small server to build tutorials"
|
|
731
974
|
|
|
@@ -845,6 +1088,48 @@ export default class ServeCommand extends SessionCommand {
|
|
|
845
1088
|
|
|
846
1089
|
const upload = createUploadMiddleware()
|
|
847
1090
|
|
|
1091
|
+
// Initialize DOMPurify for SVG sanitization
|
|
1092
|
+
// eslint-disable-next-line
|
|
1093
|
+
const createDOMPurify = require("isomorphic-dompurify");
|
|
1094
|
+
const window = new JSDOM("").window
|
|
1095
|
+
const DOMPurify = createDOMPurify(window)
|
|
1096
|
+
|
|
1097
|
+
const sanitizeSVGBuffer = (buffer: Buffer): Buffer => {
|
|
1098
|
+
try {
|
|
1099
|
+
const svgContent = buffer.toString("utf-8")
|
|
1100
|
+
|
|
1101
|
+
const config = {
|
|
1102
|
+
USE_PROFILES: { svg: true, svgFilters: true },
|
|
1103
|
+
FORBID_TAGS: ["script", "iframe", "embed", "object", "foreignObject"],
|
|
1104
|
+
FORBID_ATTR: [
|
|
1105
|
+
"onerror",
|
|
1106
|
+
"onload",
|
|
1107
|
+
"onclick",
|
|
1108
|
+
"onmouseover",
|
|
1109
|
+
"onmouseout",
|
|
1110
|
+
"onanimationend",
|
|
1111
|
+
"onanimationstart",
|
|
1112
|
+
"ontransitionend",
|
|
1113
|
+
"onfocus",
|
|
1114
|
+
"onblur",
|
|
1115
|
+
],
|
|
1116
|
+
ALLOW_DATA_ATTR: false, // Block data-* attributes that might contain code
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
const sanitized = DOMPurify.sanitize(svgContent, config)
|
|
1120
|
+
|
|
1121
|
+
// Validate that sanitization didn't remove all content
|
|
1122
|
+
if (!sanitized || sanitized.trim().length === 0) {
|
|
1123
|
+
throw new Error("SVG file is invalid or empty after sanitization")
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
return Buffer.from(sanitized, "utf-8")
|
|
1127
|
+
} catch (error) {
|
|
1128
|
+
console.error("Error sanitizing SVG:", error)
|
|
1129
|
+
throw new Error(`SVG sanitization failed: ${(error as Error).message}`)
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
848
1133
|
app.post("/upload-image-file", upload.single("file"), async (req, res) => {
|
|
849
1134
|
console.log("INFO: Uploading image file")
|
|
850
1135
|
|
|
@@ -861,7 +1146,27 @@ export default class ServeCommand extends SessionCommand {
|
|
|
861
1146
|
try {
|
|
862
1147
|
// eslint-disable-next-line
|
|
863
1148
|
// @ts-ignore
|
|
864
|
-
|
|
1149
|
+
let fileData = req.file.buffer
|
|
1150
|
+
|
|
1151
|
+
// Sanitize if it's an SVG file
|
|
1152
|
+
// eslint-disable-next-line
|
|
1153
|
+
// @ts-ignore
|
|
1154
|
+
if (
|
|
1155
|
+
// eslint-disable-next-line
|
|
1156
|
+
// @ts-ignore
|
|
1157
|
+
req.file.mimetype === "image/svg+xml" ||
|
|
1158
|
+
destination.toLowerCase().endsWith(".svg")
|
|
1159
|
+
) {
|
|
1160
|
+
try {
|
|
1161
|
+
fileData = sanitizeSVGBuffer(fileData)
|
|
1162
|
+
console.log("INFO: SVG file sanitized successfully")
|
|
1163
|
+
} catch (sanitizeError) {
|
|
1164
|
+
console.error("Error sanitizing SVG:", sanitizeError)
|
|
1165
|
+
return res.status(400).json({
|
|
1166
|
+
error: "Invalid SVG file or sanitization failed",
|
|
1167
|
+
})
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
865
1170
|
|
|
866
1171
|
const file = bucket.file(destination)
|
|
867
1172
|
await file.save(fileData, {
|
|
@@ -869,6 +1174,10 @@ export default class ServeCommand extends SessionCommand {
|
|
|
869
1174
|
// eslint-disable-next-line
|
|
870
1175
|
// @ts-ignore
|
|
871
1176
|
contentType: req.file.mimetype,
|
|
1177
|
+
metadata: {
|
|
1178
|
+
cacheControl: "public, max-age=31536000",
|
|
1179
|
+
contentDisposition: "inline",
|
|
1180
|
+
},
|
|
872
1181
|
})
|
|
873
1182
|
|
|
874
1183
|
console.log(`INFO: Image uploaded to ${file.name}`)
|
|
@@ -1910,20 +2219,155 @@ export default class ServeCommand extends SessionCommand {
|
|
|
1910
2219
|
|
|
1911
2220
|
console.log("RECEIVING TRANSLATION WEBHOOK", body)
|
|
1912
2221
|
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
2222
|
+
try {
|
|
2223
|
+
// Check if there's an error from Rigobot
|
|
2224
|
+
if (body.error || body.status === "ERROR") {
|
|
2225
|
+
console.error("Translation failed for", exSlug, body.error)
|
|
2226
|
+
|
|
2227
|
+
const language = body.parsed?.output_language_code || body.language
|
|
2228
|
+
|
|
2229
|
+
// Update syllabus with error status
|
|
2230
|
+
if (language) {
|
|
2231
|
+
try {
|
|
2232
|
+
const syllabus = await getSyllabus(courseSlug, bucket)
|
|
2233
|
+
const lessonIndex = syllabus.lessons.findIndex(
|
|
2234
|
+
lesson => slugify(lesson.id + "-" + lesson.title) === exSlug
|
|
2235
|
+
)
|
|
1916
2236
|
|
|
1917
|
-
|
|
2237
|
+
if (lessonIndex !== -1) {
|
|
2238
|
+
const lesson = syllabus.lessons[lessonIndex]
|
|
1918
2239
|
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
status: "completed",
|
|
1923
|
-
message: `Translation completed for ${exSlug} to ${body.parsed.output_language_code}`,
|
|
1924
|
-
})
|
|
2240
|
+
if (!lesson.translations) {
|
|
2241
|
+
lesson.translations = {}
|
|
2242
|
+
}
|
|
1925
2243
|
|
|
1926
|
-
|
|
2244
|
+
if (lesson.translations[language]) {
|
|
2245
|
+
lesson.translations[language].completedAt = Date.now()
|
|
2246
|
+
lesson.translations[language].error = true
|
|
2247
|
+
} else {
|
|
2248
|
+
// Create entry if it doesn't exist
|
|
2249
|
+
lesson.translations[language] = {
|
|
2250
|
+
completionId: 0,
|
|
2251
|
+
startedAt: Date.now(),
|
|
2252
|
+
completedAt: Date.now(),
|
|
2253
|
+
error: true,
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
await saveSyllabus(courseSlug, syllabus, bucket)
|
|
2259
|
+
} catch (syllabusError) {
|
|
2260
|
+
console.error(
|
|
2261
|
+
"Error updating syllabus with error status:",
|
|
2262
|
+
syllabusError
|
|
2263
|
+
)
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
// Notify frontend via WebSocket
|
|
2268
|
+
emitToCourse(courseSlug, "translation-error", {
|
|
2269
|
+
exercise: exSlug,
|
|
2270
|
+
language: language,
|
|
2271
|
+
error: body.error || "Translation failed",
|
|
2272
|
+
})
|
|
2273
|
+
|
|
2274
|
+
return res.status(500).json({ status: "ERROR", error: body.error })
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
// Validate required data
|
|
2278
|
+
if (!body.parsed?.translation || !body.parsed?.output_language_code) {
|
|
2279
|
+
console.error("Missing required translation data", body)
|
|
2280
|
+
return res.status(400).json({
|
|
2281
|
+
status: "ERROR",
|
|
2282
|
+
error: "Missing translation or language code",
|
|
2283
|
+
})
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
// Translation successful
|
|
2287
|
+
const readmePath = `courses/${courseSlug}/exercises/${exSlug}/README${getReadmeExtension(
|
|
2288
|
+
body.parsed.output_language_code
|
|
2289
|
+
)}`
|
|
2290
|
+
|
|
2291
|
+
await uploadFileToBucket(bucket, body.parsed.translation, readmePath)
|
|
2292
|
+
|
|
2293
|
+
// Verify file exists before updating syllabus (resilience: ensure file was actually saved)
|
|
2294
|
+
const [fileExists] = await bucket.file(readmePath).exists()
|
|
2295
|
+
|
|
2296
|
+
// Update syllabus with completed status only if file was successfully saved
|
|
2297
|
+
if (fileExists) {
|
|
2298
|
+
try {
|
|
2299
|
+
const syllabus = await getSyllabus(courseSlug, bucket)
|
|
2300
|
+
const lessonIndex = syllabus.lessons.findIndex(
|
|
2301
|
+
lesson => slugify(lesson.id + "-" + lesson.title) === exSlug
|
|
2302
|
+
)
|
|
2303
|
+
|
|
2304
|
+
if (lessonIndex !== -1) {
|
|
2305
|
+
const lesson = syllabus.lessons[lessonIndex]
|
|
2306
|
+
const language = body.parsed.output_language_code
|
|
2307
|
+
|
|
2308
|
+
if (!lesson.translations) {
|
|
2309
|
+
lesson.translations = {}
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
if (lesson.translations[language]) {
|
|
2313
|
+
lesson.translations[language].completedAt = Date.now()
|
|
2314
|
+
// Clear error flag if it existed
|
|
2315
|
+
if (lesson.translations[language].error) {
|
|
2316
|
+
delete lesson.translations[language].error
|
|
2317
|
+
}
|
|
2318
|
+
} else {
|
|
2319
|
+
// Create entry if it doesn't exist
|
|
2320
|
+
lesson.translations[language] = {
|
|
2321
|
+
completionId: 0,
|
|
2322
|
+
startedAt: Date.now(),
|
|
2323
|
+
completedAt: Date.now(),
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
await saveSyllabus(courseSlug, syllabus, bucket)
|
|
2329
|
+
} catch (syllabusError) {
|
|
2330
|
+
console.error(
|
|
2331
|
+
"Error updating syllabus with completed status:",
|
|
2332
|
+
syllabusError
|
|
2333
|
+
)
|
|
2334
|
+
// File exists but syllabus update failed - frontend will detect file and mark as complete
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
// Notify frontend via WebSocket ONLY if file exists
|
|
2338
|
+
emitToCourse(courseSlug, "translation-completed", {
|
|
2339
|
+
exercise: exSlug,
|
|
2340
|
+
language: body.parsed.output_language_code,
|
|
2341
|
+
})
|
|
2342
|
+
} else {
|
|
2343
|
+
console.error(
|
|
2344
|
+
`File ${readmePath} was not found after upload, skipping syllabus update`
|
|
2345
|
+
)
|
|
2346
|
+
// File upload failed - emit error event instead of success
|
|
2347
|
+
emitToCourse(courseSlug, "translation-error", {
|
|
2348
|
+
exercise: exSlug,
|
|
2349
|
+
language: body.parsed.output_language_code,
|
|
2350
|
+
error: "File upload verification failed",
|
|
2351
|
+
})
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
res.json({ status: "SUCCESS" })
|
|
2355
|
+
} catch (error) {
|
|
2356
|
+
console.error("Error processing translation webhook:", error)
|
|
2357
|
+
|
|
2358
|
+
// Notify frontend of error
|
|
2359
|
+
const language =
|
|
2360
|
+
body.parsed?.output_language_code || body.language || "unknown"
|
|
2361
|
+
emitToCourse(courseSlug, "translation-error", {
|
|
2362
|
+
exercise: exSlug,
|
|
2363
|
+
language: language,
|
|
2364
|
+
error: (error as Error).message,
|
|
2365
|
+
})
|
|
2366
|
+
|
|
2367
|
+
res
|
|
2368
|
+
.status(500)
|
|
2369
|
+
.json({ status: "ERROR", error: (error as Error).message })
|
|
2370
|
+
}
|
|
1927
2371
|
}
|
|
1928
2372
|
)
|
|
1929
2373
|
|
|
@@ -1985,6 +2429,11 @@ export default class ServeCommand extends SessionCommand {
|
|
|
1985
2429
|
res.sendFile(file)
|
|
1986
2430
|
})
|
|
1987
2431
|
|
|
2432
|
+
app.get("/preview/:slug/webview", async (req, res) => {
|
|
2433
|
+
const file = path.resolve(__dirname, "../ui/_app/index.html")
|
|
2434
|
+
res.sendFile(file)
|
|
2435
|
+
})
|
|
2436
|
+
|
|
1988
2437
|
app.get("/config", async (req, res) => {
|
|
1989
2438
|
const courseSlug = req.query.slug as string
|
|
1990
2439
|
// GEt the x-rigo-token
|
|
@@ -2089,9 +2538,36 @@ export default class ServeCommand extends SessionCommand {
|
|
|
2089
2538
|
return res.status(404).send("File not found")
|
|
2090
2539
|
}
|
|
2091
2540
|
|
|
2541
|
+
// Determine Content-Type based on file extension
|
|
2542
|
+
const ext = file.split(".").pop()?.toLowerCase()
|
|
2543
|
+
let contentType = "application/octet-stream"
|
|
2544
|
+
let contentDisposition = `inline; filename="${file}"` // inline for images
|
|
2545
|
+
|
|
2546
|
+
switch (ext) {
|
|
2547
|
+
case "svg":
|
|
2548
|
+
contentType = "image/svg+xml"
|
|
2549
|
+
break
|
|
2550
|
+
case "jpg":
|
|
2551
|
+
case "jpeg":
|
|
2552
|
+
contentType = "image/jpeg"
|
|
2553
|
+
break
|
|
2554
|
+
case "png":
|
|
2555
|
+
contentType = "image/png"
|
|
2556
|
+
break
|
|
2557
|
+
case "gif":
|
|
2558
|
+
contentType = "image/gif"
|
|
2559
|
+
break
|
|
2560
|
+
case "webp":
|
|
2561
|
+
contentType = "image/webp"
|
|
2562
|
+
break
|
|
2563
|
+
default:
|
|
2564
|
+
// For non-image files, use attachment to force download
|
|
2565
|
+
contentDisposition = `attachment; filename="${file}"`
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2092
2568
|
const fileStream = fileRef.createReadStream()
|
|
2093
|
-
res.set("Content-Type",
|
|
2094
|
-
res.set("Content-Disposition",
|
|
2569
|
+
res.set("Content-Type", contentType)
|
|
2570
|
+
res.set("Content-Disposition", contentDisposition)
|
|
2095
2571
|
fileStream.pipe(res)
|
|
2096
2572
|
})
|
|
2097
2573
|
|
|
@@ -2280,6 +2756,20 @@ export default class ServeCommand extends SessionCommand {
|
|
|
2280
2756
|
bucket: Bucket
|
|
2281
2757
|
) {
|
|
2282
2758
|
try {
|
|
2759
|
+
// Track which languages already exist vs which are being translated
|
|
2760
|
+
const existingLanguages = new Set<string>()
|
|
2761
|
+
const translatingLanguages = new Set<string>()
|
|
2762
|
+
|
|
2763
|
+
// Get syllabus to track translation status
|
|
2764
|
+
let syllabus: Syllabus | null = null
|
|
2765
|
+
try {
|
|
2766
|
+
syllabus = await getSyllabus(courseSlug, bucket)
|
|
2767
|
+
} catch {
|
|
2768
|
+
console.log(
|
|
2769
|
+
"Syllabus not found, translations will not be tracked in syllabus"
|
|
2770
|
+
)
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2283
2773
|
emitToCourse(courseSlug, "translation-started", {
|
|
2284
2774
|
languages: languageCodes,
|
|
2285
2775
|
exercises: exerciseSlugs,
|
|
@@ -2292,7 +2782,19 @@ export default class ServeCommand extends SessionCommand {
|
|
|
2292
2782
|
const readmePath = `courses/${courseSlug}/exercises/${slug}/README${getReadmeExtension(
|
|
2293
2783
|
currentLanguage
|
|
2294
2784
|
)}`
|
|
2295
|
-
|
|
2785
|
+
|
|
2786
|
+
// Validate that README exists before attempting translation
|
|
2787
|
+
const readmeFile = bucket.file(readmePath)
|
|
2788
|
+
const [readmeExists] = await readmeFile.exists()
|
|
2789
|
+
if (!readmeExists) {
|
|
2790
|
+
console.error(
|
|
2791
|
+
`README not found for exercise ${slug} in language ${currentLanguage}`
|
|
2792
|
+
)
|
|
2793
|
+
return // Skip this exercise
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
const [readmeBuffer] = await readmeFile.download()
|
|
2797
|
+
const readme = readmeBuffer.toString()
|
|
2296
2798
|
|
|
2297
2799
|
await Promise.all(
|
|
2298
2800
|
languageCodes.map(async (language: string) => {
|
|
@@ -2305,6 +2807,7 @@ export default class ServeCommand extends SessionCommand {
|
|
|
2305
2807
|
console.log(
|
|
2306
2808
|
`Translation in ${language} already exists for exercise ${slug}`
|
|
2307
2809
|
)
|
|
2810
|
+
existingLanguages.add(language)
|
|
2308
2811
|
emitToCourse(courseSlug, "translation-progress", {
|
|
2309
2812
|
exercise: slug,
|
|
2310
2813
|
language: language,
|
|
@@ -2314,15 +2817,40 @@ export default class ServeCommand extends SessionCommand {
|
|
|
2314
2817
|
return
|
|
2315
2818
|
}
|
|
2316
2819
|
|
|
2317
|
-
|
|
2820
|
+
// Call translateExercise first to get the real completion ID
|
|
2821
|
+
const translateResponse = await translateExercise(
|
|
2318
2822
|
rigoToken,
|
|
2319
2823
|
{
|
|
2320
|
-
text_to_translate: readme
|
|
2824
|
+
text_to_translate: readme,
|
|
2321
2825
|
output_language: language,
|
|
2322
2826
|
},
|
|
2323
2827
|
`${process.env.HOST}/webhooks/${courseSlug}/${slug}/save-translation`
|
|
2324
2828
|
)
|
|
2325
2829
|
|
|
2830
|
+
// Mark this language as being translated
|
|
2831
|
+
translatingLanguages.add(language)
|
|
2832
|
+
|
|
2833
|
+
// Update syllabus with translation status if available
|
|
2834
|
+
if (syllabus) {
|
|
2835
|
+
const lessonIndex = syllabus.lessons.findIndex(
|
|
2836
|
+
lesson => slugify(lesson.id + "-" + lesson.title) === slug
|
|
2837
|
+
)
|
|
2838
|
+
|
|
2839
|
+
if (lessonIndex !== -1) {
|
|
2840
|
+
const lesson = syllabus.lessons[lessonIndex]
|
|
2841
|
+
if (!lesson.translations) {
|
|
2842
|
+
lesson.translations = {}
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
// Use the real completion ID from the response, fallback to Date.now() if not available
|
|
2846
|
+
lesson.translations[language] = {
|
|
2847
|
+
completionId: translateResponse?.id || Date.now(),
|
|
2848
|
+
startedAt: Date.now(),
|
|
2849
|
+
completedAt: undefined,
|
|
2850
|
+
}
|
|
2851
|
+
}
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2326
2854
|
emitToCourse(courseSlug, "translation-progress", {
|
|
2327
2855
|
exercise: slug,
|
|
2328
2856
|
language: language,
|
|
@@ -2334,6 +2862,11 @@ export default class ServeCommand extends SessionCommand {
|
|
|
2334
2862
|
})
|
|
2335
2863
|
)
|
|
2336
2864
|
|
|
2865
|
+
// Save syllabus with translation status
|
|
2866
|
+
if (syllabus && courseSlug) {
|
|
2867
|
+
await saveSyllabus(courseSlug, syllabus, bucket)
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2337
2870
|
const course = await bucket
|
|
2338
2871
|
.file(`courses/${courseSlug}/learn.json`)
|
|
2339
2872
|
.download()
|
|
@@ -2398,7 +2931,6 @@ export default class ServeCommand extends SessionCommand {
|
|
|
2398
2931
|
|
|
2399
2932
|
await Promise.all(
|
|
2400
2933
|
missingReadmeTranslations.map(async languageCode => {
|
|
2401
|
-
|
|
2402
2934
|
await translateExercise(
|
|
2403
2935
|
rigoToken,
|
|
2404
2936
|
{
|
|
@@ -2450,29 +2982,526 @@ export default class ServeCommand extends SessionCommand {
|
|
|
2450
2982
|
})
|
|
2451
2983
|
const languageCodes = languageCodesRes.parsed.language_codes
|
|
2452
2984
|
|
|
2985
|
+
// Pre-calculate which languages already exist vs which need translation
|
|
2986
|
+
// This provides immediate, accurate feedback to the frontend
|
|
2987
|
+
const existingLanguages = new Set<string>()
|
|
2988
|
+
const translatingLanguages = new Set<string>()
|
|
2989
|
+
|
|
2990
|
+
// Quick check: for each language, check if ALL exercises already have that translation
|
|
2991
|
+
await Promise.all(
|
|
2992
|
+
languageCodes.map(async (language: string) => {
|
|
2993
|
+
const allExist = await Promise.all(
|
|
2994
|
+
exerciseSlugs.map(async (slug: string) => {
|
|
2995
|
+
const translationPath = `courses/${courseSlug}/exercises/${slug}/README${getReadmeExtension(
|
|
2996
|
+
language
|
|
2997
|
+
)}`
|
|
2998
|
+
const [exists] = await bucket.file(translationPath).exists()
|
|
2999
|
+
return exists
|
|
3000
|
+
})
|
|
3001
|
+
)
|
|
3002
|
+
|
|
3003
|
+
// If all exercises have this language, mark as existing
|
|
3004
|
+
if (allExist.every(exists => exists)) {
|
|
3005
|
+
existingLanguages.add(language)
|
|
3006
|
+
} else {
|
|
3007
|
+
// Otherwise, it will be translated
|
|
3008
|
+
translatingLanguages.add(language)
|
|
3009
|
+
}
|
|
3010
|
+
})
|
|
3011
|
+
)
|
|
3012
|
+
|
|
3013
|
+
// Convert Sets to Arrays for JSON response
|
|
3014
|
+
const translatingLanguagesList = [...translatingLanguages]
|
|
3015
|
+
const existingLanguagesList = [...existingLanguages]
|
|
3016
|
+
|
|
2453
3017
|
res.status(200).json({
|
|
2454
3018
|
message: "Translation started",
|
|
2455
3019
|
languages: languageCodes,
|
|
2456
3020
|
exercises: exerciseSlugs,
|
|
2457
3021
|
status: "processing",
|
|
3022
|
+
translatingLanguages: translatingLanguagesList,
|
|
3023
|
+
existingLanguages: existingLanguagesList,
|
|
2458
3024
|
})
|
|
2459
3025
|
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
3026
|
+
// Only process translations if there are languages to translate
|
|
3027
|
+
if (translatingLanguagesList.length > 0) {
|
|
3028
|
+
processTranslationsAsync(
|
|
3029
|
+
courseSlug,
|
|
3030
|
+
exerciseSlugs,
|
|
3031
|
+
languageCodes,
|
|
3032
|
+
rigoToken,
|
|
3033
|
+
currentLanguage,
|
|
3034
|
+
bucket
|
|
3035
|
+
).catch(error => {
|
|
3036
|
+
console.error("Error in background translation processing:", error)
|
|
3037
|
+
})
|
|
3038
|
+
}
|
|
2470
3039
|
} catch (error) {
|
|
2471
3040
|
console.log(error, "ERROR")
|
|
2472
3041
|
return res.status(400).json({ error: (error as Error).message })
|
|
2473
3042
|
}
|
|
2474
3043
|
})
|
|
2475
3044
|
|
|
3045
|
+
// ============================================
|
|
3046
|
+
// SYNC NOTIFICATIONS ENDPOINTS
|
|
3047
|
+
// ============================================
|
|
3048
|
+
|
|
3049
|
+
// Create or update sync notification
|
|
3050
|
+
app.post(
|
|
3051
|
+
"/courses/:courseSlug/lessons/:exerciseSlug/sync-notification",
|
|
3052
|
+
express.json(),
|
|
3053
|
+
async (req, res) => {
|
|
3054
|
+
console.log(
|
|
3055
|
+
"POST /courses/:courseSlug/lessons/:exerciseSlug/sync-notification"
|
|
3056
|
+
)
|
|
3057
|
+
const { courseSlug, exerciseSlug } = req.params
|
|
3058
|
+
const { sourceLanguage } = req.body
|
|
3059
|
+
|
|
3060
|
+
try {
|
|
3061
|
+
if (!courseSlug || !exerciseSlug || !sourceLanguage) {
|
|
3062
|
+
return res.status(400).json({
|
|
3063
|
+
error: "Missing required parameters",
|
|
3064
|
+
code: "MISSING_PARAMETERS",
|
|
3065
|
+
})
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
const syllabus = await getSyllabus(courseSlug, bucket)
|
|
3069
|
+
if (!syllabus) {
|
|
3070
|
+
return res.status(404).json({
|
|
3071
|
+
error: "Syllabus not found",
|
|
3072
|
+
code: "SYLLABUS_NOT_FOUND",
|
|
3073
|
+
})
|
|
3074
|
+
}
|
|
3075
|
+
|
|
3076
|
+
// Find lesson
|
|
3077
|
+
const lessonIndex = syllabus.lessons.findIndex(
|
|
3078
|
+
lesson => slugify(lesson.id + "-" + lesson.title) === exerciseSlug
|
|
3079
|
+
)
|
|
3080
|
+
|
|
3081
|
+
if (lessonIndex === -1) {
|
|
3082
|
+
return res.status(404).json({
|
|
3083
|
+
error: "Lesson not found",
|
|
3084
|
+
code: "LESSON_NOT_FOUND",
|
|
3085
|
+
})
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
const lesson = syllabus.lessons[lessonIndex]
|
|
3089
|
+
|
|
3090
|
+
// Verify source README exists
|
|
3091
|
+
const readmePath = `courses/${courseSlug}/exercises/${exerciseSlug}/README${getReadmeExtension(
|
|
3092
|
+
sourceLanguage
|
|
3093
|
+
)}`
|
|
3094
|
+
const [readmeExists] = await bucket.file(readmePath).exists()
|
|
3095
|
+
|
|
3096
|
+
if (!readmeExists) {
|
|
3097
|
+
return res.status(404).json({
|
|
3098
|
+
error: "Source README not found",
|
|
3099
|
+
code: "SOURCE_README_NOT_FOUND",
|
|
3100
|
+
details: { sourceLanguage, path: readmePath },
|
|
3101
|
+
})
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
if (!lesson.syncNotifications) {
|
|
3105
|
+
lesson.syncNotifications = []
|
|
3106
|
+
}
|
|
3107
|
+
|
|
3108
|
+
// Find existing notification for this language in pending status
|
|
3109
|
+
const existingNotification = lesson.syncNotifications.find(
|
|
3110
|
+
n => n.sourceLanguage === sourceLanguage && n.status === "pending"
|
|
3111
|
+
)
|
|
3112
|
+
|
|
3113
|
+
let notificationToReturn
|
|
3114
|
+
|
|
3115
|
+
if (existingNotification) {
|
|
3116
|
+
// Update timestamp of existing notification
|
|
3117
|
+
existingNotification.updatedAt = Date.now()
|
|
3118
|
+
notificationToReturn = existingNotification
|
|
3119
|
+
} else {
|
|
3120
|
+
const newNotification: SyncNotification = {
|
|
3121
|
+
id: String(Date.now()),
|
|
3122
|
+
sourceLanguage,
|
|
3123
|
+
createdAt: Date.now(),
|
|
3124
|
+
updatedAt: Date.now(),
|
|
3125
|
+
status: "pending",
|
|
3126
|
+
}
|
|
3127
|
+
lesson.syncNotifications.push(newNotification)
|
|
3128
|
+
notificationToReturn = newNotification
|
|
3129
|
+
}
|
|
3130
|
+
|
|
3131
|
+
await saveSyllabus(courseSlug, syllabus, bucket)
|
|
3132
|
+
|
|
3133
|
+
emitToCourse(courseSlug, "sync-notification-created", {
|
|
3134
|
+
exerciseSlug,
|
|
3135
|
+
sourceLanguage,
|
|
3136
|
+
notificationId: notificationToReturn.id,
|
|
3137
|
+
})
|
|
3138
|
+
|
|
3139
|
+
res.json({
|
|
3140
|
+
status: "SUCCESS",
|
|
3141
|
+
notification: notificationToReturn,
|
|
3142
|
+
})
|
|
3143
|
+
} catch (error) {
|
|
3144
|
+
console.error("Error creating sync notification:", error)
|
|
3145
|
+
res.status(500).json({
|
|
3146
|
+
error: "Internal server error",
|
|
3147
|
+
code: "INTERNAL_ERROR",
|
|
3148
|
+
message: (error as Error).message,
|
|
3149
|
+
})
|
|
3150
|
+
}
|
|
3151
|
+
}
|
|
3152
|
+
)
|
|
3153
|
+
|
|
3154
|
+
// Get all sync notifications for a course
|
|
3155
|
+
app.get("/courses/:courseSlug/sync-notifications", async (req, res) => {
|
|
3156
|
+
console.log("GET /courses/:courseSlug/sync-notifications")
|
|
3157
|
+
try {
|
|
3158
|
+
const { courseSlug } = req.params
|
|
3159
|
+
|
|
3160
|
+
if (!courseSlug) {
|
|
3161
|
+
return res.status(400).json({ error: "Course slug is required" })
|
|
3162
|
+
}
|
|
3163
|
+
|
|
3164
|
+
const syllabus = await getSyllabus(courseSlug, bucket)
|
|
3165
|
+
if (!syllabus) {
|
|
3166
|
+
return res.status(404).json({ error: "Syllabus not found" })
|
|
3167
|
+
}
|
|
3168
|
+
|
|
3169
|
+
const PROCESSING_TIMEOUT = 3 * 60 * 1000 // 3 minutes
|
|
3170
|
+
let modified = false
|
|
3171
|
+
|
|
3172
|
+
// Collect active notifications (pending, processing, or error)
|
|
3173
|
+
const notifications: any[] = []
|
|
3174
|
+
|
|
3175
|
+
for (const lesson of syllabus.lessons) {
|
|
3176
|
+
if (lesson.syncNotifications && lesson.syncNotifications.length > 0) {
|
|
3177
|
+
for (const notification of lesson.syncNotifications) {
|
|
3178
|
+
// Check for timeout in processing notifications
|
|
3179
|
+
if (notification.status === "processing") {
|
|
3180
|
+
// Use processingLastUpdate if available, otherwise fallback to updatedAt
|
|
3181
|
+
const processingLastUpdateTime =
|
|
3182
|
+
notification.processingLastUpdate || notification.updatedAt
|
|
3183
|
+
const timeSinceProcessingStarted =
|
|
3184
|
+
Date.now() - processingLastUpdateTime
|
|
3185
|
+
|
|
3186
|
+
if (timeSinceProcessingStarted > PROCESSING_TIMEOUT) {
|
|
3187
|
+
notification.status = "error"
|
|
3188
|
+
notification.error = {
|
|
3189
|
+
message: "Synchronization timeout - process took too long",
|
|
3190
|
+
code: "PROCESSING_TIMEOUT",
|
|
3191
|
+
timestamp: Date.now(),
|
|
3192
|
+
}
|
|
3193
|
+
modified = true
|
|
3194
|
+
|
|
3195
|
+
// Emit error event
|
|
3196
|
+
emitToCourse(courseSlug, "sync-notification-error", {
|
|
3197
|
+
exerciseSlug: slugify(lesson.id + "-" + lesson.title),
|
|
3198
|
+
notificationId: notification.id,
|
|
3199
|
+
error: "Processing timeout",
|
|
3200
|
+
})
|
|
3201
|
+
}
|
|
3202
|
+
}
|
|
3203
|
+
|
|
3204
|
+
// Include active notifications (pending, processing, or error)
|
|
3205
|
+
if (
|
|
3206
|
+
notification.status === "pending" ||
|
|
3207
|
+
notification.status === "processing" ||
|
|
3208
|
+
notification.status === "error"
|
|
3209
|
+
) {
|
|
3210
|
+
notifications.push({
|
|
3211
|
+
...notification,
|
|
3212
|
+
lessonSlug: slugify(lesson.id + "-" + lesson.title),
|
|
3213
|
+
lessonTitle: lesson.title,
|
|
3214
|
+
})
|
|
3215
|
+
}
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
|
|
3220
|
+
// Save syllabus if any notification was modified
|
|
3221
|
+
if (modified) {
|
|
3222
|
+
await saveSyllabus(courseSlug, syllabus, bucket)
|
|
3223
|
+
}
|
|
3224
|
+
|
|
3225
|
+
res.json({ notifications })
|
|
3226
|
+
} catch (error) {
|
|
3227
|
+
console.error("Error fetching sync notifications:", error)
|
|
3228
|
+
res.status(500).json({
|
|
3229
|
+
error: "Error fetching sync notifications",
|
|
3230
|
+
message: (error as Error).message,
|
|
3231
|
+
})
|
|
3232
|
+
}
|
|
3233
|
+
})
|
|
3234
|
+
|
|
3235
|
+
// Dismiss sync notification
|
|
3236
|
+
app.delete(
|
|
3237
|
+
"/courses/:courseSlug/lessons/:lessonSlug/sync-notification/:notificationId",
|
|
3238
|
+
async (req, res) => {
|
|
3239
|
+
console.log(
|
|
3240
|
+
"DELETE /courses/:courseSlug/lessons/:lessonSlug/sync-notification/:notificationId"
|
|
3241
|
+
)
|
|
3242
|
+
try {
|
|
3243
|
+
const { courseSlug, lessonSlug, notificationId } = req.params
|
|
3244
|
+
|
|
3245
|
+
if (!courseSlug || !lessonSlug || !notificationId) {
|
|
3246
|
+
return res
|
|
3247
|
+
.status(400)
|
|
3248
|
+
.json({ error: "Missing required parameters" })
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3251
|
+
const syllabus = await getSyllabus(courseSlug, bucket)
|
|
3252
|
+
if (!syllabus) {
|
|
3253
|
+
return res.status(404).json({ error: "Syllabus not found" })
|
|
3254
|
+
}
|
|
3255
|
+
|
|
3256
|
+
// Find lesson
|
|
3257
|
+
const lessonIndex = syllabus.lessons.findIndex(
|
|
3258
|
+
lesson => slugify(lesson.id + "-" + lesson.title) === lessonSlug
|
|
3259
|
+
)
|
|
3260
|
+
|
|
3261
|
+
if (lessonIndex === -1) {
|
|
3262
|
+
return res.status(404).json({ error: "Lesson not found" })
|
|
3263
|
+
}
|
|
3264
|
+
|
|
3265
|
+
const lesson = syllabus.lessons[lessonIndex]
|
|
3266
|
+
|
|
3267
|
+
if (
|
|
3268
|
+
!lesson.syncNotifications ||
|
|
3269
|
+
lesson.syncNotifications.length === 0
|
|
3270
|
+
) {
|
|
3271
|
+
return res.status(404).json({ error: "Notification not found" })
|
|
3272
|
+
}
|
|
3273
|
+
|
|
3274
|
+
const notificationExists = lesson.syncNotifications.some(
|
|
3275
|
+
n => n.id === notificationId
|
|
3276
|
+
)
|
|
3277
|
+
|
|
3278
|
+
if (!notificationExists) {
|
|
3279
|
+
return res.status(404).json({ error: "Notification not found" })
|
|
3280
|
+
}
|
|
3281
|
+
|
|
3282
|
+
// Filter out the notification
|
|
3283
|
+
lesson.syncNotifications = lesson.syncNotifications.filter(
|
|
3284
|
+
n => n.id !== notificationId
|
|
3285
|
+
)
|
|
3286
|
+
|
|
3287
|
+
await saveSyllabus(courseSlug, syllabus, bucket)
|
|
3288
|
+
|
|
3289
|
+
res.json({ status: "SUCCESS", message: "Notification dismissed" })
|
|
3290
|
+
} catch (error) {
|
|
3291
|
+
console.error("Error dismissing notification:", error)
|
|
3292
|
+
res.status(500).json({
|
|
3293
|
+
error: "Error dismissing notification",
|
|
3294
|
+
message: (error as Error).message,
|
|
3295
|
+
})
|
|
3296
|
+
}
|
|
3297
|
+
}
|
|
3298
|
+
)
|
|
3299
|
+
|
|
3300
|
+
// Accept sync notification and start synchronization
|
|
3301
|
+
app.post(
|
|
3302
|
+
"/courses/:courseSlug/lessons/:exerciseSlug/sync-notification/:notificationId/accept",
|
|
3303
|
+
express.json(),
|
|
3304
|
+
async (req, res) => {
|
|
3305
|
+
console.log(
|
|
3306
|
+
"POST /courses/:courseSlug/lessons/:exerciseSlug/sync-notification/:notificationId/accept"
|
|
3307
|
+
)
|
|
3308
|
+
const { courseSlug, exerciseSlug, notificationId } = req.params
|
|
3309
|
+
const rigoToken = req.header("x-rigo-token")
|
|
3310
|
+
|
|
3311
|
+
try {
|
|
3312
|
+
if (!rigoToken) {
|
|
3313
|
+
return res.status(400).json({ error: "Rigo token not found" })
|
|
3314
|
+
}
|
|
3315
|
+
|
|
3316
|
+
if (!courseSlug || !exerciseSlug || !notificationId) {
|
|
3317
|
+
return res
|
|
3318
|
+
.status(400)
|
|
3319
|
+
.json({ error: "Missing required parameters" })
|
|
3320
|
+
}
|
|
3321
|
+
|
|
3322
|
+
const syllabus = await getSyllabus(courseSlug, bucket)
|
|
3323
|
+
if (!syllabus) {
|
|
3324
|
+
return res.status(404).json({ error: "Syllabus not found" })
|
|
3325
|
+
}
|
|
3326
|
+
|
|
3327
|
+
// Find lesson
|
|
3328
|
+
const lessonIndex = syllabus.lessons.findIndex(
|
|
3329
|
+
lesson => slugify(lesson.id + "-" + lesson.title) === exerciseSlug
|
|
3330
|
+
)
|
|
3331
|
+
|
|
3332
|
+
if (lessonIndex === -1) {
|
|
3333
|
+
return res.status(404).json({ error: "Lesson not found" })
|
|
3334
|
+
}
|
|
3335
|
+
|
|
3336
|
+
const lesson = syllabus.lessons[lessonIndex]
|
|
3337
|
+
|
|
3338
|
+
// Find notification
|
|
3339
|
+
const notification = lesson.syncNotifications?.find(
|
|
3340
|
+
n => n.id === notificationId
|
|
3341
|
+
)
|
|
3342
|
+
|
|
3343
|
+
if (!notification) {
|
|
3344
|
+
return res.status(404).json({ error: "Notification not found" })
|
|
3345
|
+
}
|
|
3346
|
+
|
|
3347
|
+
if (notification.status !== "pending") {
|
|
3348
|
+
return res.status(400).json({
|
|
3349
|
+
error: "Notification is not pending",
|
|
3350
|
+
currentStatus: notification.status,
|
|
3351
|
+
})
|
|
3352
|
+
}
|
|
3353
|
+
|
|
3354
|
+
// Get source README
|
|
3355
|
+
const sourceReadmePath = `courses/${courseSlug}/exercises/${exerciseSlug}/README${getReadmeExtension(
|
|
3356
|
+
notification.sourceLanguage
|
|
3357
|
+
)}`
|
|
3358
|
+
let sourceReadmeContent: string
|
|
3359
|
+
|
|
3360
|
+
try {
|
|
3361
|
+
const [content] = await bucket.file(sourceReadmePath).download()
|
|
3362
|
+
sourceReadmeContent = content.toString()
|
|
3363
|
+
} catch (error) {
|
|
3364
|
+
console.error("Source README not found:", error)
|
|
3365
|
+
return res.status(404).json({
|
|
3366
|
+
error: "Source README not found or inaccessible",
|
|
3367
|
+
code: "SOURCE_README_ERROR",
|
|
3368
|
+
})
|
|
3369
|
+
}
|
|
3370
|
+
|
|
3371
|
+
// Determine target languages
|
|
3372
|
+
const availableLanguages = Object.keys(lesson.translations || {})
|
|
3373
|
+
const targetLanguages = availableLanguages.filter(
|
|
3374
|
+
lang => lang !== notification.sourceLanguage
|
|
3375
|
+
)
|
|
3376
|
+
|
|
3377
|
+
if (targetLanguages.length === 0) {
|
|
3378
|
+
return res.status(400).json({
|
|
3379
|
+
error: "No target languages found",
|
|
3380
|
+
code: "NO_TARGET_LANGUAGES",
|
|
3381
|
+
})
|
|
3382
|
+
}
|
|
3383
|
+
|
|
3384
|
+
// Remove all other notifications for this lesson
|
|
3385
|
+
lesson.syncNotifications =
|
|
3386
|
+
lesson.syncNotifications?.filter(n => n.id === notificationId) ||
|
|
3387
|
+
[]
|
|
3388
|
+
|
|
3389
|
+
notification.status = "processing"
|
|
3390
|
+
notification.processingLastUpdate = Date.now()
|
|
3391
|
+
notification.syncProgress = {
|
|
3392
|
+
totalLanguages: targetLanguages.length,
|
|
3393
|
+
completedLanguages: [], // Array of completed language codes
|
|
3394
|
+
failedLanguages: [],
|
|
3395
|
+
}
|
|
3396
|
+
|
|
3397
|
+
await saveSyllabus(courseSlug, syllabus, bucket)
|
|
3398
|
+
|
|
3399
|
+
emitToCourse(courseSlug, "sync-notification-started", {
|
|
3400
|
+
exerciseSlug,
|
|
3401
|
+
notificationId,
|
|
3402
|
+
totalLanguages: targetLanguages.length,
|
|
3403
|
+
})
|
|
3404
|
+
|
|
3405
|
+
// Respond immediately
|
|
3406
|
+
res.json({
|
|
3407
|
+
status: "PROCESSING",
|
|
3408
|
+
notificationId,
|
|
3409
|
+
targetLanguages: targetLanguages.length,
|
|
3410
|
+
})
|
|
3411
|
+
|
|
3412
|
+
// Process translations sequentially (no race conditions)
|
|
3413
|
+
processSyncTranslationsSequentially(
|
|
3414
|
+
courseSlug,
|
|
3415
|
+
exerciseSlug,
|
|
3416
|
+
notificationId,
|
|
3417
|
+
sourceReadmeContent,
|
|
3418
|
+
targetLanguages,
|
|
3419
|
+
rigoToken,
|
|
3420
|
+
bucket
|
|
3421
|
+
).catch(error => {
|
|
3422
|
+
console.error("Error in sync translation processing:", error)
|
|
3423
|
+
|
|
3424
|
+
// Update notification with critical error
|
|
3425
|
+
getSyllabus(courseSlug, bucket).then(syl => {
|
|
3426
|
+
const les = syl.lessons.find(
|
|
3427
|
+
l => slugify(l.id + "-" + l.title) === exerciseSlug
|
|
3428
|
+
)
|
|
3429
|
+
const notif = les?.syncNotifications?.find(
|
|
3430
|
+
n => n.id === notificationId
|
|
3431
|
+
)
|
|
3432
|
+
|
|
3433
|
+
if (notif) {
|
|
3434
|
+
notif.status = "error"
|
|
3435
|
+
notif.error = {
|
|
3436
|
+
message: (error as Error).message,
|
|
3437
|
+
code: "CRITICAL_ERROR",
|
|
3438
|
+
timestamp: Date.now(),
|
|
3439
|
+
}
|
|
3440
|
+
saveSyllabus(courseSlug, syl, bucket)
|
|
3441
|
+
|
|
3442
|
+
emitToCourse(courseSlug, "sync-notification-error", {
|
|
3443
|
+
exerciseSlug,
|
|
3444
|
+
notificationId,
|
|
3445
|
+
error: (error as Error).message,
|
|
3446
|
+
})
|
|
3447
|
+
}
|
|
3448
|
+
})
|
|
3449
|
+
})
|
|
3450
|
+
} catch (error) {
|
|
3451
|
+
console.error("Error accepting sync notification:", error)
|
|
3452
|
+
res.status(500).json({
|
|
3453
|
+
error: "Internal server error",
|
|
3454
|
+
message: (error as Error).message,
|
|
3455
|
+
})
|
|
3456
|
+
}
|
|
3457
|
+
}
|
|
3458
|
+
)
|
|
3459
|
+
|
|
3460
|
+
// Update lesson status to DONE
|
|
3461
|
+
app.put(
|
|
3462
|
+
"/courses/:courseSlug/lessons/:exerciseSlug/status",
|
|
3463
|
+
express.json(),
|
|
3464
|
+
asyncHandler(async (req, res) => {
|
|
3465
|
+
console.log("PUT /courses/:courseSlug/lessons/:exerciseSlug/status")
|
|
3466
|
+
const { courseSlug, exerciseSlug } = req.params
|
|
3467
|
+
const rigoToken = req.header("x-rigo-token")
|
|
3468
|
+
|
|
3469
|
+
if (!rigoToken) {
|
|
3470
|
+
throw new ValidationError(
|
|
3471
|
+
"Rigo token is required. x-rigo-token header is missing"
|
|
3472
|
+
)
|
|
3473
|
+
}
|
|
3474
|
+
|
|
3475
|
+
if (!courseSlug || !exerciseSlug) {
|
|
3476
|
+
throw new ValidationError(
|
|
3477
|
+
"Course slug and exercise slug are required"
|
|
3478
|
+
)
|
|
3479
|
+
}
|
|
3480
|
+
|
|
3481
|
+
// Verify authorization
|
|
3482
|
+
const { isAuthor } = await isPackageAuthor(rigoToken, courseSlug)
|
|
3483
|
+
if (!isAuthor) {
|
|
3484
|
+
throw new ValidationError(
|
|
3485
|
+
"You are not authorized to update lesson status for this course"
|
|
3486
|
+
)
|
|
3487
|
+
}
|
|
3488
|
+
|
|
3489
|
+
// Update lesson status to DONE
|
|
3490
|
+
await updateLessonStatusToDone(courseSlug, exerciseSlug, bucket)
|
|
3491
|
+
|
|
3492
|
+
// Emit WebSocket event to notify frontend
|
|
3493
|
+
emitToCourse(courseSlug, "lesson-status-updated", {
|
|
3494
|
+
exerciseSlug,
|
|
3495
|
+
status: "DONE",
|
|
3496
|
+
})
|
|
3497
|
+
|
|
3498
|
+
res.json({
|
|
3499
|
+
status: "SUCCESS",
|
|
3500
|
+
message: `Lesson ${exerciseSlug} status updated to DONE`,
|
|
3501
|
+
})
|
|
3502
|
+
})
|
|
3503
|
+
)
|
|
3504
|
+
|
|
2476
3505
|
app.delete("/exercise/:slug/delete", async (req, res) => {
|
|
2477
3506
|
console.log("DELETE /exercise/:slug/delete")
|
|
2478
3507
|
|