@learnpack/learnpack 5.0.358 → 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.
package/src/ui/app.tar.gz CHANGED
Binary file
package/src/utils/api.ts CHANGED
@@ -7,6 +7,7 @@ import * as dotenv from "dotenv"
7
7
  dotenv.config()
8
8
 
9
9
  const HOST = "https://breathecode.herokuapp.com"
10
+ export const BREATHECODE_HOST = HOST
10
11
 
11
12
  /**
12
13
  * Student telemetry, and publication events. Both share the endpoint: a body
@@ -446,34 +447,66 @@ export type AssetSyncError =
446
447
  | { kind: "lang_error"; lang: string; error: { detail: string } }
447
448
  | { kind: "package_error"; error: { detail: string } };
448
449
 
449
- function parseLearnpackPackageId(raw: unknown): number | null {
450
- if (raw === undefined || raw === null) return null
451
- const n = typeof raw === "number" ? raw : Number(raw)
452
- if (!Number.isFinite(n) || !Number.isInteger(n)) return null
453
- return n
450
+ export type RigoPackageLookup =
451
+ | { kind: "found"; id: number; assetIds: number[] }
452
+ | { kind: "missing" }
453
+ | { kind: "error"; error: string };
454
+
455
+ /**
456
+ * Human-readable summary of a failed axios call, for warnings and error payloads.
457
+ * @param error - Whatever the request rejected with.
458
+ * @returns "HTTP <status>" when the server answered, the error message otherwise.
459
+ */
460
+ export function describeRequestError(error: unknown): string {
461
+ if (axios.isAxiosError(error)) {
462
+ if (error.response) {
463
+ const detail =
464
+ error.response.data?.detail ?? error.response.data?.error ?? ""
465
+ return `HTTP ${error.response.status}${detail ? ` (${detail})` : ""}`
466
+ }
467
+
468
+ return error.message
469
+ }
470
+
471
+ return error instanceof Error ? error.message : String(error)
454
472
  }
455
473
 
456
474
  /**
457
- * GET Rigobot package by slug after a successful upload. Does not throw.
458
- * @param rigoToken Rigobot API token (Bearer-style `Token` header value).
475
+ * GET a Rigobot package by slug. Distinguishes "no such package" from
476
+ * "could not check", so callers can fail closed on the latter.
477
+ * @param rigoToken Rigobot API token (`Token` header value).
459
478
  * @param courseSlug Learnpack package slug used in the Rigobot URL path.
460
- * @returns Resolved numeric package id from Rigobot, or `null` if the request fails, the id is missing, or it is not a finite integer.
479
+ * @returns The package id and its asset_ids, `missing` on 404, `error` otherwise (403 included: a package owned by somebody else is not ours to read).
461
480
  */
462
- export async function resolveLearnpackPackageId(
481
+ export async function getRigoPackage(
463
482
  rigoToken: string,
464
483
  courseSlug: string
465
- ): Promise<number | null> {
466
- if (!rigoToken?.trim() || !courseSlug) return null
484
+ ): Promise<RigoPackageLookup> {
485
+ const cleanToken = rigoToken.replace(/[\n\r]/g, "").trim()
467
486
  const url = `${RIGOBOT_HOST}/v1/learnpack/package/${encodeURIComponent(
468
487
  courseSlug
469
488
  )}/`
470
489
  try {
471
- const response = await axios.get<{ id?: unknown }>(url, {
472
- headers: { Authorization: `Token ${rigoToken.trim()}` },
490
+ const response = await axios.get(url, {
491
+ headers: { Authorization: `Token ${cleanToken}` },
473
492
  })
474
- return parseLearnpackPackageId(response.data?.id)
475
- } catch {
476
- return null
493
+ const rawId = response.data?.id
494
+ const id = typeof rawId === "number" ? rawId : Number(rawId)
495
+ if (!Number.isInteger(id)) {
496
+ return { kind: "error", error: "Rigobot package has no numeric id" }
497
+ }
498
+
499
+ const rawIds = response.data?.asset_ids
500
+ const assetIds: number[] = Array.isArray(rawIds) ?
501
+ rawIds.filter((x: unknown): x is number => Number.isInteger(x)) :
502
+ []
503
+ return { kind: "found", id, assetIds }
504
+ } catch (error) {
505
+ if (axios.isAxiosError(error) && error.response?.status === 404) {
506
+ return { kind: "missing" }
507
+ }
508
+
509
+ return { kind: "error", error: describeRequestError(error) }
477
510
  }
478
511
  }
479
512
 
@@ -556,52 +589,125 @@ export const createAsset = async (token: string, asset: TAssetMissing) => {
556
589
  }
557
590
  }
558
591
 
559
- export const doesAssetExists = async (
560
- token: string,
561
- assetSlug: string
562
- ): Promise<{ exists: boolean; academyId?: number }> => {
563
- const url = `https://breathecode.herokuapp.com/v1/registry/asset/${assetSlug}`
592
+ /** The subset of a Breathecode asset the publish flow reads. */
593
+ export type BcAsset = {
594
+ id: number;
595
+ slug: string;
596
+ lang: string | null;
597
+ status: string | null;
598
+ learnpackId: number | null;
599
+ learnpackDeployUrl: string | null;
600
+ ownerId: number | null;
601
+ academyId: number | null;
602
+ };
564
603
 
565
- const headers = {
566
- Authorization: `Token ${token}`,
604
+ export type AssetLookup =
605
+ | { kind: "found"; asset: BcAsset }
606
+ | { kind: "missing" }
607
+ | { kind: "error"; error: string };
608
+
609
+ const asIntegerOrNull = (raw: unknown): number | null => {
610
+ if (raw === null || raw === undefined || raw === "") return null
611
+ const n = typeof raw === "number" ? raw : Number(raw)
612
+ return Number.isInteger(n) ? n : null
613
+ }
614
+
615
+ const toBcAsset = (data: any): BcAsset | null => {
616
+ if (!data || typeof data !== "object") return null
617
+ const id = asIntegerOrNull(data.id)
618
+ if (id === null || typeof data.slug !== "string") return null
619
+ return {
620
+ id,
621
+ slug: data.slug,
622
+ lang: typeof data.lang === "string" && data.lang ? data.lang : null,
623
+ status: typeof data.status === "string" ? data.status : null,
624
+ learnpackId: asIntegerOrNull(data.learnpack_id),
625
+ learnpackDeployUrl:
626
+ typeof data.learnpack_deploy_url === "string" ?
627
+ data.learnpack_deploy_url :
628
+ null,
629
+ ownerId: asIntegerOrNull(data.owner?.id ?? data.owner),
630
+ academyId: asIntegerOrNull(data.academy?.id ?? data.academy_id),
567
631
  }
632
+ }
568
633
 
634
+ const lookupAsset = async (
635
+ token: string,
636
+ slugOrId: string
637
+ ): Promise<AssetLookup> => {
638
+ const url = `${HOST}/v1/registry/asset/${encodeURIComponent(slugOrId)}`
569
639
  try {
570
- const response = await axios.get(url, { headers })
571
- if (response.status === 200) {
572
- const academyId = response.data?.academy?.id || response.data?.academy_id
573
- return { exists: true, academyId }
640
+ const response = await axios.get(url, {
641
+ headers: { Authorization: `Token ${token}` },
642
+ })
643
+ const asset = toBcAsset(response.data)
644
+ if (asset === null) {
645
+ // A 200 that is not an asset (e.g. the readme renderer answering for a
646
+ // path with a period) proves nothing either way: fail closed.
647
+ return {
648
+ kind: "error",
649
+ error: `Unexpected response for asset ${slugOrId}`,
650
+ }
574
651
  }
575
652
 
576
- return { exists: false }
577
- } catch {
578
- // console.error("Failed to get asset:", error)
579
- return { exists: false }
653
+ return { kind: "found", asset }
654
+ } catch (error) {
655
+ if (axios.isAxiosError(error) && error.response?.status === 404) {
656
+ return { kind: "missing" }
657
+ }
658
+
659
+ return { kind: "error", error: describeRequestError(error) }
580
660
  }
581
661
  }
582
662
 
663
+ /**
664
+ * GET a Breathecode asset by numeric id. The endpoint applies no status or
665
+ * visibility filter for a single asset, so this is the stable anchor.
666
+ * @param token Breathecode token.
667
+ * @param assetId Asset primary key.
668
+ * @returns `found` with the asset, `missing` on 404, `error` when it could not be checked.
669
+ */
670
+ export const getAssetById = (token: string, assetId: number) =>
671
+ lookupAsset(token, String(assetId))
672
+
673
+ /**
674
+ * GET a Breathecode asset by slug.
675
+ * @param token Breathecode token.
676
+ * @param assetSlug Asset slug.
677
+ * @returns `found` with the asset, `missing` on 404, `error` when it could not be checked.
678
+ */
679
+ export const getAssetBySlug = (token: string, assetSlug: string) =>
680
+ lookupAsset(token, assetSlug)
681
+
583
682
  const updateAsset = async (
584
683
  token: string,
585
- assetSlug: string,
684
+ assetId: number,
586
685
  asset: Partial<TAssetMissing> & { learnpack_id: number }
587
686
  ) => {
588
- const url = `https://breathecode.herokuapp.com/v1/registry/asset/me/${assetSlug}`
687
+ // The list form of PUT /asset/me updates by id, so a slug rename or an
688
+ // alias never sends the update to the wrong asset.
689
+ const url = `${HOST}/v1/registry/asset/me`
589
690
  const headers = {
590
691
  Authorization: `Token ${token}`,
591
692
  }
592
693
 
593
- const body = { ...asset, learnpack_id: asset.learnpack_id }
694
+ const body = [{ ...asset, id: assetId, learnpack_id: asset.learnpack_id }]
594
695
 
595
- console.log("[BC] PUT", url, "| academy_id:", asset.academy_id ?? "none")
696
+ console.log(
697
+ "[BC] PUT",
698
+ url,
699
+ "| id:",
700
+ assetId,
701
+ "| academy_id:",
702
+ asset.academy_id ?? "none"
703
+ )
596
704
 
597
705
  try {
598
706
  const response = await axios.put(url, body, { headers })
599
- return response.data
707
+ return Array.isArray(response.data) ? response.data[0] : response.data
600
708
  } catch (error: any) {
601
709
  console.error("Failed to update asset:", error)
602
- // Try to print the data
603
- // console.log(error.response.data)
604
- throw error.response.data
710
+ throw error.response?.data ?? error
605
711
  }
606
712
  }
607
713
 
@@ -619,10 +725,18 @@ const getCategories = async (token: string) => {
619
725
  }
620
726
  }
621
727
 
728
+ /**
729
+ * PUT on the Rigobot package. `asset_id` appends one id to asset_ids (Rigobot
730
+ * side), `asset_ids` replaces the whole list.
731
+ * @param token Rigobot token.
732
+ * @param slug Package slug.
733
+ * @param updates Fields to write.
734
+ * @returns The updated package payload.
735
+ */
622
736
  const updateRigoPackage = async (
623
737
  token: string,
624
738
  slug: string,
625
- updates: { asset_id?: number; new_slug?: string }
739
+ updates: { asset_id?: number; asset_ids?: number[] }
626
740
  ) => {
627
741
  const cleanToken = token.replace(/[\n\r]/g, "")
628
742
 
@@ -755,9 +869,10 @@ export default {
755
869
  listUserAcademies,
756
870
  validateToken,
757
871
  createAsset,
758
- doesAssetExists,
872
+ getAssetById,
873
+ getAssetBySlug,
759
874
  updateAsset,
760
- resolveLearnpackPackageId,
875
+ getRigoPackage,
761
876
  getCategories,
762
877
  updateRigoPackage,
763
878
  createRigoPackage,
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Resolves which Breathecode assets already represent a Learnpack package,
3
+ * one per language, before publish decides whether to create or update.
4
+ *
5
+ * The anchor is the package's `asset_ids` in Rigobot, read back by id from
6
+ * Breathecode. The title-derived slug is only a fallback for packages that
7
+ * predate `asset_ids`; it is never the criterion for a package whose ids are
8
+ * known. Every "could not check" outcome is reported as such, never as "does
9
+ * not exist", so a network hiccup cannot turn into a duplicate asset.
10
+ */
11
+ import api, { BcAsset } from "./api"
12
+ import { buildAssetSlug } from "./creatorUtilities"
13
+
14
+ export type LangResolution =
15
+ | { status: "found"; asset: BcAsset; via: "id" | "slug" }
16
+ | { status: "missing" }
17
+ | { status: "ambiguous"; candidates: BcAsset[] }
18
+ | { status: "unknown"; error: string };
19
+
20
+ export type PackageAssetsResolution = {
21
+ packageSlug: string;
22
+ /** Rigobot package id, or null when the package does not exist yet (or no Rigobot token was given). */
23
+ learnpackId: number | null;
24
+ /** `unknown` when Rigobot or Breathecode could not be read: nothing may be created or written. */
25
+ packageState: "ok" | "unknown";
26
+ packageError?: string;
27
+ /** Ids from Rigobot that still resolve to an asset of this package. */
28
+ knownAssetIds: number[];
29
+ /** Ids from Rigobot whose asset has no language: never adopted, but never dropped either. */
30
+ orphanIds: number[];
31
+ /** Keyed by canonical language (see canonicalLang). */
32
+ byLang: Map<string, LangResolution>;
33
+ warnings: string[];
34
+ };
35
+
36
+ export type AcademyMode =
37
+ | { type: "select" }
38
+ | { type: "locked"; academyId: number }
39
+ | { type: "conflict"; academies: number[] };
40
+
41
+ /**
42
+ * Breathecode stores "en" as "us"; both name the same language.
43
+ * @param lang - Language code as found in learn.json or on an asset.
44
+ * @returns The canonical code, or null when there is none.
45
+ */
46
+ export const canonicalLang = (lang?: string | null): string | null => {
47
+ if (typeof lang !== "string") return null
48
+ const lower = lang.trim().toLowerCase()
49
+ if (!lower) return null
50
+ return lower === "en" ? "us" : lower
51
+ }
52
+
53
+ /**
54
+ * Language keys of learn.json.title, in declaration order.
55
+ * @param learnJson - Parsed learn.json.
56
+ * @returns Non-empty string keys of `title`.
57
+ */
58
+ export const getAvailableLangs = (learnJson: any): string[] => {
59
+ const langs = Object.keys(learnJson?.title || {})
60
+ return langs.filter(l => typeof l === "string" && l.length > 0)
61
+ }
62
+
63
+ /**
64
+ * Drops keys that name the same language ("en" and "us"), keeping "en" when
65
+ * both are present and the first one declared otherwise.
66
+ * @param langs - Language keys as declared in learn.json.
67
+ * @returns One key per canonical language.
68
+ */
69
+ export const dedupeLangs = (langs: string[]): string[] => {
70
+ const byCanonical = new Map<string, string>()
71
+ for (const lang of langs) {
72
+ const canonical = canonicalLang(lang)
73
+ if (canonical === null) continue
74
+ const current = byCanonical.get(canonical)
75
+ if (current === undefined || (lang === "en" && current === "us")) {
76
+ byCanonical.set(canonical, lang)
77
+ }
78
+ }
79
+
80
+ return [...byCanonical.values()]
81
+ }
82
+
83
+ /**
84
+ * Slugs an asset for this language may have been created under.
85
+ *
86
+ * One per learn.json title naming this language (title.en and title.us can
87
+ * differ, and a historical asset came from whichever existed at the time),
88
+ * each with both suffixes when the language is "us"/"en".
89
+ * @param learnJson - Parsed learn.json.
90
+ * @param canonical - Canonical language.
91
+ * @returns Distinct candidate slugs, most likely first.
92
+ */
93
+ export const slugCandidatesForLang = (
94
+ learnJson: any,
95
+ canonical: string
96
+ ): string[] => {
97
+ const suffixes = canonical === "us" ? ["en", "us"] : [canonical]
98
+ const seen = new Set<string>()
99
+ const out: string[] = []
100
+ for (const key of getAvailableLangs(learnJson)) {
101
+ if (canonicalLang(key) !== canonical) continue
102
+ const title = learnJson.title[key]
103
+ if (typeof title !== "string" || !title.trim()) continue
104
+ for (const suffix of suffixes) {
105
+ const slug = buildAssetSlug(title, suffix)
106
+ if (!seen.has(slug)) {
107
+ seen.add(slug)
108
+ out.push(slug)
109
+ }
110
+ }
111
+ }
112
+
113
+ return out
114
+ }
115
+
116
+ const describeAsset = (asset: BcAsset): string =>
117
+ `#${asset.id} ${asset.slug} (${asset.status ?? "?"}${
118
+ asset.academyId === null ? "" : `, academy ${asset.academyId}`
119
+ })`
120
+
121
+ /**
122
+ * Text for the error a language gets when more than one asset claims it.
123
+ * @param lang - Language key from learn.json.
124
+ * @param candidates - The competing assets.
125
+ * @returns An actionable message naming every candidate.
126
+ */
127
+ export const describeAmbiguity = (
128
+ lang: string,
129
+ candidates: BcAsset[]
130
+ ): string =>
131
+ `Found ${
132
+ candidates.length
133
+ } Breathecode assets for language "${lang}": ${candidates
134
+ .map(describeAsset)
135
+ .join(
136
+ "; "
137
+ )}. Nothing was created or updated for this language. Keep only the definitive ids in the Rigobot package (asset_ids) and publish again.`
138
+
139
+ export type ResolverApi = {
140
+ getRigoPackage: typeof api.getRigoPackage;
141
+ getAssetById: typeof api.getAssetById;
142
+ getAssetBySlug: typeof api.getAssetBySlug;
143
+ };
144
+
145
+ export type ResolveInput = {
146
+ bcToken: string;
147
+ /** Optional: without it only the slug fallback runs (older IDEs do not send it). */
148
+ rigoToken?: string;
149
+ packageSlug: string;
150
+ learnJson: any;
151
+ deps?: ResolverApi;
152
+ };
153
+
154
+ /**
155
+ * Resolves the existing Breathecode asset for every language of a package.
156
+ * @param input - Tokens, package slug and parsed learn.json.
157
+ * @returns Per-language resolution plus the package-level facts publish needs.
158
+ */
159
+ export async function resolvePackageAssets(
160
+ input: ResolveInput
161
+ ): Promise<PackageAssetsResolution> {
162
+ const deps = input.deps ?? api
163
+ const warnings: string[] = []
164
+ const langs = dedupeLangs(getAvailableLangs(input.learnJson))
165
+ const byLang = new Map<string, LangResolution>()
166
+
167
+ const unknownEverywhere = (
168
+ error: string,
169
+ learnpackId: number | null
170
+ ): PackageAssetsResolution => {
171
+ for (const lang of langs) {
172
+ byLang.set(canonicalLang(lang) as string, { status: "unknown", error })
173
+ }
174
+
175
+ return {
176
+ packageSlug: input.packageSlug,
177
+ learnpackId,
178
+ packageState: "unknown",
179
+ packageError: error,
180
+ knownAssetIds: [],
181
+ orphanIds: [],
182
+ byLang,
183
+ warnings,
184
+ }
185
+ }
186
+
187
+ // Step 0: the package in Rigobot, and the asset ids it already knows.
188
+ let learnpackId: number | null = null
189
+ let previousIds: number[] = []
190
+ if (input.rigoToken?.trim()) {
191
+ const pkg = await deps.getRigoPackage(input.rigoToken, input.packageSlug)
192
+ if (pkg.kind === "error") {
193
+ return unknownEverywhere(
194
+ `Could not read the Rigobot package "${input.packageSlug}": ${pkg.error}`,
195
+ null
196
+ )
197
+ }
198
+
199
+ if (pkg.kind === "found") {
200
+ learnpackId = pkg.id
201
+ previousIds = [...new Set(pkg.assetIds)]
202
+ }
203
+ } else {
204
+ warnings.push(
205
+ "No Rigobot token: existing assets are looked up by slug only."
206
+ )
207
+ }
208
+
209
+ // Step 1: read every known id back from Breathecode.
210
+ const knownAssetIds: number[] = []
211
+ const orphanIds: number[] = []
212
+ const candidatesByLang = new Map<string, BcAsset[]>()
213
+ for (const id of previousIds) {
214
+ // eslint-disable-next-line no-await-in-loop
215
+ const lookup = await deps.getAssetById(input.bcToken, id)
216
+ if (lookup.kind === "error") {
217
+ return unknownEverywhere(
218
+ `Could not read Breathecode asset #${id}: ${lookup.error}`,
219
+ learnpackId
220
+ )
221
+ }
222
+
223
+ if (lookup.kind === "missing") {
224
+ warnings.push(
225
+ `Asset #${id} listed in the Rigobot package no longer exists in Breathecode; dropped.`
226
+ )
227
+ continue
228
+ }
229
+
230
+ const asset = lookup.asset
231
+ if (
232
+ asset.learnpackId !== null &&
233
+ learnpackId !== null &&
234
+ asset.learnpackId !== learnpackId
235
+ ) {
236
+ warnings.push(
237
+ `Asset ${describeAsset(asset)} belongs to Learnpack package ${
238
+ asset.learnpackId
239
+ }, not ${learnpackId}; ignored.`
240
+ )
241
+ continue
242
+ }
243
+
244
+ const canonical = canonicalLang(asset.lang)
245
+ if (canonical === null) {
246
+ orphanIds.push(asset.id)
247
+ warnings.push(
248
+ `Asset ${describeAsset(
249
+ asset
250
+ )} has no language; kept in the package but not used.`
251
+ )
252
+ continue
253
+ }
254
+
255
+ knownAssetIds.push(asset.id)
256
+ const group = candidatesByLang.get(canonical) ?? []
257
+ group.push(asset)
258
+ candidatesByLang.set(canonical, group)
259
+ }
260
+
261
+ // Step 2: languages with no known id fall back to the title-derived slugs.
262
+ for (const lang of langs) {
263
+ const canonical = canonicalLang(lang) as string
264
+ const known = candidatesByLang.get(canonical) ?? []
265
+ if (known.length === 1) {
266
+ byLang.set(canonical, { status: "found", asset: known[0], via: "id" })
267
+ continue
268
+ }
269
+
270
+ if (known.length > 1) {
271
+ byLang.set(canonical, { status: "ambiguous", candidates: known })
272
+ continue
273
+ }
274
+
275
+ const found = new Map<number, BcAsset>()
276
+ let error: string | null = null
277
+ for (const slug of slugCandidatesForLang(input.learnJson, canonical)) {
278
+ // eslint-disable-next-line no-await-in-loop
279
+ const lookup = await deps.getAssetBySlug(input.bcToken, slug)
280
+ if (lookup.kind === "error") {
281
+ error = `Could not check Breathecode asset "${slug}": ${lookup.error}`
282
+ break
283
+ }
284
+
285
+ if (lookup.kind === "missing") continue
286
+ const asset = lookup.asset
287
+ if (
288
+ asset.learnpackId !== null &&
289
+ learnpackId !== null &&
290
+ asset.learnpackId !== learnpackId
291
+ ) {
292
+ warnings.push(
293
+ `Asset ${describeAsset(
294
+ asset
295
+ )} matches the slug for "${lang}" but belongs to Learnpack package ${
296
+ asset.learnpackId
297
+ }; ignored.`
298
+ )
299
+ continue
300
+ }
301
+
302
+ found.set(asset.id, asset)
303
+ }
304
+
305
+ if (error !== null) {
306
+ byLang.set(canonical, { status: "unknown", error })
307
+ } else if (found.size === 0) {
308
+ byLang.set(canonical, { status: "missing" })
309
+ } else if (found.size === 1) {
310
+ const asset = [...found.values()][0]
311
+ if (previousIds.length > 0) {
312
+ warnings.push(
313
+ `Asset ${describeAsset(
314
+ asset
315
+ )} for "${lang}" was found by slug, not through the Rigobot package; it will be added to asset_ids.`
316
+ )
317
+ }
318
+
319
+ byLang.set(canonical, { status: "found", asset, via: "slug" })
320
+ } else {
321
+ byLang.set(canonical, {
322
+ status: "ambiguous",
323
+ candidates: [...found.values()],
324
+ })
325
+ }
326
+ }
327
+
328
+ return {
329
+ packageSlug: input.packageSlug,
330
+ learnpackId,
331
+ packageState: "ok",
332
+ knownAssetIds,
333
+ orphanIds,
334
+ byLang,
335
+ warnings,
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Academy handling for the publish prompt, from the assets that were found.
341
+ * @param resolution - Output of resolvePackageAssets.
342
+ * @returns `select` when no found asset has an academy, `locked` when they all share one, `conflict` otherwise.
343
+ */
344
+ export const determineAcademyMode = (
345
+ resolution: PackageAssetsResolution
346
+ ): AcademyMode => {
347
+ const academyIds: number[] = []
348
+ for (const entry of resolution.byLang.values()) {
349
+ if (entry.status === "found" && entry.asset.academyId !== null) {
350
+ academyIds.push(entry.asset.academyId)
351
+ }
352
+ }
353
+
354
+ const unique = [...new Set(academyIds)]
355
+ if (unique.length === 0) return { type: "select" }
356
+ if (unique.length === 1) return { type: "locked", academyId: unique[0] }
357
+ return { type: "conflict", academies: unique }
358
+ }
359
+
360
+ /**
361
+ * Whether at least one language already has an asset.
362
+ * @param resolution - Output of resolvePackageAssets.
363
+ * @returns True when any language resolved to `found`.
364
+ */
365
+ export const hasPublishedAsset = (
366
+ resolution: PackageAssetsResolution
367
+ ): boolean =>
368
+ [...resolution.byLang.values()].some(entry => entry.status === "found")