@learnpack/learnpack 5.0.361 → 5.0.363

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 (41) hide show
  1. package/lib/commands/serve.js +44 -7
  2. package/lib/creatorDist/assets/{index-B7s5Nmq8.css → index-Cco3XhDR.css} +96 -0
  3. package/lib/creatorDist/assets/{index-CqVEKiJR.js → index-o3Cw0bsF.js} +69682 -66808
  4. package/lib/creatorDist/index.html +2 -2
  5. package/lib/utils/api.d.ts +0 -9
  6. package/lib/utils/api.js +1 -65
  7. package/lib/utils/lessonRecovery.d.ts +18 -0
  8. package/lib/utils/lessonRecovery.js +31 -0
  9. package/package.json +1 -1
  10. package/src/commands/serve.ts +65 -18
  11. package/src/creator/package-lock.json +297 -3
  12. package/src/creator/package.json +1 -0
  13. package/src/creator/src/App.test.tsx +124 -1
  14. package/src/creator/src/App.tsx +52 -36
  15. package/src/creator/src/components/FileCard.tsx +63 -19
  16. package/src/creator/src/components/FileReadListener.test.tsx +128 -0
  17. package/src/creator/src/components/FileReadListener.tsx +75 -0
  18. package/src/creator/src/components/FileReviewModal.test.tsx +193 -0
  19. package/src/creator/src/components/FileReviewModal.tsx +128 -0
  20. package/src/creator/src/components/FileUploader.test.tsx +229 -0
  21. package/src/creator/src/components/FileUploader.tsx +212 -58
  22. package/src/creator/src/components/GenerationErrorCard.tsx +57 -0
  23. package/src/creator/src/components/MarkdownRenderer.tsx +4 -2
  24. package/src/creator/src/components/syllabus/SyllabusEditor.tsx +6 -9
  25. package/src/creator/src/index.css +45 -0
  26. package/src/creator/src/locales/en.json +27 -5
  27. package/src/creator/src/locales/es.json +27 -5
  28. package/src/creator/src/main.tsx +2 -0
  29. package/src/creator/src/utils/completionResult.test.ts +61 -0
  30. package/src/creator/src/utils/completionResult.ts +61 -2
  31. package/src/creator/src/utils/courseLanguage.test.ts +6 -0
  32. package/src/creator/src/utils/creationRequest.test.ts +53 -18
  33. package/src/creator/src/utils/creationRequest.ts +17 -12
  34. package/src/creator/src/utils/fileRead.ts +60 -0
  35. package/src/creator/src/utils/lib.ts +0 -5
  36. package/src/creator/src/utils/store.ts +0 -10
  37. package/src/creatorDist/assets/{index-B7s5Nmq8.css → index-Cco3XhDR.css} +96 -0
  38. package/src/creatorDist/assets/{index-CqVEKiJR.js → index-o3Cw0bsF.js} +69682 -66808
  39. package/src/creatorDist/index.html +2 -2
  40. package/src/utils/api.ts +0 -83
  41. package/src/utils/lessonRecovery.ts +32 -0
@@ -0,0 +1,193 @@
1
+ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
2
+ import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
3
+ import toast from "react-hot-toast";
4
+ import "../i18n";
5
+ import { FileCard } from "./FileCard";
6
+ import { FileReviewButton } from "./FileReviewModal";
7
+ import { MarkdownRenderer } from "./MarkdownRenderer";
8
+ import useStore from "../utils/store";
9
+ import { TooltipProvider } from "@/components/ui/tooltip";
10
+ import type { ParsedFile, ReadReport } from "./FileUploader";
11
+
12
+ vi.mock("react-hot-toast");
13
+
14
+ const report: ReadReport = {
15
+ total_pages: 3,
16
+ ai_pages: [],
17
+ unread_pages: [],
18
+ failed_pages: [],
19
+ markdown: true,
20
+ };
21
+
22
+ const file = (changes: Partial<ParsedFile> = {}): ParsedFile => ({
23
+ name: "seed.pdf",
24
+ text: "\n[Page 1]\n|Term|Definition|\n|---|---|\n|Loop|Repeats|\n",
25
+ status: "SUCCESS",
26
+ notificationId: "read-1",
27
+ report,
28
+ ...changes,
29
+ });
30
+
31
+ const storedText = () => useStore.getState().uploadedFiles[0].text;
32
+
33
+ const open = (name: string) => fireEvent.click(screen.getByText(name));
34
+
35
+ beforeEach(() => {
36
+ vi.resetAllMocks();
37
+ useStore.getState().setUploadedFiles([file()]);
38
+ });
39
+
40
+ afterEach(cleanup);
41
+
42
+ describe("FileReviewButton", () => {
43
+ it("stays quiet on an ordinary read", () => {
44
+ render(<FileReviewButton file={file()} />);
45
+
46
+ expect(screen.getByText("View text")).toBeTruthy();
47
+ });
48
+
49
+ it("asks for a review when the AI read pages", () => {
50
+ render(<FileReviewButton file={file({ report: { ...report, ai_pages: [1, 2] } })} />);
51
+
52
+ open("Review text");
53
+
54
+ expect(
55
+ screen.getByText("We read 2 of 3 pages with AI. Check names, figures and dates.")
56
+ ).toBeTruthy();
57
+ });
58
+
59
+ it("names the pages that are missing, without repeating them", () => {
60
+ render(
61
+ <FileReviewButton
62
+ file={file({ report: { ...report, unread_pages: [3, 2], failed_pages: [2] } })}
63
+ />
64
+ );
65
+
66
+ open("Review text");
67
+
68
+ expect(screen.getByText("We could not fully read these pages: 2, 3.")).toBeTruthy();
69
+ });
70
+
71
+ it("draws tables instead of showing their markdown", () => {
72
+ render(<FileReviewButton file={file()} />);
73
+
74
+ open("View text");
75
+
76
+ expect(screen.getByRole("table")).toBeTruthy();
77
+ expect(screen.getByRole("cell", { name: "Repeats" })).toBeTruthy();
78
+ });
79
+
80
+ it("shows page headings on screen and keeps the markers in the stored text", () => {
81
+ render(<FileReviewButton file={file()} />);
82
+
83
+ open("View text");
84
+
85
+ expect(screen.getByRole("heading", { name: "Page 1" })).toBeTruthy();
86
+ expect(screen.queryByText("[Page 1]")).toBeNull();
87
+ expect(storedText()).toContain("[Page 1]");
88
+ });
89
+
90
+ it("keeps the lines of plain text apart", () => {
91
+ render(<FileReviewButton file={file({ text: "One\nTwo", report: undefined })} />);
92
+
93
+ open("View text");
94
+
95
+ expect(screen.getByText(/One/).className).toContain("whitespace-pre-wrap");
96
+ });
97
+
98
+ it("saves edits where every prompt reads the file from", () => {
99
+ render(<FileReviewButton file={file()} />);
100
+ open("View text");
101
+ open("Edit");
102
+
103
+ fireEvent.change(screen.getByRole("textbox"), {
104
+ target: { value: "Corrected text" },
105
+ });
106
+ open("Save changes");
107
+
108
+ expect(storedText()).toBe("Corrected text");
109
+ expect(screen.queryByText("Save changes")).toBeNull();
110
+ });
111
+
112
+ it("discards edits on close", () => {
113
+ render(<FileReviewButton file={file()} />);
114
+ open("View text");
115
+ open("Edit");
116
+
117
+ fireEvent.change(screen.getByRole("textbox"), { target: { value: "Changed" } });
118
+ open("Close");
119
+
120
+ expect(storedText()).toBe(file().text);
121
+ });
122
+
123
+ it("does not let the user save an empty document", () => {
124
+ render(<FileReviewButton file={file()} />);
125
+ open("View text");
126
+ open("Edit");
127
+
128
+ fireEvent.change(screen.getByRole("textbox"), { target: { value: " " } });
129
+
130
+ expect(
131
+ (screen.getByText("Save changes") as HTMLButtonElement).disabled
132
+ ).toBe(true);
133
+ });
134
+ });
135
+
136
+ describe("MarkdownRenderer", () => {
137
+ it("leaves the chat's markdown without table support", () => {
138
+ render(<MarkdownRenderer content={"|a|b|\n|---|---|\n|1|2|"} />);
139
+
140
+ expect(screen.queryByRole("table")).toBeNull();
141
+ });
142
+ });
143
+
144
+ describe("FileCard", () => {
145
+ const renderCard = (value: ParsedFile) =>
146
+ render(
147
+ <TooltipProvider>
148
+ <FileCard file={value} handleRemove={() => {}} />
149
+ </TooltipProvider>
150
+ );
151
+
152
+ const rerenderCard = (
153
+ rerender: ReturnType<typeof render>["rerender"],
154
+ value: ParsedFile
155
+ ) =>
156
+ act(() =>
157
+ rerender(
158
+ <TooltipProvider>
159
+ <FileCard file={value} handleRemove={() => {}} />
160
+ </TooltipProvider>
161
+ )
162
+ );
163
+
164
+ it("warns once when a read from the sidebar used the AI", () => {
165
+ const { rerender } = renderCard(
166
+ file({ status: "PROCESSING", text: "", report: undefined })
167
+ );
168
+
169
+ const finished = file({ report: { ...report, ai_pages: [1] } });
170
+ rerenderCard(rerender, finished);
171
+ rerenderCard(rerender, { ...finished });
172
+
173
+ expect(toast).toHaveBeenCalledTimes(1);
174
+ });
175
+
176
+ it("says nothing when the page reloads with a read already finished", () => {
177
+ renderCard(file({ report: { ...report, ai_pages: [1] } }));
178
+
179
+ expect(toast).not.toHaveBeenCalled();
180
+ });
181
+
182
+ it("reports a failed read", () => {
183
+ const { rerender } = renderCard(
184
+ file({ status: "PROCESSING", text: "", report: undefined })
185
+ );
186
+
187
+ rerenderCard(rerender, file({ status: "ERROR", text: "", report: undefined }));
188
+
189
+ expect(toast.error).toHaveBeenCalledWith(
190
+ "We could not read this file. Remove it or upload it again."
191
+ );
192
+ });
193
+ });
@@ -0,0 +1,128 @@
1
+ import { useState } from "react"
2
+ import { createPortal } from "react-dom"
3
+ import { useTranslation } from "react-i18next"
4
+ import useStore from "../utils/store"
5
+ import { isMarkdownRead, readNotices, withPageHeadings } from "../utils/fileRead"
6
+ import { MarkdownRenderer } from "./MarkdownRenderer"
7
+ import type { ParsedFile } from "./FileUploader"
8
+
9
+ const tabClass = (active: boolean) =>
10
+ "px-3 py-1 rounded-md cursor-pointer text-sm " +
11
+ (active ? "bg-blue-600 text-white" : "bg-gray-200 text-gray-800")
12
+
13
+ // Rendered in a portal because the sidebar is transformed on small screens,
14
+ // and a fixed element inside a transformed parent stops covering the page.
15
+ export const FileReviewModal = ({
16
+ file,
17
+ onClose,
18
+ }: {
19
+ file: ParsedFile
20
+ onClose: () => void
21
+ }) => {
22
+ const { t } = useTranslation()
23
+ const [mode, setMode] = useState<"read" | "edit">("read")
24
+ const [draft, setDraft] = useState(file.text)
25
+
26
+ const notices = readNotices(file, t)
27
+ const canSave = draft.trim().length > 0 && draft !== file.text
28
+
29
+ const save = () => {
30
+ const { uploadedFiles, setUploadedFiles } = useStore.getState()
31
+ setUploadedFiles(
32
+ uploadedFiles.map((item) =>
33
+ item.name === file.name ? { ...item, text: draft } : item
34
+ )
35
+ )
36
+ onClose()
37
+ }
38
+
39
+ return createPortal(
40
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-1000">
41
+ <div className="bg-white p-6 rounded-xl shadow-md max-w-3xl w-full mx-4 max-h-[90vh] flex flex-col gap-3 text-left text-gray-800">
42
+ <h2 className="text-xl font-semibold truncate">{file.name}</h2>
43
+
44
+ {notices.map((notice) => (
45
+ <p key={notice} className="text-sm bg-amber-50 border border-amber-300 rounded-md p-2">
46
+ {notice}
47
+ </p>
48
+ ))}
49
+
50
+ <div className="flex gap-2">
51
+ <button className={tabClass(mode === "read")} onClick={() => setMode("read")}>
52
+ {t("uploader.files.reviewRead")}
53
+ </button>
54
+ <button className={tabClass(mode === "edit")} onClick={() => setMode("edit")}>
55
+ {t("uploader.files.reviewEdit")}
56
+ </button>
57
+ </div>
58
+
59
+ {mode === "read" ? (
60
+ <div className="file-read overflow-y-auto min-h-48 flex-1 text-sm border border-gray-200 rounded-md p-3">
61
+ {isMarkdownRead(file) ? (
62
+ <MarkdownRenderer content={withPageHeadings(draft, t)} gfm />
63
+ ) : (
64
+ <p className="whitespace-pre-wrap">{draft}</p>
65
+ )}
66
+ </div>
67
+ ) : (
68
+ <textarea
69
+ aria-label={t("uploader.files.reviewEdit")}
70
+ className="min-h-48 h-[50vh] w-full text-sm border border-gray-200 rounded-md p-3 font-mono"
71
+ value={draft}
72
+ onChange={(e) => setDraft(e.target.value)}
73
+ />
74
+ )}
75
+
76
+ <p className="text-xs text-gray-500">{t("uploader.files.reviewEditNote")}</p>
77
+
78
+ <div className="flex justify-end gap-2">
79
+ <button
80
+ className="bg-gray-200 text-gray-800 px-4 py-2 rounded-md cursor-pointer"
81
+ onClick={onClose}
82
+ >
83
+ {t("uploader.files.reviewClose")}
84
+ </button>
85
+ <button
86
+ className="bg-blue-600 text-white px-4 py-2 rounded-md cursor-pointer disabled:opacity-50"
87
+ disabled={!canSave}
88
+ onClick={save}
89
+ >
90
+ {t("uploader.files.reviewSave")}
91
+ </button>
92
+ </div>
93
+ </div>
94
+ </div>,
95
+ document.body
96
+ )
97
+ }
98
+
99
+ /**
100
+ * Opens the review of a file. The label asks for attention only when the read
101
+ * has something to check, so an ordinary PDF stays one quiet click away.
102
+ */
103
+ export const FileReviewButton = ({
104
+ file,
105
+ label,
106
+ className = "",
107
+ }: {
108
+ file: ParsedFile
109
+ label?: string
110
+ className?: string
111
+ }) => {
112
+ const { t } = useTranslation()
113
+ const [isOpen, setIsOpen] = useState(false)
114
+
115
+ const needsReview = readNotices(file, t).length > 0
116
+
117
+ return (
118
+ <>
119
+ <button
120
+ className={"cursor-pointer " + (needsReview ? "font-semibold " : "") + className}
121
+ onClick={() => setIsOpen(true)}
122
+ >
123
+ {label ?? t(needsReview ? "uploader.files.reviewText" : "uploader.files.viewText")}
124
+ </button>
125
+ {isOpen && <FileReviewModal file={file} onClose={() => setIsOpen(false)} />}
126
+ </>
127
+ )
128
+ }
@@ -0,0 +1,229 @@
1
+ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
2
+ import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
3
+ import axios from "axios";
4
+ import toast from "react-hot-toast";
5
+ import "../i18n";
6
+ import FileUploader from "./FileUploader";
7
+ import useStore from "../utils/store";
8
+ import { TooltipProvider } from "@/components/ui/tooltip";
9
+
10
+ vi.mock("axios");
11
+ vi.mock("react-hot-toast");
12
+
13
+ const post = vi.mocked(axios.post);
14
+
15
+ const pdf = (name = "seed.pdf") =>
16
+ new File(["%PDF"], name, { type: "application/pdf" });
17
+
18
+ const picker = () =>
19
+ document.querySelector('input[type="file"]') as HTMLInputElement;
20
+
21
+ const choose = async (...files: File[]) => {
22
+ await act(async () => {
23
+ fireEvent.change(picker(), { target: { files } });
24
+ });
25
+ };
26
+
27
+ const renderUploader = () =>
28
+ render(
29
+ <TooltipProvider>
30
+ <FileUploader styledAs="card" />
31
+ </TooltipProvider>
32
+ );
33
+
34
+ const files = () => useStore.getState().uploadedFiles;
35
+
36
+ beforeEach(() => {
37
+ vi.resetAllMocks();
38
+ useStore.getState().setUploadedFiles([]);
39
+ post.mockResolvedValue({ status: 202, data: { status: "PROCESSING" } });
40
+ });
41
+
42
+ afterEach(cleanup);
43
+
44
+ describe("FileUploader", () => {
45
+ it("adds the file as being read and sends the interface language", async () => {
46
+ renderUploader();
47
+
48
+ await choose(pdf());
49
+
50
+ expect(files()).toMatchObject([{ name: "seed.pdf", status: "PROCESSING" }]);
51
+ const form = post.mock.calls[0][1] as FormData;
52
+ expect(form.get("lang")).toBe("en");
53
+ });
54
+
55
+ it("replaces a file with the same name instead of adding a second one", async () => {
56
+ useStore.getState().setUploadedFiles([
57
+ { name: "seed.pdf", text: "old", status: "SUCCESS", notificationId: "old" },
58
+ { name: "other.pdf", text: "keep", status: "SUCCESS", notificationId: "other" },
59
+ ]);
60
+ renderUploader();
61
+
62
+ await choose(pdf());
63
+
64
+ expect(files().map((file) => file.name)).toEqual(["other.pdf", "seed.pdf"]);
65
+ expect(files()[1]).toMatchObject({ status: "PROCESSING", text: "" });
66
+ });
67
+
68
+ it("keeps a read that finished while another file was uploading", async () => {
69
+ useStore.getState().setUploadedFiles([
70
+ { name: "first.pdf", text: "", status: "PROCESSING", notificationId: "1" },
71
+ ]);
72
+ let finishUpload: (value: unknown) => void = () => {};
73
+ post.mockReturnValue(new Promise((resolve) => (finishUpload = resolve)) as never);
74
+ renderUploader();
75
+
76
+ await choose(pdf("second.pdf"));
77
+ act(() =>
78
+ useStore.getState().setUploadedFiles([
79
+ { name: "first.pdf", text: "Done", status: "SUCCESS", notificationId: "1" },
80
+ ])
81
+ );
82
+ await act(async () => {
83
+ finishUpload({ status: 202, data: { status: "PROCESSING" } });
84
+ });
85
+
86
+ expect(files()).toMatchObject([
87
+ { name: "first.pdf", status: "SUCCESS", text: "Done" },
88
+ { name: "second.pdf", status: "PROCESSING" },
89
+ ]);
90
+ });
91
+
92
+ it("does not lose the current file when its replacement is rejected", async () => {
93
+ useStore.getState().setUploadedFiles([
94
+ { name: "seed.pdf", text: "good", status: "SUCCESS", notificationId: "1" },
95
+ ]);
96
+ post.mockRejectedValue({ response: { status: 400, data: { detail: "Nope" } } });
97
+ renderUploader();
98
+
99
+ await choose(pdf());
100
+
101
+ expect(files()).toMatchObject([{ text: "good", status: "SUCCESS" }]);
102
+ });
103
+
104
+ it("shows the reason rigobot gives for rejecting a file", async () => {
105
+ post.mockRejectedValue({
106
+ response: { status: 400, data: { detail: "Este PDF tiene contraseña." } },
107
+ });
108
+ renderUploader();
109
+
110
+ await choose(pdf());
111
+
112
+ expect(toast.error).toHaveBeenCalledWith(
113
+ "❌ Este PDF tiene contraseña.",
114
+ expect.anything()
115
+ );
116
+ expect(files()).toEqual([]);
117
+ });
118
+
119
+ describe("a PDF that is mostly images", () => {
120
+ const needsConfirmation = (imagePages = 8) => ({
121
+ status: 200,
122
+ data: {
123
+ status: "NEEDS_CONFIRMATION",
124
+ report: { total_pages: 12, image_pages: imagePages, blank_pages: 0 },
125
+ ai_limit: 15,
126
+ },
127
+ });
128
+
129
+ const finishButton = () =>
130
+ screen.getByRole("button", { name: "🚀 Finish" }) as HTMLButtonElement;
131
+
132
+ beforeEach(() => {
133
+ post.mockResolvedValueOnce(needsConfirmation());
134
+ });
135
+
136
+ it("asks first, using no AI and blocking Finish until the user chooses", async () => {
137
+ renderUploader();
138
+
139
+ await choose(pdf());
140
+
141
+ const form = post.mock.calls[0][1] as FormData;
142
+ expect(form.get("preflight")).toBe("true");
143
+ expect(
144
+ screen.getByText("This PDF is saved almost entirely as images (8 of 12 pages).")
145
+ ).toBeTruthy();
146
+ expect(files()).toEqual([]);
147
+ expect(finishButton().disabled).toBe(true);
148
+ });
149
+
150
+ it("sends the same file again without the check when the user accepts", async () => {
151
+ renderUploader();
152
+ await choose(pdf());
153
+
154
+ await act(async () => {
155
+ fireEvent.click(screen.getByText("Use this PDF"));
156
+ });
157
+
158
+ expect(post).toHaveBeenCalledTimes(2);
159
+ const form = post.mock.calls[1][1] as FormData;
160
+ expect(form.get("preflight")).toBeNull();
161
+ expect((form.get("file") as File).name).toBe("seed.pdf");
162
+ expect(files()).toMatchObject([{ name: "seed.pdf", status: "PROCESSING" }]);
163
+ expect(screen.queryByText("Use this PDF")).toBeNull();
164
+ });
165
+
166
+ it("opens the file picker and drops the upload for another version", async () => {
167
+ renderUploader();
168
+ await choose(pdf());
169
+ const click = vi.spyOn(picker(), "click");
170
+
171
+ fireEvent.click(screen.getByText("Upload another version"));
172
+
173
+ expect(click).toHaveBeenCalled();
174
+ expect(screen.queryByText("Use this PDF")).toBeNull();
175
+ expect(post).toHaveBeenCalledTimes(1);
176
+ expect(files()).toEqual([]);
177
+ });
178
+
179
+ it("clears the question when a file with the same name is accepted", async () => {
180
+ renderUploader();
181
+ await choose(pdf());
182
+
183
+ await choose(pdf());
184
+
185
+ expect(screen.queryByText("Use this PDF")).toBeNull();
186
+ expect(files()).toMatchObject([{ name: "seed.pdf", status: "PROCESSING" }]);
187
+ });
188
+ });
189
+
190
+ it("warns when a scan goes over the reading limit", async () => {
191
+ post.mockResolvedValueOnce({
192
+ status: 200,
193
+ data: {
194
+ status: "NEEDS_CONFIRMATION",
195
+ report: { total_pages: 30, image_pages: 30, blank_pages: 0 },
196
+ ai_limit: 15,
197
+ },
198
+ });
199
+ renderUploader();
200
+
201
+ await choose(pdf());
202
+
203
+ expect(screen.getByText("We will only read the first 15 pages.")).toBeTruthy();
204
+ });
205
+
206
+ it("reads straight away from the sidebar, which has no room to ask", async () => {
207
+ render(
208
+ <TooltipProvider>
209
+ <FileUploader />
210
+ </TooltipProvider>
211
+ );
212
+
213
+ await choose(pdf());
214
+
215
+ const form = post.mock.calls[0][1] as FormData;
216
+ expect(form.get("preflight")).toBeNull();
217
+ });
218
+
219
+ it("survives a network failure and is ready for another try", async () => {
220
+ post.mockRejectedValue(new Error("Network Error"));
221
+ renderUploader();
222
+
223
+ await choose(pdf());
224
+
225
+ expect(toast.error).toHaveBeenCalled();
226
+ expect(screen.queryByText("Processing...")).toBeNull();
227
+ expect(picker().value).toBe("");
228
+ });
229
+ });