@learnpack/learnpack 5.0.354 → 5.0.357
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.d.ts +0 -17
- package/lib/commands/serve.js +160 -130
- 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/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 +1 -1
- package/src/commands/serve.ts +163 -115
- 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 +325 -325
- package/src/ui/app.tar.gz +0 -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
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which sockets are listening on which channel.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `creatorSocket.ts` so the bookkeeping can be tested without
|
|
5
|
+
* standing up a socket.io server. The registry deals in socket ids only; the
|
|
6
|
+
* socket objects themselves stay with the transport layer.
|
|
7
|
+
*
|
|
8
|
+
* The behaviour that matters is `removeSocket`: a disconnecting client has to
|
|
9
|
+
* disappear from **every** channel it joined. The notification map used to be
|
|
10
|
+
* skipped on disconnect, so it grew for the lifetime of the process and
|
|
11
|
+
* accumulated ids of sockets that were long gone.
|
|
12
|
+
*/
|
|
13
|
+
export declare class SocketRegistry {
|
|
14
|
+
private readonly channels;
|
|
15
|
+
/**
|
|
16
|
+
* Adds a socket to a channel.
|
|
17
|
+
* @param channel - Channel key, such as a course slug or a notification id.
|
|
18
|
+
* @param socketId - The socket joining it.
|
|
19
|
+
*/
|
|
20
|
+
register(channel: string, socketId: string): void;
|
|
21
|
+
/**
|
|
22
|
+
* Removes a socket from one channel, dropping the channel when it empties.
|
|
23
|
+
* @param channel - Channel to leave.
|
|
24
|
+
* @param socketId - The socket leaving it.
|
|
25
|
+
*/
|
|
26
|
+
unregister(channel: string, socketId: string): void;
|
|
27
|
+
/**
|
|
28
|
+
* Removes a socket from every channel it joined.
|
|
29
|
+
*
|
|
30
|
+
* Called when the socket disconnects. Without it the registry keeps growing
|
|
31
|
+
* with ids that can never receive anything again.
|
|
32
|
+
* @param socketId - The socket that went away.
|
|
33
|
+
*/
|
|
34
|
+
removeSocket(socketId: string): void;
|
|
35
|
+
/**
|
|
36
|
+
* The sockets currently listening on a channel.
|
|
37
|
+
* @param channel - Channel to look up.
|
|
38
|
+
* @returns Their socket ids, empty when nobody is listening.
|
|
39
|
+
*/
|
|
40
|
+
listeners(channel: string): string[];
|
|
41
|
+
/**
|
|
42
|
+
* How many channels are being tracked. Exposed for tests and diagnostics.
|
|
43
|
+
* @returns The number of non-empty channels.
|
|
44
|
+
*/
|
|
45
|
+
channelCount(): number;
|
|
46
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SocketRegistry = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Which sockets are listening on which channel.
|
|
6
|
+
*
|
|
7
|
+
* Extracted from `creatorSocket.ts` so the bookkeeping can be tested without
|
|
8
|
+
* standing up a socket.io server. The registry deals in socket ids only; the
|
|
9
|
+
* socket objects themselves stay with the transport layer.
|
|
10
|
+
*
|
|
11
|
+
* The behaviour that matters is `removeSocket`: a disconnecting client has to
|
|
12
|
+
* disappear from **every** channel it joined. The notification map used to be
|
|
13
|
+
* skipped on disconnect, so it grew for the lifetime of the process and
|
|
14
|
+
* accumulated ids of sockets that were long gone.
|
|
15
|
+
*/
|
|
16
|
+
class SocketRegistry {
|
|
17
|
+
constructor() {
|
|
18
|
+
this.channels = new Map();
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Adds a socket to a channel.
|
|
22
|
+
* @param channel - Channel key, such as a course slug or a notification id.
|
|
23
|
+
* @param socketId - The socket joining it.
|
|
24
|
+
*/
|
|
25
|
+
register(channel, socketId) {
|
|
26
|
+
if (!channel || !socketId) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
let listeners = this.channels.get(channel);
|
|
30
|
+
if (!listeners) {
|
|
31
|
+
listeners = new Set();
|
|
32
|
+
this.channels.set(channel, listeners);
|
|
33
|
+
}
|
|
34
|
+
listeners.add(socketId);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Removes a socket from one channel, dropping the channel when it empties.
|
|
38
|
+
* @param channel - Channel to leave.
|
|
39
|
+
* @param socketId - The socket leaving it.
|
|
40
|
+
*/
|
|
41
|
+
unregister(channel, socketId) {
|
|
42
|
+
const listeners = this.channels.get(channel);
|
|
43
|
+
if (!listeners) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
listeners.delete(socketId);
|
|
47
|
+
if (listeners.size === 0) {
|
|
48
|
+
this.channels.delete(channel);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Removes a socket from every channel it joined.
|
|
53
|
+
*
|
|
54
|
+
* Called when the socket disconnects. Without it the registry keeps growing
|
|
55
|
+
* with ids that can never receive anything again.
|
|
56
|
+
* @param socketId - The socket that went away.
|
|
57
|
+
*/
|
|
58
|
+
removeSocket(socketId) {
|
|
59
|
+
for (const [channel, listeners] of this.channels) {
|
|
60
|
+
listeners.delete(socketId);
|
|
61
|
+
if (listeners.size === 0) {
|
|
62
|
+
this.channels.delete(channel);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The sockets currently listening on a channel.
|
|
68
|
+
* @param channel - Channel to look up.
|
|
69
|
+
* @returns Their socket ids, empty when nobody is listening.
|
|
70
|
+
*/
|
|
71
|
+
listeners(channel) {
|
|
72
|
+
const listeners = this.channels.get(channel);
|
|
73
|
+
return listeners ? [...listeners] : [];
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* How many channels are being tracked. Exposed for tests and diagnostics.
|
|
77
|
+
* @returns The number of non-empty channels.
|
|
78
|
+
*/
|
|
79
|
+
channelCount() {
|
|
80
|
+
return this.channels.size;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
exports.SocketRegistry = SocketRegistry;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@learnpack/learnpack",
|
|
3
3
|
"description": "Seamlessly build, sell and/or take interactive & auto-graded tutorials, start learning now or build a new tutorial to your audience.",
|
|
4
|
-
"version": "5.0.
|
|
4
|
+
"version": "5.0.357",
|
|
5
5
|
"author": "Alejandro Sanchez @alesanchezr",
|
|
6
6
|
"contributors": [
|
|
7
7
|
{
|
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,6 +74,13 @@ import {
|
|
|
74
74
|
SyncNotification,
|
|
75
75
|
} from "../models/creator"
|
|
76
76
|
import { exportToScorm, exportToEpub, exportToZip } from "../utils/export"
|
|
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"
|
|
77
84
|
import { generateAndPersistPackageManifest } from "../utils/packageManifest"
|
|
78
85
|
import { requireGcsBucketName } from "../utils/gcsBucketName"
|
|
79
86
|
import {
|
|
@@ -161,56 +168,6 @@ async function serializeByCourse<T>(
|
|
|
161
168
|
return next
|
|
162
169
|
}
|
|
163
170
|
|
|
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
171
|
const getGithubCredentials = () => {
|
|
215
172
|
const token = process.env.GITHUB_TOKEN?.trim()
|
|
216
173
|
const username = process.env.GITHUB_USERNAME?.trim()
|
|
@@ -518,20 +475,6 @@ export const processImage = async (
|
|
|
518
475
|
}
|
|
519
476
|
}
|
|
520
477
|
|
|
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
478
|
const uploadInitialReadme = async (
|
|
536
479
|
bucket: Bucket,
|
|
537
480
|
exSlug: string,
|
|
@@ -1257,6 +1200,34 @@ const getTitleFromHTML = (html: string) => {
|
|
|
1257
1200
|
return titleMatch ? titleMatch[1] : null
|
|
1258
1201
|
}
|
|
1259
1202
|
|
|
1203
|
+
/** How often the notification is touched while a translation is running. */
|
|
1204
|
+
const SYNC_HEARTBEAT_INTERVAL_MS = 60 * 1000
|
|
1205
|
+
|
|
1206
|
+
/**
|
|
1207
|
+
* A synchronization with no news for this long is considered stuck: the GET
|
|
1208
|
+
* endpoint flags it as failed and it can be accepted again.
|
|
1209
|
+
*/
|
|
1210
|
+
const SYNC_PROCESSING_TIMEOUT_MS = 3 * 60 * 1000
|
|
1211
|
+
|
|
1212
|
+
// True when a processing notification stopped reporting progress.
|
|
1213
|
+
function isSyncProcessingStale(notification: SyncNotification): boolean {
|
|
1214
|
+
const lastUpdate =
|
|
1215
|
+
notification.processingLastUpdate || notification.updatedAt
|
|
1216
|
+
return Date.now() - lastUpdate > SYNC_PROCESSING_TIMEOUT_MS
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
function findSyncNotification(
|
|
1220
|
+
syllabus: Syllabus,
|
|
1221
|
+
exerciseSlug: string,
|
|
1222
|
+
notificationId: string
|
|
1223
|
+
) {
|
|
1224
|
+
const lesson = syllabus.lessons.find(
|
|
1225
|
+
lesson => slugify(lesson.id + "-" + lesson.title) === exerciseSlug
|
|
1226
|
+
)
|
|
1227
|
+
|
|
1228
|
+
return lesson?.syncNotifications?.find(n => n.id === notificationId)
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1260
1231
|
async function processSyncTranslationsSequentially(
|
|
1261
1232
|
courseSlug: string,
|
|
1262
1233
|
exerciseSlug: string,
|
|
@@ -1269,30 +1240,72 @@ async function processSyncTranslationsSequentially(
|
|
|
1269
1240
|
) {
|
|
1270
1241
|
try {
|
|
1271
1242
|
const sanitizedSource = sanitizeReadmeNewlines(sourceReadmeContent)
|
|
1243
|
+
|
|
1244
|
+
let lastHeartbeatAt = Date.now()
|
|
1245
|
+
|
|
1246
|
+
// Keeps `processingLastUpdate` fresh while a language is being translated,
|
|
1247
|
+
// otherwise the PROCESSING_TIMEOUT in GET /sync-notifications can flag a
|
|
1248
|
+
// running synchronization as failed. Throttled, since every beat rewrites
|
|
1249
|
+
// the syllabus in the bucket.
|
|
1250
|
+
const heartbeat = async (targetLang: string) => {
|
|
1251
|
+
if (Date.now() - lastHeartbeatAt < SYNC_HEARTBEAT_INTERVAL_MS) {
|
|
1252
|
+
return
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
lastHeartbeatAt = Date.now()
|
|
1256
|
+
|
|
1257
|
+
try {
|
|
1258
|
+
await serializeByCourse(courseSlug, async () => {
|
|
1259
|
+
const syllabus = await getSyllabus(courseSlug, bucket)
|
|
1260
|
+
const notification = findSyncNotification(
|
|
1261
|
+
syllabus,
|
|
1262
|
+
exerciseSlug,
|
|
1263
|
+
notificationId
|
|
1264
|
+
)
|
|
1265
|
+
|
|
1266
|
+
if (!notification) {
|
|
1267
|
+
return
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
notification.processingLastUpdate = Date.now()
|
|
1271
|
+
|
|
1272
|
+
if (notification.syncProgress) {
|
|
1273
|
+
notification.syncProgress.currentLanguage = targetLang
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
await saveSyllabus(courseSlug, syllabus, bucket)
|
|
1277
|
+
})
|
|
1278
|
+
} catch (error) {
|
|
1279
|
+
console.warn(
|
|
1280
|
+
`🔄 SYNC: heartbeat failed for ${exerciseSlug}:`,
|
|
1281
|
+
(error as Error).message
|
|
1282
|
+
)
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1272
1286
|
// Process translations sequentially (no race conditions)
|
|
1273
1287
|
for (const targetLang of targetLanguages) {
|
|
1274
1288
|
try {
|
|
1275
|
-
|
|
1289
|
+
const languageStartedAt = Date.now()
|
|
1290
|
+
|
|
1291
|
+
// The translation runs as an asynchronous Rigobot job that we poll:
|
|
1292
|
+
// waiting for it inside a single request takes 28-45s for a full
|
|
1293
|
+
// README and Heroku kills anything above 30s.
|
|
1276
1294
|
// eslint-disable-next-line no-await-in-loop
|
|
1277
|
-
const
|
|
1278
|
-
|
|
1295
|
+
const translationResult = await translateExerciseAndWait(
|
|
1296
|
+
rigoToken,
|
|
1279
1297
|
{
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
output_language: targetLang,
|
|
1283
|
-
},
|
|
1284
|
-
include_purpose_objective: false,
|
|
1285
|
-
execute_async: false, // Synchronous execution
|
|
1298
|
+
text_to_translate: sanitizedSource,
|
|
1299
|
+
output_language: targetLang,
|
|
1286
1300
|
},
|
|
1287
|
-
{
|
|
1288
|
-
headers: {
|
|
1289
|
-
"Content-Type": "application/json",
|
|
1290
|
-
Authorization: "Token " + rigoToken,
|
|
1291
|
-
},
|
|
1292
|
-
}
|
|
1301
|
+
{ onPoll: () => heartbeat(targetLang) }
|
|
1293
1302
|
)
|
|
1294
1303
|
|
|
1295
|
-
|
|
1304
|
+
console.log(
|
|
1305
|
+
`🔄 SYNC: ${targetLang} translated in ${Math.round(
|
|
1306
|
+
(Date.now() - languageStartedAt) / 1000
|
|
1307
|
+
)}s (job ${translationResult.id})`
|
|
1308
|
+
)
|
|
1296
1309
|
|
|
1297
1310
|
// Check if translation was successful
|
|
1298
1311
|
if (!translationResult.parsed?.translation) {
|
|
@@ -1428,10 +1441,22 @@ async function processSyncTranslationsSequentially(
|
|
|
1428
1441
|
(notification.syncProgress.failedLanguages?.length || 0)
|
|
1429
1442
|
|
|
1430
1443
|
if (totalProcessed === notification.syncProgress.totalLanguages) {
|
|
1431
|
-
|
|
1432
|
-
notification.syncProgress.completedLanguages.length
|
|
1433
|
-
|
|
1434
|
-
|
|
1444
|
+
const completedCount =
|
|
1445
|
+
notification.syncProgress.completedLanguages.length
|
|
1446
|
+
const failedCount =
|
|
1447
|
+
notification.syncProgress.failedLanguages?.length || 0
|
|
1448
|
+
|
|
1449
|
+
// A partial result stays actionable: the notification remains
|
|
1450
|
+
// visible so the user can retry just the languages that failed.
|
|
1451
|
+
if (completedCount === 0) {
|
|
1452
|
+
notification.status = "error"
|
|
1453
|
+
} else if (failedCount > 0) {
|
|
1454
|
+
notification.status = "partial"
|
|
1455
|
+
} else {
|
|
1456
|
+
notification.status = "completed"
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
notification.updatedAt = Date.now()
|
|
1435
1460
|
|
|
1436
1461
|
await saveSyllabus(courseSlug, syllabus, bucket)
|
|
1437
1462
|
|
|
@@ -1598,6 +1623,7 @@ class ServeCommand extends SessionCommand {
|
|
|
1598
1623
|
// app.use(express.static(distPath))
|
|
1599
1624
|
app.use(express.json({ limit: "20mb" }))
|
|
1600
1625
|
app.use(cors())
|
|
1626
|
+
app.use("/generation", createIngestRouter())
|
|
1601
1627
|
|
|
1602
1628
|
const appPath = path.resolve(__dirname, "../ui/_app")
|
|
1603
1629
|
const tarPath = path.resolve(__dirname, "../ui/app.tar.gz")
|
|
@@ -4343,45 +4369,42 @@ class ServeCommand extends SessionCommand {
|
|
|
4343
4369
|
return res.status(404).json({ error: "Syllabus not found" })
|
|
4344
4370
|
}
|
|
4345
4371
|
|
|
4346
|
-
const PROCESSING_TIMEOUT = 3 * 60 * 1000 // 3 minutes
|
|
4347
4372
|
let modified = false
|
|
4348
4373
|
|
|
4349
|
-
// Collect active notifications (pending, processing, or error)
|
|
4374
|
+
// Collect active notifications (pending, processing, partial or error)
|
|
4350
4375
|
const notifications: any[] = []
|
|
4351
4376
|
|
|
4352
4377
|
for (const lesson of syllabus.lessons) {
|
|
4353
4378
|
if (lesson.syncNotifications && lesson.syncNotifications.length > 0) {
|
|
4354
4379
|
for (const notification of lesson.syncNotifications) {
|
|
4355
4380
|
// 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
|
-
})
|
|
4381
|
+
if (
|
|
4382
|
+
notification.status === "processing" &&
|
|
4383
|
+
isSyncProcessingStale(notification)
|
|
4384
|
+
) {
|
|
4385
|
+
notification.status = "error"
|
|
4386
|
+
notification.error = {
|
|
4387
|
+
message: "Synchronization timeout - process took too long",
|
|
4388
|
+
code: "PROCESSING_TIMEOUT",
|
|
4389
|
+
timestamp: Date.now(),
|
|
4378
4390
|
}
|
|
4391
|
+
modified = true
|
|
4392
|
+
|
|
4393
|
+
// Emit error event
|
|
4394
|
+
emitToCourse(courseSlug, "sync-notification-error", {
|
|
4395
|
+
exerciseSlug: slugify(lesson.id + "-" + lesson.title),
|
|
4396
|
+
notificationId: notification.id,
|
|
4397
|
+
error: "Processing timeout",
|
|
4398
|
+
})
|
|
4379
4399
|
}
|
|
4380
4400
|
|
|
4381
|
-
// Include active notifications (pending, processing, or
|
|
4401
|
+
// Include active notifications (pending, processing, partial or
|
|
4402
|
+
// error): a partial sync is still actionable, the user can retry
|
|
4403
|
+
// the languages that failed.
|
|
4382
4404
|
if (
|
|
4383
4405
|
notification.status === "pending" ||
|
|
4384
4406
|
notification.status === "processing" ||
|
|
4407
|
+
notification.status === "partial" ||
|
|
4385
4408
|
notification.status === "error"
|
|
4386
4409
|
) {
|
|
4387
4410
|
notifications.push({
|
|
@@ -4521,9 +4544,24 @@ class ServeCommand extends SessionCommand {
|
|
|
4521
4544
|
return res.status(404).json({ error: "Notification not found" })
|
|
4522
4545
|
}
|
|
4523
4546
|
|
|
4524
|
-
|
|
4547
|
+
// A synchronization can be accepted again unless it is actively
|
|
4548
|
+
// running: after a failure or a partial result this same
|
|
4549
|
+
// notification is what the Retry button acts on.
|
|
4550
|
+
if (
|
|
4551
|
+
notification.status === "processing" &&
|
|
4552
|
+
!isSyncProcessingStale(notification)
|
|
4553
|
+
) {
|
|
4554
|
+
return res.status(409).json({
|
|
4555
|
+
error: "Notification is already being synchronized",
|
|
4556
|
+
code: "ALREADY_PROCESSING",
|
|
4557
|
+
currentStatus: notification.status,
|
|
4558
|
+
})
|
|
4559
|
+
}
|
|
4560
|
+
|
|
4561
|
+
if (notification.status === "completed") {
|
|
4525
4562
|
return res.status(400).json({
|
|
4526
|
-
error: "Notification is
|
|
4563
|
+
error: "Notification is already synchronized",
|
|
4564
|
+
code: "ALREADY_COMPLETED",
|
|
4527
4565
|
currentStatus: notification.status,
|
|
4528
4566
|
})
|
|
4529
4567
|
}
|
|
@@ -4545,10 +4583,16 @@ class ServeCommand extends SessionCommand {
|
|
|
4545
4583
|
})
|
|
4546
4584
|
}
|
|
4547
4585
|
|
|
4548
|
-
// Determine target languages
|
|
4586
|
+
// Determine target languages. On a retry only what is still missing
|
|
4587
|
+
// is translated again, so the languages that already succeeded are
|
|
4588
|
+
// neither paid for twice nor overwritten.
|
|
4589
|
+
const alreadyCompleted =
|
|
4590
|
+
notification.syncProgress?.completedLanguages || []
|
|
4549
4591
|
const availableLanguages = Object.keys(lesson.translations || {})
|
|
4550
4592
|
const targetLanguages = availableLanguages.filter(
|
|
4551
|
-
lang =>
|
|
4593
|
+
lang =>
|
|
4594
|
+
lang !== notification.sourceLanguage &&
|
|
4595
|
+
!alreadyCompleted.includes(lang)
|
|
4552
4596
|
)
|
|
4553
4597
|
|
|
4554
4598
|
if (targetLanguages.length === 0) {
|
|
@@ -4563,8 +4607,12 @@ class ServeCommand extends SessionCommand {
|
|
|
4563
4607
|
lesson.syncNotifications?.filter(n => n.id === notificationId) ||
|
|
4564
4608
|
[]
|
|
4565
4609
|
|
|
4610
|
+
// Start from a clean slate so a previous failure does not leak into
|
|
4611
|
+
// this run.
|
|
4566
4612
|
notification.status = "processing"
|
|
4613
|
+
notification.updatedAt = Date.now()
|
|
4567
4614
|
notification.processingLastUpdate = Date.now()
|
|
4615
|
+
delete notification.error
|
|
4568
4616
|
notification.syncProgress = {
|
|
4569
4617
|
totalLanguages: targetLanguages.length,
|
|
4570
4618
|
completedLanguages: [], // Array of completed language codes
|
|
@@ -6731,7 +6779,7 @@ class ServeCommand extends SessionCommand {
|
|
|
6731
6779
|
|
|
6732
6780
|
const buffer = Buffer.from(content, "base64")
|
|
6733
6781
|
|
|
6734
|
-
if (isImageFile(item.relativePath)) {
|
|
6782
|
+
if (item.isAsset || isImageFile(item.relativePath)) {
|
|
6735
6783
|
// eslint-disable-next-line no-await-in-loop -- Sequential processing to avoid rate limits
|
|
6736
6784
|
await uploadBinaryToBucket(
|
|
6737
6785
|
bucket,
|
package/src/creator/README.md
CHANGED
|
@@ -1,54 +1,66 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
1
|
+
# LearnPack Creator
|
|
2
|
+
|
|
3
|
+
The React + Vite app behind the `/creator` routes: the wizard that collects a course
|
|
4
|
+
brief, and the syllabus editor where the user refines the generated outline before the
|
|
5
|
+
course is created.
|
|
6
|
+
|
|
7
|
+
It is a separate TypeScript project from the CLI. The root `tsconfig.json` explicitly
|
|
8
|
+
excludes `src/creator`, and this app is ESM (`"type": "module"`) while the CLI is
|
|
9
|
+
CommonJS. Nothing here is compiled by the CLI build.
|
|
10
|
+
|
|
11
|
+
## ⚠️ Read this before you commit
|
|
12
|
+
|
|
13
|
+
A PR that changes `src/creator/src/**` **must not** include `src/creatorDist/**`.
|
|
14
|
+
|
|
15
|
+
`src/creatorDist/` is the compiled output of this app, committed to git because it is
|
|
16
|
+
what the CLI serves at runtime and ships to npm. It is a **release artifact**: it gets
|
|
17
|
+
rebuilt and committed only in the release commit, by `autoUpload.sh` at the repository
|
|
18
|
+
root.
|
|
19
|
+
|
|
20
|
+
Committing it in a feature PR causes conflicts that cannot be resolved by merging —
|
|
21
|
+
Vite emits content-hashed filenames, so every rebuild rewrites `index.html` and adds a
|
|
22
|
+
differently named asset.
|
|
23
|
+
|
|
24
|
+
Corollary: **merging a creator change does not deploy it.** It ships when someone
|
|
25
|
+
publishes.
|
|
26
|
+
|
|
27
|
+
Full rationale in [`docs/creator-bundle.md`](../../docs/creator-bundle.md).
|
|
28
|
+
|
|
29
|
+
## Commands
|
|
30
|
+
|
|
31
|
+
Run these from `src/creator/`:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm install # this app has its own node_modules; the root install does not cover it
|
|
35
|
+
npm run dev # Vite dev server with HMR
|
|
36
|
+
npm run build # tsc -b && vite build -> ../creatorDist (not committed, see above)
|
|
37
|
+
npm run watch # vite build --watch, for testing against a local CLI server
|
|
38
|
+
npm run test # vitest; this app has its own runner, the root `npm test` does not cover it
|
|
39
|
+
npm run lint # ESLint
|
|
32
40
|
```
|
|
33
41
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
```js
|
|
37
|
-
// eslint.config.js
|
|
38
|
-
import reactX from "eslint-plugin-react-x"
|
|
39
|
-
import reactDom from "eslint-plugin-react-dom"
|
|
40
|
-
|
|
41
|
-
export default tseslint.config({
|
|
42
|
-
plugins: {
|
|
43
|
-
// Add the react-x and react-dom plugins
|
|
44
|
-
"react-x": reactX,
|
|
45
|
-
"react-dom": reactDom,
|
|
46
|
-
},
|
|
47
|
-
rules: {
|
|
48
|
-
// other rules...
|
|
49
|
-
// Enable its recommended typescript rules
|
|
50
|
-
...reactX.configs["recommended-typescript"].rules,
|
|
51
|
-
...reactDom.configs.recommended.rules,
|
|
52
|
-
},
|
|
53
|
-
})
|
|
42
|
+
## Layout
|
|
43
|
+
|
|
54
44
|
```
|
|
45
|
+
src/
|
|
46
|
+
App.tsx wizard: brief -> purpose -> duration -> human check
|
|
47
|
+
components/
|
|
48
|
+
NotificationListener.tsx socket.io subscription to a Rigobot completion webhook
|
|
49
|
+
syllabus/ syllabus editor and its chat sidebar
|
|
50
|
+
utils/
|
|
51
|
+
rigo.ts calls to the Rigobot prompting API
|
|
52
|
+
socket.ts socket.io client wrapper
|
|
53
|
+
store.ts zustand store, persisted to localStorage
|
|
54
|
+
locales/ i18n resources (en, es)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## How generation works
|
|
58
|
+
|
|
59
|
+
Course generation is asynchronous. `publicInteractiveCreation` posts to Rigobot with a
|
|
60
|
+
`webhook_url` pointing at the CLI server (`POST /notifications/:id`), and Rigobot answers
|
|
61
|
+
`201` immediately with a pending job. The CLI relays the eventual webhook over socket.io,
|
|
62
|
+
and `NotificationListener` resolves it in the browser.
|
|
63
|
+
|
|
64
|
+
That means **an HTTP 2xx from Rigobot says nothing about whether generation succeeded**.
|
|
65
|
+
The job can still fail later and arrive over the socket with `status: "ERROR"` and
|
|
66
|
+
`parsed: null`. Any code consuming a notification payload has to handle that.
|