@learnpack/learnpack 5.0.358 → 5.0.360

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.
Files changed (47) hide show
  1. package/lib/commands/publish.d.ts +0 -10
  2. package/lib/commands/publish.js +52 -168
  3. package/lib/commands/serve.js +52 -116
  4. package/lib/creatorDist/assets/{index-zrPponAn.css → index-B7s5Nmq8.css} +59 -0
  5. package/lib/creatorDist/assets/{index-D6pmbMe9.js → index-GYxGB_X0.js} +22609 -22363
  6. package/lib/creatorDist/index.html +2 -2
  7. package/lib/utils/api.d.ts +62 -15
  8. package/lib/utils/api.js +124 -45
  9. package/lib/utils/assetResolver.d.ts +116 -0
  10. package/lib/utils/assetResolver.js +268 -0
  11. package/lib/utils/assetSync.d.ts +48 -0
  12. package/lib/utils/assetSync.js +267 -0
  13. package/lib/utils/creatorUtilities.d.ts +1 -0
  14. package/lib/utils/creatorUtilities.js +17 -1
  15. package/package.json +1 -1
  16. package/src/commands/publish.ts +61 -255
  17. package/src/commands/serve.ts +65 -140
  18. package/src/creator/src/App.test.tsx +338 -0
  19. package/src/creator/src/App.tsx +152 -26
  20. package/src/creator/src/components/CourseLanguagePicker.tsx +51 -0
  21. package/src/creator/src/components/LanguageDetectionModal.tsx +29 -9
  22. package/src/creator/src/components/StepWizard.tsx +11 -3
  23. package/src/creator/src/components/Uploader.test.tsx +65 -0
  24. package/src/creator/src/components/Uploader.tsx +9 -3
  25. package/src/creator/src/locales/en.json +7 -0
  26. package/src/creator/src/locales/es.json +7 -0
  27. package/src/creator/src/utils/courseLanguage.test.ts +215 -0
  28. package/src/creator/src/utils/courseLanguage.ts +173 -0
  29. package/src/creator/src/utils/creationRequest.test.ts +104 -0
  30. package/src/creator/src/utils/creationRequest.ts +63 -0
  31. package/src/creator/src/utils/store.ts +7 -0
  32. package/src/creatorDist/assets/{index-zrPponAn.css → index-B7s5Nmq8.css} +59 -0
  33. package/src/creatorDist/assets/{index-D6pmbMe9.js → index-GYxGB_X0.js} +22609 -22363
  34. package/src/creatorDist/index.html +2 -2
  35. package/src/ui/_app/app.css +1 -1
  36. package/src/ui/_app/app.js +2105 -2105
  37. package/src/ui/_app/sw.js +13 -0
  38. package/src/ui/app.tar.gz +0 -0
  39. package/src/utils/api.ts +157 -42
  40. package/src/utils/assetResolver.ts +368 -0
  41. package/src/utils/assetSync.ts +343 -0
  42. package/src/utils/awsCredentials.ts +57 -57
  43. package/src/utils/creatorUtilities.ts +16 -0
  44. package/src/utils/descriptions/publishStage.ts +394 -394
  45. package/src/utils/descriptions/resumePublication.ts +217 -217
  46. package/src/utils/descriptions/s3Storage.ts +214 -214
  47. package/src/utils/s3/packageManifestBackfill.ts +771 -771
@@ -4,12 +4,25 @@ interface LanguageDetectionModalProps {
4
4
  detectedLanguage: string
5
5
  onSwitch: () => void
6
6
  onStay: () => void
7
+ // Overrides for asking which language the course should be in, rather than
8
+ // suggesting an interface switch. Neither answer is the default there, so
9
+ // `equalChoices` gives both buttons the same weight.
10
+ title?: string
11
+ message?: string
12
+ switchLabel?: string
13
+ stayLabel?: string
14
+ equalChoices?: boolean
7
15
  }
8
16
 
9
17
  export default function LanguageDetectionModal({
10
18
  detectedLanguage,
11
19
  onSwitch,
12
20
  onStay,
21
+ title,
22
+ message,
23
+ switchLabel,
24
+ stayLabel,
25
+ equalChoices = false,
13
26
  }: LanguageDetectionModalProps) {
14
27
  const { t, i18n } = useTranslation()
15
28
 
@@ -17,27 +30,34 @@ export default function LanguageDetectionModal({
17
30
  <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-1000">
18
31
  <div className="bg-white p-6 rounded-xl shadow-md max-w-md w-full mx-4">
19
32
  <h2 className="text-xl font-semibold text-center mb-4">
20
- {t("languageDetection.title")}
33
+ {title ?? t("languageDetection.title")}
21
34
  </h2>
22
35
  <p className="text-gray-600 text-center mb-6">
23
- {t("languageDetection.message", {
24
- language: t(`languageDetection.languages.${detectedLanguage.toLowerCase()}`)
25
- })}
36
+ {message ??
37
+ t("languageDetection.message", {
38
+ language: t(`languageDetection.languages.${detectedLanguage.toLowerCase()}`)
39
+ })}
26
40
  </p>
27
41
  <div className="flex flex-col sm:flex-row gap-3">
28
42
  <button
29
43
  onClick={onSwitch}
30
- className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-md font-semibold hover:bg-blue-700 transition-colors"
44
+ className={
45
+ equalChoices
46
+ ? "flex-1 bg-gray-200 text-gray-800 py-2 px-4 rounded-md font-semibold hover:bg-gray-300 transition-colors"
47
+ : "flex-1 bg-blue-600 text-white py-2 px-4 rounded-md font-semibold hover:bg-blue-700 transition-colors"
48
+ }
31
49
  >
32
- {t("languageDetection.switchTo", {
33
- language: t(`languageDetection.languages.${detectedLanguage.toLowerCase()}`)
34
- })}
50
+ {switchLabel ??
51
+ t("languageDetection.switchTo", {
52
+ language: t(`languageDetection.languages.${detectedLanguage.toLowerCase()}`)
53
+ })}
35
54
  </button>
36
55
  <button
37
56
  onClick={onStay}
38
57
  className="flex-1 bg-gray-200 text-gray-800 py-2 px-4 rounded-md font-semibold hover:bg-gray-300 transition-colors"
39
58
  >
40
- {t(`languageDetection.stayIn${i18n.language.toLowerCase().split("-")[0]}`)}
59
+ {stayLabel ??
60
+ t(`languageDetection.stayIn${i18n.language.toLowerCase().split("-")[0]}`)}
41
61
  </button>
42
62
  </div>
43
63
  </div>
@@ -20,6 +20,8 @@ type Props = {
20
20
  setFormState: (formState: any) => void
21
21
  onFinish: () => void
22
22
  hideLastButton?: boolean
23
+ // Shown between Back and Next on every step.
24
+ footer?: React.ReactNode
23
25
  }
24
26
 
25
27
  const StepWizard: React.FC<Props> = ({
@@ -28,6 +30,7 @@ const StepWizard: React.FC<Props> = ({
28
30
  setFormState,
29
31
  onFinish,
30
32
  hideLastButton = false,
33
+ footer,
31
34
  }) => {
32
35
  const { t } = useTranslation()
33
36
  const currentStep = formState.currentStep
@@ -99,20 +102,25 @@ const StepWizard: React.FC<Props> = ({
99
102
  {steps.find((step) => step.slug === currentStep)?.content}
100
103
  </div>
101
104
 
102
- <div className="flex justify-between">
105
+ {/* Three columns keep the footer centered even when Next is hidden. On
106
+ narrow screens it gets its own row under Back and Next. */}
107
+ <div className="grid grid-cols-2 sm:grid-cols-[1fr_auto_1fr] items-center gap-2">
103
108
  <button
104
109
  onClick={goBack}
105
110
  disabled={index === 0}
106
- className={`text-sm text-gray-500 hover:text-gray-900 disabled:opacity-40 ${
111
+ className={`justify-self-start text-sm text-gray-500 hover:text-gray-900 disabled:opacity-40 ${
107
112
  index === 0 ? "cursor-not-allowed" : "cursor-pointer"
108
113
  }`}
109
114
  >
110
115
  ⬅ {t("stepWizard.back")}
111
116
  </button>
117
+ <div className="col-span-2 row-start-2 justify-self-center sm:col-span-1 sm:col-start-2 sm:row-start-1">
118
+ {footer}
119
+ </div>
112
120
  {!(hideLastButton && index === totalSteps - 1) && (
113
121
  <button
114
122
  onClick={index === totalSteps - 1 ? onFinish : goNext}
115
- className={`text-sm text-blue-600 hover:text-blue-800 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed p-2 rounded-md ${
123
+ className={`justify-self-end text-sm text-blue-600 hover:text-blue-800 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed p-2 rounded-md ${
116
124
  index === totalSteps - 1 ? "bg-blue-600 text-white" : ""
117
125
  }`}
118
126
  >
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
3
+ import "../i18n";
4
+ import { Uploader } from "./Uploader";
5
+
6
+ const LINK = {
7
+ url: "https://www.youtube.com/watch?v=abc",
8
+ title: "Bases de datos",
9
+ transcript: "Hoy vamos a ver cómo se diseña una base de datos relacional",
10
+ };
11
+
12
+ // The real uploaders call Rigobot; these stand-ins finish straight away.
13
+ vi.mock("./LinkUploader", () => ({
14
+ default: ({ onResult }: { onResult: (link: typeof LINK) => void }) => (
15
+ <button onClick={() => onResult(LINK)}>mock-link</button>
16
+ ),
17
+ }));
18
+ vi.mock("./FileUploader", () => ({
19
+ default: ({ onFinish }: { onFinish: () => void }) => (
20
+ <button onClick={onFinish}>mock-files</button>
21
+ ),
22
+ }));
23
+
24
+ afterEach(cleanup);
25
+
26
+ describe("Uploader", () => {
27
+ it("hands pasted text over as both the content index and the seed", () => {
28
+ const onFinish = vi.fn();
29
+ render(<Uploader onFinish={onFinish} />);
30
+
31
+ fireEvent.click(screen.getByText("Write or paste a content table"));
32
+ fireEvent.change(screen.getByPlaceholderText("Add your text here..."), {
33
+ target: { value: "1. Tablas\n2. Claves" },
34
+ });
35
+ fireEvent.click(screen.getByText("Finish"));
36
+
37
+ expect(onFinish).toHaveBeenCalledWith(
38
+ "1. Tablas\n2. Claves",
39
+ "1. Tablas\n2. Claves"
40
+ );
41
+ });
42
+
43
+ it("hands a video's transcript over as the seed, outside the instructions", () => {
44
+ const onFinish = vi.fn();
45
+ render(<Uploader onFinish={onFinish} />);
46
+
47
+ fireEvent.click(screen.getByText("Share a Youtube link"));
48
+ fireEvent.click(screen.getByText("mock-link"));
49
+
50
+ expect(onFinish).toHaveBeenCalledWith(
51
+ `The following information comes from a Youtube video uploaded by the user. Use the transcript to create the course: ${JSON.stringify(LINK)}`,
52
+ LINK.transcript
53
+ );
54
+ });
55
+
56
+ it("gives no seed text for files, which are read from the store", () => {
57
+ const onFinish = vi.fn();
58
+ render(<Uploader onFinish={onFinish} />);
59
+
60
+ fireEvent.click(screen.getByText("Upload files"));
61
+ fireEvent.click(screen.getByText("mock-files"));
62
+
63
+ expect(onFinish).toHaveBeenCalledWith("");
64
+ });
65
+ });
@@ -30,7 +30,10 @@ const TextUploader = ({ onFinish }: { onFinish: (text: string) => void }) => {
30
30
  export const Uploader = ({
31
31
  onFinish,
32
32
  }: {
33
- onFinish: (text: string) => void
33
+ // seedText is the readable content behind `text`, used to detect the
34
+ // course language: the pasted text, or a video's transcript without the
35
+ // instructions wrapped around it. Uploaded files are read from the store.
36
+ onFinish: (text: string, seedText?: string) => void
34
37
  }) => {
35
38
  const [selectedOption, setSelectedOption] = useState<string | null>(null)
36
39
  const { t } = useTranslation()
@@ -62,7 +65,9 @@ export const Uploader = ({
62
65
  </>
63
66
  )}
64
67
 
65
- {selectedOption === "text" && <TextUploader onFinish={onFinish} />}
68
+ {selectedOption === "text" && (
69
+ <TextUploader onFinish={(text) => onFinish(text, text)} />
70
+ )}
66
71
 
67
72
  {selectedOption === "files" && (
68
73
  <FileUploader
@@ -80,7 +85,8 @@ export const Uploader = ({
80
85
  onFinish(
81
86
  `The following information comes from a Youtube video uploaded by the user. Use the transcript to create the course: ${JSON.stringify(
82
87
  link
83
- )}`
88
+ )}`,
89
+ link.transcript
84
90
  )
85
91
  }}
86
92
  />
@@ -121,11 +121,18 @@
121
121
  "switchTo": "Yes, switch to {{language}}",
122
122
  "stayInen": "Stay in English",
123
123
  "stayInes": "Stay in Spanish",
124
+ "chooseTitle": "Course language",
125
+ "chooseMessage": "Which language should the course be in?",
124
126
  "languages": {
125
127
  "en": "English",
126
128
  "es": "Spanish"
127
129
  }
128
130
  },
131
+ "courseLanguage": {
132
+ "label": "Course language",
133
+ "auto": "Auto",
134
+ "autoHint": "Uses the language of your files and your description. If they don't match, we'll ask you."
135
+ },
129
136
  "login": {
130
137
  "creatingAccount": "Creating account…",
131
138
  "loggedInSuccessfully": "Logged in successfully",
@@ -121,11 +121,18 @@
121
121
  "switchTo": "Sí, cambiar a {{language}}",
122
122
  "stayInen": "Mantenerse en Inglés",
123
123
  "stayInes": "Mantenerse en Español",
124
+ "chooseTitle": "Idioma del curso",
125
+ "chooseMessage": "¿En qué idioma quieres el curso?",
124
126
  "languages": {
125
127
  "en": "Inglés",
126
128
  "es": "Español"
127
129
  }
128
130
  },
131
+ "courseLanguage": {
132
+ "label": "Idioma del curso",
133
+ "auto": "Automático",
134
+ "autoHint": "Usa el idioma de tus archivos y tu descripción. Si no coinciden, te preguntamos."
135
+ },
129
136
  "login": {
130
137
  "creatingAccount": "Creando cuenta…",
131
138
  "loggedInSuccessfully": "Inicio de sesión exitoso",
@@ -0,0 +1,215 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ decideCourseLanguage,
4
+ detectDescriptionLanguage,
5
+ detectSeedLanguage,
6
+ detectTextLanguage,
7
+ toCourseLanguage,
8
+ } from "./courseLanguage";
9
+
10
+ const ENGLISH =
11
+ "Relational databases store information in tables made of rows and columns. " +
12
+ "Each table describes one kind of thing, such as customers or orders, and the " +
13
+ "relationships between tables are expressed through keys. In this chapter you " +
14
+ "will learn how to write queries that filter, sort and join data, and why " +
15
+ "indexes make those queries fast even when the tables hold millions of rows. ";
16
+
17
+ const SPANISH =
18
+ "Las bases de datos relacionales guardan la información en tablas formadas " +
19
+ "por filas y columnas. Cada tabla describe un tipo de cosa, como clientes o " +
20
+ "pedidos, y las relaciones entre tablas se expresan mediante claves. En este " +
21
+ "capítulo aprenderás a escribir consultas que filtran, ordenan y combinan " +
22
+ "datos, y por qué los índices hacen que esas consultas sean rápidas. ";
23
+
24
+ const PORTUGUESE =
25
+ "Os bancos de dados relacionais armazenam as informações em tabelas formadas " +
26
+ "por linhas e colunas. Cada tabela descreve um tipo de coisa, como clientes ou " +
27
+ "pedidos, e as relações entre as tabelas são expressas por meio de chaves. " +
28
+ "Neste capítulo você vai aprender a escrever consultas que filtram, ordenam e " +
29
+ "combinam dados, e por que os índices tornam essas consultas rápidas. ";
30
+
31
+ const FRENCH =
32
+ "Les bases de données relationnelles stockent les informations dans des " +
33
+ "tables composées de lignes et de colonnes. Chaque table décrit un type de " +
34
+ "chose, comme les clients ou les commandes, et les relations entre les tables " +
35
+ "sont exprimées par des clés. Dans ce chapitre, vous apprendrez à écrire des " +
36
+ "requêtes qui filtrent, trient et combinent des données. ";
37
+
38
+ const ENGLISH_COVER =
39
+ "University of Northfield. School of Engineering. Department of Computer " +
40
+ "Science. Lecture notes for the second year. Copyright 2024, all rights " +
41
+ "reserved. Prepared by the teaching staff for internal use only. ";
42
+
43
+ const ENGLISH_REFERENCES =
44
+ "Codd, E. F. A relational model of data for large shared data banks. " +
45
+ "Communications of the ACM, volume 13, issue 6, pages 377 to 387. " +
46
+ "Date, C. J. An introduction to database systems. Addison Wesley. ";
47
+
48
+ describe("toCourseLanguage", () => {
49
+ it.each([
50
+ ["es", "es"],
51
+ ["en", "en"],
52
+ ["EN", "en"],
53
+ [" es ", "es"],
54
+ ])("accepts %j as %s", (value, expected) => {
55
+ expect(toCourseLanguage(value)).toBe(expected);
56
+ });
57
+
58
+ it.each(["fr", "pt", "es-MX", "en-US", "", "spanish"])(
59
+ "rejects %j",
60
+ (value) => {
61
+ expect(toCourseLanguage(value)).toBeNull();
62
+ }
63
+ );
64
+
65
+ it("rejects a missing value", () => {
66
+ expect(toCourseLanguage(undefined)).toBeNull();
67
+ expect(toCourseLanguage(null)).toBeNull();
68
+ });
69
+ });
70
+
71
+ describe("detectTextLanguage", () => {
72
+ it("detects English and Spanish text", () => {
73
+ expect(detectTextLanguage(ENGLISH.repeat(2))).toBe("en");
74
+ expect(detectTextLanguage(SPANISH.repeat(2))).toBe("es");
75
+ });
76
+
77
+ it("reports other languages instead of passing them off as Spanish or English", () => {
78
+ expect(detectTextLanguage(PORTUGUESE.repeat(2))).toBe("other");
79
+ expect(detectTextLanguage(FRENCH.repeat(2))).toBe("other");
80
+ });
81
+
82
+ it("does not judge text with fewer than 200 letters", () => {
83
+ expect(detectTextLanguage("Python basics for data analysis")).toBeNull();
84
+ });
85
+
86
+ it("does not count digits and symbols as letters", () => {
87
+ // Over 200 characters, but under 200 letters: too little language to judge.
88
+ const text =
89
+ "Las ventas del trimestre por región y por producto son estas: " +
90
+ "| 2024 | 15.30 | 7 | 0.25 | 1999 | 48.10 | 3 | 0.75 |\n".repeat(6);
91
+ expect(text.length).toBeGreaterThan(200);
92
+ expect(detectTextLanguage(text)).toBeNull();
93
+ });
94
+
95
+ it("is not swayed by an English cover page on a Spanish document", () => {
96
+ const text = ENGLISH_COVER + SPANISH.repeat(20);
97
+ expect(detectTextLanguage(text)).toBe("es");
98
+ });
99
+
100
+ it("is not swayed by a long English bibliography after a Spanish body", () => {
101
+ // The body fills the first five windows, so the references never vote.
102
+ const text = SPANISH.repeat(40) + ENGLISH_REFERENCES.repeat(200);
103
+ expect(detectTextLanguage(text)).toBe("es");
104
+ });
105
+
106
+ it("does not guess when no language holds two thirds of the windows", () => {
107
+ // About half of the first five windows are Spanish, half English.
108
+ const text = SPANISH.repeat(14) + ENGLISH.repeat(40);
109
+ expect(detectTextLanguage(text)).toBeNull();
110
+ });
111
+ });
112
+
113
+ describe("detectSeedLanguage", () => {
114
+ const en = ENGLISH.repeat(2);
115
+ const es = SPANISH.repeat(2);
116
+ const pt = PORTUGUESE.repeat(2);
117
+
118
+ it("returns the language all sources share", () => {
119
+ expect(detectSeedLanguage([en])).toBe("en");
120
+ expect(detectSeedLanguage([es, es])).toBe("es");
121
+ });
122
+
123
+ it("reports sources that disagree instead of letting the longest win", () => {
124
+ expect(detectSeedLanguage([en.repeat(10), es])).toBe("mixed");
125
+ });
126
+
127
+ it("ignores sources too short to judge", () => {
128
+ expect(detectSeedLanguage([en, "Notas"])).toBe("en");
129
+ });
130
+
131
+ it("settles nothing when any source is in an unsupported language", () => {
132
+ expect(detectSeedLanguage([en, pt])).toBeNull();
133
+ });
134
+
135
+ it("settles nothing without judgeable sources", () => {
136
+ expect(detectSeedLanguage([])).toBeNull();
137
+ expect(detectSeedLanguage(["", "Notas"])).toBeNull();
138
+ });
139
+ });
140
+
141
+ describe("detectDescriptionLanguage", () => {
142
+ it.each([
143
+ "Learn TypeScript generics",
144
+ "Write better SQL queries",
145
+ "How to use pandas DataFrames",
146
+ "I want to learn how to build REST APIs with Flask",
147
+ "Teach the API documentation of our payment service",
148
+ ])("reads %j as English", (description) => {
149
+ expect(detectDescriptionLanguage(description)).toBe("en");
150
+ });
151
+
152
+ it.each([
153
+ "Curso de React hooks",
154
+ "Quiero aprender a construir APIs REST con Flask",
155
+ "Álgebra lineal para machine learning",
156
+ "Pipelines de CI/CD con GitHub Actions",
157
+ "¿Cómo funciona Docker?",
158
+ ])("reads %j as Spanish", (description) => {
159
+ expect(detectDescriptionLanguage(description)).toBe("es");
160
+ });
161
+
162
+ it.each([
163
+ "React hooks",
164
+ "Kubernetes",
165
+ "Data science con python course for beginners",
166
+ "",
167
+ ])("cannot tell %j", (description) => {
168
+ expect(detectDescriptionLanguage(description)).toBeNull();
169
+ });
170
+ });
171
+
172
+ describe("decideCourseLanguage", () => {
173
+ it.each([
174
+ // The user's choice wins over everything, with or without a seed.
175
+ [
176
+ { chosen: "es", seed: null, description: null },
177
+ { kind: "use", language: "es" },
178
+ ],
179
+ [
180
+ { chosen: "es", seed: "en", description: "en" },
181
+ { kind: "use", language: "es" },
182
+ ],
183
+ [
184
+ { chosen: "en", seed: "mixed", description: "es" },
185
+ { kind: "use", language: "en" },
186
+ ],
187
+ // Without a seed nothing is settled: the request goes out as before.
188
+ [{ chosen: null, seed: null, description: null }, { kind: "model" }],
189
+ [{ chosen: null, seed: null, description: "es" }, { kind: "model" }],
190
+ // The seed is used when the description agrees or cannot be judged.
191
+ [
192
+ { chosen: null, seed: "en", description: "en" },
193
+ { kind: "use", language: "en" },
194
+ ],
195
+ [
196
+ { chosen: null, seed: "en", description: null },
197
+ { kind: "use", language: "en" },
198
+ ],
199
+ [
200
+ { chosen: null, seed: "es", description: "es" },
201
+ { kind: "use", language: "es" },
202
+ ],
203
+ [
204
+ { chosen: null, seed: "es", description: null },
205
+ { kind: "use", language: "es" },
206
+ ],
207
+ // Any disagreement is the user's call.
208
+ [{ chosen: null, seed: "en", description: "es" }, { kind: "ask" }],
209
+ [{ chosen: null, seed: "es", description: "en" }, { kind: "ask" }],
210
+ [{ chosen: null, seed: "mixed", description: null }, { kind: "ask" }],
211
+ [{ chosen: null, seed: "mixed", description: "en" }, { kind: "ask" }],
212
+ ] as const)("%j decides %j", (input, expected) => {
213
+ expect(decideCourseLanguage(input)).toEqual(expected);
214
+ });
215
+ });
@@ -0,0 +1,173 @@
1
+ import { franc } from "franc";
2
+
3
+ export type TCourseLanguage = "en" | "es";
4
+
5
+ /**
6
+ * Narrows a language code to one the creator can build a course in.
7
+ * @param value - A code such as "es", "EN" or an i18next resolved language.
8
+ * @returns "en" or "es", or null for anything else (including "es-MX").
9
+ */
10
+ export const toCourseLanguage = (
11
+ value: string | null | undefined
12
+ ): TCourseLanguage | null => {
13
+ const code = value?.trim().toLowerCase();
14
+ return code === "en" || code === "es" ? code : null;
15
+ };
16
+
17
+ // franc only reads the first 2048 characters of whatever it is given, so long
18
+ // text is judged window by window.
19
+ const WINDOW_LENGTH = 2048;
20
+ // Enough windows that a cover page or front matter cannot decide alone, few
21
+ // enough that an English bibliography at the end of a Spanish document cannot
22
+ // outvote its body.
23
+ const MAX_WINDOWS = 5;
24
+ // Below this many letters, titles, code and tables misclassify. Digits and
25
+ // symbols are not counted because they carry no language.
26
+ const MIN_LETTERS = 200;
27
+ // A window shorter than this at the end of the text is too small to vote.
28
+ const MIN_WINDOW_LENGTH = 200;
29
+ const MAJORITY = 2 / 3;
30
+
31
+ const countLetters = (text: string) => (text.match(/\p{L}/gu) || []).length;
32
+
33
+ /**
34
+ * Detects whether a piece of seed text is English or Spanish.
35
+ *
36
+ * Votes over the first few 2048-character windows and only answers on a
37
+ * two-thirds majority, so mixed or ambiguous text comes back as null rather
38
+ * than as a guess. Windows in any other language vote too, so a Portuguese
39
+ * or French document is not passed off as Spanish or English.
40
+ * @param text - Text extracted from an uploaded file, pasted text or a
41
+ * transcript.
42
+ * @returns "en" or "es", "other" when the text is mostly in a language the
43
+ * creator does not support, or null when there is too little text or no clear
44
+ * majority.
45
+ */
46
+ export const detectTextLanguage = (
47
+ text: string
48
+ ): TCourseLanguage | "other" | null => {
49
+ if (countLetters(text) < MIN_LETTERS) return null;
50
+
51
+ let english = 0;
52
+ let spanish = 0;
53
+ let other = 0;
54
+ let windows = 0;
55
+ for (
56
+ let start = 0;
57
+ start < text.length && windows < MAX_WINDOWS;
58
+ start += WINDOW_LENGTH
59
+ ) {
60
+ const window = text.slice(start, start + WINDOW_LENGTH);
61
+ if (start > 0 && window.length < MIN_WINDOW_LENGTH) continue;
62
+ windows++;
63
+ const lang = franc(window);
64
+ if (lang === "eng") english++;
65
+ else if (lang === "spa") spanish++;
66
+ else if (lang !== "und") other++;
67
+ }
68
+
69
+ const votes = english + spanish + other;
70
+ if (votes === 0) return null;
71
+ if (english / votes >= MAJORITY) return "en";
72
+ if (spanish / votes >= MAJORITY) return "es";
73
+ if (other / votes >= MAJORITY) return "other";
74
+ return null;
75
+ };
76
+
77
+ export type TSeedLanguage = TCourseLanguage | "mixed" | null;
78
+
79
+ /**
80
+ * Detects the language of everything the user gave as course content, judging
81
+ * each source separately so one long file cannot outvote a short one.
82
+ * @param seeds - The text of each uploaded file, pasted text or transcript.
83
+ * @returns The shared language, "mixed" when confidently detected sources
84
+ * disagree, or null when none could be judged or any source is in a language
85
+ * the creator does not support (the model then picks, as before).
86
+ */
87
+ export const detectSeedLanguage = (seeds: string[]): TSeedLanguage => {
88
+ const detected = seeds.map(detectTextLanguage);
89
+ if (detected.includes("other")) return null;
90
+ const languages = new Set(
91
+ detected.filter((lang): lang is TCourseLanguage => lang !== null)
92
+ );
93
+ if (languages.size === 0) return null;
94
+ if (languages.size > 1) return "mixed";
95
+ return [...languages][0];
96
+ };
97
+
98
+ // Words that exist in only one of the two languages. Ambiguous ones such as
99
+ // "a", "me", "no" or "son" are left out on purpose.
100
+ const ENGLISH_ONLY_WORDS = new Set(
101
+ "the to and of for with how what why when your my our this that these those is are was be into from about on in an i we you want learn teach build create make use using write explain help course lesson beginners introduction basics guide".split(
102
+ " "
103
+ )
104
+ );
105
+ const SPANISH_ONLY_WORDS = new Set(
106
+ "el la los las del de para con por sobre cómo como qué que y en un una unos unas mi mis nuestro nuestra nuestros es son está quiero aprender aprende enseña enseñar crear crea construir usar usando escribir explica ayúdame curso lección principiantes introducción básico básica guía desde".split(
107
+ " "
108
+ )
109
+ );
110
+ const SPANISH_CHARACTERS = /[ñáéíóú¿¡]/i;
111
+
112
+ /**
113
+ * Detects the language a course description was written in.
114
+ *
115
+ * Descriptions are a few words long and full of technical terms, which
116
+ * statistical detection misreads ("Learn TypeScript generics" comes out as
117
+ * Spanish). Counting words that only exist in one language is reliable at
118
+ * that length, and answering only when the other language shows no sign at
119
+ * all keeps mixed descriptions out.
120
+ * @param description - The course description from the wizard.
121
+ * @returns "en" or "es", or null when the description is mixed or has no
122
+ * telling words.
123
+ */
124
+ export const detectDescriptionLanguage = (
125
+ description: string
126
+ ): TCourseLanguage | null => {
127
+ const words = description.toLowerCase().match(/\p{L}+/gu) || [];
128
+ let english = 0;
129
+ let spanish = 0;
130
+ for (const word of words) {
131
+ if (ENGLISH_ONLY_WORDS.has(word)) english++;
132
+ if (SPANISH_ONLY_WORDS.has(word)) spanish++;
133
+ }
134
+ if (SPANISH_CHARACTERS.test(description)) spanish++;
135
+
136
+ if (english > 0 && spanish === 0) return "en";
137
+ if (spanish > 0 && english === 0) return "es";
138
+ return null;
139
+ };
140
+
141
+ export type TCourseLanguageDecision =
142
+ // Nothing settles the language: the request goes out exactly as before and
143
+ // the model picks.
144
+ | { kind: "model" }
145
+ | { kind: "use"; language: TCourseLanguage }
146
+ // Only the user can say which language they meant.
147
+ | { kind: "ask" };
148
+
149
+ /**
150
+ * Decides the course language before generation.
151
+ *
152
+ * A language the user chose always wins. Otherwise the seed's language is used
153
+ * when the description agrees or cannot be judged, and any disagreement is put
154
+ * to the user. A misread description can therefore only cause a question,
155
+ * never a course in the wrong language.
156
+ */
157
+ export const decideCourseLanguage = ({
158
+ chosen,
159
+ seed,
160
+ description,
161
+ }: {
162
+ chosen: TCourseLanguage | null;
163
+ seed: TSeedLanguage;
164
+ description: TCourseLanguage | null;
165
+ }): TCourseLanguageDecision => {
166
+ if (chosen) return { kind: "use", language: chosen };
167
+ if (seed === null) return { kind: "model" };
168
+ if (seed === "mixed") return { kind: "ask" };
169
+ if (description === null || description === seed) {
170
+ return { kind: "use", language: seed };
171
+ }
172
+ return { kind: "ask" };
173
+ };