@learnpack/learnpack 5.0.352 → 5.0.354
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 +277 -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/awsCredentials.d.ts +20 -0
- package/lib/utils/awsCredentials.js +43 -0
- 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/backfillEvents.d.ts +60 -0
- package/lib/utils/descriptions/backfillEvents.js +107 -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 +66 -0
- package/lib/utils/descriptions/generateCourseDescriptions.js +176 -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 +245 -0
- package/lib/utils/descriptions/resumePublication.d.ts +36 -0
- package/lib/utils/descriptions/resumePublication.js +128 -0
- package/lib/utils/descriptions/s3Storage.d.ts +30 -0
- package/lib/utils/descriptions/s3Storage.js +141 -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 +22 -0
- package/lib/utils/packageManifest.js +57 -5
- 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/repair/legacyPackageRepair.d.ts +131 -0
- package/lib/utils/repair/legacyPackageRepair.js +492 -0
- package/lib/utils/repair/repairStorage.d.ts +68 -0
- package/lib/utils/repair/repairStorage.js +89 -0
- package/lib/utils/rigoActions.d.ts +44 -0
- package/lib/utils/rigoActions.js +75 -1
- package/lib/utils/s3/packageManifestBackfill.d.ts +2 -0
- package/lib/utils/s3/packageManifestBackfill.js +5 -8
- package/lib/utils/s3/packageSourcesAudit.d.ts +75 -0
- package/lib/utils/s3/packageSourcesAudit.js +184 -0
- package/lib/utils/syllabusSync.d.ts +71 -0
- package/lib/utils/syllabusSync.js +273 -0
- package/package.json +3 -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/README.md +244 -0
- package/src/scripts/descriptionsGcsBackfill.ts +193 -0
- package/src/scripts/descriptionsS3Backfill.ts +376 -0
- package/src/scripts/descriptionsSweep.ts +185 -0
- package/src/ui/_app/app.css +1 -1
- package/src/ui/_app/app.js +142 -140
- package/src/ui/app.tar.gz +0 -0
- package/src/utils/api.ts +9 -0
- package/src/utils/awsCredentials.ts +57 -0
- package/src/utils/creatorUtilities.ts +2 -1
- package/src/utils/descriptionHash.ts +196 -0
- package/src/utils/descriptions/backfillEvents.ts +152 -0
- package/src/utils/descriptions/gcsStorage.ts +67 -0
- package/src/utils/descriptions/generateCourseDescriptions.ts +311 -0
- package/src/utils/descriptions/mirrorDescriptions.ts +191 -0
- package/src/utils/descriptions/publishStage.ts +394 -0
- package/src/utils/descriptions/resumePublication.ts +217 -0
- package/src/utils/descriptions/s3Storage.ts +214 -0
- package/src/utils/descriptions/workList.ts +283 -0
- package/src/utils/gcsBucketName.ts +19 -0
- package/src/utils/packageManifest.ts +62 -5
- package/src/utils/publishEvents.ts +181 -0
- package/src/utils/publishJournal.ts +383 -0
- package/src/utils/repair/legacyPackageRepair.ts +731 -0
- package/src/utils/repair/repairStorage.ts +168 -0
- package/src/utils/rigoActions.ts +130 -0
- package/src/utils/s3/packageManifestBackfill.ts +771 -776
- package/src/utils/s3/packageSourcesAudit.ts +311 -0
- package/src/utils/syllabusSync.ts +390 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { ListObjectsV2Command } from "@aws-sdk/client-s3"
|
|
2
|
+
import { AwsClient, withRetry } from "./packageManifestBackfill"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Inventory of the files a published package needs for its
|
|
6
|
+
* `package-manifest.json` to be generated at full fidelity.
|
|
7
|
+
*
|
|
8
|
+
* This reads **key names only** — one paginated `ListObjectsV2` over the bucket,
|
|
9
|
+
* zero `GetObject`. Cheap enough to run over the whole catalogue on demand, and
|
|
10
|
+
* safe at any time: it never writes.
|
|
11
|
+
*
|
|
12
|
+
* What it answers: which packages would *fail* the manifest backfill (no
|
|
13
|
+
* `config.json`, no exercises), and which ones only produce a *degraded*
|
|
14
|
+
* manifest (no `initialSyllabus.json` → every lesson typed `READ`, ids derived
|
|
15
|
+
* from the folder name, and descriptions permanently `null`, because the
|
|
16
|
+
* descriptions pipeline builds its work list from the syllabus keys and cannot
|
|
17
|
+
* see a course that has none).
|
|
18
|
+
*
|
|
19
|
+
* Companion of `legacyPackageRepair.ts`, which writes what this reports missing.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export type PackageSourcesAuditEntry = {
|
|
23
|
+
slug: string;
|
|
24
|
+
/** A published package is a prefix holding an `index.html` at its root. */
|
|
25
|
+
hasIndexHtml: boolean;
|
|
26
|
+
/** Required: without it the manifest backfill reports `failed`. */
|
|
27
|
+
hasConfigJson: boolean;
|
|
28
|
+
configJsonKey: string | null;
|
|
29
|
+
/**
|
|
30
|
+
* Optional *as a key*: `config.json` may carry the same metadata under
|
|
31
|
+
* `config`. Only a `GetObject` could tell, so `false` here means "resolves
|
|
32
|
+
* through the fallback, or not at all" — not "broken".
|
|
33
|
+
*/
|
|
34
|
+
hasLearnJson: boolean;
|
|
35
|
+
hasSyllabus: boolean;
|
|
36
|
+
hasSidebar: boolean;
|
|
37
|
+
sidebarKey: string | null;
|
|
38
|
+
hasManifest: boolean;
|
|
39
|
+
/** Folders directly under `exercises/` holding at least one object. */
|
|
40
|
+
exerciseFolders: number;
|
|
41
|
+
/** `README.md` / `README.xx.md` files found in those folders. */
|
|
42
|
+
readmeCount: number;
|
|
43
|
+
/** Missing inputs, named as in the docs, for quick reporting. */
|
|
44
|
+
missing: string[];
|
|
45
|
+
/** True when the manifest backfill cannot produce a manifest at all. */
|
|
46
|
+
blocking: boolean;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type PackageSourcesAuditSummary = {
|
|
50
|
+
missingConfigJson: number;
|
|
51
|
+
missingLearnJson: number;
|
|
52
|
+
missingSyllabus: number;
|
|
53
|
+
missingSidebar: number;
|
|
54
|
+
missingManifest: number;
|
|
55
|
+
noExerciseFolders: number;
|
|
56
|
+
blocking: number;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type PackageSourcesAuditReport = {
|
|
60
|
+
generatedAt: string;
|
|
61
|
+
bucket: string;
|
|
62
|
+
scannedKeys: number;
|
|
63
|
+
totalPackages: number;
|
|
64
|
+
summary: PackageSourcesAuditSummary;
|
|
65
|
+
packages: PackageSourcesAuditEntry[];
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type AuditOptions = {
|
|
69
|
+
/** Restrict the scan to a single package (uses a prefixed listing). */
|
|
70
|
+
slug?: string;
|
|
71
|
+
/** Keep prefixes with no `index.html` (drafts, leftovers). Default false. */
|
|
72
|
+
includeUnpublished?: boolean;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const README_FILENAME_PATTERN = /^readme(?:\.[a-z]{2})?\.md$/i
|
|
76
|
+
|
|
77
|
+
const ROOT_FILES = new Set([
|
|
78
|
+
"index.html",
|
|
79
|
+
"learn.json",
|
|
80
|
+
"config.json",
|
|
81
|
+
".learn/config.json",
|
|
82
|
+
".learn/initialSyllabus.json",
|
|
83
|
+
".learn/sidebar.json",
|
|
84
|
+
"sidebar.json",
|
|
85
|
+
".learn/package-manifest.json",
|
|
86
|
+
])
|
|
87
|
+
|
|
88
|
+
type PackageAccumulator = {
|
|
89
|
+
slug: string;
|
|
90
|
+
hasIndexHtml: boolean;
|
|
91
|
+
configJsonKey: string | null;
|
|
92
|
+
hasLearnJson: boolean;
|
|
93
|
+
hasSyllabus: boolean;
|
|
94
|
+
sidebarKey: string | null;
|
|
95
|
+
hasManifest: boolean;
|
|
96
|
+
exerciseFolders: Set<string>;
|
|
97
|
+
readmeCount: number;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function emptyAccumulator(slug: string): PackageAccumulator {
|
|
101
|
+
return {
|
|
102
|
+
slug,
|
|
103
|
+
hasIndexHtml: false,
|
|
104
|
+
configJsonKey: null,
|
|
105
|
+
hasLearnJson: false,
|
|
106
|
+
hasSyllabus: false,
|
|
107
|
+
sidebarKey: null,
|
|
108
|
+
hasManifest: false,
|
|
109
|
+
exerciseFolders: new Set<string>(),
|
|
110
|
+
readmeCount: 0,
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Fold one object key into the accumulator map. */
|
|
115
|
+
function classifyKey(
|
|
116
|
+
entries: Map<string, PackageAccumulator>,
|
|
117
|
+
key: string
|
|
118
|
+
): void {
|
|
119
|
+
const separatorIndex = key.indexOf("/")
|
|
120
|
+
if (separatorIndex <= 0) {
|
|
121
|
+
// Object at the bucket root: not part of any package.
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const slug = key.slice(0, separatorIndex)
|
|
126
|
+
const rest = key.slice(separatorIndex + 1)
|
|
127
|
+
if (!rest) {
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const exerciseParts = rest.startsWith("exercises/") ? rest.split("/") : []
|
|
132
|
+
// `exercises/<slug>/<file>`: shorter paths are the folder marker itself.
|
|
133
|
+
const exerciseSlug =
|
|
134
|
+
exerciseParts.length >= 3 && exerciseParts[1] ? exerciseParts[1] : null
|
|
135
|
+
|
|
136
|
+
if (!ROOT_FILES.has(rest) && !exerciseSlug) {
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let entry = entries.get(slug)
|
|
141
|
+
if (!entry) {
|
|
142
|
+
entry = emptyAccumulator(slug)
|
|
143
|
+
entries.set(slug, entry)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (exerciseSlug) {
|
|
147
|
+
entry.exerciseFolders.add(exerciseSlug)
|
|
148
|
+
const filename = exerciseParts[exerciseParts.length - 1]
|
|
149
|
+
if (exerciseParts.length === 3 && README_FILENAME_PATTERN.test(filename)) {
|
|
150
|
+
entry.readmeCount += 1
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
switch (rest) {
|
|
157
|
+
case "index.html":
|
|
158
|
+
entry.hasIndexHtml = true
|
|
159
|
+
break
|
|
160
|
+
case "learn.json":
|
|
161
|
+
entry.hasLearnJson = true
|
|
162
|
+
break
|
|
163
|
+
// The root copy wins over `.learn/config.json`, matching the order
|
|
164
|
+
// `fetchPackageSources` resolves them in. Same for the sidebar, inverted.
|
|
165
|
+
case "config.json":
|
|
166
|
+
entry.configJsonKey = key
|
|
167
|
+
break
|
|
168
|
+
case ".learn/config.json":
|
|
169
|
+
entry.configJsonKey = entry.configJsonKey || key
|
|
170
|
+
break
|
|
171
|
+
case ".learn/sidebar.json":
|
|
172
|
+
entry.sidebarKey = key
|
|
173
|
+
break
|
|
174
|
+
case "sidebar.json":
|
|
175
|
+
entry.sidebarKey = entry.sidebarKey || key
|
|
176
|
+
break
|
|
177
|
+
case ".learn/initialSyllabus.json":
|
|
178
|
+
entry.hasSyllabus = true
|
|
179
|
+
break
|
|
180
|
+
case ".learn/package-manifest.json":
|
|
181
|
+
entry.hasManifest = true
|
|
182
|
+
break
|
|
183
|
+
default:
|
|
184
|
+
break
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function finalizeEntry(entry: PackageAccumulator): PackageSourcesAuditEntry {
|
|
189
|
+
const missing: string[] = []
|
|
190
|
+
|
|
191
|
+
if (!entry.configJsonKey) {
|
|
192
|
+
missing.push("config.json")
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!entry.hasLearnJson) {
|
|
196
|
+
missing.push("learn.json")
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!entry.hasSyllabus) {
|
|
200
|
+
missing.push("initialSyllabus.json")
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!entry.sidebarKey) {
|
|
204
|
+
missing.push("sidebar.json")
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (entry.exerciseFolders.size === 0) {
|
|
208
|
+
missing.push("exercises/")
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!entry.hasManifest) {
|
|
212
|
+
missing.push("package-manifest.json")
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
slug: entry.slug,
|
|
217
|
+
hasIndexHtml: entry.hasIndexHtml,
|
|
218
|
+
hasConfigJson: Boolean(entry.configJsonKey),
|
|
219
|
+
configJsonKey: entry.configJsonKey,
|
|
220
|
+
hasLearnJson: entry.hasLearnJson,
|
|
221
|
+
hasSyllabus: entry.hasSyllabus,
|
|
222
|
+
hasSidebar: Boolean(entry.sidebarKey),
|
|
223
|
+
sidebarKey: entry.sidebarKey,
|
|
224
|
+
hasManifest: entry.hasManifest,
|
|
225
|
+
exerciseFolders: entry.exerciseFolders.size,
|
|
226
|
+
readmeCount: entry.readmeCount,
|
|
227
|
+
missing,
|
|
228
|
+
// A missing `learn.json` is not blocking on its own: `config.config` can
|
|
229
|
+
// stand in for it, and only a download would tell.
|
|
230
|
+
blocking: !entry.configJsonKey || entry.exerciseFolders.size === 0,
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function count(
|
|
235
|
+
packages: PackageSourcesAuditEntry[],
|
|
236
|
+
predicate: (entry: PackageSourcesAuditEntry) => boolean
|
|
237
|
+
): number {
|
|
238
|
+
return packages.filter(entry => predicate(entry)).length
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function summarize(
|
|
242
|
+
packages: PackageSourcesAuditEntry[]
|
|
243
|
+
): PackageSourcesAuditSummary {
|
|
244
|
+
return {
|
|
245
|
+
missingConfigJson: count(packages, entry => !entry.hasConfigJson),
|
|
246
|
+
missingLearnJson: count(packages, entry => !entry.hasLearnJson),
|
|
247
|
+
missingSyllabus: count(packages, entry => !entry.hasSyllabus),
|
|
248
|
+
missingSidebar: count(packages, entry => !entry.hasSidebar),
|
|
249
|
+
missingManifest: count(packages, entry => !entry.hasManifest),
|
|
250
|
+
noExerciseFolders: count(packages, entry => entry.exerciseFolders === 0),
|
|
251
|
+
blocking: count(packages, entry => entry.blocking),
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* One paginated pass over the bucket, folding each page into the accumulator.
|
|
257
|
+
*
|
|
258
|
+
* Deliberately not built on `listObjectKeys`: that one materializes every key in
|
|
259
|
+
* the catalogue before the caller sees the first one.
|
|
260
|
+
*/
|
|
261
|
+
export async function auditPackageSources(
|
|
262
|
+
s3: AwsClient,
|
|
263
|
+
bucket: string,
|
|
264
|
+
options: AuditOptions = {}
|
|
265
|
+
): Promise<PackageSourcesAuditReport> {
|
|
266
|
+
const entries = new Map<string, PackageAccumulator>()
|
|
267
|
+
const prefix = options.slug ? `${options.slug}/` : undefined
|
|
268
|
+
let continuationToken: string | undefined
|
|
269
|
+
let scannedKeys = 0
|
|
270
|
+
|
|
271
|
+
do {
|
|
272
|
+
// eslint-disable-next-line no-await-in-loop -- S3 pagination is sequential
|
|
273
|
+
const response = (await withRetry(() =>
|
|
274
|
+
s3.send(
|
|
275
|
+
new ListObjectsV2Command({
|
|
276
|
+
Bucket: bucket,
|
|
277
|
+
Prefix: prefix,
|
|
278
|
+
ContinuationToken: continuationToken,
|
|
279
|
+
})
|
|
280
|
+
)
|
|
281
|
+
)) as {
|
|
282
|
+
Contents?: Array<{ Key?: string }>;
|
|
283
|
+
NextContinuationToken?: string;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
for (const item of response.Contents || []) {
|
|
287
|
+
if (!item.Key) {
|
|
288
|
+
continue
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
scannedKeys += 1
|
|
292
|
+
classifyKey(entries, item.Key)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
continuationToken = response.NextContinuationToken
|
|
296
|
+
} while (continuationToken)
|
|
297
|
+
|
|
298
|
+
const packages = [...entries.values()]
|
|
299
|
+
.filter(entry => options.includeUnpublished || entry.hasIndexHtml)
|
|
300
|
+
.map(entry => finalizeEntry(entry))
|
|
301
|
+
.sort((a, b) => a.slug.localeCompare(b.slug))
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
generatedAt: new Date().toISOString(),
|
|
305
|
+
bucket,
|
|
306
|
+
scannedKeys,
|
|
307
|
+
totalPackages: packages.length,
|
|
308
|
+
summary: summarize(packages),
|
|
309
|
+
packages,
|
|
310
|
+
}
|
|
311
|
+
}
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { Bucket } from "@google-cloud/storage"
|
|
2
|
+
import { Lesson, Syllabus } from "../models/creator"
|
|
3
|
+
import { buildConfig } from "./configBuilder"
|
|
4
|
+
import { slugify } from "./creatorUtilities"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Provisional syllabus ↔ bucket reconciliation.
|
|
8
|
+
*
|
|
9
|
+
* The `initialSyllabus.json` occasionally drifts from what actually lives in the
|
|
10
|
+
* bucket (lessons/translations present as files but missing from the syllabus,
|
|
11
|
+
* or vice versa). Because step descriptions are stored per (step, lang) in the
|
|
12
|
+
* syllabus, that drift leaves missing slots and causes skipped/spurious
|
|
13
|
+
* generations. This reconciles the two.
|
|
14
|
+
*
|
|
15
|
+
* Extracted from the inline `/actions/synchronize-syllabus` handler so it can be
|
|
16
|
+
* reused by the sweep/backfill, and generalized over a minimal storage adapter
|
|
17
|
+
* so it works for both GCS and S3.
|
|
18
|
+
*
|
|
19
|
+
* NOTE: this is a temporary patch until the root cause of the drift is fixed.
|
|
20
|
+
* Track its removal via the `DESCRIPTIONS_RECONCILE_SYLLABUS` flag on callers.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export interface SyllabusSyncStorage {
|
|
24
|
+
/**
|
|
25
|
+
* Map of `folderSlug -> fileCount` for every folder directly under
|
|
26
|
+
* `exercises/`. A single authoritative listing: if it throws, callers must
|
|
27
|
+
* abort any destructive action (never delete on an errored/unknown listing).
|
|
28
|
+
*/
|
|
29
|
+
listExerciseFolderFileCounts(
|
|
30
|
+
courseSlug: string
|
|
31
|
+
): Promise<Map<string, number>>;
|
|
32
|
+
/** README language codes (lowercased) present per exercise folder slug. */
|
|
33
|
+
translationLangsBySlug(courseSlug: string): Promise<Map<string, string[]>>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type SyllabusSyncOptions = {
|
|
37
|
+
/**
|
|
38
|
+
* When false (default, "additive" mode) only non-destructive passes run:
|
|
39
|
+
* add-missing, repair-translations, unstick-status. Removal/dedupe are
|
|
40
|
+
* skipped. When true (manual dev button / root-cause runs) removals apply.
|
|
41
|
+
*/
|
|
42
|
+
prune?: boolean;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type SyllabusSyncCounters = {
|
|
46
|
+
totalLessons: number;
|
|
47
|
+
keptLessons: number;
|
|
48
|
+
removedLessons: number;
|
|
49
|
+
duplicatesResolved: number;
|
|
50
|
+
addedLessons: number;
|
|
51
|
+
fixedLessons: number;
|
|
52
|
+
repairedTranslationsInLessons: number;
|
|
53
|
+
repairedTranslationEntries: number;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type LessonRef = { id: string; title: string; slug: string };
|
|
57
|
+
type LessonRefWithCount = LessonRef & { fileCount: number };
|
|
58
|
+
|
|
59
|
+
export type SyllabusSyncResult = {
|
|
60
|
+
syllabus: Syllabus;
|
|
61
|
+
changed: boolean;
|
|
62
|
+
counters: SyllabusSyncCounters;
|
|
63
|
+
details: {
|
|
64
|
+
removed: LessonRef[];
|
|
65
|
+
duplicates: LessonRefWithCount[];
|
|
66
|
+
kept: Array<LessonRef & { fileCount?: number }>;
|
|
67
|
+
added: LessonRef[];
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function isInFlight(lesson: Lesson): boolean {
|
|
72
|
+
// A lesson still being generated may legitimately have no files yet; never
|
|
73
|
+
// treat it as "missing" (E4 hardening against deleting something real).
|
|
74
|
+
return (
|
|
75
|
+
lesson.generated === false ||
|
|
76
|
+
lesson.status === "GENERATING" ||
|
|
77
|
+
lesson.status === "PENDING"
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function synchronizeSyllabusWithBucket(
|
|
82
|
+
storage: SyllabusSyncStorage,
|
|
83
|
+
courseSlug: string,
|
|
84
|
+
syllabus: Syllabus,
|
|
85
|
+
options: SyllabusSyncOptions = {}
|
|
86
|
+
): Promise<SyllabusSyncResult> {
|
|
87
|
+
const prune = options.prune ?? false
|
|
88
|
+
|
|
89
|
+
const removed: LessonRef[] = []
|
|
90
|
+
const duplicates: LessonRefWithCount[] = []
|
|
91
|
+
const kept: Array<LessonRef & { fileCount?: number }> = []
|
|
92
|
+
const added: LessonRef[] = []
|
|
93
|
+
let repairedTranslationsInLessons = 0
|
|
94
|
+
let repairedTranslationEntries = 0
|
|
95
|
+
let fixedLessons = 0
|
|
96
|
+
let changed = false
|
|
97
|
+
|
|
98
|
+
// Single authoritative listing. If it fails it throws (callers must not
|
|
99
|
+
// delete on an unknown listing). The adapter is expected to retry internally.
|
|
100
|
+
const folderCounts = await storage.listExerciseFolderFileCounts(courseSlug)
|
|
101
|
+
|
|
102
|
+
// First pass: resolve existence per lesson, confirming absence under both the
|
|
103
|
+
// slugified id-title and the uid folder-name variants.
|
|
104
|
+
const existingLessons: Array<{
|
|
105
|
+
lesson: Lesson;
|
|
106
|
+
slug: string;
|
|
107
|
+
fileCount: number;
|
|
108
|
+
}> = []
|
|
109
|
+
|
|
110
|
+
for (const lesson of syllabus.lessons) {
|
|
111
|
+
const lessonSlug = slugify(lesson.id + "-" + lesson.title)
|
|
112
|
+
let resolvedSlug = lessonSlug
|
|
113
|
+
let fileCount = folderCounts.get(lessonSlug) ?? 0
|
|
114
|
+
|
|
115
|
+
if (
|
|
116
|
+
fileCount === 0 &&
|
|
117
|
+
lesson.uid &&
|
|
118
|
+
(folderCounts.get(lesson.uid) ?? 0) > 0
|
|
119
|
+
) {
|
|
120
|
+
resolvedSlug = lesson.uid
|
|
121
|
+
fileCount = folderCounts.get(lesson.uid) ?? 0
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (fileCount === 0) {
|
|
125
|
+
if (isInFlight(lesson)) {
|
|
126
|
+
// Keep untouched: do not mark for removal nor as existing.
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
removed.push({ id: lesson.id, title: lesson.title, slug: lessonSlug })
|
|
131
|
+
} else {
|
|
132
|
+
existingLessons.push({ lesson, slug: resolvedSlug, fileCount })
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Second pass: detect duplicates (multiple lessons resolving to one folder).
|
|
137
|
+
const slugMap = new Map<
|
|
138
|
+
string,
|
|
139
|
+
Array<{ lesson: Lesson; slug: string; fileCount: number }>
|
|
140
|
+
>()
|
|
141
|
+
for (const item of existingLessons) {
|
|
142
|
+
if (!slugMap.has(item.slug)) {
|
|
143
|
+
slugMap.set(item.slug, [])
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
slugMap.get(item.slug)!.push(item)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
for (const items of slugMap.values()) {
|
|
150
|
+
if (items.length > 1) {
|
|
151
|
+
items.sort((a, b) => b.fileCount - a.fileCount)
|
|
152
|
+
const winner = items[0]
|
|
153
|
+
kept.push({
|
|
154
|
+
id: winner.lesson.id,
|
|
155
|
+
title: winner.lesson.title,
|
|
156
|
+
slug: winner.slug,
|
|
157
|
+
fileCount: winner.fileCount,
|
|
158
|
+
})
|
|
159
|
+
for (const loser of items.slice(1)) {
|
|
160
|
+
duplicates.push({
|
|
161
|
+
id: loser.lesson.id,
|
|
162
|
+
title: loser.lesson.title,
|
|
163
|
+
slug: loser.slug,
|
|
164
|
+
fileCount: loser.fileCount,
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
} else {
|
|
168
|
+
kept.push({
|
|
169
|
+
id: items[0].lesson.id,
|
|
170
|
+
title: items[0].lesson.title,
|
|
171
|
+
slug: items[0].slug,
|
|
172
|
+
fileCount: items[0].fileCount,
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Apply removals only in prune mode (additive mode never deletes).
|
|
178
|
+
if (prune && (removed.length > 0 || duplicates.length > 0)) {
|
|
179
|
+
syllabus.lessons = syllabus.lessons.filter(lesson => {
|
|
180
|
+
const lessonSlug = slugify(lesson.id + "-" + lesson.title)
|
|
181
|
+
const isRemoved = removed.some(r => r.slug === lessonSlug)
|
|
182
|
+
const isDuplicate = duplicates.some(
|
|
183
|
+
d => d.id === lesson.id && d.title === lesson.title
|
|
184
|
+
)
|
|
185
|
+
return !isRemoved && !isDuplicate
|
|
186
|
+
})
|
|
187
|
+
changed = true
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Third pass: add lessons that exist in the bucket but are missing from the
|
|
191
|
+
// syllabus, then re-sort by numeric id.
|
|
192
|
+
const existingSlugs = new Set(existingLessons.map(e => e.slug))
|
|
193
|
+
for (const [folderSlug, count] of folderCounts) {
|
|
194
|
+
if (count <= 0 || existingSlugs.has(folderSlug)) {
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const idMatch = folderSlug.match(/^(\d+(?:\.\d+)?)(?:-(.*))?$/)
|
|
199
|
+
const id = idMatch ? idMatch[1] : folderSlug
|
|
200
|
+
const titlePart = idMatch && idMatch[2] ? idMatch[2] : folderSlug
|
|
201
|
+
const title = titlePart.replace(/-/g, " ").trim() || folderSlug
|
|
202
|
+
const newLesson: Lesson = {
|
|
203
|
+
id,
|
|
204
|
+
uid: folderSlug,
|
|
205
|
+
title,
|
|
206
|
+
type: "READ",
|
|
207
|
+
description: title,
|
|
208
|
+
duration: 2,
|
|
209
|
+
generated: true,
|
|
210
|
+
status: "DONE",
|
|
211
|
+
}
|
|
212
|
+
syllabus.lessons.push(newLesson)
|
|
213
|
+
added.push({ id, title, slug: folderSlug })
|
|
214
|
+
changed = true
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (added.length > 0) {
|
|
218
|
+
syllabus.lessons.sort((a, b) => {
|
|
219
|
+
const na = Number.parseFloat(a.id)
|
|
220
|
+
const nb = Number.parseFloat(b.id)
|
|
221
|
+
if (!Number.isNaN(na) && !Number.isNaN(nb)) {
|
|
222
|
+
return na - nb
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return String(a.id).localeCompare(String(b.id))
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Fourth + fifth pass: reconcile translations metadata from the actual README
|
|
230
|
+
// files, and unstick lessons stuck in GENERATING/ERROR whose primary-language
|
|
231
|
+
// README already exists.
|
|
232
|
+
try {
|
|
233
|
+
const translationsBySlug = await storage.translationLangsBySlug(courseSlug)
|
|
234
|
+
|
|
235
|
+
for (const lesson of syllabus.lessons) {
|
|
236
|
+
const candidateSlugs = [
|
|
237
|
+
slugify(lesson.id + "-" + lesson.title),
|
|
238
|
+
lesson.uid,
|
|
239
|
+
].filter(Boolean) as string[]
|
|
240
|
+
const matchedSlug = candidateSlugs.find(s => translationsBySlug.has(s))
|
|
241
|
+
if (!matchedSlug) {
|
|
242
|
+
continue
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const languageCodes = translationsBySlug.get(matchedSlug) || []
|
|
246
|
+
if (languageCodes.length === 0) {
|
|
247
|
+
continue
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const currentTranslations = lesson.translations || {}
|
|
251
|
+
let lessonChanged = false
|
|
252
|
+
|
|
253
|
+
for (const lang of languageCodes) {
|
|
254
|
+
const now = Date.now()
|
|
255
|
+
if (!currentTranslations[lang]) {
|
|
256
|
+
currentTranslations[lang] = {
|
|
257
|
+
completionId: 0,
|
|
258
|
+
startedAt: now,
|
|
259
|
+
completedAt: now,
|
|
260
|
+
}
|
|
261
|
+
repairedTranslationEntries += 1
|
|
262
|
+
lessonChanged = true
|
|
263
|
+
} else {
|
|
264
|
+
if (!currentTranslations[lang].startedAt) {
|
|
265
|
+
currentTranslations[lang].startedAt = now
|
|
266
|
+
lessonChanged = true
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (!currentTranslations[lang].completedAt) {
|
|
270
|
+
currentTranslations[lang].completedAt = now
|
|
271
|
+
repairedTranslationEntries += 1
|
|
272
|
+
lessonChanged = true
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (lessonChanged) {
|
|
278
|
+
lesson.translations = currentTranslations
|
|
279
|
+
repairedTranslationsInLessons += 1
|
|
280
|
+
changed = true
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const primaryLanguage = syllabus.courseInfo.language || "en"
|
|
285
|
+
for (const lesson of syllabus.lessons) {
|
|
286
|
+
if (lesson.generated !== false) {
|
|
287
|
+
continue
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (lesson.status !== "GENERATING" && lesson.status !== "ERROR") {
|
|
291
|
+
continue
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const candidateSlugs = [
|
|
295
|
+
slugify(lesson.id + "-" + lesson.title),
|
|
296
|
+
lesson.uid,
|
|
297
|
+
].filter(Boolean) as string[]
|
|
298
|
+
const matchedSlug = candidateSlugs.find(s => translationsBySlug.has(s))
|
|
299
|
+
if (!matchedSlug) {
|
|
300
|
+
continue
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const langs = translationsBySlug.get(matchedSlug) || []
|
|
304
|
+
if (!langs.includes(primaryLanguage)) {
|
|
305
|
+
continue
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
lesson.generated = true
|
|
309
|
+
lesson.status = "DONE"
|
|
310
|
+
fixedLessons += 1
|
|
311
|
+
changed = true
|
|
312
|
+
}
|
|
313
|
+
} catch (error) {
|
|
314
|
+
console.error(
|
|
315
|
+
"⚠️ Could not reconcile lesson translations during syllabus sync:",
|
|
316
|
+
error
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const counters: SyllabusSyncCounters = {
|
|
321
|
+
totalLessons: syllabus.lessons.length,
|
|
322
|
+
keptLessons: kept.length,
|
|
323
|
+
removedLessons: prune ? removed.length : 0,
|
|
324
|
+
duplicatesResolved: prune ? duplicates.length : 0,
|
|
325
|
+
addedLessons: added.length,
|
|
326
|
+
fixedLessons,
|
|
327
|
+
repairedTranslationsInLessons,
|
|
328
|
+
repairedTranslationEntries,
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
syllabus,
|
|
333
|
+
changed,
|
|
334
|
+
counters,
|
|
335
|
+
details: { removed, duplicates, kept, added },
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function withRetry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
|
|
340
|
+
let lastError: unknown
|
|
341
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
342
|
+
try {
|
|
343
|
+
// eslint-disable-next-line no-await-in-loop
|
|
344
|
+
return await fn()
|
|
345
|
+
} catch (error) {
|
|
346
|
+
lastError = error
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
throw lastError
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** GCS-backed storage adapter (uses the bucket listing + buildConfig). */
|
|
354
|
+
export function createGcsSyllabusSyncStorage(
|
|
355
|
+
bucket: Bucket
|
|
356
|
+
): SyllabusSyncStorage {
|
|
357
|
+
return {
|
|
358
|
+
async listExerciseFolderFileCounts(courseSlug) {
|
|
359
|
+
const prefix = `courses/${courseSlug}/exercises/`
|
|
360
|
+
const [files] = await withRetry(() => bucket.getFiles({ prefix }))
|
|
361
|
+
const counts = new Map<string, number>()
|
|
362
|
+
for (const file of files) {
|
|
363
|
+
if (!file.name.startsWith(prefix)) {
|
|
364
|
+
continue
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const segment = file.name.slice(prefix.length).split("/")[0]
|
|
368
|
+
if (segment) {
|
|
369
|
+
counts.set(segment, (counts.get(segment) || 0) + 1)
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return counts
|
|
374
|
+
},
|
|
375
|
+
async translationLangsBySlug(courseSlug) {
|
|
376
|
+
const { exercises } = await buildConfig(bucket, courseSlug)
|
|
377
|
+
const map = new Map<string, string[]>()
|
|
378
|
+
for (const exercise of exercises) {
|
|
379
|
+
const langs = Object.keys(exercise.translations || {})
|
|
380
|
+
.map(lang => lang.toLowerCase())
|
|
381
|
+
.filter(Boolean)
|
|
382
|
+
if (langs.length > 0) {
|
|
383
|
+
map.set(exercise.slug, [...new Set(langs)])
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return map
|
|
388
|
+
},
|
|
389
|
+
}
|
|
390
|
+
}
|