@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/lib/commands/publish.d.ts +0 -10
- package/lib/commands/publish.js +52 -168
- package/lib/commands/serve.js +52 -116
- package/lib/utils/api.d.ts +62 -15
- package/lib/utils/api.js +124 -45
- package/lib/utils/assetResolver.d.ts +116 -0
- package/lib/utils/assetResolver.js +268 -0
- package/lib/utils/assetSync.d.ts +48 -0
- package/lib/utils/assetSync.js +267 -0
- package/lib/utils/creatorUtilities.d.ts +1 -0
- package/lib/utils/creatorUtilities.js +17 -1
- package/package.json +1 -1
- package/src/commands/publish.ts +61 -255
- package/src/commands/serve.ts +65 -140
- package/src/ui/_app/app.js +1784 -1784
- package/src/ui/app.tar.gz +0 -0
- package/src/utils/api.ts +157 -42
- package/src/utils/assetResolver.ts +368 -0
- package/src/utils/assetSync.ts +343 -0
- package/src/utils/awsCredentials.ts +57 -57
- package/src/utils/creatorUtilities.ts +16 -0
- package/src/utils/descriptions/publishStage.ts +394 -394
- package/src/utils/descriptions/resumePublication.ts +217 -217
- package/src/utils/descriptions/s3Storage.ts +214 -214
- package/src/utils/s3/packageManifestBackfill.ts +771 -771
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
|
458
|
-
*
|
|
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
|
|
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
|
|
481
|
+
export async function getRigoPackage(
|
|
463
482
|
rigoToken: string,
|
|
464
483
|
courseSlug: string
|
|
465
|
-
): Promise<
|
|
466
|
-
|
|
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
|
|
472
|
-
headers: { Authorization: `Token ${
|
|
490
|
+
const response = await axios.get(url, {
|
|
491
|
+
headers: { Authorization: `Token ${cleanToken}` },
|
|
473
492
|
})
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
566
|
-
|
|
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, {
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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 {
|
|
577
|
-
} catch {
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
|
|
684
|
+
assetId: number,
|
|
586
685
|
asset: Partial<TAssetMissing> & { learnpack_id: number }
|
|
587
686
|
) => {
|
|
588
|
-
|
|
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(
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
872
|
+
getAssetById,
|
|
873
|
+
getAssetBySlug,
|
|
759
874
|
updateAsset,
|
|
760
|
-
|
|
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")
|