@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/lib/commands/serve.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.processImage =
|
|
3
|
+
exports.processImage = void 0;
|
|
4
4
|
const tslib_1 = require("tslib");
|
|
5
5
|
const command_1 = require("@oclif/command");
|
|
6
6
|
const buffer_1 = require("buffer");
|
|
@@ -33,7 +33,12 @@ const creatorUtilities_2 = require("../utils/creatorUtilities");
|
|
|
33
33
|
const sidebarGenerator_1 = require("../utils/sidebarGenerator");
|
|
34
34
|
const publish_1 = require("./publish");
|
|
35
35
|
const export_1 = require("../utils/export");
|
|
36
|
+
const router_1 = require("../services/ingest/http/router");
|
|
37
|
+
const bucketIo_1 = require("../utils/coursePackage/bucketIo");
|
|
38
|
+
const learnJson_1 = require("../utils/coursePackage/learnJson");
|
|
39
|
+
const sidebar_1 = require("../utils/coursePackage/sidebar");
|
|
36
40
|
const packageManifest_1 = require("../utils/packageManifest");
|
|
41
|
+
const branding_1 = require("../utils/branding");
|
|
37
42
|
const gcsBucketName_1 = require("../utils/gcsBucketName");
|
|
38
43
|
const syllabusSync_1 = require("../utils/syllabusSync");
|
|
39
44
|
const publishJournal_1 = require("../utils/publishJournal");
|
|
@@ -86,39 +91,6 @@ async function serializeByCourse(courseSlug, fn) {
|
|
|
86
91
|
courseQueues.set(courseSlug, next);
|
|
87
92
|
return next;
|
|
88
93
|
}
|
|
89
|
-
const createLearnJson = (courseInfo) => {
|
|
90
|
-
// console.log("courseInfo to create learn json", courseInfo)
|
|
91
|
-
const expectedPreviewUrl = `https://${courseInfo.slug}.learn-pack.com/preview.png`;
|
|
92
|
-
const language = courseInfo.language || "en";
|
|
93
|
-
const learnJson = {
|
|
94
|
-
slug: courseInfo.slug,
|
|
95
|
-
title: {
|
|
96
|
-
[language]: courseInfo.title,
|
|
97
|
-
},
|
|
98
|
-
technologies: courseInfo.technologies || [],
|
|
99
|
-
difficulty: "beginner",
|
|
100
|
-
description: {
|
|
101
|
-
[language]: courseInfo.description,
|
|
102
|
-
},
|
|
103
|
-
grading: "isolated",
|
|
104
|
-
telemetry: {
|
|
105
|
-
batch: api_1.BREATHECODE_TELEMETRY_URL,
|
|
106
|
-
},
|
|
107
|
-
preview: expectedPreviewUrl,
|
|
108
|
-
};
|
|
109
|
-
return learnJson;
|
|
110
|
-
};
|
|
111
|
-
exports.createLearnJson = createLearnJson;
|
|
112
|
-
const uploadFileToBucket = async (bucket, file, path) => {
|
|
113
|
-
const isReadme = /readme(\.\w+)?\.md$/i.test(path);
|
|
114
|
-
const content = isReadme ? (0, readmeSanitizer_1.sanitizeReadmeNewlines)(file) : file;
|
|
115
|
-
const fileRef = bucket.file(path);
|
|
116
|
-
await fileRef.save(buffer_1.Buffer.from(content, "utf8"));
|
|
117
|
-
};
|
|
118
|
-
const uploadBinaryToBucket = async (bucket, buffer, path, contentType) => {
|
|
119
|
-
const fileRef = bucket.file(path);
|
|
120
|
-
await fileRef.save(buffer, Object.assign({ resumable: false }, (contentType && { contentType })));
|
|
121
|
-
};
|
|
122
94
|
const getGithubCredentials = () => {
|
|
123
95
|
var _a, _b;
|
|
124
96
|
const token = (_a = process.env.GITHUB_TOKEN) === null || _a === void 0 ? void 0 : _a.trim();
|
|
@@ -331,15 +303,6 @@ const processImage = async (url, description, rigoToken, courseSlug) => {
|
|
|
331
303
|
}
|
|
332
304
|
};
|
|
333
305
|
exports.processImage = processImage;
|
|
334
|
-
const createInitialSidebar = async (slugs, initialLanguage = "en") => {
|
|
335
|
-
const sidebar = {};
|
|
336
|
-
for (const slug of slugs) {
|
|
337
|
-
sidebar[slug] = {
|
|
338
|
-
[initialLanguage]: slug,
|
|
339
|
-
};
|
|
340
|
-
}
|
|
341
|
-
return sidebar;
|
|
342
|
-
};
|
|
343
306
|
const uploadInitialReadme = async (bucket, exSlug, targetDir, packageContext) => {
|
|
344
307
|
const isGeneratingText = `
|
|
345
308
|
\`\`\`loader slug="${exSlug}"
|
|
@@ -347,7 +310,7 @@ const uploadInitialReadme = async (bucket, exSlug, targetDir, packageContext) =>
|
|
|
347
310
|
\`\`\`
|
|
348
311
|
`;
|
|
349
312
|
const readmeFilename = `README${(0, creatorUtilities_1.getReadmeExtension)(packageContext.language || "en")}`;
|
|
350
|
-
await uploadFileToBucket(bucket, isGeneratingText, `${targetDir}/${readmeFilename}`);
|
|
313
|
+
await (0, bucketIo_1.uploadFileToBucket)(bucket, isGeneratingText, `${targetDir}/${readmeFilename}`);
|
|
351
314
|
};
|
|
352
315
|
const cleanFormState = (formState) => {
|
|
353
316
|
const { description, technologies, purpose, hasContentIndex, duration, isCompleted, variables, currentStep, language } = formState, rest = tslib_1.__rest(formState, ["description", "technologies", "purpose", "hasContentIndex", "duration", "isCompleted", "variables", "currentStep", "language"]);
|
|
@@ -581,7 +544,7 @@ function sanitizeSyllabusFromUnknown(syllabus) {
|
|
|
581
544
|
}
|
|
582
545
|
async function saveSyllabus(courseSlug, syllabus, bucket) {
|
|
583
546
|
sanitizeSyllabusFromUnknown(syllabus);
|
|
584
|
-
await uploadFileToBucket(bucket, JSON.stringify(syllabus), `courses/${courseSlug}/.learn/initialSyllabus.json`);
|
|
547
|
+
await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(syllabus), `courses/${courseSlug}/.learn/initialSyllabus.json`);
|
|
585
548
|
}
|
|
586
549
|
// Instrumented syllabus operations for race condition observability
|
|
587
550
|
async function getSyllabusWithLog(courseSlug, bucket, context) {
|
|
@@ -771,44 +734,150 @@ async function continueWithNextLesson(courseSlug, currentExerciseIndex, rigoToke
|
|
|
771
734
|
}) });
|
|
772
735
|
await saveSyllabus(courseSlug, newSyllabus, bucket);
|
|
773
736
|
}
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
737
|
+
/**
|
|
738
|
+
* The `og:image` of a published course.
|
|
739
|
+
*
|
|
740
|
+
* A published package is served from `{slug}.learn-pack.com`, so the course's
|
|
741
|
+
* own `preview.png` is the right card whenever it exists. This used to go
|
|
742
|
+
* through a helper that returned null for an empty preview, and both call sites
|
|
743
|
+
* passed an empty string — so every published course, preview or not, fell back
|
|
744
|
+
* to a shared SVG that no unfurl crawler can render.
|
|
745
|
+
*/
|
|
746
|
+
const resolveCoursePreviewUrl = async (bucket, slug, configuredPreview) => {
|
|
747
|
+
// `config` is parsed from a config.json in the bucket, so its declared types
|
|
748
|
+
// are an assertion rather than a guarantee: an old or hand-edited course can
|
|
749
|
+
// carry anything here, and a throw would fail the whole publication.
|
|
750
|
+
if (typeof configuredPreview === "string" &&
|
|
751
|
+
configuredPreview.startsWith("http")) {
|
|
752
|
+
return configuredPreview;
|
|
777
753
|
}
|
|
778
|
-
|
|
779
|
-
|
|
754
|
+
try {
|
|
755
|
+
const [exists] = await bucket.file(`courses/${slug}/preview.png`).exists();
|
|
756
|
+
if (exists) {
|
|
757
|
+
return (0, packageManifest_1.resolvePreviewUrl)(slug);
|
|
758
|
+
}
|
|
780
759
|
}
|
|
781
|
-
|
|
782
|
-
|
|
760
|
+
catch (error) {
|
|
761
|
+
console.error(`WARNING: could not check preview.png for ${slug}, using the brand card`, error);
|
|
762
|
+
}
|
|
763
|
+
return (0, branding_1.getFallbackPreviewUrl)();
|
|
783
764
|
};
|
|
784
765
|
const getTitleFromHTML = (html) => {
|
|
785
766
|
const titleRegex = /<title>(.*?)<\/title>/;
|
|
786
767
|
const titleMatch = html.match(titleRegex);
|
|
787
768
|
return titleMatch ? titleMatch[1] : null;
|
|
788
769
|
};
|
|
770
|
+
const escapeHtmlAttribute = (value) => value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
771
|
+
/**
|
|
772
|
+
* The IDE shell served to draft courses, with its meta tags filled in.
|
|
773
|
+
*
|
|
774
|
+
* The shell ships with `{{title}}`, `{{description}}` and `{{preview}}`
|
|
775
|
+
* placeholders that only get substituted when a course is published. Drafts were
|
|
776
|
+
* served the raw template, so a link shared in Slack or WhatsApp unfurled to a
|
|
777
|
+
* literal `{{title}}` / `{{description}}`.
|
|
778
|
+
*/
|
|
779
|
+
let draftShellTemplate = null;
|
|
780
|
+
const loadDraftShellTemplate = () => {
|
|
781
|
+
const file = path.resolve(__dirname, "../ui/_app/index.html");
|
|
782
|
+
draftShellTemplate = fs
|
|
783
|
+
.readFileSync(file, "utf-8")
|
|
784
|
+
// A draft is not a real Course, so it must not emit Course structured data.
|
|
785
|
+
// The match is anchored to the typed opening tag and non-greedy on purpose:
|
|
786
|
+
// the shell also carries an inline <script> that loads the app bundle from
|
|
787
|
+
// CloudFront, and a looser pattern would swallow it and blank the page.
|
|
788
|
+
.replace(/[^\S\n]*<script type="application\/ld\+json">[\S\s]*?<\/script>\n?/, "");
|
|
789
|
+
return draftShellTemplate;
|
|
790
|
+
};
|
|
791
|
+
const renderDraftShell = (req) => {
|
|
792
|
+
const template = draftShellTemplate || loadDraftShellTemplate();
|
|
793
|
+
// Only the path: a preview URL can carry tokens in its query string, and
|
|
794
|
+
// og:url ends up in whatever crawler cache unfurls the link.
|
|
795
|
+
const canonicalUrl = escapeHtmlAttribute(`${(0, branding_1.getPublicHost)()}${req.path}`);
|
|
796
|
+
return (template
|
|
797
|
+
.replace(/<title>.*<\/title>/, `<title>${branding_1.DRAFT_PREVIEW_TITLE}</title>`)
|
|
798
|
+
.replace(/{{title}}/g, branding_1.DRAFT_PREVIEW_TITLE)
|
|
799
|
+
.replace(/{{description}}/g, branding_1.DRAFT_PREVIEW_DESCRIPTION)
|
|
800
|
+
.replace(/{{preview}}/g, (0, branding_1.getFallbackPreviewUrl)())
|
|
801
|
+
.replace("</head>", ` <meta property="og:url" content="${canonicalUrl}" />\n` +
|
|
802
|
+
` <meta name="robots" content="noindex, nofollow, noarchive" />\n </head>`)
|
|
803
|
+
// Safety net for placeholders a future shell may add: shipping an empty
|
|
804
|
+
// value to a crawler beats shipping "{{whatever}}".
|
|
805
|
+
.replace(/{{\w+}}/g, ""));
|
|
806
|
+
};
|
|
807
|
+
/**
|
|
808
|
+
* Drafts are unlisted work in progress. `noindex` goes out as both a meta tag
|
|
809
|
+
* and a header so it reaches crawlers that skip the markup; a `robots.txt`
|
|
810
|
+
* `Disallow` would be the wrong tool, since it blocks the fetch itself and
|
|
811
|
+
* would stop Slack and X from unfurling the link at all.
|
|
812
|
+
*/
|
|
813
|
+
const sendDraftShell = (req, res) => {
|
|
814
|
+
res
|
|
815
|
+
.type("html")
|
|
816
|
+
.set("X-Robots-Tag", "noindex, nofollow")
|
|
817
|
+
.send(renderDraftShell(req));
|
|
818
|
+
};
|
|
819
|
+
/** How often the notification is touched while a translation is running. */
|
|
820
|
+
const SYNC_HEARTBEAT_INTERVAL_MS = 60 * 1000;
|
|
821
|
+
/**
|
|
822
|
+
* A synchronization with no news for this long is considered stuck: the GET
|
|
823
|
+
* endpoint flags it as failed and it can be accepted again.
|
|
824
|
+
*/
|
|
825
|
+
const SYNC_PROCESSING_TIMEOUT_MS = 3 * 60 * 1000;
|
|
826
|
+
// True when a processing notification stopped reporting progress.
|
|
827
|
+
function isSyncProcessingStale(notification) {
|
|
828
|
+
const lastUpdate = notification.processingLastUpdate || notification.updatedAt;
|
|
829
|
+
return Date.now() - lastUpdate > SYNC_PROCESSING_TIMEOUT_MS;
|
|
830
|
+
}
|
|
831
|
+
function findSyncNotification(syllabus, exerciseSlug, notificationId) {
|
|
832
|
+
var _a;
|
|
833
|
+
const lesson = syllabus.lessons.find(lesson => (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title) === exerciseSlug);
|
|
834
|
+
return (_a = lesson === null || lesson === void 0 ? void 0 : lesson.syncNotifications) === null || _a === void 0 ? void 0 : _a.find(n => n.id === notificationId);
|
|
835
|
+
}
|
|
789
836
|
async function processSyncTranslationsSequentially(courseSlug, exerciseSlug, notificationId, sourceReadmeContent, targetLanguages, rigoToken, bucket, historyManager) {
|
|
790
|
-
var _a, _b, _c, _d, _e, _f;
|
|
837
|
+
var _a, _b, _c, _d, _e, _f, _g;
|
|
791
838
|
try {
|
|
792
839
|
const sanitizedSource = (0, readmeSanitizer_1.sanitizeReadmeNewlines)(sourceReadmeContent);
|
|
840
|
+
let lastHeartbeatAt = Date.now();
|
|
841
|
+
// Keeps `processingLastUpdate` fresh while a language is being translated,
|
|
842
|
+
// otherwise the PROCESSING_TIMEOUT in GET /sync-notifications can flag a
|
|
843
|
+
// running synchronization as failed. Throttled, since every beat rewrites
|
|
844
|
+
// the syllabus in the bucket.
|
|
845
|
+
const heartbeat = async (targetLang) => {
|
|
846
|
+
if (Date.now() - lastHeartbeatAt < SYNC_HEARTBEAT_INTERVAL_MS) {
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
lastHeartbeatAt = Date.now();
|
|
850
|
+
try {
|
|
851
|
+
await serializeByCourse(courseSlug, async () => {
|
|
852
|
+
const syllabus = await getSyllabus(courseSlug, bucket);
|
|
853
|
+
const notification = findSyncNotification(syllabus, exerciseSlug, notificationId);
|
|
854
|
+
if (!notification) {
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
notification.processingLastUpdate = Date.now();
|
|
858
|
+
if (notification.syncProgress) {
|
|
859
|
+
notification.syncProgress.currentLanguage = targetLang;
|
|
860
|
+
}
|
|
861
|
+
await saveSyllabus(courseSlug, syllabus, bucket);
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
catch (error) {
|
|
865
|
+
console.warn(`🔄 SYNC: heartbeat failed for ${exerciseSlug}:`, error.message);
|
|
866
|
+
}
|
|
867
|
+
};
|
|
793
868
|
// Process translations sequentially (no race conditions)
|
|
794
869
|
for (const targetLang of targetLanguages) {
|
|
795
870
|
try {
|
|
796
|
-
|
|
871
|
+
const languageStartedAt = Date.now();
|
|
872
|
+
// The translation runs as an asynchronous Rigobot job that we poll:
|
|
873
|
+
// waiting for it inside a single request takes 28-45s for a full
|
|
874
|
+
// README and Heroku kills anything above 30s.
|
|
797
875
|
// eslint-disable-next-line no-await-in-loop
|
|
798
|
-
const
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
include_purpose_objective: false,
|
|
804
|
-
execute_async: false, // Synchronous execution
|
|
805
|
-
}, {
|
|
806
|
-
headers: {
|
|
807
|
-
"Content-Type": "application/json",
|
|
808
|
-
Authorization: "Token " + rigoToken,
|
|
809
|
-
},
|
|
810
|
-
});
|
|
811
|
-
const translationResult = response.data;
|
|
876
|
+
const translationResult = await (0, rigoActions_1.translateExerciseAndWait)(rigoToken, {
|
|
877
|
+
text_to_translate: sanitizedSource,
|
|
878
|
+
output_language: targetLang,
|
|
879
|
+
}, { onPoll: () => heartbeat(targetLang) });
|
|
880
|
+
console.log(`🔄 SYNC: ${targetLang} translated in ${Math.round((Date.now() - languageStartedAt) / 1000)}s (job ${translationResult.id})`);
|
|
812
881
|
// Check if translation was successful
|
|
813
882
|
if (!((_a = translationResult.parsed) === null || _a === void 0 ? void 0 : _a.translation)) {
|
|
814
883
|
throw new Error("Translation result is empty");
|
|
@@ -897,10 +966,20 @@ async function processSyncTranslationsSequentially(courseSlug, exerciseSlug, not
|
|
|
897
966
|
const totalProcessed = notification.syncProgress.completedLanguages.length +
|
|
898
967
|
(((_e = notification.syncProgress.failedLanguages) === null || _e === void 0 ? void 0 : _e.length) || 0);
|
|
899
968
|
if (totalProcessed === notification.syncProgress.totalLanguages) {
|
|
900
|
-
notification.
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
969
|
+
const completedCount = notification.syncProgress.completedLanguages.length;
|
|
970
|
+
const failedCount = ((_f = notification.syncProgress.failedLanguages) === null || _f === void 0 ? void 0 : _f.length) || 0;
|
|
971
|
+
// A partial result stays actionable: the notification remains
|
|
972
|
+
// visible so the user can retry just the languages that failed.
|
|
973
|
+
if (completedCount === 0) {
|
|
974
|
+
notification.status = "error";
|
|
975
|
+
}
|
|
976
|
+
else if (failedCount > 0) {
|
|
977
|
+
notification.status = "partial";
|
|
978
|
+
}
|
|
979
|
+
else {
|
|
980
|
+
notification.status = "completed";
|
|
981
|
+
}
|
|
982
|
+
notification.updatedAt = Date.now();
|
|
904
983
|
await saveSyllabus(courseSlug, syllabus, bucket);
|
|
905
984
|
console.log(`🔄 SYNC: ✅ All translations completed for ${exerciseSlug} - Status: ${notification.status}`);
|
|
906
985
|
(0, creatorSocket_1.emitToCourse)(courseSlug, "sync-notification-completed", {
|
|
@@ -908,7 +987,7 @@ async function processSyncTranslationsSequentially(courseSlug, exerciseSlug, not
|
|
|
908
987
|
notificationId,
|
|
909
988
|
status: notification.status,
|
|
910
989
|
completed: notification.syncProgress.completedLanguages.length,
|
|
911
|
-
failed: ((
|
|
990
|
+
failed: ((_g = notification.syncProgress.failedLanguages) === null || _g === void 0 ? void 0 : _g.length) || 0,
|
|
912
991
|
});
|
|
913
992
|
}
|
|
914
993
|
}
|
|
@@ -1021,6 +1100,7 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
1021
1100
|
// app.use(express.static(distPath))
|
|
1022
1101
|
app.use(express.json({ limit: "20mb" }));
|
|
1023
1102
|
app.use(cors());
|
|
1103
|
+
app.use("/generation", (0, router_1.createIngestRouter)());
|
|
1024
1104
|
const appPath = path.resolve(__dirname, "../ui/_app");
|
|
1025
1105
|
const tarPath = path.resolve(__dirname, "../ui/app.tar.gz");
|
|
1026
1106
|
if (fs.existsSync(appPath)) {
|
|
@@ -1033,6 +1113,10 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
1033
1113
|
await (0, file_1.decompress)(`${__dirname}/../ui/app.tar.gz`, `${__dirname}/../ui/_app/`);
|
|
1034
1114
|
const localAppPath = path.resolve(__dirname, "../ui/_app");
|
|
1035
1115
|
// app.use(express.static(localAppPath))
|
|
1116
|
+
// Warm the draft shell now that _app is on disk. Doing it here rather than
|
|
1117
|
+
// on first request means a corrupt tarball fails at boot, with a stack
|
|
1118
|
+
// trace, instead of on some user's preview link.
|
|
1119
|
+
loadDraftShellTemplate();
|
|
1036
1120
|
app.post("/upload", async (req, res) => {
|
|
1037
1121
|
const { content, destination } = req.body;
|
|
1038
1122
|
// console.log("UPLOAD", content, destination)
|
|
@@ -1207,7 +1291,7 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
1207
1291
|
});
|
|
1208
1292
|
}
|
|
1209
1293
|
const filePath = `courses/${courseSlug}/README${(0, creatorUtilities_1.getReadmeExtension)(langCode)}`;
|
|
1210
|
-
await uploadFileToBucket(bucket, content, filePath);
|
|
1294
|
+
await (0, bucketIo_1.uploadFileToBucket)(bucket, content, filePath);
|
|
1211
1295
|
res.json({ status: "SUCCESS" });
|
|
1212
1296
|
});
|
|
1213
1297
|
app.post("/webhooks/:courseSlug/images/:imageId", async (req, res) => {
|
|
@@ -1302,7 +1386,7 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
1302
1386
|
if (fileObj.name && fileObj.content) {
|
|
1303
1387
|
const filePath = `${exerciseDir}/${flatFileName}`;
|
|
1304
1388
|
// eslint-disable-next-line no-await-in-loop
|
|
1305
|
-
await uploadFileToBucket(bucket, fileObj.content, filePath);
|
|
1389
|
+
await (0, bucketIo_1.uploadFileToBucket)(bucket, fileObj.content, filePath);
|
|
1306
1390
|
console.log(`✅ Saved file: ${filePath}`);
|
|
1307
1391
|
}
|
|
1308
1392
|
// Save the solution file if it exists
|
|
@@ -1314,7 +1398,7 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
1314
1398
|
const solutionFileName = `${baseName}.solution.hide.${extension}`;
|
|
1315
1399
|
const solutionFilePath = `${exerciseDir}/${solutionFileName}`;
|
|
1316
1400
|
// eslint-disable-next-line no-await-in-loop
|
|
1317
|
-
await uploadFileToBucket(bucket, fileObj.solution, solutionFilePath);
|
|
1401
|
+
await (0, bucketIo_1.uploadFileToBucket)(bucket, fileObj.solution, solutionFilePath);
|
|
1318
1402
|
console.log(`✅ Saved solution file: ${solutionFilePath}`);
|
|
1319
1403
|
}
|
|
1320
1404
|
else {
|
|
@@ -1322,7 +1406,7 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
1322
1406
|
const solutionFileName = `${flatFileName}.solution.hide`;
|
|
1323
1407
|
const solutionFilePath = `${exerciseDir}/${solutionFileName}`;
|
|
1324
1408
|
// eslint-disable-next-line no-await-in-loop
|
|
1325
|
-
await uploadFileToBucket(bucket, fileObj.solution, solutionFilePath);
|
|
1409
|
+
await (0, bucketIo_1.uploadFileToBucket)(bucket, fileObj.solution, solutionFilePath);
|
|
1326
1410
|
console.log(`✅ Saved solution file: ${solutionFilePath}`);
|
|
1327
1411
|
}
|
|
1328
1412
|
}
|
|
@@ -1870,7 +1954,7 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
1870
1954
|
const readmeFilename = `README${(0, creatorUtilities_1.getReadmeExtension)(response.parsed.output_language ||
|
|
1871
1955
|
syllabus.courseInfo.language ||
|
|
1872
1956
|
"en")}`;
|
|
1873
|
-
await uploadFileToBucket(bucket, readability.newMarkdown, `${targetDir}/${readmeFilename}`);
|
|
1957
|
+
await (0, bucketIo_1.uploadFileToBucket)(bucket, readability.newMarkdown, `${targetDir}/${readmeFilename}`);
|
|
1874
1958
|
// Update used components if provided by the AI
|
|
1875
1959
|
if (response.parsed.used_components &&
|
|
1876
1960
|
Array.isArray(response.parsed.used_components)) {
|
|
@@ -1994,7 +2078,7 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
1994
2078
|
}
|
|
1995
2079
|
// Translation successful
|
|
1996
2080
|
const readmePath = `courses/${courseSlug}/exercises/${exSlug}/README${(0, creatorUtilities_1.getReadmeExtension)(body.parsed.output_language_code)}`;
|
|
1997
|
-
await uploadFileToBucket(bucket, body.parsed.translation, readmePath);
|
|
2081
|
+
await (0, bucketIo_1.uploadFileToBucket)(bucket, body.parsed.translation, readmePath);
|
|
1998
2082
|
// Verify file exists before updating syllabus (resilience: ensure file was actually saved)
|
|
1999
2083
|
const [fileExists] = await bucket.file(readmePath).exists();
|
|
2000
2084
|
// Update syllabus with completed status only if file was successfully saved
|
|
@@ -2132,7 +2216,7 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
2132
2216
|
console.log("BODY", body);
|
|
2133
2217
|
const readmePath = `courses/${courseSlug}/exercises/${exSlug}/README${(0, creatorUtilities_1.getReadmeExtension)(lang)}`;
|
|
2134
2218
|
if (body.parsed.content) {
|
|
2135
|
-
await uploadFileToBucket(bucket, body.parsed.content, readmePath);
|
|
2219
|
+
await (0, bucketIo_1.uploadFileToBucket)(bucket, body.parsed.content, readmePath);
|
|
2136
2220
|
}
|
|
2137
2221
|
(0, creatorSocket_1.emitToNotification)(notificationId, {
|
|
2138
2222
|
status: "SUCCESS",
|
|
@@ -2148,8 +2232,7 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
2148
2232
|
console.log("GET /");
|
|
2149
2233
|
const slug = req.query.slug;
|
|
2150
2234
|
if (slug) {
|
|
2151
|
-
|
|
2152
|
-
res.sendFile(file);
|
|
2235
|
+
sendDraftShell(req, res);
|
|
2153
2236
|
}
|
|
2154
2237
|
else {
|
|
2155
2238
|
res.redirect("https://learnpack.co");
|
|
@@ -2157,12 +2240,16 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
2157
2240
|
});
|
|
2158
2241
|
app.get("/preview/:slug", async (req, res) => {
|
|
2159
2242
|
console.log("GET COURSE PREVIEW /");
|
|
2160
|
-
|
|
2161
|
-
res.sendFile(file);
|
|
2243
|
+
sendDraftShell(req, res);
|
|
2162
2244
|
});
|
|
2163
2245
|
app.get("/preview/:slug/webview", async (req, res) => {
|
|
2164
|
-
|
|
2165
|
-
|
|
2246
|
+
sendDraftShell(req, res);
|
|
2247
|
+
});
|
|
2248
|
+
// The brand card used as og:image by drafts, and as the last-resort
|
|
2249
|
+
// fallback by published courses without a preview of their own.
|
|
2250
|
+
app.get("/og-image.png", (req, res) => {
|
|
2251
|
+
res.set("Cache-Control", "public, max-age=86400");
|
|
2252
|
+
res.sendFile(path.resolve(__dirname, "../ui/static/og-image.png"));
|
|
2166
2253
|
});
|
|
2167
2254
|
app.get("/config", async (req, res) => {
|
|
2168
2255
|
const courseSlug = req.query.slug;
|
|
@@ -2178,7 +2265,7 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
2178
2265
|
const config = await mergeConfigPreservingGithub(bucket, courseSlug, builtConfig);
|
|
2179
2266
|
res.set("X-Creator-Web", "true");
|
|
2180
2267
|
res.set("Access-Control-Expose-Headers", "X-Creator-Web");
|
|
2181
|
-
await uploadFileToBucket(bucket, JSON.stringify({ config, exercises }), `courses/${courseSlug}/.learn/config.json`);
|
|
2268
|
+
await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify({ config, exercises }), `courses/${courseSlug}/.learn/config.json`);
|
|
2182
2269
|
res.json({ config, exercises });
|
|
2183
2270
|
}
|
|
2184
2271
|
catch (error) {
|
|
@@ -2590,7 +2677,7 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
2590
2677
|
completedAt: 0,
|
|
2591
2678
|
},
|
|
2592
2679
|
};
|
|
2593
|
-
await uploadFileToBucket(bucket, JSON.stringify(Object.assign(Object.assign({}, initialSyllabus), { lessons: newLessons })), `courses/${courseSlug}/.learn/initialSyllabus.json`);
|
|
2680
|
+
await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(Object.assign(Object.assign({}, initialSyllabus), { lessons: newLessons })), `courses/${courseSlug}/.learn/initialSyllabus.json`);
|
|
2594
2681
|
const targetDir = `courses/${courseSlug}/exercises/${stepSlug}`;
|
|
2595
2682
|
await uploadInitialReadme(bucket, stepSlug, targetDir, initialSyllabus.courseInfo);
|
|
2596
2683
|
res.json({ status: "SUCCESS", message: "Exercise generati on started!" });
|
|
@@ -2633,20 +2720,20 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
2633
2720
|
if (!learnJson.title)
|
|
2634
2721
|
learnJson.title = {};
|
|
2635
2722
|
learnJson.title[language] = title;
|
|
2636
|
-
await uploadFileToBucket(bucket, JSON.stringify(learnJson), `courses/${courseSlug}/learn.json`);
|
|
2723
|
+
await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(learnJson), `courses/${courseSlug}/learn.json`);
|
|
2637
2724
|
const configFile = bucket.file(`courses/${courseSlug}/.learn/config.json`);
|
|
2638
2725
|
const [configContent] = await configFile.download();
|
|
2639
2726
|
const configJson = JSON.parse(configContent.toString());
|
|
2640
2727
|
configJson.config = configJson.config || {};
|
|
2641
2728
|
configJson.config.title = Object.assign(Object.assign({}, configJson.config.title), learnJson.title);
|
|
2642
|
-
await uploadFileToBucket(bucket, JSON.stringify(configJson), `courses/${courseSlug}/.learn/config.json`);
|
|
2729
|
+
await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(configJson), `courses/${courseSlug}/.learn/config.json`);
|
|
2643
2730
|
try {
|
|
2644
2731
|
const syllabusFile = bucket.file(`courses/${courseSlug}/.learn/initialSyllabus.json`);
|
|
2645
2732
|
const [syllabusContent] = await syllabusFile.download();
|
|
2646
2733
|
const syllabusJson = JSON.parse(syllabusContent.toString());
|
|
2647
2734
|
if (syllabusJson === null || syllabusJson === void 0 ? void 0 : syllabusJson.courseInfo) {
|
|
2648
2735
|
syllabusJson.courseInfo.title = title;
|
|
2649
|
-
await uploadFileToBucket(bucket, JSON.stringify(syllabusJson), `courses/${courseSlug}/.learn/initialSyllabus.json`);
|
|
2736
|
+
await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(syllabusJson), `courses/${courseSlug}/.learn/initialSyllabus.json`);
|
|
2650
2737
|
}
|
|
2651
2738
|
}
|
|
2652
2739
|
catch (error) {
|
|
@@ -2664,9 +2751,9 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
2664
2751
|
if ((_a = result === null || result === void 0 ? void 0 : result.parsed) === null || _a === void 0 ? void 0 : _a.title) {
|
|
2665
2752
|
const translatedTitle = JSON.parse(result.parsed.title);
|
|
2666
2753
|
learnJson.title = Object.assign(Object.assign(Object.assign({}, learnJson.title), translatedTitle), { [language]: title });
|
|
2667
|
-
await uploadFileToBucket(bucket, JSON.stringify(learnJson), `courses/${courseSlug}/learn.json`);
|
|
2754
|
+
await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(learnJson), `courses/${courseSlug}/learn.json`);
|
|
2668
2755
|
configJson.config.title = Object.assign(Object.assign({}, configJson.config.title), learnJson.title);
|
|
2669
|
-
await uploadFileToBucket(bucket, JSON.stringify(configJson), `courses/${courseSlug}/.learn/config.json`);
|
|
2756
|
+
await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(configJson), `courses/${courseSlug}/.learn/config.json`);
|
|
2670
2757
|
}
|
|
2671
2758
|
}
|
|
2672
2759
|
catch (translationError) {
|
|
@@ -2791,7 +2878,7 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
2791
2878
|
const translatedDescription = JSON.parse(result.parsed.description);
|
|
2792
2879
|
courseJson.title = translatedTitle;
|
|
2793
2880
|
courseJson.description = translatedDescription;
|
|
2794
|
-
await uploadFileToBucket(bucket, JSON.stringify(courseJson), `courses/${courseSlug}/learn.json`);
|
|
2881
|
+
await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(courseJson), `courses/${courseSlug}/learn.json`);
|
|
2795
2882
|
currentLanguages = Object.keys(courseJson.title);
|
|
2796
2883
|
}
|
|
2797
2884
|
const missingReadmeTranslations = [];
|
|
@@ -2989,37 +3076,35 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
2989
3076
|
if (!syllabus) {
|
|
2990
3077
|
return res.status(404).json({ error: "Syllabus not found" });
|
|
2991
3078
|
}
|
|
2992
|
-
const PROCESSING_TIMEOUT = 3 * 60 * 1000; // 3 minutes
|
|
2993
3079
|
let modified = false;
|
|
2994
|
-
// Collect active notifications (pending, processing, or error)
|
|
3080
|
+
// Collect active notifications (pending, processing, partial or error)
|
|
2995
3081
|
const notifications = [];
|
|
2996
3082
|
for (const lesson of syllabus.lessons) {
|
|
2997
3083
|
if (lesson.syncNotifications && lesson.syncNotifications.length > 0) {
|
|
2998
3084
|
for (const notification of lesson.syncNotifications) {
|
|
2999
3085
|
// Check for timeout in processing notifications
|
|
3000
|
-
if (notification.status === "processing"
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
notificationId: notification.id,
|
|
3016
|
-
error: "Processing timeout",
|
|
3017
|
-
});
|
|
3018
|
-
}
|
|
3086
|
+
if (notification.status === "processing" &&
|
|
3087
|
+
isSyncProcessingStale(notification)) {
|
|
3088
|
+
notification.status = "error";
|
|
3089
|
+
notification.error = {
|
|
3090
|
+
message: "Synchronization timeout - process took too long",
|
|
3091
|
+
code: "PROCESSING_TIMEOUT",
|
|
3092
|
+
timestamp: Date.now(),
|
|
3093
|
+
};
|
|
3094
|
+
modified = true;
|
|
3095
|
+
// Emit error event
|
|
3096
|
+
(0, creatorSocket_1.emitToCourse)(courseSlug, "sync-notification-error", {
|
|
3097
|
+
exerciseSlug: (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title),
|
|
3098
|
+
notificationId: notification.id,
|
|
3099
|
+
error: "Processing timeout",
|
|
3100
|
+
});
|
|
3019
3101
|
}
|
|
3020
|
-
// Include active notifications (pending, processing, or
|
|
3102
|
+
// Include active notifications (pending, processing, partial or
|
|
3103
|
+
// error): a partial sync is still actionable, the user can retry
|
|
3104
|
+
// the languages that failed.
|
|
3021
3105
|
if (notification.status === "pending" ||
|
|
3022
3106
|
notification.status === "processing" ||
|
|
3107
|
+
notification.status === "partial" ||
|
|
3023
3108
|
notification.status === "error") {
|
|
3024
3109
|
notifications.push(Object.assign(Object.assign({}, notification), { lessonSlug: (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title), lessonTitle: lesson.title }));
|
|
3025
3110
|
}
|
|
@@ -3083,7 +3168,7 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
3083
3168
|
});
|
|
3084
3169
|
// Accept sync notification and start synchronization
|
|
3085
3170
|
app.post("/courses/:courseSlug/lessons/:exerciseSlug/sync-notification/:notificationId/accept", express.json(), async (req, res) => {
|
|
3086
|
-
var _a, _b;
|
|
3171
|
+
var _a, _b, _c;
|
|
3087
3172
|
console.log("POST /courses/:courseSlug/lessons/:exerciseSlug/sync-notification/:notificationId/accept");
|
|
3088
3173
|
const { courseSlug, exerciseSlug, notificationId } = req.params;
|
|
3089
3174
|
const rigoToken = req.header("x-rigo-token");
|
|
@@ -3111,9 +3196,21 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
3111
3196
|
if (!notification) {
|
|
3112
3197
|
return res.status(404).json({ error: "Notification not found" });
|
|
3113
3198
|
}
|
|
3114
|
-
|
|
3199
|
+
// A synchronization can be accepted again unless it is actively
|
|
3200
|
+
// running: after a failure or a partial result this same
|
|
3201
|
+
// notification is what the Retry button acts on.
|
|
3202
|
+
if (notification.status === "processing" &&
|
|
3203
|
+
!isSyncProcessingStale(notification)) {
|
|
3204
|
+
return res.status(409).json({
|
|
3205
|
+
error: "Notification is already being synchronized",
|
|
3206
|
+
code: "ALREADY_PROCESSING",
|
|
3207
|
+
currentStatus: notification.status,
|
|
3208
|
+
});
|
|
3209
|
+
}
|
|
3210
|
+
if (notification.status === "completed") {
|
|
3115
3211
|
return res.status(400).json({
|
|
3116
|
-
error: "Notification is
|
|
3212
|
+
error: "Notification is already synchronized",
|
|
3213
|
+
code: "ALREADY_COMPLETED",
|
|
3117
3214
|
currentStatus: notification.status,
|
|
3118
3215
|
});
|
|
3119
3216
|
}
|
|
@@ -3131,9 +3228,13 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
3131
3228
|
code: "SOURCE_README_ERROR",
|
|
3132
3229
|
});
|
|
3133
3230
|
}
|
|
3134
|
-
// Determine target languages
|
|
3231
|
+
// Determine target languages. On a retry only what is still missing
|
|
3232
|
+
// is translated again, so the languages that already succeeded are
|
|
3233
|
+
// neither paid for twice nor overwritten.
|
|
3234
|
+
const alreadyCompleted = ((_b = notification.syncProgress) === null || _b === void 0 ? void 0 : _b.completedLanguages) || [];
|
|
3135
3235
|
const availableLanguages = Object.keys(lesson.translations || {});
|
|
3136
|
-
const targetLanguages = availableLanguages.filter(lang => lang !== notification.sourceLanguage
|
|
3236
|
+
const targetLanguages = availableLanguages.filter(lang => lang !== notification.sourceLanguage &&
|
|
3237
|
+
!alreadyCompleted.includes(lang));
|
|
3137
3238
|
if (targetLanguages.length === 0) {
|
|
3138
3239
|
return res.status(400).json({
|
|
3139
3240
|
error: "No target languages found",
|
|
@@ -3142,10 +3243,14 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
3142
3243
|
}
|
|
3143
3244
|
// Remove all other notifications for this lesson
|
|
3144
3245
|
lesson.syncNotifications =
|
|
3145
|
-
((
|
|
3246
|
+
((_c = lesson.syncNotifications) === null || _c === void 0 ? void 0 : _c.filter(n => n.id === notificationId)) ||
|
|
3146
3247
|
[];
|
|
3248
|
+
// Start from a clean slate so a previous failure does not leak into
|
|
3249
|
+
// this run.
|
|
3147
3250
|
notification.status = "processing";
|
|
3251
|
+
notification.updatedAt = Date.now();
|
|
3148
3252
|
notification.processingLastUpdate = Date.now();
|
|
3253
|
+
delete notification.error;
|
|
3149
3254
|
notification.syncProgress = {
|
|
3150
3255
|
totalLanguages: targetLanguages.length,
|
|
3151
3256
|
completedLanguages: [], // Array of completed language codes
|
|
@@ -3391,7 +3496,7 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
3391
3496
|
const removedCount = initialCount - kept.length;
|
|
3392
3497
|
exercise.files = kept;
|
|
3393
3498
|
if (removedCount > 0) {
|
|
3394
|
-
await uploadFileToBucket(bucket, JSON.stringify({ config: configJson.config, exercises }), `courses/${courseSlug}/.learn/config.json`);
|
|
3499
|
+
await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify({ config: configJson.config, exercises }), `courses/${courseSlug}/.learn/config.json`);
|
|
3395
3500
|
}
|
|
3396
3501
|
if (movedCount > 0 || removedCount > 0) {
|
|
3397
3502
|
console.log(`[sync-lesson-files] ${lessonSlug}: ${movedCount} moved from subdir, ${removedCount} removed from config`);
|
|
@@ -3427,7 +3532,7 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
3427
3532
|
const { exercises } = await (0, configBuilder_1.buildConfig)(bucket, courseSlug);
|
|
3428
3533
|
const { fixedSidebar, valid } = await (0, sidebarGenerator_1.checkAndFixSidebarPure)(sidebar, exercises, rigoToken);
|
|
3429
3534
|
if (fixedSidebar) {
|
|
3430
|
-
await uploadFileToBucket(bucket, JSON.stringify(fixedSidebar), `courses/${courseSlug}/.learn/sidebar.json`);
|
|
3535
|
+
await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(fixedSidebar), `courses/${courseSlug}/.learn/sidebar.json`);
|
|
3431
3536
|
return res.json(fixedSidebar);
|
|
3432
3537
|
}
|
|
3433
3538
|
}
|
|
@@ -3437,12 +3542,12 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
3437
3542
|
if (error.code === 404) {
|
|
3438
3543
|
const { exercises } = await (0, configBuilder_1.buildConfig)(bucket, courseSlug);
|
|
3439
3544
|
const exerciseSlugsArray = exercises.map(exercise => exercise.slug);
|
|
3440
|
-
const sidebar = await createInitialSidebar(exerciseSlugsArray);
|
|
3441
|
-
await uploadFileToBucket(bucket, JSON.stringify(sidebar), `courses/${courseSlug}/.learn/sidebar.json`);
|
|
3545
|
+
const sidebar = await (0, sidebar_1.createInitialSidebar)(exerciseSlugsArray);
|
|
3546
|
+
await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(sidebar), `courses/${courseSlug}/.learn/sidebar.json`);
|
|
3442
3547
|
if (rigoToken) {
|
|
3443
3548
|
const { fixedSidebar } = await (0, sidebarGenerator_1.checkAndFixSidebarPure)(sidebar, exercises, rigoToken);
|
|
3444
3549
|
if (fixedSidebar) {
|
|
3445
|
-
await uploadFileToBucket(bucket, JSON.stringify(fixedSidebar), `courses/${courseSlug}/.learn/sidebar.json`);
|
|
3550
|
+
await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(fixedSidebar), `courses/${courseSlug}/.learn/sidebar.json`);
|
|
3446
3551
|
}
|
|
3447
3552
|
return res.status(200).json(fixedSidebar);
|
|
3448
3553
|
}
|
|
@@ -3486,7 +3591,7 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
3486
3591
|
}
|
|
3487
3592
|
const courseSlug = syllabus.courseInfo.slug;
|
|
3488
3593
|
const tutorialDir = `courses/${courseSlug}`;
|
|
3489
|
-
const learnJson = (0,
|
|
3594
|
+
const learnJson = (0, learnJson_1.createLearnJson)(syllabus.courseInfo);
|
|
3490
3595
|
try {
|
|
3491
3596
|
await api_1.default.createRigoPackage(rigoToken, courseSlug, learnJson);
|
|
3492
3597
|
}
|
|
@@ -3495,7 +3600,7 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
3495
3600
|
return res.status(400).json({ error: "Failed to create Rigo package" });
|
|
3496
3601
|
}
|
|
3497
3602
|
try {
|
|
3498
|
-
await uploadFileToBucket(bucket, JSON.stringify(learnJson), `${tutorialDir}/learn.json`);
|
|
3603
|
+
await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(learnJson), `${tutorialDir}/learn.json`);
|
|
3499
3604
|
}
|
|
3500
3605
|
catch (error) {
|
|
3501
3606
|
console.error("Failed to upload learn.json:", error);
|
|
@@ -3509,15 +3614,15 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
3509
3614
|
// eslint-disable-next-line no-await-in-loop
|
|
3510
3615
|
await uploadInitialReadme(bucket, exSlug, targetDir, syllabus.courseInfo);
|
|
3511
3616
|
}
|
|
3512
|
-
const sidebar = await createInitialSidebar(syllabus.lessons.map(lesson => (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title)), syllabus.courseInfo.language);
|
|
3617
|
+
const sidebar = await (0, sidebar_1.createInitialSidebar)(syllabus.lessons.map(lesson => (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title)), syllabus.courseInfo.language);
|
|
3513
3618
|
const initialSyllabus = Object.assign(Object.assign({}, syllabus), { lessons: syllabus.lessons.map((lesson, index) => {
|
|
3514
3619
|
if (index < 1) {
|
|
3515
3620
|
return Object.assign(Object.assign({}, lesson), { generated: false, status: "GENERATING" });
|
|
3516
3621
|
}
|
|
3517
3622
|
return Object.assign(Object.assign({}, lesson), { generated: false, status: "PENDING" });
|
|
3518
3623
|
}) });
|
|
3519
|
-
await uploadFileToBucket(bucket, JSON.stringify(initialSyllabus), `${tutorialDir}/.learn/initialSyllabus.json`);
|
|
3520
|
-
await uploadFileToBucket(bucket, JSON.stringify(sidebar), `${tutorialDir}/.learn/sidebar.json`);
|
|
3624
|
+
await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(initialSyllabus), `${tutorialDir}/.learn/initialSyllabus.json`);
|
|
3625
|
+
await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(sidebar), `${tutorialDir}/.learn/sidebar.json`);
|
|
3521
3626
|
const firstLesson = syllabus.lessons[0];
|
|
3522
3627
|
const lastResult = "---";
|
|
3523
3628
|
// Use new two-phase generation workflow
|
|
@@ -3712,13 +3817,13 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
3712
3817
|
title = config.title[availableLangs[0]];
|
|
3713
3818
|
selectedLang = availableLangs[0];
|
|
3714
3819
|
}
|
|
3820
|
+
const previewUrl = await resolveCoursePreviewUrl(bucket, slug, config.preview);
|
|
3715
3821
|
const idxTpl = fs.readFileSync(path.join(uiSrc, "index.html"), "utf-8");
|
|
3716
3822
|
const idxHtml = idxTpl
|
|
3717
3823
|
.replace(/{{title}}/g, title)
|
|
3718
3824
|
.replace(/<title>.*<\/title>/, `<title>${title}</title>`)
|
|
3719
3825
|
.replace(/{{description}}/g, config.description[selectedLang])
|
|
3720
|
-
.replace(/{{preview}}/g,
|
|
3721
|
-
"https://raw.githubusercontent.com/learnpack/ide/master/public/learnpack.svg")
|
|
3826
|
+
.replace(/{{preview}}/g, previewUrl)
|
|
3722
3827
|
.replace(/{{slug}}/g, slug)
|
|
3723
3828
|
.replace(/{{duration}}/g, (0, misc_1.minutesToISO8601Duration)(config.duration));
|
|
3724
3829
|
fs.writeFileSync(path.join(buildRoot, "index.html"), idxHtml);
|
|
@@ -3748,9 +3853,9 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
3748
3853
|
// 8) Crear el config.json en buildRoot con lo que retorna buildConfig
|
|
3749
3854
|
const fullConfig = { config, exercises };
|
|
3750
3855
|
fullConfig.config.slug = slug;
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3856
|
+
// Same URL the meta tags got: this used to overwrite the real preview
|
|
3857
|
+
// of every published package with the unusable SVG fallback.
|
|
3858
|
+
fullConfig.config.preview = previewUrl;
|
|
3754
3859
|
fs.writeFileSync(path.join(buildRoot, "config.json"), JSON.stringify(fullConfig, null, 2), "utf-8");
|
|
3755
3860
|
// 9) Empaquetar en ZIP (solo contenido de buildRoot)
|
|
3756
3861
|
const zipName = `${slug}.zip`;
|
|
@@ -4220,19 +4325,19 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
4220
4325
|
const [learnJsonContent] = await learnJsonFile.download();
|
|
4221
4326
|
const learnJson = JSON.parse(learnJsonContent.toString());
|
|
4222
4327
|
learnJson.slug = newSlug;
|
|
4223
|
-
await uploadFileToBucket(bucket, JSON.stringify(learnJson), `${newPrefix}learn.json`);
|
|
4328
|
+
await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(learnJson), `${newPrefix}learn.json`);
|
|
4224
4329
|
// Update initialSyllabus.json with new slug
|
|
4225
4330
|
const syllabusFile = bucket.file(`${newPrefix}.learn/initialSyllabus.json`);
|
|
4226
4331
|
const [syllabusContent] = await syllabusFile.download();
|
|
4227
4332
|
const syllabus = JSON.parse(syllabusContent.toString());
|
|
4228
4333
|
syllabus.courseInfo.slug = newSlug;
|
|
4229
|
-
await uploadFileToBucket(bucket, JSON.stringify(syllabus), `${newPrefix}.learn/initialSyllabus.json`);
|
|
4334
|
+
await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(syllabus), `${newPrefix}.learn/initialSyllabus.json`);
|
|
4230
4335
|
// Update config.json with new slug
|
|
4231
4336
|
const configFile = bucket.file(`${newPrefix}.learn/config.json`);
|
|
4232
4337
|
const [configContent] = await configFile.download();
|
|
4233
4338
|
const config = JSON.parse(configContent.toString());
|
|
4234
4339
|
config.config.slug = newSlug;
|
|
4235
|
-
await uploadFileToBucket(bucket, JSON.stringify(config), `${newPrefix}.learn/config.json`);
|
|
4340
|
+
await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(config), `${newPrefix}.learn/config.json`);
|
|
4236
4341
|
// Update Rigobot package slug
|
|
4237
4342
|
const updateUrl = `${api_1.RIGOBOT_HOST}/v1/learnpack/package/${currentSlug}/`;
|
|
4238
4343
|
await axios_1.default.put(updateUrl, { new_slug: newSlug }, {
|
|
@@ -4394,7 +4499,7 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
4394
4499
|
defaultBranch,
|
|
4395
4500
|
lastSyncSHA: commitRes.sha,
|
|
4396
4501
|
};
|
|
4397
|
-
await uploadFileToBucket(bucket, JSON.stringify(configJson), `courses/${courseSlug}/.learn/config.json`);
|
|
4502
|
+
await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(configJson), `courses/${courseSlug}/.learn/config.json`);
|
|
4398
4503
|
return res.json({
|
|
4399
4504
|
success: true,
|
|
4400
4505
|
repository,
|
|
@@ -4568,7 +4673,7 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
4568
4673
|
compareRes.files.length === 0) {
|
|
4569
4674
|
configJson.config = configJson.config || {};
|
|
4570
4675
|
configJson.config.github = Object.assign(Object.assign({}, configJson.config.github), { lastSyncSHA: targetSHA });
|
|
4571
|
-
await uploadFileToBucket(bucket, JSON.stringify(configJson), `courses/${courseSlug}/.learn/config.json`);
|
|
4676
|
+
await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(configJson), `courses/${courseSlug}/.learn/config.json`);
|
|
4572
4677
|
return res.json({
|
|
4573
4678
|
success: true,
|
|
4574
4679
|
syncedLessons: [],
|
|
@@ -4700,12 +4805,12 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
4700
4805
|
const buffer = buffer_1.Buffer.from(content, "base64");
|
|
4701
4806
|
if (item.isAsset || isImageFile(item.relativePath)) {
|
|
4702
4807
|
// eslint-disable-next-line no-await-in-loop -- Sequential processing to avoid rate limits
|
|
4703
|
-
await uploadBinaryToBucket(bucket, buffer, item.bucketPath, "application/octet-stream");
|
|
4808
|
+
await (0, bucketIo_1.uploadBinaryToBucket)(bucket, buffer, item.bucketPath, "application/octet-stream");
|
|
4704
4809
|
}
|
|
4705
4810
|
else {
|
|
4706
4811
|
const text = buffer.toString("utf8");
|
|
4707
4812
|
// eslint-disable-next-line no-await-in-loop -- Sequential processing to avoid rate limits
|
|
4708
|
-
await uploadFileToBucket(bucket, text, item.bucketPath);
|
|
4813
|
+
await (0, bucketIo_1.uploadFileToBucket)(bucket, text, item.bucketPath);
|
|
4709
4814
|
}
|
|
4710
4815
|
if (item.isAsset) {
|
|
4711
4816
|
syncedAssetsUploaded++;
|
|
@@ -4718,7 +4823,7 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
4718
4823
|
}
|
|
4719
4824
|
configJson.config = configJson.config || {};
|
|
4720
4825
|
configJson.config.github = Object.assign(Object.assign({}, configJson.config.github), { lastSyncSHA: targetSHA });
|
|
4721
|
-
await uploadFileToBucket(bucket, JSON.stringify(configJson), `courses/${courseSlug}/.learn/config.json`);
|
|
4826
|
+
await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(configJson), `courses/${courseSlug}/.learn/config.json`);
|
|
4722
4827
|
return res.json({
|
|
4723
4828
|
success: true,
|
|
4724
4829
|
syncedLessons: [...syncedLessonSlugs],
|
|
@@ -4787,7 +4892,7 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
4787
4892
|
});
|
|
4788
4893
|
configJson.config = configJson.config || {};
|
|
4789
4894
|
configJson.config.github = Object.assign(Object.assign({}, configJson.config.github), { lastSyncSHA: commitRes.sha });
|
|
4790
|
-
await uploadFileToBucket(bucket, JSON.stringify(configJson), `courses/${courseSlug}/.learn/config.json`);
|
|
4895
|
+
await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(configJson), `courses/${courseSlug}/.learn/config.json`);
|
|
4791
4896
|
return res.json({
|
|
4792
4897
|
success: true,
|
|
4793
4898
|
repository,
|
|
@@ -4862,7 +4967,7 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
4862
4967
|
}
|
|
4863
4968
|
try {
|
|
4864
4969
|
const memoryBankPath = `courses/${courseSlug}/.learn/memory_bank.txt`;
|
|
4865
|
-
await uploadFileToBucket(bucket, content, memoryBankPath);
|
|
4970
|
+
await (0, bucketIo_1.uploadFileToBucket)(bucket, content, memoryBankPath);
|
|
4866
4971
|
console.log(`✅ Memory bank updated for course: ${courseSlug}`);
|
|
4867
4972
|
res.json({ message: "Memory bank updated successfully" });
|
|
4868
4973
|
}
|