@learnpack/learnpack 5.0.357 → 5.0.359

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.
@@ -19,281 +19,78 @@ import api, { getConsumable, RIGOBOT_HOST, TAcademy } from "../utils/api"
19
19
  import * as prompts from "prompts"
20
20
  import { isValidRigoToken } from "../utils/rigoActions"
21
21
  import { minutesToISO8601Duration } from "../utils/misc"
22
- import { getReadmeExtension, slugify } from "../utils/creatorUtilities"
22
+ import { getReadmeExtension } from "../utils/creatorUtilities"
23
+ import { getFallbackPreviewUrl } from "../utils/branding"
24
+ import {
25
+ determineAcademyMode,
26
+ getAvailableLangs,
27
+ PackageAssetsResolution,
28
+ resolvePackageAssets,
29
+ } from "../utils/assetResolver"
30
+ import { getLocalizedValue, syncPackageAssets } from "../utils/assetSync"
23
31
 
24
32
  const uploadZipEndpont = RIGOBOT_HOST + "/v1/learnpack/upload"
25
33
 
26
- const getAvailableLangs = (learnJson: any): string[] => {
27
- const langs = Object.keys(learnJson?.title || {})
28
- return langs.filter((l) => typeof l === "string" && l.length > 0)
29
- }
30
-
31
34
  const getDefaultLang = (learnJson: any): string => {
32
35
  const availableLangs = getAvailableLangs(learnJson)
33
36
  if (availableLangs.length === 0) return "en"
34
37
  return availableLangs.includes("en") ? "en" : availableLangs[0]
35
38
  }
36
39
 
37
- const getLocalizedValue = (
38
- translations: Record<string, any> | undefined,
39
- lang: string,
40
- fallbackLangs: string[] = ["en", "us"]
41
- ): string => {
42
- if (!translations || typeof translations !== "object") return ""
43
-
44
- const direct = translations[lang]
45
- if (typeof direct === "string" && direct.trim().length > 0) return direct
46
-
47
- for (const fb of fallbackLangs) {
48
- const v = translations[fb]
49
- if (typeof v === "string" && v.trim().length > 0) return v
50
- }
51
-
52
- const firstKey = Object.keys(translations)[0]
53
- const first = firstKey ? translations[firstKey] : ""
54
- return typeof first === "string" ? first : ""
55
- }
56
-
57
- type ExistingAssetInfo = {
58
- lang: string;
59
- slug: string;
60
- exists: boolean;
61
- academyId?: number;
62
- };
63
-
64
- type AcademyMode =
65
- | { type: "select" }
66
- | { type: "locked"; academyId: number }
67
- | { type: "conflict"; academies: number[] };
68
-
69
- const getExistingAssetsInfo = async (
70
- token: string,
71
- learnJson: any
72
- ): Promise<ExistingAssetInfo[]> => {
73
- const availableLangs = getAvailableLangs(learnJson)
74
- const results: ExistingAssetInfo[] = []
75
-
76
- for (const lang of availableLangs) {
77
- const assetTitle = getLocalizedValue(learnJson?.title, lang)
78
- if (!assetTitle) continue
79
-
80
- let slug = slugify(assetTitle).slice(0, 47)
81
- slug = `${slug}-${lang}`
82
-
83
- // eslint-disable-next-line no-await-in-loop
84
- const { exists, academyId } = await api.doesAssetExists(token, slug)
85
- results.push({ lang, slug, exists, academyId })
86
- }
87
-
88
- return results
89
- }
90
-
91
- const determinePublishAcademyMode = (
92
- existingAssets: ExistingAssetInfo[]
93
- ): AcademyMode => {
94
- const academyIds = existingAssets
95
- .filter((a) => a.exists && a.academyId !== undefined)
96
- .map((a) => a.academyId as number)
97
-
98
- const unique = [...new Set(academyIds)]
99
-
100
- if (unique.length === 0) return { type: "select" }
101
- if (unique.length === 1) return { type: "locked", academyId: unique[0] }
102
- return { type: "conflict", academies: unique }
103
- }
104
-
105
- export const handleAssetCreation = async (
106
- sessionPayload: { token: string; rigobotToken: string },
107
- learnJson: any,
108
- selectedLang: string,
109
- learnpackDeployUrl: string,
110
- b64IndexReadme: string,
111
- academyId: number | undefined,
112
- learnpackId: number,
113
- preflightInfo?: ExistingAssetInfo,
114
- all_translations: string[] = []
115
- ) => {
116
- const category = "uncategorized"
117
-
40
+ const readReadmeFromDisk = async (lang: string): Promise<string> => {
41
+ const readmePath = path.join(
42
+ process.cwd(),
43
+ `README${getReadmeExtension(lang)}`
44
+ )
118
45
  try {
119
- const user = await api.validateToken(sessionPayload.token)
120
-
121
- const assetTitle = getLocalizedValue(learnJson?.title, selectedLang)
122
- const assetDescription = getLocalizedValue(
123
- learnJson?.description,
124
- selectedLang
125
- )
126
-
127
- if (!assetTitle) {
128
- throw new Error(
129
- `Missing learn.json title for language "${selectedLang}"`
130
- )
131
- }
132
-
133
- let slug = slugify(assetTitle).slice(0, 47)
134
- slug = `${slug}-${selectedLang}`
135
-
136
- // Use pre-flight info when available to avoid an extra GET request
137
- const { exists, academyId: existingAcademyId } =
138
- preflightInfo ?? (await api.doesAssetExists(sessionPayload.token, slug))
139
-
140
- // const technologies: unknown[] = Array.isArray(learnJson?.technologies) ?
141
- // learnJson.technologies :
142
- // []
143
-
144
- if (!exists) {
145
- Console.info("Asset does not exist in this academy, creating it")
146
- const assetPayload: any = {
147
- slug: slug,
148
- title: assetTitle,
149
- lang: selectedLang,
150
- graded: true,
151
- description: assetDescription,
152
- learnpack_deploy_url: learnpackDeployUrl,
153
- // technologies: technologies.map((tech: unknown) =>
154
- // String(tech).toLowerCase().replace(/\s+/g, "-")
155
- // ),
156
- technologies: [],
157
- url: learnpackDeployUrl,
158
- category: category,
159
- owner: user.id,
160
- author: user.id,
161
- preview: learnJson.preview,
162
- readme_raw: b64IndexReadme,
163
- all_translations,
164
- learnpack_id: learnpackId,
165
- }
166
- if (academyId !== undefined) {
167
- assetPayload.academy_id = academyId
168
- }
169
-
170
- const asset = await api.createAsset(sessionPayload.token, assetPayload)
171
- try {
172
- await api.updateRigoPackage(
173
- sessionPayload.rigobotToken.trim(),
174
- learnJson.slug,
175
- {
176
- asset_id: asset.id,
177
- }
178
- )
179
- } catch (error) {
180
- Console.error("Error updating Rigo package:", error)
181
- }
182
-
183
- Console.info("Asset created with id", asset.id)
184
- return asset
185
- }
186
-
187
- Console.info("Asset exists, updating it")
188
- const updatePayload: any = {
189
- graded: true,
190
- learnpack_deploy_url: learnpackDeployUrl,
191
- title: assetTitle,
192
- category: category,
193
- description: assetDescription,
194
- all_translations,
195
- learnpack_id: learnpackId,
196
- }
197
- // Only set academy when the asset has none yet and the user selected one
198
- if (existingAcademyId === undefined && academyId !== undefined) {
199
- updatePayload.academy_id = academyId
200
- }
201
-
202
- const asset = await api.updateAsset(
203
- sessionPayload.token,
204
- slug,
205
- updatePayload
206
- )
207
- try {
208
- await api.updateRigoPackage(
209
- sessionPayload.rigobotToken.trim(),
210
- learnJson.slug,
211
- {
212
- asset_id: asset.id,
213
- }
214
- )
215
- } catch (error) {
216
- Console.error("Error updating Rigo package:", error)
217
- }
218
-
219
- Console.info("Asset updated with id", asset.id)
220
- return asset
46
+ return fs.existsSync(readmePath) ?
47
+ fs.readFileSync(readmePath, "utf-8") :
48
+ ""
221
49
  } catch (error) {
222
- Console.error("Error updating or creating asset:", error)
223
- throw error
50
+ Console.error("Error reading index readme:", error)
51
+ return ""
224
52
  }
225
53
  }
226
54
 
227
- const createMultiLangAssetFromDisk = async (
55
+ const syncAssetsFromDisk = async (
228
56
  sessionPayload: { token: string; rigobotToken: string },
229
57
  learnJson: any,
230
58
  deployUrl: string,
231
- selectedAcademyId?: number,
232
- existingAssetsInfo: ExistingAssetInfo[] = []
59
+ selectedAcademyId: number | undefined,
60
+ resolution: PackageAssetsResolution
233
61
  ) => {
234
- const availableLangs = getAvailableLangs(learnJson)
235
-
236
- if (availableLangs.length === 0) {
62
+ if (getAvailableLangs(learnJson).length === 0) {
237
63
  Console.error(
238
64
  "No languages found in learn.json.title. Add at least one language (e.g. title.en)."
239
65
  )
240
66
  return
241
67
  }
242
68
 
243
- const learnpackId = await api.resolveLearnpackPackageId(
244
- sessionPayload.rigobotToken,
245
- learnJson.slug
246
- )
247
- if (learnpackId === null) {
248
- Console.warning(
249
- "Breathecode assets skipped: could not resolve Learnpack package id"
250
- )
251
- return
252
- }
69
+ // The pre-upload resolution is reused unless the package did not exist in
70
+ // Rigobot yet (first publish): the upload has just created it.
71
+ const result = await syncPackageAssets({
72
+ bcToken: sessionPayload.token,
73
+ rigoToken: sessionPayload.rigobotToken,
74
+ packageSlug: learnJson.slug,
75
+ learnJson,
76
+ deployUrl,
77
+ academyId: selectedAcademyId,
78
+ readReadme: readReadmeFromDisk,
79
+ resolution: resolution.learnpackId === null ? undefined : resolution,
80
+ })
253
81
 
254
- const all_translations: string[] = []
255
- for (const lang of availableLangs) {
256
- const readmePath = path.join(
257
- process.cwd(),
258
- `README${getReadmeExtension(lang)}`
82
+ for (const warning of result.warnings) Console.warning(warning)
83
+ for (const synced of result.synced) {
84
+ Console.info(
85
+ `Asset ${synced.action} for "${synced.lang}": #${synced.asset.id} ${synced.asset.slug}`
259
86
  )
87
+ }
260
88
 
261
- let indexReadmeString = ""
262
- try {
263
- if (fs.existsSync(readmePath)) {
264
- indexReadmeString = fs.readFileSync(readmePath, "utf-8")
265
- }
266
- } catch (error) {
267
- Console.error("Error reading index readme:", error)
268
- indexReadmeString = ""
269
- }
270
-
271
- const b64IndexReadme = Buffer.from(indexReadmeString).toString("base64")
272
- const preflightInfo = existingAssetsInfo.find((a) => a.lang === lang)
273
-
274
- try {
275
- // eslint-disable-next-line no-await-in-loop
276
- const asset = await handleAssetCreation(
277
- sessionPayload,
278
- learnJson,
279
- lang,
280
- deployUrl,
281
- b64IndexReadme,
282
- selectedAcademyId,
283
- learnpackId,
284
- preflightInfo,
285
- all_translations
286
- )
287
-
288
- if (!asset) {
289
- Console.debug("Could not create/update asset for lang", lang)
290
- continue
291
- }
292
-
293
- all_translations.push(asset.slug)
294
- } catch (error) {
295
- Console.error("Error creating asset for language", lang, error)
296
- // Continue with other languages
89
+ for (const error of result.errors) {
90
+ if (error.kind === "lang_error") {
91
+ Console.error(`Asset for "${error.lang}": ${error.error.detail}`)
92
+ } else {
93
+ Console.error(error.error.detail)
297
94
  }
298
95
  }
299
96
  }
@@ -487,11 +284,21 @@ class BuildCommand extends SessionCommand {
487
284
  }
488
285
 
489
286
  Console.info("Checking existing assets...")
490
- const existingAssetsInfo = await getExistingAssetsInfo(
491
- sessionPayload.token,
492
- learnJson
493
- )
494
- const academyMode = determinePublishAcademyMode(existingAssetsInfo)
287
+ const resolution = await resolvePackageAssets({
288
+ bcToken: sessionPayload.token,
289
+ rigoToken: rigoToken,
290
+ packageSlug: learnJson.slug,
291
+ learnJson,
292
+ })
293
+ for (const warning of resolution.warnings) Console.warning(warning)
294
+ if (resolution.packageState === "unknown") {
295
+ Console.error(
296
+ `${resolution.packageError}. Existing assets cannot be checked, so publishing now could create duplicates. Try again later.`
297
+ )
298
+ process.exit(1)
299
+ }
300
+
301
+ const academyMode = determineAcademyMode(resolution)
495
302
 
496
303
  let selectedAcademyId: number | undefined
497
304
 
@@ -588,9 +395,7 @@ class BuildCommand extends SessionCommand {
588
395
  getLocalizedValue(learnJson?.title, selectedLang) ||
589
396
  "LearnPack: Interactive Learning as a Service"
590
397
 
591
- const previewUrl =
592
- learnJson.preview ||
593
- "https://raw.githubusercontent.com/learnpack/ide/refs/heads/master/public/learnpack.svg"
398
+ const previewUrl = learnJson.preview || getFallbackPreviewUrl()
594
399
  // Replace placeholders and the <title>Old title </title> tag for a new tag with the title
595
400
  indexHtmlContent = indexHtmlContent
596
401
  .replace(/{{description}}/g, description)
@@ -689,12 +494,12 @@ class BuildCommand extends SessionCommand {
689
494
  fs.unlinkSync(zipFilePath)
690
495
  this.removeDirectory(buildDir)
691
496
 
692
- await createMultiLangAssetFromDisk(
497
+ await syncAssetsFromDisk(
693
498
  { token: sessionPayload.token, rigobotToken: rigoToken },
694
499
  learnJson,
695
500
  res.data.url,
696
501
  selectedAcademyId,
697
- existingAssetsInfo
502
+ resolution
698
503
  )
699
504
  } catch (error) {
700
505
  if (axios.isAxiosError(error)) {