@learnpack/learnpack 5.0.355 → 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 +159 -129
- 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 +162 -114
- 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,343 @@
|
|
|
1
|
+
import Console from "../../../utils/console"
|
|
2
|
+
import { mapWithConcurrency } from "../../../utils/s3/packageManifestBackfill"
|
|
3
|
+
import { CourseStorage } from "../ports/courseStorage"
|
|
4
|
+
import { PackageRegistry, RigoPackage } from "../ports/packageRegistry"
|
|
5
|
+
import { IngestError } from "../errors"
|
|
6
|
+
import { buildIngestConfig } from "./buildIngestConfig"
|
|
7
|
+
import { buildInitialSyllabus } from "./buildInitialSyllabus"
|
|
8
|
+
import { buildSidebar } from "./buildSidebar"
|
|
9
|
+
import { configPath, coursePrefix } from "./coursePaths"
|
|
10
|
+
import { normalizeLearnJson } from "./normalizeLearnJson"
|
|
11
|
+
import { pairLessons } from "./pairLessons"
|
|
12
|
+
import { planDeletions, planWrites } from "./planWrites"
|
|
13
|
+
import { resolveSlug, suggestSlugs } from "./resolveSlug"
|
|
14
|
+
import {
|
|
15
|
+
LEARN_JSON_PATH,
|
|
16
|
+
LESSON_PLAN_PATH,
|
|
17
|
+
PackageFiles,
|
|
18
|
+
readExercises,
|
|
19
|
+
validatePackage,
|
|
20
|
+
} from "./validatePackage"
|
|
21
|
+
import { LessonPlan, SkippedLesson } from "./types"
|
|
22
|
+
|
|
23
|
+
/** Enough parallelism to hide the round trip without opening a socket per file. */
|
|
24
|
+
const WRITE_CONCURRENCY = 10
|
|
25
|
+
|
|
26
|
+
export type IngestInput = {
|
|
27
|
+
files: PackageFiles;
|
|
28
|
+
/** Slug the caller asked for; derived from the title when absent. */
|
|
29
|
+
requestedSlug?: string;
|
|
30
|
+
/** Whether the caller accepted overwriting a course they already own. */
|
|
31
|
+
overwrite: boolean;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type IngestDeps = {
|
|
35
|
+
storage: CourseStorage;
|
|
36
|
+
/** Registry already scoped to the calling user's token. */
|
|
37
|
+
registry: PackageRegistry;
|
|
38
|
+
/** Organization every ingested package must end up under. */
|
|
39
|
+
organization: string;
|
|
40
|
+
now?: number;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type IngestReport = {
|
|
44
|
+
slug: string;
|
|
45
|
+
packageId: number;
|
|
46
|
+
organization: string;
|
|
47
|
+
lessons: number;
|
|
48
|
+
/** Planned lessons the package held no content for. */
|
|
49
|
+
skipped: SkippedLesson[];
|
|
50
|
+
written: number;
|
|
51
|
+
deleted: number;
|
|
52
|
+
/**
|
|
53
|
+
* True when the slug was already registered but its bucket was empty, so this
|
|
54
|
+
* run completed an earlier attempt instead of overwriting anything.
|
|
55
|
+
*/
|
|
56
|
+
recoveredEmptyPackage: boolean;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const parseJson = <T>(raw: string, path: string): T => {
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(raw) as T
|
|
62
|
+
} catch (error) {
|
|
63
|
+
throw new IngestError(
|
|
64
|
+
"INVALID_PACKAGE",
|
|
65
|
+
`${path} is not valid JSON: ${(error as Error).message}`,
|
|
66
|
+
error
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Reads the GitHub connection a previous config may hold.
|
|
73
|
+
*
|
|
74
|
+
* A user can link a course to a repository from the creator, and that link
|
|
75
|
+
* lives in `config.github`. Re-ingesting rewrites `config.json` wholesale, so
|
|
76
|
+
* without carrying this across, an overwrite would quietly disconnect a
|
|
77
|
+
* repository nobody asked to disconnect. `GET /config` guards the same field
|
|
78
|
+
* the same way.
|
|
79
|
+
* @param storage - Course storage.
|
|
80
|
+
* @param slug - Course slug.
|
|
81
|
+
* @returns The stored github block, or undefined.
|
|
82
|
+
*/
|
|
83
|
+
async function readGithubConnection(
|
|
84
|
+
storage: CourseStorage,
|
|
85
|
+
slug: string
|
|
86
|
+
): Promise<unknown> {
|
|
87
|
+
const raw = await storage.read(configPath(slug))
|
|
88
|
+
if (!raw) {
|
|
89
|
+
return undefined
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const existing = JSON.parse(raw)
|
|
94
|
+
return existing?.config?.github
|
|
95
|
+
} catch {
|
|
96
|
+
// A config we cannot parse has nothing to preserve; the fresh one wins.
|
|
97
|
+
return undefined
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Turns the configured organization into the id everything else speaks.
|
|
103
|
+
*
|
|
104
|
+
* Configuration names an organization the way a person would, but a package
|
|
105
|
+
* reports its organization as a numeric id: the field is a plain ForeignKey on
|
|
106
|
+
* a ModelSerializer, so it serializes to the primary key, and no endpoint ever
|
|
107
|
+
* returns a slug. Comparing the two directly is how a correctly configured
|
|
108
|
+
* ingest ends up refused with "belongs to organization 105, not learnpack-labs".
|
|
109
|
+
*
|
|
110
|
+
* Accepts an id as-is, and otherwise matches case-insensitively against the
|
|
111
|
+
* names of the organizations the caller belongs to. A miss lists them, since
|
|
112
|
+
* nothing else in the flow would ever show the reader an id to use.
|
|
113
|
+
* @param configured - Value of INGEST_ORGANIZATION.
|
|
114
|
+
* @param registry - Used to list the caller's organizations.
|
|
115
|
+
* @returns The organization id.
|
|
116
|
+
* @throws IngestError `ORGANIZATION_MISMATCH` listing the available options.
|
|
117
|
+
*/
|
|
118
|
+
async function resolveOrganizationId(
|
|
119
|
+
configured: string,
|
|
120
|
+
registry: PackageRegistry
|
|
121
|
+
): Promise<number> {
|
|
122
|
+
const asId = Number(configured)
|
|
123
|
+
if (Number.isInteger(asId) && asId > 0) {
|
|
124
|
+
return asId
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const organizations = await registry.listOrganizations()
|
|
128
|
+
const wanted = configured.trim().toLowerCase()
|
|
129
|
+
const match = organizations.find(
|
|
130
|
+
org => (org.name || "").trim().toLowerCase() === wanted
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if (match) {
|
|
134
|
+
return match.id
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const available = organizations
|
|
138
|
+
.map(org => `${org.id} (${org.name})`)
|
|
139
|
+
.join(", ")
|
|
140
|
+
|
|
141
|
+
throw new IngestError(
|
|
142
|
+
"ORGANIZATION_MISMATCH",
|
|
143
|
+
available ?
|
|
144
|
+
`INGEST_ORGANIZATION is "${configured}", which matches none of your organizations: ${available}. Use one of those ids or its exact name.` :
|
|
145
|
+
`INGEST_ORGANIZATION is "${configured}", but this token belongs to no organization`
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Settles the package registration for a slug the caller may already own.
|
|
151
|
+
*
|
|
152
|
+
* Registering before writing is deliberate: it is the cheap check that catches
|
|
153
|
+
* a slug collision or a wrong organization while the bucket is still untouched.
|
|
154
|
+
* @param slug - Resolved slug.
|
|
155
|
+
* @param learnJson - Normalized learn.json, stored as the package config.
|
|
156
|
+
* @param deps - Registry and expected organization.
|
|
157
|
+
* @param overwrite - Whether the caller accepted overwriting their own course.
|
|
158
|
+
* @param storage - Used to tell an abandoned registration from a real course.
|
|
159
|
+
* @returns The package and whether this run is completing an earlier attempt.
|
|
160
|
+
*/
|
|
161
|
+
async function registerPackage(
|
|
162
|
+
slug: string,
|
|
163
|
+
learnJson: Record<string, unknown>,
|
|
164
|
+
deps: IngestDeps,
|
|
165
|
+
overwrite: boolean,
|
|
166
|
+
storage: CourseStorage
|
|
167
|
+
): Promise<{ pkg: RigoPackage; recovered: boolean }> {
|
|
168
|
+
const available = await deps.registry.isSlugAvailable(slug)
|
|
169
|
+
|
|
170
|
+
if (available) {
|
|
171
|
+
return {
|
|
172
|
+
pkg: await deps.registry.createPackage(slug, learnJson),
|
|
173
|
+
recovered: false,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const existing = await deps.registry.getPackageBySlug(slug)
|
|
178
|
+
|
|
179
|
+
if (!existing) {
|
|
180
|
+
// Taken, and the registry will not show it to us: it belongs to another
|
|
181
|
+
// user. No suggestion is offered here — the caller cannot fix this by
|
|
182
|
+
// retrying, only by choosing a different slug.
|
|
183
|
+
throw new IngestError(
|
|
184
|
+
"SLUG_OWNED_BY_OTHER",
|
|
185
|
+
`The slug "${slug}" belongs to another user`
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (!overwrite) {
|
|
190
|
+
const contents = await storage.list(coursePrefix(slug))
|
|
191
|
+
|
|
192
|
+
// A registered slug with an empty bucket is the fingerprint of a run that
|
|
193
|
+
// registered and then failed before writing. Refusing it would strand the
|
|
194
|
+
// user behind a 409 for a course that, as far as they can tell, does not
|
|
195
|
+
// exist.
|
|
196
|
+
if (contents.length === 0) {
|
|
197
|
+
Console.info(
|
|
198
|
+
`[ingest] "${slug}" was registered but never written; completing that attempt`
|
|
199
|
+
)
|
|
200
|
+
return { pkg: existing, recovered: true }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const suggestions = await suggestSlugs(slug, deps.registry)
|
|
204
|
+
throw new IngestError(
|
|
205
|
+
"SLUG_TAKEN",
|
|
206
|
+
`You already have a course at "${slug}". Re-run with overwrite to replace it, or use one of: ${suggestions.join(
|
|
207
|
+
", "
|
|
208
|
+
)}`
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return { pkg: existing, recovered: false }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Ingests a finished course package: registers it and writes it to the bucket.
|
|
217
|
+
*
|
|
218
|
+
* The order is validate → resolve slug → register → write → prune, and it is
|
|
219
|
+
* not arbitrary. Validation and slug resolution are free and catch most bad
|
|
220
|
+
* requests; registration is the first thing that can conflict, and doing it
|
|
221
|
+
* before any write means a rejected course leaves nothing behind.
|
|
222
|
+
*
|
|
223
|
+
* Failures throw `IngestError` rather than being collected into the report. A
|
|
224
|
+
* partial ingest has no useful middle state to report — unlike publishing,
|
|
225
|
+
* nothing here runs detached after the response, so there is nothing for a
|
|
226
|
+
* later sweep to resume. Re-running is the recovery path, and the empty-package
|
|
227
|
+
* case above is what keeps that from being blocked.
|
|
228
|
+
* @param input - The extracted package and the caller's choices.
|
|
229
|
+
* @param deps - Storage, registry and expected organization.
|
|
230
|
+
* @returns What was written.
|
|
231
|
+
*/
|
|
232
|
+
export async function ingestCoursePackage(
|
|
233
|
+
input: IngestInput,
|
|
234
|
+
deps: IngestDeps
|
|
235
|
+
): Promise<IngestReport> {
|
|
236
|
+
validatePackage(input.files)
|
|
237
|
+
|
|
238
|
+
const rawLearnJson = parseJson<Record<string, unknown>>(
|
|
239
|
+
input.files[LEARN_JSON_PATH],
|
|
240
|
+
LEARN_JSON_PATH
|
|
241
|
+
)
|
|
242
|
+
const plan = parseJson<LessonPlan>(
|
|
243
|
+
input.files[LESSON_PLAN_PATH],
|
|
244
|
+
LESSON_PLAN_PATH
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
const slug = resolveSlug({
|
|
248
|
+
requestedSlug: input.requestedSlug,
|
|
249
|
+
courseTitle: plan.course_title,
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
const learnJson = normalizeLearnJson({ learnJson: rawLearnJson, slug })
|
|
253
|
+
|
|
254
|
+
const { pkg, recovered } = await registerPackage(
|
|
255
|
+
slug,
|
|
256
|
+
learnJson,
|
|
257
|
+
deps,
|
|
258
|
+
input.overwrite,
|
|
259
|
+
deps.storage
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
const organizationId = await resolveOrganizationId(
|
|
263
|
+
deps.organization,
|
|
264
|
+
deps.registry
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
if (pkg.organization === null) {
|
|
268
|
+
await deps.registry.assignOrganization(pkg.id, organizationId)
|
|
269
|
+
} else if (pkg.organization !== organizationId) {
|
|
270
|
+
// There is no way to detach an organization through the API, so landing in
|
|
271
|
+
// the wrong one is only fixable by hand. Better to stop before writing.
|
|
272
|
+
throw new IngestError(
|
|
273
|
+
"ORGANIZATION_MISMATCH",
|
|
274
|
+
`"${slug}" belongs to organization ${pkg.organization}, not ${organizationId} (${deps.organization})`
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const { slugs, languagesBySlug } = readExercises(input.files)
|
|
279
|
+
const { lessons, skipped } = pairLessons(plan, slugs, languagesBySlug)
|
|
280
|
+
|
|
281
|
+
const language = (plan.language || "en").trim().toLowerCase() || "en"
|
|
282
|
+
const syllabus = buildInitialSyllabus({
|
|
283
|
+
slug,
|
|
284
|
+
language,
|
|
285
|
+
lessons,
|
|
286
|
+
learnJson,
|
|
287
|
+
now: deps.now,
|
|
288
|
+
})
|
|
289
|
+
const sidebar = buildSidebar(lessons)
|
|
290
|
+
|
|
291
|
+
const github = input.overwrite ?
|
|
292
|
+
await readGithubConnection(deps.storage, slug) :
|
|
293
|
+
undefined
|
|
294
|
+
const projected = buildIngestConfig({ lessons, learnJson })
|
|
295
|
+
const config = github ?
|
|
296
|
+
{ ...projected, config: { ...projected.config, github } } :
|
|
297
|
+
projected
|
|
298
|
+
|
|
299
|
+
const writes = planWrites({
|
|
300
|
+
slug,
|
|
301
|
+
files: input.files,
|
|
302
|
+
lessons,
|
|
303
|
+
learnJson,
|
|
304
|
+
syllabus,
|
|
305
|
+
sidebar,
|
|
306
|
+
config,
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
let deleted = 0
|
|
310
|
+
if (input.overwrite) {
|
|
311
|
+
const existing = await deps.storage.list(coursePrefix(slug))
|
|
312
|
+
const stale = planDeletions(existing, new Set(writes.keys()), slug)
|
|
313
|
+
await deps.storage.deleteMany(stale)
|
|
314
|
+
deleted = stale.length
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Written in parallel, with a cap. A course is around fifty small objects —
|
|
318
|
+
// one README per lesson plus the generation artifacts — and one round trip at
|
|
319
|
+
// a time turned a few seconds of work into most of a minute, long enough for
|
|
320
|
+
// the uploading client to give up while the server was still writing.
|
|
321
|
+
await mapWithConcurrency(
|
|
322
|
+
[...writes.entries()],
|
|
323
|
+
WRITE_CONCURRENCY,
|
|
324
|
+
([path, contents]) => deps.storage.write(path, contents)
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
if (skipped.length > 0) {
|
|
328
|
+
Console.warning(
|
|
329
|
+
`[ingest] "${slug}": ${skipped.length} planned lesson(s) had no content and were left out`
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
slug,
|
|
335
|
+
packageId: pkg.id,
|
|
336
|
+
organization: deps.organization,
|
|
337
|
+
lessons: lessons.length,
|
|
338
|
+
skipped,
|
|
339
|
+
written: writes.size,
|
|
340
|
+
deleted,
|
|
341
|
+
recoveredEmptyPackage: recovered,
|
|
342
|
+
}
|
|
343
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { BREATHECODE_TELEMETRY_URL } from "../../../utils/api"
|
|
2
|
+
|
|
3
|
+
export type NormalizeLearnJsonInput = {
|
|
4
|
+
/** The `learn.json` shipped inside the package. */
|
|
5
|
+
learnJson: Record<string, unknown>;
|
|
6
|
+
/** The slug the ingest resolved, which wins over whatever the package says. */
|
|
7
|
+
slug: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Fills in the fields the generator's exporter omits.
|
|
12
|
+
*
|
|
13
|
+
* The generator writes a `learn.json` good enough to run locally, but three
|
|
14
|
+
* fields only matter once the course lives in the bucket, and each fails
|
|
15
|
+
* quietly when missing:
|
|
16
|
+
*
|
|
17
|
+
* - `telemetry.batch` — without it student telemetry is never sent.
|
|
18
|
+
* - `preview` — publish falls back to the generic LearnPack logo.
|
|
19
|
+
* - `technologies` — the organization package listing filters on it.
|
|
20
|
+
*
|
|
21
|
+
* The slug is overwritten rather than trusted: it is resolved against the
|
|
22
|
+
* registry before anything is written, and the copy inside the package predates
|
|
23
|
+
* that. Leaving them to disagree puts the bucket path and `learn.json` out of
|
|
24
|
+
* sync, which `change-slug` would then fail to reconcile.
|
|
25
|
+
* @param input - The package's learn.json and the resolved slug.
|
|
26
|
+
* @returns A learn.json ready to be written to the bucket.
|
|
27
|
+
*/
|
|
28
|
+
export function normalizeLearnJson(
|
|
29
|
+
input: NormalizeLearnJsonInput
|
|
30
|
+
): Record<string, unknown> {
|
|
31
|
+
const { learnJson, slug } = input
|
|
32
|
+
const telemetry =
|
|
33
|
+
(learnJson.telemetry as Record<string, unknown> | undefined) || {}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
...learnJson,
|
|
37
|
+
slug,
|
|
38
|
+
technologies: Array.isArray(learnJson.technologies) ?
|
|
39
|
+
learnJson.technologies :
|
|
40
|
+
[],
|
|
41
|
+
telemetry: {
|
|
42
|
+
...telemetry,
|
|
43
|
+
batch: telemetry.batch || BREATHECODE_TELEMETRY_URL,
|
|
44
|
+
},
|
|
45
|
+
preview: learnJson.preview || `https://${slug}.learn-pack.com/preview.png`,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import Console from "../../../utils/console"
|
|
2
|
+
import { LessonType } from "../../../utils/packageManifest"
|
|
3
|
+
import {
|
|
4
|
+
IngestLesson,
|
|
5
|
+
LessonPlan,
|
|
6
|
+
PlannedLesson,
|
|
7
|
+
SkippedLesson,
|
|
8
|
+
} from "./types"
|
|
9
|
+
|
|
10
|
+
const VALID_LESSON_TYPES = new Set<LessonType>(["READ", "CODE", "QUIZ"])
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The exercise folder a lesson id owns, e.g. "01.1" for "01.1-split-a-class".
|
|
14
|
+
*
|
|
15
|
+
* Parsed by splitting rather than by a two-digit regex: the generator's own id
|
|
16
|
+
* parser accepts "1.0", so a stricter pattern here would reject a plan the
|
|
17
|
+
* generator considers valid.
|
|
18
|
+
* @param exerciseSlug - Folder name inside `exercises/`.
|
|
19
|
+
* @returns The leading id, or the whole slug when there is no hyphen.
|
|
20
|
+
*/
|
|
21
|
+
export function lessonIdFromSlug(exerciseSlug: string): string {
|
|
22
|
+
const [head] = exerciseSlug.split("-")
|
|
23
|
+
return head
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Reads the lesson type, falling back rather than failing.
|
|
28
|
+
*
|
|
29
|
+
* The generator declares this as a Pydantic Literal, so a successfully
|
|
30
|
+
* generated course cannot carry anything else — but the package is consumed
|
|
31
|
+
* long after the fact, and a course that renders every lesson as READ beats one
|
|
32
|
+
* that refuses to ingest.
|
|
33
|
+
* @param type - Raw value from the plan.
|
|
34
|
+
* @param lessonId - Used to make the warning actionable.
|
|
35
|
+
* @returns A valid lesson type.
|
|
36
|
+
*/
|
|
37
|
+
function coerceLessonType(
|
|
38
|
+
type: string | undefined,
|
|
39
|
+
lessonId: string
|
|
40
|
+
): LessonType {
|
|
41
|
+
if (type && VALID_LESSON_TYPES.has(type as LessonType)) {
|
|
42
|
+
return type as LessonType
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (type) {
|
|
46
|
+
Console.warning(
|
|
47
|
+
`[ingest] Lesson ${lessonId} has unknown type "${type}"; treating it as READ`
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return "READ"
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The generation topic for a lesson.
|
|
56
|
+
*
|
|
57
|
+
* `lesson_focus` is absent in packages from the pre-ADR-0001 schema, and this
|
|
58
|
+
* value is not what students read — the student-facing description lives per
|
|
59
|
+
* language in `translations[lang].description`, which the descriptions backfill
|
|
60
|
+
* fills in later. The title is a fine stand-in.
|
|
61
|
+
* @param lesson - Planned lesson.
|
|
62
|
+
* @returns The focus, or the title.
|
|
63
|
+
*/
|
|
64
|
+
function describeLesson(lesson: PlannedLesson): string {
|
|
65
|
+
const focus = (lesson.lesson_focus || "").trim()
|
|
66
|
+
return focus || lesson.title
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type PairLessonsResult = {
|
|
70
|
+
lessons: IngestLesson[];
|
|
71
|
+
skipped: SkippedLesson[];
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Matches planned lessons to the exercise folders that actually hold content.
|
|
76
|
+
*
|
|
77
|
+
* A plan lists every lesson that was *planned*; module-batched generation can
|
|
78
|
+
* stop early, leaving later modules without files. Pairing against the folders
|
|
79
|
+
* present is what makes a partial course ingest as the eight lessons it has
|
|
80
|
+
* rather than the twelve it intended — and the leftovers are reported instead
|
|
81
|
+
* of dropped, so nobody discovers the gap by scrolling the IDE.
|
|
82
|
+
* @param plan - The generator's lesson plan.
|
|
83
|
+
* @param exerciseSlugs - Folder names found under `exercises/` in the package.
|
|
84
|
+
* @param languagesBySlug - Language codes with a README, per exercise slug.
|
|
85
|
+
* @returns Paired lessons in plan order, plus the entries that had no content.
|
|
86
|
+
*/
|
|
87
|
+
export function pairLessons(
|
|
88
|
+
plan: LessonPlan,
|
|
89
|
+
exerciseSlugs: string[],
|
|
90
|
+
languagesBySlug: Record<string, string[]>
|
|
91
|
+
): PairLessonsResult {
|
|
92
|
+
const slugsById = new Map<string, string>()
|
|
93
|
+
for (const slug of exerciseSlugs) {
|
|
94
|
+
const id = lessonIdFromSlug(slug)
|
|
95
|
+
// First folder wins: a duplicate id means the package is malformed, and
|
|
96
|
+
// picking arbitrarily is no worse than picking the last one.
|
|
97
|
+
if (!slugsById.has(id)) {
|
|
98
|
+
slugsById.set(id, slug)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const lessons: IngestLesson[] = []
|
|
103
|
+
const skipped: SkippedLesson[] = []
|
|
104
|
+
|
|
105
|
+
for (const planned of plan.lessons || []) {
|
|
106
|
+
const exerciseSlug = slugsById.get(planned.id)
|
|
107
|
+
|
|
108
|
+
if (!exerciseSlug) {
|
|
109
|
+
skipped.push({
|
|
110
|
+
id: planned.id,
|
|
111
|
+
title: planned.title,
|
|
112
|
+
reason: "no-content",
|
|
113
|
+
})
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
lessons.push({
|
|
118
|
+
id: planned.id,
|
|
119
|
+
exerciseSlug,
|
|
120
|
+
title: planned.title,
|
|
121
|
+
type: coerceLessonType(planned.type, planned.id),
|
|
122
|
+
durationMinutes:
|
|
123
|
+
typeof planned.duration_minutes === "number" ?
|
|
124
|
+
planned.duration_minutes :
|
|
125
|
+
0,
|
|
126
|
+
description: describeLesson(planned),
|
|
127
|
+
languages: languagesBySlug[exerciseSlug] || [],
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { lessons, skipped }
|
|
132
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import {
|
|
2
|
+
configPath,
|
|
3
|
+
coursePrefix,
|
|
4
|
+
learnJsonPath,
|
|
5
|
+
prunablePrefixes,
|
|
6
|
+
sidebarPath,
|
|
7
|
+
syllabusPath,
|
|
8
|
+
} from "./coursePaths"
|
|
9
|
+
import { PackageFiles } from "./validatePackage"
|
|
10
|
+
import { IngestLesson } from "./types"
|
|
11
|
+
|
|
12
|
+
/** Package paths copied through untouched, keyed by their prefix. */
|
|
13
|
+
const PASSTHROUGH_PREFIXES = [".learn/artifacts/", "exercises/"]
|
|
14
|
+
|
|
15
|
+
export type PlanWritesInput = {
|
|
16
|
+
slug: string;
|
|
17
|
+
files: PackageFiles;
|
|
18
|
+
lessons: IngestLesson[];
|
|
19
|
+
learnJson: Record<string, unknown>;
|
|
20
|
+
syllabus: unknown;
|
|
21
|
+
sidebar: unknown;
|
|
22
|
+
config: unknown;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const normalize = (path: string): string => path.replace(/\\/g, "/")
|
|
26
|
+
|
|
27
|
+
const stringify = (value: unknown): string => JSON.stringify(value, null, 2)
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Lays out every object the ingest will write, keyed by its bucket path.
|
|
31
|
+
*
|
|
32
|
+
* Returned rather than written so the orchestrator can compare the plan against
|
|
33
|
+
* what is already in the bucket before touching anything — which is what makes
|
|
34
|
+
* pruning stale lessons possible, and what lets a test assert the whole tree
|
|
35
|
+
* without a storage double.
|
|
36
|
+
*
|
|
37
|
+
* Content files ride through verbatim; only the four derived documents are
|
|
38
|
+
* generated. Note `.learn/config.json` from the package is *not* among them:
|
|
39
|
+
* it is a projection of the other files, and the copy the generator ships
|
|
40
|
+
* carries localhost URLs and a stale exercise list.
|
|
41
|
+
* @param input - Slug, package files and the derived documents.
|
|
42
|
+
* @returns Bucket path → contents.
|
|
43
|
+
*/
|
|
44
|
+
export function planWrites(input: PlanWritesInput): Map<string, string> {
|
|
45
|
+
const writes = new Map<string, string>()
|
|
46
|
+
const prefix = coursePrefix(input.slug)
|
|
47
|
+
|
|
48
|
+
for (const [rawPath, contents] of Object.entries(input.files)) {
|
|
49
|
+
const path = normalize(rawPath)
|
|
50
|
+
|
|
51
|
+
if (PASSTHROUGH_PREFIXES.some(known => path.startsWith(known))) {
|
|
52
|
+
writes.set(`${prefix}${path}`, contents)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
writes.set(learnJsonPath(input.slug), stringify(input.learnJson))
|
|
57
|
+
writes.set(syllabusPath(input.slug), stringify(input.syllabus))
|
|
58
|
+
writes.set(sidebarPath(input.slug), stringify(input.sidebar))
|
|
59
|
+
writes.set(configPath(input.slug), stringify(input.config))
|
|
60
|
+
|
|
61
|
+
return writes
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Objects the course still holds that this ingest is not going to rewrite.
|
|
66
|
+
*
|
|
67
|
+
* Scoped to the prefixes the ingest owns. Everything else under the course
|
|
68
|
+
* belongs to somebody else — preview images the user uploaded,
|
|
69
|
+
* `package-manifest.json`, the publish journal — and deleting those would
|
|
70
|
+
* destroy work the ingest never created.
|
|
71
|
+
*
|
|
72
|
+
* Pruning matters because `buildConfig` derives the exercise list from whatever
|
|
73
|
+
* files exist: a regenerated course with fewer lessons would otherwise keep
|
|
74
|
+
* listing the old folders as lessons that open empty.
|
|
75
|
+
* @param existing - Every object path currently under the course.
|
|
76
|
+
* @param planned - Paths this ingest is about to write.
|
|
77
|
+
* @param slug - Course slug, for scoping the prunable prefixes.
|
|
78
|
+
* @returns Paths safe to delete.
|
|
79
|
+
*/
|
|
80
|
+
export function planDeletions(
|
|
81
|
+
existing: string[],
|
|
82
|
+
planned: Set<string>,
|
|
83
|
+
slug: string
|
|
84
|
+
): string[] {
|
|
85
|
+
const prunable = prunablePrefixes(slug)
|
|
86
|
+
|
|
87
|
+
return existing.filter(
|
|
88
|
+
path =>
|
|
89
|
+
!planned.has(path) && prunable.some(prefix => path.startsWith(prefix))
|
|
90
|
+
)
|
|
91
|
+
}
|