@learnpack/learnpack 5.0.351 → 5.0.353
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 +6 -0
- package/lib/commands/serve.js +85 -259
- package/lib/models/creator.d.ts +6 -0
- package/lib/scripts/descriptionsGcsBackfill.d.ts +1 -0
- package/lib/scripts/descriptionsGcsBackfill.js +141 -0
- package/lib/scripts/descriptionsS3Backfill.d.ts +1 -0
- package/lib/scripts/descriptionsS3Backfill.js +157 -0
- package/lib/scripts/descriptionsSweep.d.ts +1 -0
- package/lib/scripts/descriptionsSweep.js +142 -0
- package/lib/utils/api.d.ts +7 -0
- package/lib/utils/api.js +8 -1
- package/lib/utils/creatorUtilities.js +2 -1
- package/lib/utils/descriptionHash.d.ts +66 -0
- package/lib/utils/descriptionHash.js +173 -0
- package/lib/utils/descriptions/gcsStorage.d.ts +16 -0
- package/lib/utils/descriptions/gcsStorage.js +60 -0
- package/lib/utils/descriptions/generateCourseDescriptions.d.ts +59 -0
- package/lib/utils/descriptions/generateCourseDescriptions.js +173 -0
- package/lib/utils/descriptions/mirrorDescriptions.d.ts +49 -0
- package/lib/utils/descriptions/mirrorDescriptions.js +109 -0
- package/lib/utils/descriptions/publishStage.d.ts +69 -0
- package/lib/utils/descriptions/publishStage.js +234 -0
- package/lib/utils/descriptions/resumePublication.d.ts +36 -0
- package/lib/utils/descriptions/resumePublication.js +113 -0
- package/lib/utils/descriptions/s3Storage.d.ts +30 -0
- package/lib/utils/descriptions/s3Storage.js +134 -0
- package/lib/utils/descriptions/workList.d.ts +75 -0
- package/lib/utils/descriptions/workList.js +177 -0
- package/lib/utils/gcsBucketName.d.ts +10 -0
- package/lib/utils/gcsBucketName.js +19 -0
- package/lib/utils/packageManifest.d.ts +18 -0
- package/lib/utils/packageManifest.js +81 -7
- package/lib/utils/publishEvents.d.ts +66 -0
- package/lib/utils/publishEvents.js +111 -0
- package/lib/utils/publishJournal.d.ts +119 -0
- package/lib/utils/publishJournal.js +275 -0
- package/lib/utils/rigoActions.d.ts +44 -0
- package/lib/utils/rigoActions.js +75 -1
- package/lib/utils/s3/packageManifestBackfill.d.ts +84 -0
- package/lib/utils/s3/packageManifestBackfill.js +487 -0
- package/lib/utils/syllabusSync.d.ts +71 -0
- package/lib/utils/syllabusSync.js +273 -0
- package/package.json +4 -1
- package/src/commands/publish.ts +7 -0
- package/src/commands/serve.ts +144 -335
- package/src/models/creator.ts +9 -0
- package/src/scripts/descriptionsGcsBackfill.ts +193 -0
- package/src/scripts/descriptionsS3Backfill.ts +208 -0
- package/src/scripts/descriptionsSweep.ts +185 -0
- package/src/ui/_app/app.css +1 -1
- package/src/ui/_app/app.js +143 -141
- package/src/ui/app.tar.gz +0 -0
- package/src/utils/api.ts +9 -0
- package/src/utils/creatorUtilities.ts +2 -1
- package/src/utils/descriptionHash.ts +196 -0
- package/src/utils/descriptions/gcsStorage.ts +67 -0
- package/src/utils/descriptions/generateCourseDescriptions.ts +301 -0
- package/src/utils/descriptions/mirrorDescriptions.ts +191 -0
- package/src/utils/descriptions/publishStage.ts +382 -0
- package/src/utils/descriptions/resumePublication.ts +200 -0
- package/src/utils/descriptions/s3Storage.ts +206 -0
- package/src/utils/descriptions/workList.ts +283 -0
- package/src/utils/export/README.md +0 -2
- package/src/utils/gcsBucketName.ts +19 -0
- package/src/utils/packageManifest.ts +101 -10
- package/src/utils/publishEvents.ts +181 -0
- package/src/utils/publishJournal.ts +383 -0
- package/src/utils/rigoActions.ts +130 -0
- package/src/utils/s3/packageManifestBackfill.ts +776 -0
- package/src/utils/syllabusSync.ts +390 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.synchronizeSyllabusWithBucket = synchronizeSyllabusWithBucket;
|
|
4
|
+
exports.createGcsSyllabusSyncStorage = createGcsSyllabusSyncStorage;
|
|
5
|
+
const configBuilder_1 = require("./configBuilder");
|
|
6
|
+
const creatorUtilities_1 = require("./creatorUtilities");
|
|
7
|
+
function isInFlight(lesson) {
|
|
8
|
+
// A lesson still being generated may legitimately have no files yet; never
|
|
9
|
+
// treat it as "missing" (E4 hardening against deleting something real).
|
|
10
|
+
return (lesson.generated === false ||
|
|
11
|
+
lesson.status === "GENERATING" ||
|
|
12
|
+
lesson.status === "PENDING");
|
|
13
|
+
}
|
|
14
|
+
async function synchronizeSyllabusWithBucket(storage, courseSlug, syllabus, options = {}) {
|
|
15
|
+
var _a, _b, _c, _d;
|
|
16
|
+
const prune = (_a = options.prune) !== null && _a !== void 0 ? _a : false;
|
|
17
|
+
const removed = [];
|
|
18
|
+
const duplicates = [];
|
|
19
|
+
const kept = [];
|
|
20
|
+
const added = [];
|
|
21
|
+
let repairedTranslationsInLessons = 0;
|
|
22
|
+
let repairedTranslationEntries = 0;
|
|
23
|
+
let fixedLessons = 0;
|
|
24
|
+
let changed = false;
|
|
25
|
+
// Single authoritative listing. If it fails it throws (callers must not
|
|
26
|
+
// delete on an unknown listing). The adapter is expected to retry internally.
|
|
27
|
+
const folderCounts = await storage.listExerciseFolderFileCounts(courseSlug);
|
|
28
|
+
// First pass: resolve existence per lesson, confirming absence under both the
|
|
29
|
+
// slugified id-title and the uid folder-name variants.
|
|
30
|
+
const existingLessons = [];
|
|
31
|
+
for (const lesson of syllabus.lessons) {
|
|
32
|
+
const lessonSlug = (0, creatorUtilities_1.slugify)(lesson.id + "-" + lesson.title);
|
|
33
|
+
let resolvedSlug = lessonSlug;
|
|
34
|
+
let fileCount = (_b = folderCounts.get(lessonSlug)) !== null && _b !== void 0 ? _b : 0;
|
|
35
|
+
if (fileCount === 0 &&
|
|
36
|
+
lesson.uid &&
|
|
37
|
+
((_c = folderCounts.get(lesson.uid)) !== null && _c !== void 0 ? _c : 0) > 0) {
|
|
38
|
+
resolvedSlug = lesson.uid;
|
|
39
|
+
fileCount = (_d = folderCounts.get(lesson.uid)) !== null && _d !== void 0 ? _d : 0;
|
|
40
|
+
}
|
|
41
|
+
if (fileCount === 0) {
|
|
42
|
+
if (isInFlight(lesson)) {
|
|
43
|
+
// Keep untouched: do not mark for removal nor as existing.
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
removed.push({ id: lesson.id, title: lesson.title, slug: lessonSlug });
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
existingLessons.push({ lesson, slug: resolvedSlug, fileCount });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// Second pass: detect duplicates (multiple lessons resolving to one folder).
|
|
53
|
+
const slugMap = new Map();
|
|
54
|
+
for (const item of existingLessons) {
|
|
55
|
+
if (!slugMap.has(item.slug)) {
|
|
56
|
+
slugMap.set(item.slug, []);
|
|
57
|
+
}
|
|
58
|
+
slugMap.get(item.slug).push(item);
|
|
59
|
+
}
|
|
60
|
+
for (const items of slugMap.values()) {
|
|
61
|
+
if (items.length > 1) {
|
|
62
|
+
items.sort((a, b) => b.fileCount - a.fileCount);
|
|
63
|
+
const winner = items[0];
|
|
64
|
+
kept.push({
|
|
65
|
+
id: winner.lesson.id,
|
|
66
|
+
title: winner.lesson.title,
|
|
67
|
+
slug: winner.slug,
|
|
68
|
+
fileCount: winner.fileCount,
|
|
69
|
+
});
|
|
70
|
+
for (const loser of items.slice(1)) {
|
|
71
|
+
duplicates.push({
|
|
72
|
+
id: loser.lesson.id,
|
|
73
|
+
title: loser.lesson.title,
|
|
74
|
+
slug: loser.slug,
|
|
75
|
+
fileCount: loser.fileCount,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
kept.push({
|
|
81
|
+
id: items[0].lesson.id,
|
|
82
|
+
title: items[0].lesson.title,
|
|
83
|
+
slug: items[0].slug,
|
|
84
|
+
fileCount: items[0].fileCount,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Apply removals only in prune mode (additive mode never deletes).
|
|
89
|
+
if (prune && (removed.length > 0 || duplicates.length > 0)) {
|
|
90
|
+
syllabus.lessons = syllabus.lessons.filter(lesson => {
|
|
91
|
+
const lessonSlug = (0, creatorUtilities_1.slugify)(lesson.id + "-" + lesson.title);
|
|
92
|
+
const isRemoved = removed.some(r => r.slug === lessonSlug);
|
|
93
|
+
const isDuplicate = duplicates.some(d => d.id === lesson.id && d.title === lesson.title);
|
|
94
|
+
return !isRemoved && !isDuplicate;
|
|
95
|
+
});
|
|
96
|
+
changed = true;
|
|
97
|
+
}
|
|
98
|
+
// Third pass: add lessons that exist in the bucket but are missing from the
|
|
99
|
+
// syllabus, then re-sort by numeric id.
|
|
100
|
+
const existingSlugs = new Set(existingLessons.map(e => e.slug));
|
|
101
|
+
for (const [folderSlug, count] of folderCounts) {
|
|
102
|
+
if (count <= 0 || existingSlugs.has(folderSlug)) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const idMatch = folderSlug.match(/^(\d+(?:\.\d+)?)(?:-(.*))?$/);
|
|
106
|
+
const id = idMatch ? idMatch[1] : folderSlug;
|
|
107
|
+
const titlePart = idMatch && idMatch[2] ? idMatch[2] : folderSlug;
|
|
108
|
+
const title = titlePart.replace(/-/g, " ").trim() || folderSlug;
|
|
109
|
+
const newLesson = {
|
|
110
|
+
id,
|
|
111
|
+
uid: folderSlug,
|
|
112
|
+
title,
|
|
113
|
+
type: "READ",
|
|
114
|
+
description: title,
|
|
115
|
+
duration: 2,
|
|
116
|
+
generated: true,
|
|
117
|
+
status: "DONE",
|
|
118
|
+
};
|
|
119
|
+
syllabus.lessons.push(newLesson);
|
|
120
|
+
added.push({ id, title, slug: folderSlug });
|
|
121
|
+
changed = true;
|
|
122
|
+
}
|
|
123
|
+
if (added.length > 0) {
|
|
124
|
+
syllabus.lessons.sort((a, b) => {
|
|
125
|
+
const na = Number.parseFloat(a.id);
|
|
126
|
+
const nb = Number.parseFloat(b.id);
|
|
127
|
+
if (!Number.isNaN(na) && !Number.isNaN(nb)) {
|
|
128
|
+
return na - nb;
|
|
129
|
+
}
|
|
130
|
+
return String(a.id).localeCompare(String(b.id));
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
// Fourth + fifth pass: reconcile translations metadata from the actual README
|
|
134
|
+
// files, and unstick lessons stuck in GENERATING/ERROR whose primary-language
|
|
135
|
+
// README already exists.
|
|
136
|
+
try {
|
|
137
|
+
const translationsBySlug = await storage.translationLangsBySlug(courseSlug);
|
|
138
|
+
for (const lesson of syllabus.lessons) {
|
|
139
|
+
const candidateSlugs = [
|
|
140
|
+
(0, creatorUtilities_1.slugify)(lesson.id + "-" + lesson.title),
|
|
141
|
+
lesson.uid,
|
|
142
|
+
].filter(Boolean);
|
|
143
|
+
const matchedSlug = candidateSlugs.find(s => translationsBySlug.has(s));
|
|
144
|
+
if (!matchedSlug) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const languageCodes = translationsBySlug.get(matchedSlug) || [];
|
|
148
|
+
if (languageCodes.length === 0) {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const currentTranslations = lesson.translations || {};
|
|
152
|
+
let lessonChanged = false;
|
|
153
|
+
for (const lang of languageCodes) {
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
if (!currentTranslations[lang]) {
|
|
156
|
+
currentTranslations[lang] = {
|
|
157
|
+
completionId: 0,
|
|
158
|
+
startedAt: now,
|
|
159
|
+
completedAt: now,
|
|
160
|
+
};
|
|
161
|
+
repairedTranslationEntries += 1;
|
|
162
|
+
lessonChanged = true;
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
if (!currentTranslations[lang].startedAt) {
|
|
166
|
+
currentTranslations[lang].startedAt = now;
|
|
167
|
+
lessonChanged = true;
|
|
168
|
+
}
|
|
169
|
+
if (!currentTranslations[lang].completedAt) {
|
|
170
|
+
currentTranslations[lang].completedAt = now;
|
|
171
|
+
repairedTranslationEntries += 1;
|
|
172
|
+
lessonChanged = true;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (lessonChanged) {
|
|
177
|
+
lesson.translations = currentTranslations;
|
|
178
|
+
repairedTranslationsInLessons += 1;
|
|
179
|
+
changed = true;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const primaryLanguage = syllabus.courseInfo.language || "en";
|
|
183
|
+
for (const lesson of syllabus.lessons) {
|
|
184
|
+
if (lesson.generated !== false) {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (lesson.status !== "GENERATING" && lesson.status !== "ERROR") {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const candidateSlugs = [
|
|
191
|
+
(0, creatorUtilities_1.slugify)(lesson.id + "-" + lesson.title),
|
|
192
|
+
lesson.uid,
|
|
193
|
+
].filter(Boolean);
|
|
194
|
+
const matchedSlug = candidateSlugs.find(s => translationsBySlug.has(s));
|
|
195
|
+
if (!matchedSlug) {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const langs = translationsBySlug.get(matchedSlug) || [];
|
|
199
|
+
if (!langs.includes(primaryLanguage)) {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
lesson.generated = true;
|
|
203
|
+
lesson.status = "DONE";
|
|
204
|
+
fixedLessons += 1;
|
|
205
|
+
changed = true;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
console.error("⚠️ Could not reconcile lesson translations during syllabus sync:", error);
|
|
210
|
+
}
|
|
211
|
+
const counters = {
|
|
212
|
+
totalLessons: syllabus.lessons.length,
|
|
213
|
+
keptLessons: kept.length,
|
|
214
|
+
removedLessons: prune ? removed.length : 0,
|
|
215
|
+
duplicatesResolved: prune ? duplicates.length : 0,
|
|
216
|
+
addedLessons: added.length,
|
|
217
|
+
fixedLessons,
|
|
218
|
+
repairedTranslationsInLessons,
|
|
219
|
+
repairedTranslationEntries,
|
|
220
|
+
};
|
|
221
|
+
return {
|
|
222
|
+
syllabus,
|
|
223
|
+
changed,
|
|
224
|
+
counters,
|
|
225
|
+
details: { removed, duplicates, kept, added },
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
async function withRetry(fn, attempts = 3) {
|
|
229
|
+
let lastError;
|
|
230
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
231
|
+
try {
|
|
232
|
+
// eslint-disable-next-line no-await-in-loop
|
|
233
|
+
return await fn();
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
lastError = error;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
throw lastError;
|
|
240
|
+
}
|
|
241
|
+
/** GCS-backed storage adapter (uses the bucket listing + buildConfig). */
|
|
242
|
+
function createGcsSyllabusSyncStorage(bucket) {
|
|
243
|
+
return {
|
|
244
|
+
async listExerciseFolderFileCounts(courseSlug) {
|
|
245
|
+
const prefix = `courses/${courseSlug}/exercises/`;
|
|
246
|
+
const [files] = await withRetry(() => bucket.getFiles({ prefix }));
|
|
247
|
+
const counts = new Map();
|
|
248
|
+
for (const file of files) {
|
|
249
|
+
if (!file.name.startsWith(prefix)) {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const segment = file.name.slice(prefix.length).split("/")[0];
|
|
253
|
+
if (segment) {
|
|
254
|
+
counts.set(segment, (counts.get(segment) || 0) + 1);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return counts;
|
|
258
|
+
},
|
|
259
|
+
async translationLangsBySlug(courseSlug) {
|
|
260
|
+
const { exercises } = await (0, configBuilder_1.buildConfig)(bucket, courseSlug);
|
|
261
|
+
const map = new Map();
|
|
262
|
+
for (const exercise of exercises) {
|
|
263
|
+
const langs = Object.keys(exercise.translations || {})
|
|
264
|
+
.map(lang => lang.toLowerCase())
|
|
265
|
+
.filter(Boolean);
|
|
266
|
+
if (langs.length > 0) {
|
|
267
|
+
map.set(exercise.slug, [...new Set(langs)]);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return map;
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
}
|
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.353",
|
|
5
5
|
"author": "Alejandro Sanchez @alesanchezr",
|
|
6
6
|
"contributors": [
|
|
7
7
|
{
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
"url": "https://github.com/learnpack/learnpack-cli/issues"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
+
"@aws-sdk/client-cloudfront": "^3.1092.0",
|
|
25
|
+
"@aws-sdk/client-s3": "^3.1092.0",
|
|
24
26
|
"@google-cloud/storage": "^7.16.0",
|
|
25
27
|
"@oclif/command": "^1.6.1",
|
|
26
28
|
"@oclif/config": "^1.15.1",
|
|
@@ -161,6 +163,7 @@
|
|
|
161
163
|
]
|
|
162
164
|
},
|
|
163
165
|
"scripts": {
|
|
166
|
+
"backfill:package-manifest": "ts-node --project ./tsconfig.json ./scripts/backfill-package-manifest-s3.ts",
|
|
164
167
|
"copy-assets": "npx cpy src/creatorDist/**/* lib/creatorDist --parents --verbose && npx cpy src/utils/templates/**/* lib/utils/templates --parents --verbose && npx cpy src/lua/**/* lib/lua --parents --verbose",
|
|
165
168
|
"tsc": "tsc -b",
|
|
166
169
|
"postpack": "rm -f oclif.manifest.json && eslint . --ext .ts --config .eslintrc",
|
package/src/commands/publish.ts
CHANGED
|
@@ -666,6 +666,13 @@ class BuildCommand extends SessionCommand {
|
|
|
666
666
|
|
|
667
667
|
Console.debug("Zip file saved in project root")
|
|
668
668
|
|
|
669
|
+
// TODO(#2103): this CLI path publishes without the guarantees the
|
|
670
|
+
// creator flow has. It builds no package-manifest.json (pre-existing), it
|
|
671
|
+
// does not run the descriptions stage, and it emits neither
|
|
672
|
+
// package_published nor package_manifest_updated, so breathecode never
|
|
673
|
+
// hears about these publications. Decide between parity, deferring the
|
|
674
|
+
// work to the sweep, or documenting this path as degraded.
|
|
675
|
+
|
|
669
676
|
const formData = new FormData()
|
|
670
677
|
formData.append("file", fs.createReadStream(zipFilePath))
|
|
671
678
|
formData.append("config", JSON.stringify(learnJson))
|