@learnpack/learnpack 5.0.355 → 5.0.358
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/publish.js +2 -2
- package/lib/commands/serve.d.ts +0 -17
- package/lib/commands/serve.js +252 -147
- package/lib/creatorDist/assets/{index-DnthLsvb.js → index-D6pmbMe9.js} +14030 -13903
- package/lib/creatorDist/assets/index-zrPponAn.css +1701 -0
- package/lib/creatorDist/index.html +2 -2
- package/lib/models/creator.d.ts +1 -1
- package/lib/services/ingest/adapters/gcsCourseStorage.d.ts +13 -0
- package/lib/services/ingest/adapters/gcsCourseStorage.js +58 -0
- package/lib/services/ingest/adapters/rigoUserTokenAuth.d.ts +15 -0
- package/lib/services/ingest/adapters/rigoUserTokenAuth.js +46 -0
- package/lib/services/ingest/adapters/rigobotPackageRegistry.d.ts +7 -0
- package/lib/services/ingest/adapters/rigobotPackageRegistry.js +119 -0
- package/lib/services/ingest/core/buildIngestConfig.d.ts +27 -0
- package/lib/services/ingest/core/buildIngestConfig.js +40 -0
- package/lib/services/ingest/core/buildInitialSyllabus.d.ts +24 -0
- package/lib/services/ingest/core/buildInitialSyllabus.js +104 -0
- package/lib/services/ingest/core/buildSidebar.d.ts +13 -0
- package/lib/services/ingest/core/buildSidebar.js +28 -0
- package/lib/services/ingest/core/coursePaths.d.ts +31 -0
- package/lib/services/ingest/core/coursePaths.js +50 -0
- package/lib/services/ingest/core/ingestCoursePackage.d.ts +52 -0
- package/lib/services/ingest/core/ingestCoursePackage.js +217 -0
- package/lib/services/ingest/core/normalizeLearnJson.d.ts +25 -0
- package/lib/services/ingest/core/normalizeLearnJson.js +29 -0
- package/lib/services/ingest/core/pairLessons.d.ts +29 -0
- package/lib/services/ingest/core/pairLessons.js +102 -0
- package/lib/services/ingest/core/planWrites.d.ts +44 -0
- package/lib/services/ingest/core/planWrites.js +59 -0
- package/lib/services/ingest/core/resolveSlug.d.ts +53 -0
- package/lib/services/ingest/core/resolveSlug.js +93 -0
- package/lib/services/ingest/core/types.d.ts +53 -0
- package/lib/services/ingest/core/types.js +2 -0
- package/lib/services/ingest/core/validatePackage.d.ts +38 -0
- package/lib/services/ingest/core/validatePackage.js +97 -0
- package/lib/services/ingest/createIngestService.d.ts +34 -0
- package/lib/services/ingest/createIngestService.js +38 -0
- package/lib/services/ingest/errors.d.ts +22 -0
- package/lib/services/ingest/errors.js +34 -0
- package/lib/services/ingest/http/errorMapping.d.ts +8 -0
- package/lib/services/ingest/http/errorMapping.js +40 -0
- package/lib/services/ingest/http/router.d.ts +32 -0
- package/lib/services/ingest/http/router.js +124 -0
- package/lib/services/ingest/http/zipPackageReader.d.ts +26 -0
- package/lib/services/ingest/http/zipPackageReader.js +62 -0
- package/lib/services/ingest/ports/courseStorage.d.ts +25 -0
- package/lib/services/ingest/ports/courseStorage.js +2 -0
- package/lib/services/ingest/ports/packageRegistry.d.ts +58 -0
- package/lib/services/ingest/ports/packageRegistry.js +2 -0
- package/lib/services/ingest/ports/requestAuthenticator.d.ts +25 -0
- package/lib/services/ingest/ports/requestAuthenticator.js +2 -0
- package/lib/ui/static/og-image.png +0 -0
- package/lib/utils/branding.d.ts +43 -0
- package/lib/utils/branding.js +75 -0
- package/lib/utils/coursePackage/bucketIo.d.ts +23 -0
- package/lib/utils/coursePackage/bucketIo.js +36 -0
- package/lib/utils/coursePackage/learnJson.d.ts +26 -0
- package/lib/utils/coursePackage/learnJson.js +36 -0
- package/lib/utils/coursePackage/sidebar.d.ts +15 -0
- package/lib/utils/coursePackage/sidebar.js +26 -0
- package/lib/utils/creatorSocket.js +15 -21
- package/lib/utils/gcpCredentials.d.ts +18 -0
- package/lib/utils/gcpCredentials.js +27 -0
- package/lib/utils/rigoActions.d.ts +16 -0
- package/lib/utils/rigoActions.js +102 -1
- package/lib/utils/socketRegistry.d.ts +46 -0
- package/lib/utils/socketRegistry.js +83 -0
- package/package.json +2 -2
- package/src/commands/publish.ts +2 -3
- package/src/commands/serve.ts +297 -136
- package/src/creator/README.md +63 -51
- package/src/creator/package-lock.json +1188 -8
- package/src/creator/package.json +5 -1
- package/src/creator/src/App.tsx +120 -54
- package/src/creator/src/components/FileUploader.tsx +1 -12
- package/src/creator/src/components/NotificationListener.tsx +1 -8
- package/src/creator/src/components/syllabus/SyllabusEditor.tsx +101 -36
- package/src/creator/src/locales/en.json +8 -0
- package/src/creator/src/locales/es.json +8 -0
- package/src/creator/src/utils/completionResult.test.ts +144 -0
- package/src/creator/src/utils/completionResult.ts +154 -0
- package/src/creator/src/utils/constants.ts +17 -4
- package/src/creator/src/utils/socket.test.ts +113 -0
- package/src/creator/src/utils/socket.ts +60 -37
- package/src/creator/src/utils/store.ts +84 -67
- package/src/creator/src/utils/useCompletionWatchdog.test.ts +99 -0
- package/src/creator/src/utils/useCompletionWatchdog.ts +37 -0
- package/src/creator/vitest.config.ts +16 -0
- package/src/creatorDist/assets/{index-DnthLsvb.js → index-D6pmbMe9.js} +14030 -13903
- package/src/creatorDist/assets/index-zrPponAn.css +1701 -0
- package/src/creatorDist/index.html +2 -2
- package/src/models/creator.ts +1 -1
- package/src/services/ingest/adapters/gcsCourseStorage.ts +70 -0
- package/src/services/ingest/adapters/rigoUserTokenAuth.ts +65 -0
- package/src/services/ingest/adapters/rigobotPackageRegistry.ts +161 -0
- package/src/services/ingest/core/buildIngestConfig.ts +51 -0
- package/src/services/ingest/core/buildInitialSyllabus.ts +122 -0
- package/src/services/ingest/core/buildSidebar.ts +32 -0
- package/src/services/ingest/core/coursePaths.ts +59 -0
- package/src/services/ingest/core/ingestCoursePackage.ts +343 -0
- package/src/services/ingest/core/normalizeLearnJson.ts +47 -0
- package/src/services/ingest/core/pairLessons.ts +132 -0
- package/src/services/ingest/core/planWrites.ts +91 -0
- package/src/services/ingest/core/resolveSlug.ts +119 -0
- package/src/services/ingest/core/types.ts +57 -0
- package/src/services/ingest/core/validatePackage.ts +132 -0
- package/src/services/ingest/createIngestService.ts +66 -0
- package/src/services/ingest/errors.ts +45 -0
- package/src/services/ingest/http/errorMapping.ts +49 -0
- package/src/services/ingest/http/router.ts +156 -0
- package/src/services/ingest/http/zipPackageReader.ts +81 -0
- package/src/services/ingest/ports/courseStorage.ts +25 -0
- package/src/services/ingest/ports/packageRegistry.ts +60 -0
- package/src/services/ingest/ports/requestAuthenticator.ts +28 -0
- package/src/ui/_app/app.js +825 -825
- package/src/ui/app.tar.gz +0 -0
- package/src/ui/static/og-image.png +0 -0
- package/src/utils/branding.ts +78 -0
- package/src/utils/coursePackage/bucketIo.ts +46 -0
- package/src/utils/coursePackage/learnJson.ts +37 -0
- package/src/utils/coursePackage/sidebar.ts +28 -0
- package/src/utils/creatorSocket.ts +16 -22
- package/src/utils/gcpCredentials.ts +38 -0
- package/src/utils/rigoActions.ts +163 -0
- package/src/utils/socketRegistry.ts +85 -0
- package/lib/creatorDist/assets/index-CjddKHB_.css +0 -1
- package/src/creatorDist/assets/index-CjddKHB_.css +0 -1
package/src/commands/serve.ts
CHANGED
|
@@ -26,6 +26,7 @@ import * as fs from "fs"
|
|
|
26
26
|
import {
|
|
27
27
|
// createCodeFile,
|
|
28
28
|
translateExercise,
|
|
29
|
+
translateExerciseAndWait,
|
|
29
30
|
isValidRigoToken,
|
|
30
31
|
readmeCreator,
|
|
31
32
|
// makeReadmeReadable,
|
|
@@ -51,7 +52,6 @@ import {
|
|
|
51
52
|
import axios from "axios"
|
|
52
53
|
import * as FormData from "form-data"
|
|
53
54
|
import api, {
|
|
54
|
-
BREATHECODE_TELEMETRY_URL,
|
|
55
55
|
RIGOBOT_HOST,
|
|
56
56
|
RIGOBOT_REALTIME_HOST,
|
|
57
57
|
listUserAcademies,
|
|
@@ -74,7 +74,23 @@ import {
|
|
|
74
74
|
SyncNotification,
|
|
75
75
|
} from "../models/creator"
|
|
76
76
|
import { exportToScorm, exportToEpub, exportToZip } from "../utils/export"
|
|
77
|
-
import {
|
|
77
|
+
import { createIngestRouter } from "../services/ingest/http/router"
|
|
78
|
+
import {
|
|
79
|
+
uploadFileToBucket,
|
|
80
|
+
uploadBinaryToBucket,
|
|
81
|
+
} from "../utils/coursePackage/bucketIo"
|
|
82
|
+
import { createLearnJson } from "../utils/coursePackage/learnJson"
|
|
83
|
+
import { createInitialSidebar } from "../utils/coursePackage/sidebar"
|
|
84
|
+
import {
|
|
85
|
+
generateAndPersistPackageManifest,
|
|
86
|
+
resolvePreviewUrl,
|
|
87
|
+
} from "../utils/packageManifest"
|
|
88
|
+
import {
|
|
89
|
+
DRAFT_PREVIEW_DESCRIPTION,
|
|
90
|
+
DRAFT_PREVIEW_TITLE,
|
|
91
|
+
getFallbackPreviewUrl,
|
|
92
|
+
getPublicHost,
|
|
93
|
+
} from "../utils/branding"
|
|
78
94
|
import { requireGcsBucketName } from "../utils/gcsBucketName"
|
|
79
95
|
import {
|
|
80
96
|
createGcsSyllabusSyncStorage,
|
|
@@ -161,56 +177,6 @@ async function serializeByCourse<T>(
|
|
|
161
177
|
return next
|
|
162
178
|
}
|
|
163
179
|
|
|
164
|
-
export const createLearnJson = (courseInfo: FormState) => {
|
|
165
|
-
// console.log("courseInfo to create learn json", courseInfo)
|
|
166
|
-
|
|
167
|
-
const expectedPreviewUrl = `https://${courseInfo.slug}.learn-pack.com/preview.png`
|
|
168
|
-
|
|
169
|
-
const language = courseInfo.language || "en"
|
|
170
|
-
|
|
171
|
-
const learnJson = {
|
|
172
|
-
slug: courseInfo.slug,
|
|
173
|
-
title: {
|
|
174
|
-
[language]: courseInfo.title,
|
|
175
|
-
},
|
|
176
|
-
technologies: courseInfo.technologies || [],
|
|
177
|
-
difficulty: "beginner",
|
|
178
|
-
description: {
|
|
179
|
-
[language]: courseInfo.description,
|
|
180
|
-
},
|
|
181
|
-
grading: "isolated",
|
|
182
|
-
telemetry: {
|
|
183
|
-
batch: BREATHECODE_TELEMETRY_URL,
|
|
184
|
-
},
|
|
185
|
-
preview: expectedPreviewUrl,
|
|
186
|
-
}
|
|
187
|
-
return learnJson
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
const uploadFileToBucket = async (
|
|
191
|
-
bucket: Bucket,
|
|
192
|
-
file: string,
|
|
193
|
-
path: string
|
|
194
|
-
) => {
|
|
195
|
-
const isReadme = /readme(\.\w+)?\.md$/i.test(path)
|
|
196
|
-
const content = isReadme ? sanitizeReadmeNewlines(file) : file
|
|
197
|
-
const fileRef = bucket.file(path)
|
|
198
|
-
await fileRef.save(Buffer.from(content, "utf8"))
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const uploadBinaryToBucket = async (
|
|
202
|
-
bucket: Bucket,
|
|
203
|
-
buffer: Buffer,
|
|
204
|
-
path: string,
|
|
205
|
-
contentType?: string
|
|
206
|
-
) => {
|
|
207
|
-
const fileRef = bucket.file(path)
|
|
208
|
-
await fileRef.save(buffer, {
|
|
209
|
-
resumable: false,
|
|
210
|
-
...(contentType && { contentType }),
|
|
211
|
-
})
|
|
212
|
-
}
|
|
213
|
-
|
|
214
180
|
const getGithubCredentials = () => {
|
|
215
181
|
const token = process.env.GITHUB_TOKEN?.trim()
|
|
216
182
|
const username = process.env.GITHUB_USERNAME?.trim()
|
|
@@ -518,20 +484,6 @@ export const processImage = async (
|
|
|
518
484
|
}
|
|
519
485
|
}
|
|
520
486
|
|
|
521
|
-
const createInitialSidebar = async (
|
|
522
|
-
slugs: string[],
|
|
523
|
-
initialLanguage = "en"
|
|
524
|
-
) => {
|
|
525
|
-
const sidebar: Record<string, Record<string, string>> = {}
|
|
526
|
-
for (const slug of slugs) {
|
|
527
|
-
sidebar[slug] = {
|
|
528
|
-
[initialLanguage]: slug,
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
return sidebar
|
|
533
|
-
}
|
|
534
|
-
|
|
535
487
|
const uploadInitialReadme = async (
|
|
536
488
|
bucket: Bucket,
|
|
537
489
|
exSlug: string,
|
|
@@ -1238,17 +1190,43 @@ async function continueWithNextLesson(
|
|
|
1238
1190
|
await saveSyllabus(courseSlug, newSyllabus, bucket)
|
|
1239
1191
|
}
|
|
1240
1192
|
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1193
|
+
/**
|
|
1194
|
+
* The `og:image` of a published course.
|
|
1195
|
+
*
|
|
1196
|
+
* A published package is served from `{slug}.learn-pack.com`, so the course's
|
|
1197
|
+
* own `preview.png` is the right card whenever it exists. This used to go
|
|
1198
|
+
* through a helper that returned null for an empty preview, and both call sites
|
|
1199
|
+
* passed an empty string — so every published course, preview or not, fell back
|
|
1200
|
+
* to a shared SVG that no unfurl crawler can render.
|
|
1201
|
+
*/
|
|
1202
|
+
const resolveCoursePreviewUrl = async (
|
|
1203
|
+
bucket: Bucket,
|
|
1204
|
+
slug: string,
|
|
1205
|
+
configuredPreview?: string
|
|
1206
|
+
): Promise<string> => {
|
|
1207
|
+
// `config` is parsed from a config.json in the bucket, so its declared types
|
|
1208
|
+
// are an assertion rather than a guarantee: an old or hand-edited course can
|
|
1209
|
+
// carry anything here, and a throw would fail the whole publication.
|
|
1210
|
+
if (
|
|
1211
|
+
typeof configuredPreview === "string" &&
|
|
1212
|
+
configuredPreview.startsWith("http")
|
|
1213
|
+
) {
|
|
1214
|
+
return configuredPreview
|
|
1244
1215
|
}
|
|
1245
1216
|
|
|
1246
|
-
|
|
1247
|
-
|
|
1217
|
+
try {
|
|
1218
|
+
const [exists] = await bucket.file(`courses/${slug}/preview.png`).exists()
|
|
1219
|
+
if (exists) {
|
|
1220
|
+
return resolvePreviewUrl(slug)
|
|
1221
|
+
}
|
|
1222
|
+
} catch (error) {
|
|
1223
|
+
console.error(
|
|
1224
|
+
`WARNING: could not check preview.png for ${slug}, using the brand card`,
|
|
1225
|
+
error
|
|
1226
|
+
)
|
|
1248
1227
|
}
|
|
1249
1228
|
|
|
1250
|
-
|
|
1251
|
-
return expectedUrl
|
|
1229
|
+
return getFallbackPreviewUrl()
|
|
1252
1230
|
}
|
|
1253
1231
|
|
|
1254
1232
|
const getTitleFromHTML = (html: string) => {
|
|
@@ -1257,6 +1235,101 @@ const getTitleFromHTML = (html: string) => {
|
|
|
1257
1235
|
return titleMatch ? titleMatch[1] : null
|
|
1258
1236
|
}
|
|
1259
1237
|
|
|
1238
|
+
const escapeHtmlAttribute = (value: string) =>
|
|
1239
|
+
value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<")
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* The IDE shell served to draft courses, with its meta tags filled in.
|
|
1243
|
+
*
|
|
1244
|
+
* The shell ships with `{{title}}`, `{{description}}` and `{{preview}}`
|
|
1245
|
+
* placeholders that only get substituted when a course is published. Drafts were
|
|
1246
|
+
* served the raw template, so a link shared in Slack or WhatsApp unfurled to a
|
|
1247
|
+
* literal `{{title}}` / `{{description}}`.
|
|
1248
|
+
*/
|
|
1249
|
+
let draftShellTemplate: string | null = null
|
|
1250
|
+
|
|
1251
|
+
const loadDraftShellTemplate = (): string => {
|
|
1252
|
+
const file = path.resolve(__dirname, "../ui/_app/index.html")
|
|
1253
|
+
|
|
1254
|
+
draftShellTemplate = fs
|
|
1255
|
+
.readFileSync(file, "utf-8")
|
|
1256
|
+
// A draft is not a real Course, so it must not emit Course structured data.
|
|
1257
|
+
// The match is anchored to the typed opening tag and non-greedy on purpose:
|
|
1258
|
+
// the shell also carries an inline <script> that loads the app bundle from
|
|
1259
|
+
// CloudFront, and a looser pattern would swallow it and blank the page.
|
|
1260
|
+
.replace(
|
|
1261
|
+
/[^\S\n]*<script type="application\/ld\+json">[\S\s]*?<\/script>\n?/,
|
|
1262
|
+
""
|
|
1263
|
+
)
|
|
1264
|
+
|
|
1265
|
+
return draftShellTemplate
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
const renderDraftShell = (req: express.Request): string => {
|
|
1269
|
+
const template = draftShellTemplate || loadDraftShellTemplate()
|
|
1270
|
+
|
|
1271
|
+
// Only the path: a preview URL can carry tokens in its query string, and
|
|
1272
|
+
// og:url ends up in whatever crawler cache unfurls the link.
|
|
1273
|
+
const canonicalUrl = escapeHtmlAttribute(`${getPublicHost()}${req.path}`)
|
|
1274
|
+
|
|
1275
|
+
return (
|
|
1276
|
+
template
|
|
1277
|
+
.replace(/<title>.*<\/title>/, `<title>${DRAFT_PREVIEW_TITLE}</title>`)
|
|
1278
|
+
.replace(/{{title}}/g, DRAFT_PREVIEW_TITLE)
|
|
1279
|
+
.replace(/{{description}}/g, DRAFT_PREVIEW_DESCRIPTION)
|
|
1280
|
+
.replace(/{{preview}}/g, getFallbackPreviewUrl())
|
|
1281
|
+
.replace(
|
|
1282
|
+
"</head>",
|
|
1283
|
+
` <meta property="og:url" content="${canonicalUrl}" />\n` +
|
|
1284
|
+
` <meta name="robots" content="noindex, nofollow, noarchive" />\n </head>`
|
|
1285
|
+
)
|
|
1286
|
+
// Safety net for placeholders a future shell may add: shipping an empty
|
|
1287
|
+
// value to a crawler beats shipping "{{whatever}}".
|
|
1288
|
+
.replace(/{{\w+}}/g, "")
|
|
1289
|
+
)
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* Drafts are unlisted work in progress. `noindex` goes out as both a meta tag
|
|
1294
|
+
* and a header so it reaches crawlers that skip the markup; a `robots.txt`
|
|
1295
|
+
* `Disallow` would be the wrong tool, since it blocks the fetch itself and
|
|
1296
|
+
* would stop Slack and X from unfurling the link at all.
|
|
1297
|
+
*/
|
|
1298
|
+
const sendDraftShell = (req: express.Request, res: express.Response) => {
|
|
1299
|
+
res
|
|
1300
|
+
.type("html")
|
|
1301
|
+
.set("X-Robots-Tag", "noindex, nofollow")
|
|
1302
|
+
.send(renderDraftShell(req))
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/** How often the notification is touched while a translation is running. */
|
|
1306
|
+
const SYNC_HEARTBEAT_INTERVAL_MS = 60 * 1000
|
|
1307
|
+
|
|
1308
|
+
/**
|
|
1309
|
+
* A synchronization with no news for this long is considered stuck: the GET
|
|
1310
|
+
* endpoint flags it as failed and it can be accepted again.
|
|
1311
|
+
*/
|
|
1312
|
+
const SYNC_PROCESSING_TIMEOUT_MS = 3 * 60 * 1000
|
|
1313
|
+
|
|
1314
|
+
// True when a processing notification stopped reporting progress.
|
|
1315
|
+
function isSyncProcessingStale(notification: SyncNotification): boolean {
|
|
1316
|
+
const lastUpdate =
|
|
1317
|
+
notification.processingLastUpdate || notification.updatedAt
|
|
1318
|
+
return Date.now() - lastUpdate > SYNC_PROCESSING_TIMEOUT_MS
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
function findSyncNotification(
|
|
1322
|
+
syllabus: Syllabus,
|
|
1323
|
+
exerciseSlug: string,
|
|
1324
|
+
notificationId: string
|
|
1325
|
+
) {
|
|
1326
|
+
const lesson = syllabus.lessons.find(
|
|
1327
|
+
lesson => slugify(lesson.id + "-" + lesson.title) === exerciseSlug
|
|
1328
|
+
)
|
|
1329
|
+
|
|
1330
|
+
return lesson?.syncNotifications?.find(n => n.id === notificationId)
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1260
1333
|
async function processSyncTranslationsSequentially(
|
|
1261
1334
|
courseSlug: string,
|
|
1262
1335
|
exerciseSlug: string,
|
|
@@ -1269,30 +1342,72 @@ async function processSyncTranslationsSequentially(
|
|
|
1269
1342
|
) {
|
|
1270
1343
|
try {
|
|
1271
1344
|
const sanitizedSource = sanitizeReadmeNewlines(sourceReadmeContent)
|
|
1345
|
+
|
|
1346
|
+
let lastHeartbeatAt = Date.now()
|
|
1347
|
+
|
|
1348
|
+
// Keeps `processingLastUpdate` fresh while a language is being translated,
|
|
1349
|
+
// otherwise the PROCESSING_TIMEOUT in GET /sync-notifications can flag a
|
|
1350
|
+
// running synchronization as failed. Throttled, since every beat rewrites
|
|
1351
|
+
// the syllabus in the bucket.
|
|
1352
|
+
const heartbeat = async (targetLang: string) => {
|
|
1353
|
+
if (Date.now() - lastHeartbeatAt < SYNC_HEARTBEAT_INTERVAL_MS) {
|
|
1354
|
+
return
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
lastHeartbeatAt = Date.now()
|
|
1358
|
+
|
|
1359
|
+
try {
|
|
1360
|
+
await serializeByCourse(courseSlug, async () => {
|
|
1361
|
+
const syllabus = await getSyllabus(courseSlug, bucket)
|
|
1362
|
+
const notification = findSyncNotification(
|
|
1363
|
+
syllabus,
|
|
1364
|
+
exerciseSlug,
|
|
1365
|
+
notificationId
|
|
1366
|
+
)
|
|
1367
|
+
|
|
1368
|
+
if (!notification) {
|
|
1369
|
+
return
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
notification.processingLastUpdate = Date.now()
|
|
1373
|
+
|
|
1374
|
+
if (notification.syncProgress) {
|
|
1375
|
+
notification.syncProgress.currentLanguage = targetLang
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
await saveSyllabus(courseSlug, syllabus, bucket)
|
|
1379
|
+
})
|
|
1380
|
+
} catch (error) {
|
|
1381
|
+
console.warn(
|
|
1382
|
+
`🔄 SYNC: heartbeat failed for ${exerciseSlug}:`,
|
|
1383
|
+
(error as Error).message
|
|
1384
|
+
)
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1272
1388
|
// Process translations sequentially (no race conditions)
|
|
1273
1389
|
for (const targetLang of targetLanguages) {
|
|
1274
1390
|
try {
|
|
1275
|
-
|
|
1391
|
+
const languageStartedAt = Date.now()
|
|
1392
|
+
|
|
1393
|
+
// The translation runs as an asynchronous Rigobot job that we poll:
|
|
1394
|
+
// waiting for it inside a single request takes 28-45s for a full
|
|
1395
|
+
// README and Heroku kills anything above 30s.
|
|
1276
1396
|
// eslint-disable-next-line no-await-in-loop
|
|
1277
|
-
const
|
|
1278
|
-
|
|
1397
|
+
const translationResult = await translateExerciseAndWait(
|
|
1398
|
+
rigoToken,
|
|
1279
1399
|
{
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
output_language: targetLang,
|
|
1283
|
-
},
|
|
1284
|
-
include_purpose_objective: false,
|
|
1285
|
-
execute_async: false, // Synchronous execution
|
|
1400
|
+
text_to_translate: sanitizedSource,
|
|
1401
|
+
output_language: targetLang,
|
|
1286
1402
|
},
|
|
1287
|
-
{
|
|
1288
|
-
headers: {
|
|
1289
|
-
"Content-Type": "application/json",
|
|
1290
|
-
Authorization: "Token " + rigoToken,
|
|
1291
|
-
},
|
|
1292
|
-
}
|
|
1403
|
+
{ onPoll: () => heartbeat(targetLang) }
|
|
1293
1404
|
)
|
|
1294
1405
|
|
|
1295
|
-
|
|
1406
|
+
console.log(
|
|
1407
|
+
`🔄 SYNC: ${targetLang} translated in ${Math.round(
|
|
1408
|
+
(Date.now() - languageStartedAt) / 1000
|
|
1409
|
+
)}s (job ${translationResult.id})`
|
|
1410
|
+
)
|
|
1296
1411
|
|
|
1297
1412
|
// Check if translation was successful
|
|
1298
1413
|
if (!translationResult.parsed?.translation) {
|
|
@@ -1428,10 +1543,22 @@ async function processSyncTranslationsSequentially(
|
|
|
1428
1543
|
(notification.syncProgress.failedLanguages?.length || 0)
|
|
1429
1544
|
|
|
1430
1545
|
if (totalProcessed === notification.syncProgress.totalLanguages) {
|
|
1431
|
-
|
|
1432
|
-
notification.syncProgress.completedLanguages.length
|
|
1433
|
-
|
|
1434
|
-
|
|
1546
|
+
const completedCount =
|
|
1547
|
+
notification.syncProgress.completedLanguages.length
|
|
1548
|
+
const failedCount =
|
|
1549
|
+
notification.syncProgress.failedLanguages?.length || 0
|
|
1550
|
+
|
|
1551
|
+
// A partial result stays actionable: the notification remains
|
|
1552
|
+
// visible so the user can retry just the languages that failed.
|
|
1553
|
+
if (completedCount === 0) {
|
|
1554
|
+
notification.status = "error"
|
|
1555
|
+
} else if (failedCount > 0) {
|
|
1556
|
+
notification.status = "partial"
|
|
1557
|
+
} else {
|
|
1558
|
+
notification.status = "completed"
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
notification.updatedAt = Date.now()
|
|
1435
1562
|
|
|
1436
1563
|
await saveSyllabus(courseSlug, syllabus, bucket)
|
|
1437
1564
|
|
|
@@ -1598,6 +1725,7 @@ class ServeCommand extends SessionCommand {
|
|
|
1598
1725
|
// app.use(express.static(distPath))
|
|
1599
1726
|
app.use(express.json({ limit: "20mb" }))
|
|
1600
1727
|
app.use(cors())
|
|
1728
|
+
app.use("/generation", createIngestRouter())
|
|
1601
1729
|
|
|
1602
1730
|
const appPath = path.resolve(__dirname, "../ui/_app")
|
|
1603
1731
|
const tarPath = path.resolve(__dirname, "../ui/app.tar.gz")
|
|
@@ -1619,6 +1747,11 @@ class ServeCommand extends SessionCommand {
|
|
|
1619
1747
|
const localAppPath = path.resolve(__dirname, "../ui/_app")
|
|
1620
1748
|
// app.use(express.static(localAppPath))
|
|
1621
1749
|
|
|
1750
|
+
// Warm the draft shell now that _app is on disk. Doing it here rather than
|
|
1751
|
+
// on first request means a corrupt tarball fails at boot, with a stack
|
|
1752
|
+
// trace, instead of on some user's preview link.
|
|
1753
|
+
loadDraftShellTemplate()
|
|
1754
|
+
|
|
1622
1755
|
app.post("/upload", async (req, res) => {
|
|
1623
1756
|
const { content, destination } = req.body
|
|
1624
1757
|
// console.log("UPLOAD", content, destination)
|
|
@@ -3104,8 +3237,7 @@ class ServeCommand extends SessionCommand {
|
|
|
3104
3237
|
const slug = req.query.slug
|
|
3105
3238
|
|
|
3106
3239
|
if (slug) {
|
|
3107
|
-
|
|
3108
|
-
res.sendFile(file)
|
|
3240
|
+
sendDraftShell(req, res)
|
|
3109
3241
|
} else {
|
|
3110
3242
|
res.redirect("https://learnpack.co")
|
|
3111
3243
|
}
|
|
@@ -3114,13 +3246,18 @@ class ServeCommand extends SessionCommand {
|
|
|
3114
3246
|
app.get("/preview/:slug", async (req, res) => {
|
|
3115
3247
|
console.log("GET COURSE PREVIEW /")
|
|
3116
3248
|
|
|
3117
|
-
|
|
3118
|
-
res.sendFile(file)
|
|
3249
|
+
sendDraftShell(req, res)
|
|
3119
3250
|
})
|
|
3120
3251
|
|
|
3121
3252
|
app.get("/preview/:slug/webview", async (req, res) => {
|
|
3122
|
-
|
|
3123
|
-
|
|
3253
|
+
sendDraftShell(req, res)
|
|
3254
|
+
})
|
|
3255
|
+
|
|
3256
|
+
// The brand card used as og:image by drafts, and as the last-resort
|
|
3257
|
+
// fallback by published courses without a preview of their own.
|
|
3258
|
+
app.get("/og-image.png", (req, res) => {
|
|
3259
|
+
res.set("Cache-Control", "public, max-age=86400")
|
|
3260
|
+
res.sendFile(path.resolve(__dirname, "../ui/static/og-image.png"))
|
|
3124
3261
|
})
|
|
3125
3262
|
|
|
3126
3263
|
app.get("/config", async (req, res) => {
|
|
@@ -4343,45 +4480,42 @@ class ServeCommand extends SessionCommand {
|
|
|
4343
4480
|
return res.status(404).json({ error: "Syllabus not found" })
|
|
4344
4481
|
}
|
|
4345
4482
|
|
|
4346
|
-
const PROCESSING_TIMEOUT = 3 * 60 * 1000 // 3 minutes
|
|
4347
4483
|
let modified = false
|
|
4348
4484
|
|
|
4349
|
-
// Collect active notifications (pending, processing, or error)
|
|
4485
|
+
// Collect active notifications (pending, processing, partial or error)
|
|
4350
4486
|
const notifications: any[] = []
|
|
4351
4487
|
|
|
4352
4488
|
for (const lesson of syllabus.lessons) {
|
|
4353
4489
|
if (lesson.syncNotifications && lesson.syncNotifications.length > 0) {
|
|
4354
4490
|
for (const notification of lesson.syncNotifications) {
|
|
4355
4491
|
// Check for timeout in processing notifications
|
|
4356
|
-
if (
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
|
|
4364
|
-
|
|
4365
|
-
notification.error = {
|
|
4366
|
-
message: "Synchronization timeout - process took too long",
|
|
4367
|
-
code: "PROCESSING_TIMEOUT",
|
|
4368
|
-
timestamp: Date.now(),
|
|
4369
|
-
}
|
|
4370
|
-
modified = true
|
|
4371
|
-
|
|
4372
|
-
// Emit error event
|
|
4373
|
-
emitToCourse(courseSlug, "sync-notification-error", {
|
|
4374
|
-
exerciseSlug: slugify(lesson.id + "-" + lesson.title),
|
|
4375
|
-
notificationId: notification.id,
|
|
4376
|
-
error: "Processing timeout",
|
|
4377
|
-
})
|
|
4492
|
+
if (
|
|
4493
|
+
notification.status === "processing" &&
|
|
4494
|
+
isSyncProcessingStale(notification)
|
|
4495
|
+
) {
|
|
4496
|
+
notification.status = "error"
|
|
4497
|
+
notification.error = {
|
|
4498
|
+
message: "Synchronization timeout - process took too long",
|
|
4499
|
+
code: "PROCESSING_TIMEOUT",
|
|
4500
|
+
timestamp: Date.now(),
|
|
4378
4501
|
}
|
|
4502
|
+
modified = true
|
|
4503
|
+
|
|
4504
|
+
// Emit error event
|
|
4505
|
+
emitToCourse(courseSlug, "sync-notification-error", {
|
|
4506
|
+
exerciseSlug: slugify(lesson.id + "-" + lesson.title),
|
|
4507
|
+
notificationId: notification.id,
|
|
4508
|
+
error: "Processing timeout",
|
|
4509
|
+
})
|
|
4379
4510
|
}
|
|
4380
4511
|
|
|
4381
|
-
// Include active notifications (pending, processing, or
|
|
4512
|
+
// Include active notifications (pending, processing, partial or
|
|
4513
|
+
// error): a partial sync is still actionable, the user can retry
|
|
4514
|
+
// the languages that failed.
|
|
4382
4515
|
if (
|
|
4383
4516
|
notification.status === "pending" ||
|
|
4384
4517
|
notification.status === "processing" ||
|
|
4518
|
+
notification.status === "partial" ||
|
|
4385
4519
|
notification.status === "error"
|
|
4386
4520
|
) {
|
|
4387
4521
|
notifications.push({
|
|
@@ -4521,9 +4655,24 @@ class ServeCommand extends SessionCommand {
|
|
|
4521
4655
|
return res.status(404).json({ error: "Notification not found" })
|
|
4522
4656
|
}
|
|
4523
4657
|
|
|
4524
|
-
|
|
4658
|
+
// A synchronization can be accepted again unless it is actively
|
|
4659
|
+
// running: after a failure or a partial result this same
|
|
4660
|
+
// notification is what the Retry button acts on.
|
|
4661
|
+
if (
|
|
4662
|
+
notification.status === "processing" &&
|
|
4663
|
+
!isSyncProcessingStale(notification)
|
|
4664
|
+
) {
|
|
4665
|
+
return res.status(409).json({
|
|
4666
|
+
error: "Notification is already being synchronized",
|
|
4667
|
+
code: "ALREADY_PROCESSING",
|
|
4668
|
+
currentStatus: notification.status,
|
|
4669
|
+
})
|
|
4670
|
+
}
|
|
4671
|
+
|
|
4672
|
+
if (notification.status === "completed") {
|
|
4525
4673
|
return res.status(400).json({
|
|
4526
|
-
error: "Notification is
|
|
4674
|
+
error: "Notification is already synchronized",
|
|
4675
|
+
code: "ALREADY_COMPLETED",
|
|
4527
4676
|
currentStatus: notification.status,
|
|
4528
4677
|
})
|
|
4529
4678
|
}
|
|
@@ -4545,10 +4694,16 @@ class ServeCommand extends SessionCommand {
|
|
|
4545
4694
|
})
|
|
4546
4695
|
}
|
|
4547
4696
|
|
|
4548
|
-
// Determine target languages
|
|
4697
|
+
// Determine target languages. On a retry only what is still missing
|
|
4698
|
+
// is translated again, so the languages that already succeeded are
|
|
4699
|
+
// neither paid for twice nor overwritten.
|
|
4700
|
+
const alreadyCompleted =
|
|
4701
|
+
notification.syncProgress?.completedLanguages || []
|
|
4549
4702
|
const availableLanguages = Object.keys(lesson.translations || {})
|
|
4550
4703
|
const targetLanguages = availableLanguages.filter(
|
|
4551
|
-
lang =>
|
|
4704
|
+
lang =>
|
|
4705
|
+
lang !== notification.sourceLanguage &&
|
|
4706
|
+
!alreadyCompleted.includes(lang)
|
|
4552
4707
|
)
|
|
4553
4708
|
|
|
4554
4709
|
if (targetLanguages.length === 0) {
|
|
@@ -4563,8 +4718,12 @@ class ServeCommand extends SessionCommand {
|
|
|
4563
4718
|
lesson.syncNotifications?.filter(n => n.id === notificationId) ||
|
|
4564
4719
|
[]
|
|
4565
4720
|
|
|
4721
|
+
// Start from a clean slate so a previous failure does not leak into
|
|
4722
|
+
// this run.
|
|
4566
4723
|
notification.status = "processing"
|
|
4724
|
+
notification.updatedAt = Date.now()
|
|
4567
4725
|
notification.processingLastUpdate = Date.now()
|
|
4726
|
+
delete notification.error
|
|
4568
4727
|
notification.syncProgress = {
|
|
4569
4728
|
totalLanguages: targetLanguages.length,
|
|
4570
4729
|
completedLanguages: [], // Array of completed language codes
|
|
@@ -5358,16 +5517,18 @@ class ServeCommand extends SessionCommand {
|
|
|
5358
5517
|
selectedLang = availableLangs[0]
|
|
5359
5518
|
}
|
|
5360
5519
|
|
|
5520
|
+
const previewUrl = await resolveCoursePreviewUrl(
|
|
5521
|
+
bucket,
|
|
5522
|
+
slug,
|
|
5523
|
+
config.preview
|
|
5524
|
+
)
|
|
5525
|
+
|
|
5361
5526
|
const idxTpl = fs.readFileSync(path.join(uiSrc, "index.html"), "utf-8")
|
|
5362
5527
|
const idxHtml = idxTpl
|
|
5363
5528
|
.replace(/{{title}}/g, title)
|
|
5364
5529
|
.replace(/<title>.*<\/title>/, `<title>${title}</title>`)
|
|
5365
5530
|
.replace(/{{description}}/g, config.description[selectedLang])
|
|
5366
|
-
.replace(
|
|
5367
|
-
/{{preview}}/g,
|
|
5368
|
-
fixPreviewUrl(slug, "") ||
|
|
5369
|
-
"https://raw.githubusercontent.com/learnpack/ide/master/public/learnpack.svg"
|
|
5370
|
-
)
|
|
5531
|
+
.replace(/{{preview}}/g, previewUrl)
|
|
5371
5532
|
.replace(/{{slug}}/g, slug)
|
|
5372
5533
|
.replace(/{{duration}}/g, minutesToISO8601Duration(config.duration))
|
|
5373
5534
|
fs.writeFileSync(path.join(buildRoot, "index.html"), idxHtml)
|
|
@@ -5415,9 +5576,9 @@ class ServeCommand extends SessionCommand {
|
|
|
5415
5576
|
// 8) Crear el config.json en buildRoot con lo que retorna buildConfig
|
|
5416
5577
|
const fullConfig = { config, exercises }
|
|
5417
5578
|
fullConfig.config.slug = slug
|
|
5418
|
-
|
|
5419
|
-
|
|
5420
|
-
|
|
5579
|
+
// Same URL the meta tags got: this used to overwrite the real preview
|
|
5580
|
+
// of every published package with the unusable SVG fallback.
|
|
5581
|
+
fullConfig.config.preview = previewUrl
|
|
5421
5582
|
|
|
5422
5583
|
fs.writeFileSync(
|
|
5423
5584
|
path.join(buildRoot, "config.json"),
|