@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.
- package/lib/commands/publish.d.ts +0 -10
- package/lib/commands/publish.js +52 -168
- package/lib/commands/serve.js +52 -116
- package/lib/creatorDist/assets/{index-zrPponAn.css → index-B7s5Nmq8.css} +59 -0
- package/lib/creatorDist/assets/{index-D6pmbMe9.js → index-GYxGB_X0.js} +22609 -22363
- package/lib/creatorDist/index.html +2 -2
- 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/creator/src/App.test.tsx +338 -0
- package/src/creator/src/App.tsx +152 -26
- package/src/creator/src/components/CourseLanguagePicker.tsx +51 -0
- package/src/creator/src/components/LanguageDetectionModal.tsx +29 -9
- package/src/creator/src/components/StepWizard.tsx +11 -3
- package/src/creator/src/components/Uploader.test.tsx +65 -0
- package/src/creator/src/components/Uploader.tsx +9 -3
- package/src/creator/src/locales/en.json +7 -0
- package/src/creator/src/locales/es.json +7 -0
- package/src/creator/src/utils/courseLanguage.test.ts +215 -0
- package/src/creator/src/utils/courseLanguage.ts +173 -0
- package/src/creator/src/utils/creationRequest.test.ts +104 -0
- package/src/creator/src/utils/creationRequest.ts +63 -0
- package/src/creator/src/utils/store.ts +7 -0
- package/src/creatorDist/assets/{index-zrPponAn.css → index-B7s5Nmq8.css} +59 -0
- package/src/creatorDist/assets/{index-D6pmbMe9.js → index-GYxGB_X0.js} +22609 -22363
- package/src/creatorDist/index.html +2 -2
- package/src/ui/_app/app.css +1 -1
- package/src/ui/_app/app.js +2105 -2105
- package/src/ui/_app/sw.js +13 -0
- 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
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
act,
|
|
4
|
+
cleanup,
|
|
5
|
+
fireEvent,
|
|
6
|
+
render,
|
|
7
|
+
screen,
|
|
8
|
+
waitFor,
|
|
9
|
+
} from "@testing-library/react";
|
|
10
|
+
import { MemoryRouter } from "react-router";
|
|
11
|
+
import i18n from "./i18n";
|
|
12
|
+
import App from "./App";
|
|
13
|
+
import useStore, { FormState } from "./utils/store";
|
|
14
|
+
import { publicInteractiveCreation } from "./utils/rigo";
|
|
15
|
+
import type { ParsedFile } from "./components/FileUploader";
|
|
16
|
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
17
|
+
|
|
18
|
+
// Network edges only: everything else (store, i18n, wizard, detection) is real.
|
|
19
|
+
vi.mock("./utils/rigo", () => ({
|
|
20
|
+
publicInteractiveCreation: vi.fn(async () => ({ notificationId: "job-1" })),
|
|
21
|
+
isHuman: vi.fn(),
|
|
22
|
+
}));
|
|
23
|
+
vi.mock("./utils/lib", async (importOriginal) => ({
|
|
24
|
+
...(await importOriginal<typeof import("./utils/lib")>()),
|
|
25
|
+
isValidPublicToken: vi.fn(async () => true),
|
|
26
|
+
isValidRigoToken: vi.fn(async () => false),
|
|
27
|
+
getTechnologies: vi.fn(async () => []),
|
|
28
|
+
loginWithToken: vi.fn(),
|
|
29
|
+
}));
|
|
30
|
+
let deliverNotification: ((payload: unknown) => void) | null = null;
|
|
31
|
+
vi.mock("./components/NotificationListener", () => ({
|
|
32
|
+
default: ({ onNotification }: { onNotification: (p: unknown) => void }) => {
|
|
33
|
+
deliverNotification = onNotification;
|
|
34
|
+
return null;
|
|
35
|
+
},
|
|
36
|
+
}));
|
|
37
|
+
vi.mock("./components/TurnstileChallenge", () => ({ default: () => null }));
|
|
38
|
+
vi.mock("./components/TurnstileModal", () => ({ default: () => null }));
|
|
39
|
+
|
|
40
|
+
const ENGLISH_SEED =
|
|
41
|
+
"Relational databases store information in tables made of rows and columns. " +
|
|
42
|
+
"Each table describes one kind of thing, such as customers or orders, and the " +
|
|
43
|
+
"relationships between tables are expressed through keys. In this chapter you " +
|
|
44
|
+
"will learn how to write queries that filter, sort and join data. ";
|
|
45
|
+
const SPANISH_SEED =
|
|
46
|
+
"Las bases de datos relacionales guardan la información en tablas formadas " +
|
|
47
|
+
"por filas y columnas. Cada tabla describe un tipo de cosa, como clientes o " +
|
|
48
|
+
"pedidos, y las relaciones entre tablas se expresan mediante claves. En este " +
|
|
49
|
+
"capítulo aprenderás a escribir consultas que filtran y combinan datos. ";
|
|
50
|
+
|
|
51
|
+
const file = (text: string, status: ParsedFile["status"] = "SUCCESS"): ParsedFile => ({
|
|
52
|
+
name: "seed.pdf",
|
|
53
|
+
text,
|
|
54
|
+
status,
|
|
55
|
+
notificationId: "",
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const sentCourseInfo = () => {
|
|
59
|
+
const calls = vi.mocked(publicInteractiveCreation).mock.calls;
|
|
60
|
+
expect(calls).toHaveLength(1);
|
|
61
|
+
return calls[0][0].courseInfo;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const ASK_MESSAGE = "Which language should the course be in?";
|
|
65
|
+
|
|
66
|
+
const setUp = async ({
|
|
67
|
+
form = {},
|
|
68
|
+
files = [],
|
|
69
|
+
url = "/creator",
|
|
70
|
+
interfaceLanguage = "en",
|
|
71
|
+
}: {
|
|
72
|
+
form?: Partial<FormState>;
|
|
73
|
+
files?: ParsedFile[];
|
|
74
|
+
url?: string;
|
|
75
|
+
interfaceLanguage?: string;
|
|
76
|
+
}) => {
|
|
77
|
+
localStorage.clear();
|
|
78
|
+
window.history.replaceState({}, "", url);
|
|
79
|
+
useStore.setState(useStore.getInitialState(), true);
|
|
80
|
+
const store = useStore.getState();
|
|
81
|
+
store.setAuth({ ...store.auth, publicToken: "public-token" });
|
|
82
|
+
store.setFormState({ description: "React hooks", purpose: "learnpack-lesson-writer", duration: 30, ...form });
|
|
83
|
+
store.setUploadedFiles(files);
|
|
84
|
+
await act(() => i18n.changeLanguage(interfaceLanguage));
|
|
85
|
+
// Mounted like main.tsx does.
|
|
86
|
+
render(
|
|
87
|
+
<TooltipProvider>
|
|
88
|
+
<MemoryRouter>
|
|
89
|
+
<App />
|
|
90
|
+
</MemoryRouter>
|
|
91
|
+
</TooltipProvider>
|
|
92
|
+
);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** What the wizard does when its last step is answered. */
|
|
96
|
+
const finishWizard = () =>
|
|
97
|
+
act(() => useStore.getState().setFormState({ isCompleted: true }));
|
|
98
|
+
|
|
99
|
+
beforeEach(() => {
|
|
100
|
+
vi.mocked(publicInteractiveCreation).mockClear();
|
|
101
|
+
deliverNotification = null;
|
|
102
|
+
});
|
|
103
|
+
afterEach(cleanup);
|
|
104
|
+
|
|
105
|
+
describe("course language at generation", () => {
|
|
106
|
+
it("follows an English seed under a Spanish interface (the reported bug)", async () => {
|
|
107
|
+
await setUp({
|
|
108
|
+
form: { description: "Learn how to document REST APIs", hasContentIndex: true },
|
|
109
|
+
files: [file(ENGLISH_SEED)],
|
|
110
|
+
interfaceLanguage: "es",
|
|
111
|
+
});
|
|
112
|
+
finishWizard();
|
|
113
|
+
|
|
114
|
+
await waitFor(() => expect(publicInteractiveCreation).toHaveBeenCalled());
|
|
115
|
+
expect(screen.queryByText("¿En qué idioma quieres el curso?")).toBeNull();
|
|
116
|
+
expect(sentCourseInfo()).toContain('"language":"en"');
|
|
117
|
+
expect(sentCourseInfo()).toContain("The user chose English as the course language.");
|
|
118
|
+
|
|
119
|
+
// The model still answers Spanish: the settled language wins.
|
|
120
|
+
act(() =>
|
|
121
|
+
deliverNotification!({
|
|
122
|
+
status: "SUCCESS",
|
|
123
|
+
parsed: {
|
|
124
|
+
title: "Documentar APIs REST",
|
|
125
|
+
description: "Un curso.",
|
|
126
|
+
languageCode: "es",
|
|
127
|
+
technologies: [],
|
|
128
|
+
aiMessage: "Listo.",
|
|
129
|
+
listOfSteps: ["00.0 - Welcome [READ: ...]"],
|
|
130
|
+
},
|
|
131
|
+
})
|
|
132
|
+
);
|
|
133
|
+
const { courseInfo } = useStore.getState().history[0];
|
|
134
|
+
expect(courseInfo.language).toBe("en");
|
|
135
|
+
expect("languageChosen" in courseInfo).toBe(false);
|
|
136
|
+
expect(i18n.language).toBe("en");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("asks when the seed and the description disagree, and sends the answer", async () => {
|
|
140
|
+
await setUp({
|
|
141
|
+
form: { description: "Quiero aprender a documentar APIs REST", hasContentIndex: true },
|
|
142
|
+
files: [file(ENGLISH_SEED)],
|
|
143
|
+
});
|
|
144
|
+
finishWizard();
|
|
145
|
+
|
|
146
|
+
expect(await screen.findByText(ASK_MESSAGE)).toBeTruthy();
|
|
147
|
+
expect(publicInteractiveCreation).not.toHaveBeenCalled();
|
|
148
|
+
|
|
149
|
+
fireEvent.click(screen.getByText("Spanish"));
|
|
150
|
+
|
|
151
|
+
await waitFor(() => expect(publicInteractiveCreation).toHaveBeenCalled());
|
|
152
|
+
expect(sentCourseInfo()).toContain('"language":"es"');
|
|
153
|
+
expect(sentCourseInfo()).toContain("The user chose Spanish as the course language.");
|
|
154
|
+
expect(useStore.getState().formState).toMatchObject({
|
|
155
|
+
language: "es",
|
|
156
|
+
languageChosen: true,
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("asks when uploaded files disagree with each other", async () => {
|
|
161
|
+
await setUp({
|
|
162
|
+
form: { description: "React hooks", hasContentIndex: true },
|
|
163
|
+
files: [file(ENGLISH_SEED), file(SPANISH_SEED)],
|
|
164
|
+
});
|
|
165
|
+
finishWizard();
|
|
166
|
+
|
|
167
|
+
expect(await screen.findByText(ASK_MESSAGE)).toBeTruthy();
|
|
168
|
+
expect(publicInteractiveCreation).not.toHaveBeenCalled();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("reads pasted text as the seed", async () => {
|
|
172
|
+
await setUp({
|
|
173
|
+
form: {
|
|
174
|
+
description: "Learn how to design databases",
|
|
175
|
+
hasContentIndex: true,
|
|
176
|
+
currentStep: "contentIndex",
|
|
177
|
+
variables: ["description", "contentIndex"],
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
fireEvent.click(screen.getByText("Write or paste a content table"));
|
|
182
|
+
fireEvent.change(screen.getByPlaceholderText("Add your text here..."), {
|
|
183
|
+
target: { value: SPANISH_SEED },
|
|
184
|
+
});
|
|
185
|
+
fireEvent.click(screen.getByText("Finish"));
|
|
186
|
+
|
|
187
|
+
expect(await screen.findByText(ASK_MESSAGE)).toBeTruthy();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("ignores files that are still being read", async () => {
|
|
191
|
+
await setUp({
|
|
192
|
+
form: { description: "React hooks", hasContentIndex: true },
|
|
193
|
+
files: [file(ENGLISH_SEED), file(SPANISH_SEED, "PROCESSING")],
|
|
194
|
+
});
|
|
195
|
+
finishWizard();
|
|
196
|
+
|
|
197
|
+
await waitFor(() => expect(publicInteractiveCreation).toHaveBeenCalled());
|
|
198
|
+
expect(sentCourseInfo()).toContain("The user chose English as the course language.");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("leaves the request untouched when the user said there is no content", async () => {
|
|
202
|
+
// Files left over from an earlier attempt are not the user's seed.
|
|
203
|
+
await setUp({
|
|
204
|
+
form: { description: "Quiero aprender SQL", hasContentIndex: false },
|
|
205
|
+
files: [file(ENGLISH_SEED)],
|
|
206
|
+
});
|
|
207
|
+
finishWizard();
|
|
208
|
+
|
|
209
|
+
await waitFor(() => expect(publicInteractiveCreation).toHaveBeenCalled());
|
|
210
|
+
expect(screen.queryByText(ASK_MESSAGE)).toBeNull();
|
|
211
|
+
expect(sentCourseInfo()).not.toContain("as the course language");
|
|
212
|
+
expect(sentCourseInfo()).not.toContain("languageChosen");
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("course language picker", () => {
|
|
217
|
+
it("starts on Auto", async () => {
|
|
218
|
+
await setUp({});
|
|
219
|
+
expect((screen.getByRole("combobox") as HTMLSelectElement).value).toBe("auto");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("wins over the seed without asking", async () => {
|
|
223
|
+
await setUp({
|
|
224
|
+
form: { description: "Learn how to document REST APIs", hasContentIndex: true },
|
|
225
|
+
files: [file(ENGLISH_SEED)],
|
|
226
|
+
});
|
|
227
|
+
fireEvent.change(screen.getByRole("combobox"), { target: { value: "es" } });
|
|
228
|
+
expect(i18n.language).toBe("es");
|
|
229
|
+
finishWizard();
|
|
230
|
+
|
|
231
|
+
await waitFor(() => expect(publicInteractiveCreation).toHaveBeenCalled());
|
|
232
|
+
expect(screen.queryByText("¿En qué idioma quieres el curso?")).toBeNull();
|
|
233
|
+
expect(sentCourseInfo()).toContain("The user chose Spanish as the course language.");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("explains Auto in a hint", async () => {
|
|
237
|
+
await setUp({});
|
|
238
|
+
fireEvent.focus(screen.getByRole("combobox"));
|
|
239
|
+
|
|
240
|
+
expect(
|
|
241
|
+
(await screen.findAllByText(i18n.t("courseLanguage.autoHint"))).length
|
|
242
|
+
).toBeGreaterThan(0);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("hides the hint once a language is picked", async () => {
|
|
246
|
+
await setUp({});
|
|
247
|
+
fireEvent.change(screen.getByRole("combobox"), { target: { value: "en" } });
|
|
248
|
+
fireEvent.focus(screen.getByRole("combobox"));
|
|
249
|
+
|
|
250
|
+
expect(screen.queryAllByText(i18n.t("courseLanguage.autoHint"))).toHaveLength(0);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("goes back to the defaults on Auto", async () => {
|
|
254
|
+
await setUp({});
|
|
255
|
+
fireEvent.change(screen.getByRole("combobox"), { target: { value: "es" } });
|
|
256
|
+
fireEvent.change(screen.getByRole("combobox"), { target: { value: "auto" } });
|
|
257
|
+
|
|
258
|
+
expect(useStore.getState().formState).toMatchObject({
|
|
259
|
+
language: "en",
|
|
260
|
+
languageChosen: false,
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe("language URL param", () => {
|
|
266
|
+
it("counts as the user's choice", async () => {
|
|
267
|
+
await setUp({
|
|
268
|
+
url: "/creator?language=es",
|
|
269
|
+
form: { description: "Learn how to document REST APIs", hasContentIndex: true },
|
|
270
|
+
files: [file(ENGLISH_SEED)],
|
|
271
|
+
});
|
|
272
|
+
expect(useStore.getState().formState).toMatchObject({
|
|
273
|
+
language: "es",
|
|
274
|
+
languageChosen: true,
|
|
275
|
+
});
|
|
276
|
+
finishWizard();
|
|
277
|
+
|
|
278
|
+
await waitFor(() => expect(publicInteractiveCreation).toHaveBeenCalled());
|
|
279
|
+
expect(sentCourseInfo()).toContain("The user chose Spanish as the course language.");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it.each(["fr", "es-MX"])("ignores the unsupported value %j", async (value) => {
|
|
283
|
+
await setUp({ url: `/creator?language=${value}` });
|
|
284
|
+
expect(useStore.getState().formState).toMatchObject({
|
|
285
|
+
language: "en",
|
|
286
|
+
languageChosen: false,
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("accepts an uppercase value", async () => {
|
|
291
|
+
await setUp({ url: "/creator?language=ES" });
|
|
292
|
+
expect(useStore.getState().formState).toMatchObject({
|
|
293
|
+
language: "es",
|
|
294
|
+
languageChosen: true,
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe("description language modal", () => {
|
|
300
|
+
const typeDescriptionAndGoNext = (description: string) => {
|
|
301
|
+
fireEvent.change(screen.getByPlaceholderText(i18n.t("stepWizard.descriptionPlaceholder")), {
|
|
302
|
+
target: { value: description },
|
|
303
|
+
});
|
|
304
|
+
fireEvent.click(screen.getByText(i18n.t("stepWizard.next")));
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
it("still suggests Spanish for a Spanish description", async () => {
|
|
308
|
+
await setUp({ form: { description: "" } });
|
|
309
|
+
typeDescriptionAndGoNext("Quiero aprender a crear APIs con Flask");
|
|
310
|
+
expect(screen.getByText("Language detected")).toBeTruthy();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("records staying as a choice", async () => {
|
|
314
|
+
await setUp({ form: { description: "" } });
|
|
315
|
+
typeDescriptionAndGoNext("Quiero aprender a crear APIs con Flask");
|
|
316
|
+
fireEvent.click(screen.getByText("Stay in English"));
|
|
317
|
+
|
|
318
|
+
expect(useStore.getState().formState).toMatchObject({
|
|
319
|
+
language: "en",
|
|
320
|
+
languageChosen: true,
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("does not ask again once a language is chosen", async () => {
|
|
325
|
+
await setUp({ form: { description: "" } });
|
|
326
|
+
fireEvent.change(screen.getByRole("combobox"), { target: { value: "en" } });
|
|
327
|
+
typeDescriptionAndGoNext("Quiero aprender a crear APIs con Flask");
|
|
328
|
+
expect(screen.queryByText("Language detected")).toBeNull();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("does not offer English to an en-US interface", async () => {
|
|
332
|
+
await setUp({
|
|
333
|
+
url: "/creator?description=Learn%20how%20to%20build%20REST%20APIs%20with%20Flask",
|
|
334
|
+
interfaceLanguage: "en-US",
|
|
335
|
+
});
|
|
336
|
+
expect(screen.queryByText("Language detected")).toBeNull();
|
|
337
|
+
});
|
|
338
|
+
});
|
package/src/creator/src/App.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useState } from "react"
|
|
1
|
+
import { useEffect, useRef, useState } from "react"
|
|
2
2
|
import StepWizard from "./components/StepWizard"
|
|
3
3
|
import SelectableCard from "./components/SelectableCard"
|
|
4
4
|
import Loader from "./components/Loader"
|
|
@@ -39,6 +39,17 @@ import TurnstileModal from "./components/TurnstileModal"
|
|
|
39
39
|
import { TMessage } from "./components/Message"
|
|
40
40
|
import { Lesson } from "./components/LessonItem"
|
|
41
41
|
import LanguageDetectionModal from "./components/LanguageDetectionModal"
|
|
42
|
+
import CourseLanguagePicker, {
|
|
43
|
+
TCourseLanguageChoice,
|
|
44
|
+
} from "./components/CourseLanguagePicker"
|
|
45
|
+
import {
|
|
46
|
+
decideCourseLanguage,
|
|
47
|
+
detectDescriptionLanguage,
|
|
48
|
+
detectSeedLanguage,
|
|
49
|
+
TCourseLanguage,
|
|
50
|
+
toCourseLanguage,
|
|
51
|
+
} from "./utils/courseLanguage"
|
|
52
|
+
import { buildCourseInfo, toCourseFormState } from "./utils/creationRequest"
|
|
42
53
|
|
|
43
54
|
function App() {
|
|
44
55
|
const navigate = useNavigate()
|
|
@@ -82,6 +93,19 @@ function App() {
|
|
|
82
93
|
const [showTurnstileModal, setShowTurnstileModal] = useState(false)
|
|
83
94
|
const [showLanguageDetectionModal, setShowLanguageDetectionModal] = useState(false)
|
|
84
95
|
const [detectedLanguage, setDetectedLanguage] = useState<string>("")
|
|
96
|
+
const [showCourseLanguageModal, setShowCourseLanguageModal] = useState(false)
|
|
97
|
+
// Readable content behind the last content-index answer (pasted text or a
|
|
98
|
+
// video transcript), for detecting the course language. Uploaded files are
|
|
99
|
+
// read from the store instead.
|
|
100
|
+
const seedTextRef = useRef<string | null>(null)
|
|
101
|
+
// Course language settled when generation is fired, reused when the result
|
|
102
|
+
// arrives so the stored language matches the one that was sent.
|
|
103
|
+
const courseLanguageRef = useRef<TCourseLanguage | null>(null)
|
|
104
|
+
|
|
105
|
+
// i18next's resolved language is always one with translations, so this is
|
|
106
|
+
// "en" for an "en-US" or a French browser alike.
|
|
107
|
+
const getInterfaceLanguage = (): TCourseLanguage =>
|
|
108
|
+
toCourseLanguage(i18n.resolvedLanguage) ?? "en"
|
|
85
109
|
|
|
86
110
|
useEffect(() => {
|
|
87
111
|
if (formState.isCompleted) {
|
|
@@ -162,24 +186,31 @@ function App() {
|
|
|
162
186
|
}
|
|
163
187
|
|
|
164
188
|
// Language detection logic
|
|
165
|
-
|
|
189
|
+
// Only a language the creator supports counts, because it is now the
|
|
190
|
+
// user's choice and wins over detection and the model.
|
|
191
|
+
const urlLanguage = toCourseLanguage(language)
|
|
166
192
|
|
|
167
193
|
// Change if query param is provided
|
|
168
|
-
if (
|
|
169
|
-
console.log("LANGUAGE",
|
|
194
|
+
if (urlLanguage) {
|
|
195
|
+
console.log("LANGUAGE", urlLanguage)
|
|
170
196
|
setFormState({
|
|
171
|
-
language:
|
|
197
|
+
language: urlLanguage,
|
|
198
|
+
languageChosen: true,
|
|
172
199
|
})
|
|
173
|
-
if (i18n.language !==
|
|
174
|
-
i18n.changeLanguage(
|
|
200
|
+
if (i18n.language !== urlLanguage) {
|
|
201
|
+
i18n.changeLanguage(urlLanguage)
|
|
175
202
|
}
|
|
176
203
|
}
|
|
177
204
|
|
|
178
|
-
// If no language is provided, detect the language of
|
|
179
|
-
|
|
205
|
+
// If no language is provided or already chosen, detect the language of
|
|
206
|
+
// the description
|
|
207
|
+
if (
|
|
208
|
+
!urlLanguage &&
|
|
209
|
+
description &&
|
|
210
|
+
!useStore.getState().formState.languageChosen
|
|
211
|
+
) {
|
|
180
212
|
const detectedLang = detectLanguage(description)
|
|
181
|
-
if (detectedLang && detectedLang !==
|
|
182
|
-
finalLanguage = detectedLang
|
|
213
|
+
if (detectedLang && detectedLang !== getInterfaceLanguage()) {
|
|
183
214
|
setDetectedLanguage(detectedLang)
|
|
184
215
|
setShowLanguageDetectionModal(true)
|
|
185
216
|
}
|
|
@@ -213,8 +244,37 @@ function App() {
|
|
|
213
244
|
}
|
|
214
245
|
}
|
|
215
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Settles the course language from the user's choice or the content they
|
|
249
|
+
* gave. Reads the store directly because it also runs right after a language
|
|
250
|
+
* answer, before this render's formState has caught up.
|
|
251
|
+
*/
|
|
252
|
+
const decideLanguageForGeneration = () => {
|
|
253
|
+
const { formState: current, uploadedFiles: files } = useStore.getState()
|
|
254
|
+
const seeds = current.hasContentIndex
|
|
255
|
+
? [
|
|
256
|
+
...files.filter((f) => f.status === "SUCCESS").map((f) => f.text),
|
|
257
|
+
...(seedTextRef.current ? [seedTextRef.current] : []),
|
|
258
|
+
]
|
|
259
|
+
: []
|
|
260
|
+
|
|
261
|
+
return decideCourseLanguage({
|
|
262
|
+
chosen: current.languageChosen ? toCourseLanguage(current.language) : null,
|
|
263
|
+
seed: detectSeedLanguage(seeds),
|
|
264
|
+
description: detectDescriptionLanguage(current.description),
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
|
|
216
268
|
const handleCreateTutorial = async () => {
|
|
217
269
|
try {
|
|
270
|
+
const decision = decideLanguageForGeneration()
|
|
271
|
+
if (decision.kind === "ask") {
|
|
272
|
+
setShowCourseLanguageModal(true)
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
const courseLanguage = decision.kind === "use" ? decision.language : null
|
|
276
|
+
courseLanguageRef.current = courseLanguage
|
|
277
|
+
|
|
218
278
|
let isAuthenticated = false
|
|
219
279
|
let tokenToUse = ""
|
|
220
280
|
let tokenType = ""
|
|
@@ -248,7 +308,9 @@ function App() {
|
|
|
248
308
|
return
|
|
249
309
|
}
|
|
250
310
|
|
|
251
|
-
let techs = technologies.filter(
|
|
311
|
+
let techs = technologies.filter(
|
|
312
|
+
(t) => t.lang === (courseLanguage ?? formState.language)
|
|
313
|
+
)
|
|
252
314
|
|
|
253
315
|
if (techs.length === 0) {
|
|
254
316
|
techs = technologies.filter((t) => t.lang === "en")
|
|
@@ -256,14 +318,12 @@ function App() {
|
|
|
256
318
|
|
|
257
319
|
const res = await publicInteractiveCreation(
|
|
258
320
|
{
|
|
259
|
-
courseInfo:
|
|
260
|
-
formState
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
The following files have been uploaded by the user: ${uploadedFiles.map((f) => `<FILE name="${f.name}">${f.text}</FILE>`).join("\n")}
|
|
266
|
-
`,
|
|
321
|
+
courseInfo: buildCourseInfo({
|
|
322
|
+
formState,
|
|
323
|
+
techs,
|
|
324
|
+
uploadedFiles,
|
|
325
|
+
language: courseLanguage,
|
|
326
|
+
}),
|
|
267
327
|
prevInteractions: "USER: " + formState.description,
|
|
268
328
|
},
|
|
269
329
|
tokenToUse,
|
|
@@ -329,14 +389,28 @@ function App() {
|
|
|
329
389
|
)
|
|
330
390
|
}
|
|
331
391
|
|
|
392
|
+
// A language settled before generation (the user's choice or the
|
|
393
|
+
// seed's) wins over the model's, which follows the description.
|
|
394
|
+
const courseLanguage = courseLanguageRef.current
|
|
395
|
+
if (
|
|
396
|
+
courseLanguage &&
|
|
397
|
+
parsed.languageCode &&
|
|
398
|
+
parsed.languageCode !== courseLanguage
|
|
399
|
+
) {
|
|
400
|
+
console.warn(
|
|
401
|
+
`Asked for a course in "${courseLanguage}", the model answered "${parsed.languageCode}"`
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
|
|
332
405
|
push({
|
|
333
406
|
lessons,
|
|
334
407
|
courseInfo: {
|
|
335
|
-
...formState,
|
|
408
|
+
...toCourseFormState(formState),
|
|
336
409
|
title: parsed.title,
|
|
337
410
|
slug: slugify(fixTitleLength(parsed.title)),
|
|
338
411
|
description: parsed.description,
|
|
339
|
-
language:
|
|
412
|
+
language:
|
|
413
|
+
courseLanguage || parsed.languageCode || formState.language || "en",
|
|
340
414
|
technologies:
|
|
341
415
|
parsed.technologies.length > 0
|
|
342
416
|
? parsed.technologies
|
|
@@ -344,8 +418,9 @@ function App() {
|
|
|
344
418
|
},
|
|
345
419
|
})
|
|
346
420
|
|
|
347
|
-
|
|
348
|
-
|
|
421
|
+
const interfaceLanguage = courseLanguage || parsed.languageCode
|
|
422
|
+
if (interfaceLanguage) {
|
|
423
|
+
i18n.changeLanguage(interfaceLanguage)
|
|
349
424
|
}
|
|
350
425
|
|
|
351
426
|
const initialMessages: TMessage[] = [
|
|
@@ -397,15 +472,42 @@ function App() {
|
|
|
397
472
|
const handleLanguageSwitch = () => {
|
|
398
473
|
setFormState({
|
|
399
474
|
language: detectedLanguage,
|
|
475
|
+
languageChosen: true,
|
|
400
476
|
})
|
|
401
477
|
i18n.changeLanguage(detectedLanguage)
|
|
402
478
|
setShowLanguageDetectionModal(false)
|
|
403
479
|
}
|
|
404
480
|
|
|
405
481
|
const handleLanguageStay = () => {
|
|
482
|
+
// Staying is a choice too: the course keeps the interface language.
|
|
483
|
+
setFormState({
|
|
484
|
+
language: getInterfaceLanguage(),
|
|
485
|
+
languageChosen: true,
|
|
486
|
+
})
|
|
406
487
|
setShowLanguageDetectionModal(false)
|
|
407
488
|
}
|
|
408
489
|
|
|
490
|
+
const handleCourseLanguageChoice = (language: TCourseLanguage) => {
|
|
491
|
+
setShowCourseLanguageModal(false)
|
|
492
|
+
setFormState({ language, languageChosen: true })
|
|
493
|
+
i18n.changeLanguage(language)
|
|
494
|
+
handleCreateTutorial()
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const handleCourseLanguagePick = (choice: TCourseLanguageChoice) => {
|
|
498
|
+
if (choice === "auto") {
|
|
499
|
+
// Back to the store's defaults: nothing chosen, the model decides.
|
|
500
|
+
setFormState({ language: "en", languageChosen: false })
|
|
501
|
+
return
|
|
502
|
+
}
|
|
503
|
+
setFormState({ language: choice, languageChosen: true })
|
|
504
|
+
i18n.changeLanguage(choice)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const courseLanguageChoice: TCourseLanguageChoice =
|
|
508
|
+
(formState.languageChosen && toCourseLanguage(formState.language)) ||
|
|
509
|
+
"auto"
|
|
510
|
+
|
|
409
511
|
const buildSteps = () => {
|
|
410
512
|
const steps = [
|
|
411
513
|
{
|
|
@@ -417,7 +519,12 @@ function App() {
|
|
|
417
519
|
const lang = detectLanguage(value)
|
|
418
520
|
console.log("Description validator language detection:", { lang, currentLang: i18n.language, value })
|
|
419
521
|
|
|
420
|
-
if (
|
|
522
|
+
if (
|
|
523
|
+
!formState.languageChosen &&
|
|
524
|
+
lang &&
|
|
525
|
+
lang !== "en" &&
|
|
526
|
+
lang !== getInterfaceLanguage()
|
|
527
|
+
) {
|
|
421
528
|
setDetectedLanguage(lang)
|
|
422
529
|
setShowLanguageDetectionModal(true)
|
|
423
530
|
}
|
|
@@ -584,7 +691,8 @@ function App() {
|
|
|
584
691
|
required: true,
|
|
585
692
|
content: (
|
|
586
693
|
<Uploader
|
|
587
|
-
onFinish={(text) => {
|
|
694
|
+
onFinish={(text, seedText) => {
|
|
695
|
+
seedTextRef.current = seedText ?? null
|
|
588
696
|
setFormState({
|
|
589
697
|
contentIndex: text,
|
|
590
698
|
isCompleted: true,
|
|
@@ -612,6 +720,18 @@ function App() {
|
|
|
612
720
|
onStay={handleLanguageStay}
|
|
613
721
|
/>
|
|
614
722
|
)}
|
|
723
|
+
{showCourseLanguageModal && (
|
|
724
|
+
<LanguageDetectionModal
|
|
725
|
+
detectedLanguage="en"
|
|
726
|
+
title={t("languageDetection.chooseTitle")}
|
|
727
|
+
message={t("languageDetection.chooseMessage")}
|
|
728
|
+
switchLabel={t("languageDetection.languages.en")}
|
|
729
|
+
stayLabel={t("languageDetection.languages.es")}
|
|
730
|
+
equalChoices
|
|
731
|
+
onSwitch={() => handleCourseLanguageChoice("en")}
|
|
732
|
+
onStay={() => handleCourseLanguageChoice("es")}
|
|
733
|
+
/>
|
|
734
|
+
)}
|
|
615
735
|
{showTurnstileModal && (
|
|
616
736
|
<TurnstileModal
|
|
617
737
|
onClose={() => {
|
|
@@ -658,6 +778,12 @@ function App() {
|
|
|
658
778
|
)}
|
|
659
779
|
<StepWizard
|
|
660
780
|
hideLastButton={true}
|
|
781
|
+
footer={
|
|
782
|
+
<CourseLanguagePicker
|
|
783
|
+
value={courseLanguageChoice}
|
|
784
|
+
onChange={handleCourseLanguagePick}
|
|
785
|
+
/>
|
|
786
|
+
}
|
|
661
787
|
formState={formState}
|
|
662
788
|
steps={buildSteps()}
|
|
663
789
|
setFormState={setFormState}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useState } from "react"
|
|
2
|
+
import { useTranslation } from "react-i18next"
|
|
3
|
+
import { Icon } from "./Icon"
|
|
4
|
+
import {
|
|
5
|
+
Tooltip,
|
|
6
|
+
TooltipContent,
|
|
7
|
+
TooltipTrigger,
|
|
8
|
+
} from "@/components/ui/tooltip"
|
|
9
|
+
import { TCourseLanguage } from "../utils/courseLanguage"
|
|
10
|
+
|
|
11
|
+
export type TCourseLanguageChoice = TCourseLanguage | "auto"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Lets the user fix the course language instead of leaving it to detection.
|
|
15
|
+
* Language names are written in their own language so each is recognizable
|
|
16
|
+
* whatever the interface language.
|
|
17
|
+
*/
|
|
18
|
+
export default function CourseLanguagePicker({
|
|
19
|
+
value,
|
|
20
|
+
onChange,
|
|
21
|
+
}: {
|
|
22
|
+
value: TCourseLanguageChoice
|
|
23
|
+
onChange: (value: TCourseLanguageChoice) => void
|
|
24
|
+
}) {
|
|
25
|
+
const { t } = useTranslation()
|
|
26
|
+
// Controlled so the hint only shows for Auto without remounting the select.
|
|
27
|
+
const [hintOpen, setHintOpen] = useState(false)
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<Tooltip open={hintOpen && value === "auto"} onOpenChange={setHintOpen}>
|
|
31
|
+
<TooltipTrigger asChild>
|
|
32
|
+
<label className="flex items-center gap-1 text-sm text-blue-600 whitespace-nowrap">
|
|
33
|
+
<Icon name="Globe" size={14} aria-hidden="true" />
|
|
34
|
+
{t("courseLanguage.label")}:
|
|
35
|
+
<select
|
|
36
|
+
className="field-sizing-content bg-transparent text-blue-600 hover:text-blue-800 cursor-pointer rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-300"
|
|
37
|
+
value={value}
|
|
38
|
+
onChange={(e) => onChange(e.target.value as TCourseLanguageChoice)}
|
|
39
|
+
>
|
|
40
|
+
<option value="auto">{t("courseLanguage.auto")}</option>
|
|
41
|
+
<option value="en">English</option>
|
|
42
|
+
<option value="es">Español</option>
|
|
43
|
+
</select>
|
|
44
|
+
</label>
|
|
45
|
+
</TooltipTrigger>
|
|
46
|
+
<TooltipContent className="max-w-64 text-center">
|
|
47
|
+
{t("courseLanguage.autoHint")}
|
|
48
|
+
</TooltipContent>
|
|
49
|
+
</Tooltip>
|
|
50
|
+
)
|
|
51
|
+
}
|