@learnpack/learnpack 5.0.130 → 5.0.132
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/README.md +13 -13
- package/lib/commands/serve.d.ts +51 -0
- package/lib/commands/serve.js +145 -2
- package/lib/utils/creatorUtilities.d.ts +1 -0
- package/lib/utils/creatorUtilities.js +17 -16
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
- package/src/commands/serve.ts +301 -4
- package/src/creator/src/components/syllabus/SyllabusEditor.tsx +11 -54
- package/src/utils/cloudStorage.ts +24 -24
- package/src/utils/creatorUtilities.ts +499 -499
package/src/commands/serve.ts
CHANGED
@@ -10,21 +10,260 @@ import * as mkdirp from "mkdirp"
|
|
10
10
|
import { convert } from "html-to-text"
|
11
11
|
import * as rimraf from "rimraf"
|
12
12
|
import SessionCommand from "../utils/SessionCommand"
|
13
|
-
import { Storage } from "@google-cloud/storage"
|
13
|
+
import { Bucket, Storage } from "@google-cloud/storage"
|
14
14
|
import { downloadEditor, decompress } from "../managers/file"
|
15
15
|
import * as fs from "fs"
|
16
|
-
import {
|
16
|
+
import {
|
17
|
+
createCodeFile,
|
18
|
+
translateExercise,
|
19
|
+
isValidRigoToken,
|
20
|
+
readmeCreator,
|
21
|
+
makeReadmeReadable,
|
22
|
+
generateImage,
|
23
|
+
} from "../utils/rigoActions"
|
17
24
|
import * as dotenv from "dotenv"
|
25
|
+
import {
|
26
|
+
extractImagesFromMarkdown,
|
27
|
+
getFilenameFromUrl,
|
28
|
+
} from "../utils/creatorUtilities"
|
18
29
|
// import { handleAssetCreation } from "./publish"
|
19
30
|
import axios from "axios"
|
20
31
|
import * as FormData from "form-data"
|
21
32
|
import { RIGOBOT_HOST } from "../utils/api"
|
22
33
|
import { minutesToISO8601Duration } from "../utils/misc"
|
23
34
|
import { buildConfig, ConfigResponse } from "../utils/configBuilder"
|
35
|
+
import { checkReadability, slugify } from "../utils/creatorUtilities"
|
36
|
+
const frontMatter = require("front-matter")
|
24
37
|
|
25
38
|
dotenv.config()
|
26
39
|
|
27
|
-
const
|
40
|
+
export const createLearnJson = (courseInfo: FormState) => {
|
41
|
+
console.log("courseInfo to create learn json", courseInfo)
|
42
|
+
|
43
|
+
const learnJson = {
|
44
|
+
slug: slugify(courseInfo.title as string),
|
45
|
+
title: {
|
46
|
+
us: courseInfo.title,
|
47
|
+
},
|
48
|
+
technologies: courseInfo.technologies || [],
|
49
|
+
difficulty: "beginner",
|
50
|
+
description: {
|
51
|
+
us: courseInfo.description,
|
52
|
+
},
|
53
|
+
grading: "isolated",
|
54
|
+
telemetry: {
|
55
|
+
batch: "https://breathecode.herokuapp.com/v1/assignment/me/telemetry",
|
56
|
+
},
|
57
|
+
preview: "preview.png",
|
58
|
+
}
|
59
|
+
return learnJson
|
60
|
+
}
|
61
|
+
|
62
|
+
const uploadFileToBucket = async (
|
63
|
+
bucket: Bucket,
|
64
|
+
file: string,
|
65
|
+
path: string
|
66
|
+
) => {
|
67
|
+
const fileRef = bucket.file(path)
|
68
|
+
await fileRef.save(file)
|
69
|
+
}
|
70
|
+
|
71
|
+
const uploadImageToBucket = async (
|
72
|
+
bucket: Bucket,
|
73
|
+
url: string,
|
74
|
+
path: string
|
75
|
+
) => {
|
76
|
+
const response = await fetch(url)
|
77
|
+
if (!response.ok) {
|
78
|
+
throw new Error(`Failed to download image: ${response.statusText}`)
|
79
|
+
}
|
80
|
+
|
81
|
+
const contentType =
|
82
|
+
response.headers.get("content-type") || "application/octet-stream"
|
83
|
+
const buffer = await response.arrayBuffer()
|
84
|
+
|
85
|
+
const fileRef = bucket.file(path)
|
86
|
+
await fileRef.save(Buffer.from(buffer), {
|
87
|
+
resumable: false,
|
88
|
+
contentType,
|
89
|
+
})
|
90
|
+
}
|
91
|
+
|
92
|
+
const PARAMS = {
|
93
|
+
expected_grade_level: "7",
|
94
|
+
max_fkgl: 9,
|
95
|
+
max_words: 200,
|
96
|
+
max_rewrite_attempts: 2,
|
97
|
+
max_title_length: 50,
|
98
|
+
}
|
99
|
+
|
100
|
+
export const processImage = async (
|
101
|
+
bucket: Bucket,
|
102
|
+
tutorialDir: string,
|
103
|
+
url: string,
|
104
|
+
description: string,
|
105
|
+
rigoToken: string
|
106
|
+
) => {
|
107
|
+
try {
|
108
|
+
const filename = getFilenameFromUrl(url)
|
109
|
+
|
110
|
+
const imagePath = `${tutorialDir}/.learn/assets/${filename}`
|
111
|
+
|
112
|
+
console.log("🖼️ Generating image", imagePath)
|
113
|
+
|
114
|
+
const res = await generateImage(rigoToken, { prompt: description })
|
115
|
+
await uploadImageToBucket(bucket, res.image_url, imagePath)
|
116
|
+
|
117
|
+
console.log("✅ Image", imagePath, "generated successfully!")
|
118
|
+
return true
|
119
|
+
} catch {
|
120
|
+
return false
|
121
|
+
}
|
122
|
+
}
|
123
|
+
|
124
|
+
export async function processExercise(
|
125
|
+
bucket: Bucket,
|
126
|
+
rigoToken: string,
|
127
|
+
steps: Lesson[],
|
128
|
+
packageContext: string,
|
129
|
+
exercise: Lesson,
|
130
|
+
exercisesDir: string
|
131
|
+
): Promise<string> {
|
132
|
+
// const tid = toast.loading("Generating lesson...")
|
133
|
+
const exSlug = slugify(exercise.id + "-" + exercise.title)
|
134
|
+
const readmeFilename = "README.md"
|
135
|
+
const targetDir = `${exercisesDir}/${exSlug}`
|
136
|
+
|
137
|
+
console.log("✍🏻 Generating lesson", exercise.id, exercise.title)
|
138
|
+
const isGeneratingText = `The lesson ${exercise.id} - ${exercise.title} is being generated by Rigobot, wait a sec!
|
139
|
+
|
140
|
+
\`\`\`loader slug="${exSlug}"
|
141
|
+
# This lesson is being generated by Rigobot, wait a sec!
|
142
|
+
\`\`\`
|
143
|
+
`
|
144
|
+
|
145
|
+
await uploadFileToBucket(
|
146
|
+
bucket,
|
147
|
+
isGeneratingText,
|
148
|
+
`${targetDir}/${readmeFilename}`
|
149
|
+
)
|
150
|
+
|
151
|
+
const readme = await readmeCreator(rigoToken, {
|
152
|
+
title: `${exercise.id} - ${exercise.title}`,
|
153
|
+
output_lang: "en",
|
154
|
+
list_of_exercises: JSON.stringify(steps),
|
155
|
+
tutorial_description: packageContext,
|
156
|
+
lesson_description: exercise.description,
|
157
|
+
kind: exercise.type.toLowerCase(),
|
158
|
+
})
|
159
|
+
|
160
|
+
const duration = exercise.duration
|
161
|
+
let attempts = 0
|
162
|
+
let readability = checkReadability(readme.parsed.content, 200, duration || 1)
|
163
|
+
|
164
|
+
while (
|
165
|
+
readability.fkglResult.fkgl > PARAMS.max_fkgl &&
|
166
|
+
attempts < PARAMS.max_rewrite_attempts
|
167
|
+
) {
|
168
|
+
console.log(
|
169
|
+
"🔄 The lesson",
|
170
|
+
exercise.id,
|
171
|
+
exercise.title,
|
172
|
+
"has a readability score of",
|
173
|
+
readability.fkglResult.fkgl
|
174
|
+
)
|
175
|
+
|
176
|
+
// eslint-disable-next-line
|
177
|
+
const reducedReadme = await makeReadmeReadable(rigoToken, {
|
178
|
+
lesson: readability.body,
|
179
|
+
number_of_words: readability.minutes.toString(),
|
180
|
+
expected_number_words: PARAMS.max_words.toString(),
|
181
|
+
fkgl_results: JSON.stringify(readability.fkglResult),
|
182
|
+
expected_grade_level: PARAMS.expected_grade_level,
|
183
|
+
})
|
184
|
+
|
185
|
+
if (!reducedReadme)
|
186
|
+
break
|
187
|
+
|
188
|
+
readability = checkReadability(
|
189
|
+
reducedReadme.parsed.content,
|
190
|
+
PARAMS.max_words,
|
191
|
+
duration || 1
|
192
|
+
)
|
193
|
+
|
194
|
+
attempts++
|
195
|
+
}
|
196
|
+
|
197
|
+
console.log(
|
198
|
+
`✅ After ${attempts} attempts, the lesson ${
|
199
|
+
exercise.title
|
200
|
+
} has a readability score of ${
|
201
|
+
readability.fkglResult.fkgl
|
202
|
+
} using FKGL. And it has ${readability.minutes.toFixed(
|
203
|
+
2
|
204
|
+
)} minutes of reading time.`
|
205
|
+
)
|
206
|
+
|
207
|
+
await uploadFileToBucket(
|
208
|
+
bucket,
|
209
|
+
readability.newMarkdown,
|
210
|
+
`${targetDir}/${readmeFilename}`
|
211
|
+
)
|
212
|
+
|
213
|
+
if (exercise.type.toLowerCase() === "code") {
|
214
|
+
console.log("🔍 Creating code file for", exercise.title)
|
215
|
+
|
216
|
+
const codeFile = await createCodeFile(rigoToken, {
|
217
|
+
readme: readability.newMarkdown,
|
218
|
+
tutorial_info: packageContext,
|
219
|
+
})
|
220
|
+
await uploadFileToBucket(
|
221
|
+
bucket,
|
222
|
+
codeFile.parsed.content,
|
223
|
+
`${targetDir}/index.${codeFile.parsed.extension.replace(".", "")}`
|
224
|
+
)
|
225
|
+
}
|
226
|
+
|
227
|
+
return readability.newMarkdown
|
228
|
+
}
|
229
|
+
|
230
|
+
interface Lesson {
|
231
|
+
id: string
|
232
|
+
uid: string
|
233
|
+
title: string
|
234
|
+
type: "READ" | "CODE" | "QUIZ"
|
235
|
+
description: string
|
236
|
+
duration?: number
|
237
|
+
}
|
238
|
+
|
239
|
+
interface ParsedLink {
|
240
|
+
url: string
|
241
|
+
title?: string
|
242
|
+
text?: string
|
243
|
+
transcript?: string
|
244
|
+
description?: string
|
245
|
+
author?: string
|
246
|
+
duration?: number
|
247
|
+
thumbnail?: string
|
248
|
+
}
|
249
|
+
type FormState = {
|
250
|
+
description: string
|
251
|
+
duration: number
|
252
|
+
targetAudience: string
|
253
|
+
hasContentIndex: boolean
|
254
|
+
contentIndex: string
|
255
|
+
sources: ParsedLink[]
|
256
|
+
isCompleted: boolean
|
257
|
+
variables: string[]
|
258
|
+
currentStep: string
|
259
|
+
title: string
|
260
|
+
technologies?: string[]
|
261
|
+
}
|
262
|
+
|
263
|
+
type Syllabus = {
|
264
|
+
lessons: Lesson[]
|
265
|
+
courseInfo: FormState
|
266
|
+
}
|
28
267
|
|
29
268
|
const fixPreviewUrl = (slug: string, previewUrl: string) => {
|
30
269
|
if (!previewUrl) {
|
@@ -151,7 +390,7 @@ export default class ServeCommand extends SessionCommand {
|
|
151
390
|
|
152
391
|
app.post(
|
153
392
|
"/upload-image",
|
154
|
-
express.json({ limit:
|
393
|
+
express.json({ limit: 20_000_000 }),
|
155
394
|
async (req, res) => {
|
156
395
|
const { image_url, destination } = req.body
|
157
396
|
if (!image_url || !destination) {
|
@@ -428,6 +667,64 @@ export default class ServeCommand extends SessionCommand {
|
|
428
667
|
res.sendFile(file)
|
429
668
|
})
|
430
669
|
|
670
|
+
app.post("/actions/create-course", async (req, res) => {
|
671
|
+
console.log("POST /actions/create-course")
|
672
|
+
const { syllabus }: { syllabus: Syllabus } = req.body
|
673
|
+
const rigoToken = req.header("x-rigo-token")
|
674
|
+
const bcToken = req.header("x-breathecode-token")
|
675
|
+
|
676
|
+
if (!rigoToken || !bcToken) {
|
677
|
+
return res.status(400).json({ error: "Missing tokens" })
|
678
|
+
}
|
679
|
+
|
680
|
+
const tutorialDir = "courses/" + slugify(syllabus.courseInfo.title)
|
681
|
+
|
682
|
+
const learnJson = createLearnJson(syllabus.courseInfo)
|
683
|
+
await uploadFileToBucket(
|
684
|
+
bucket,
|
685
|
+
JSON.stringify(learnJson),
|
686
|
+
`courses/${slugify(syllabus.courseInfo.title)}/learn.json`
|
687
|
+
)
|
688
|
+
|
689
|
+
const lessonsPromises = syllabus.lessons.map(lesson =>
|
690
|
+
processExercise(
|
691
|
+
bucket,
|
692
|
+
rigoToken,
|
693
|
+
syllabus.lessons,
|
694
|
+
JSON.stringify(syllabus.courseInfo),
|
695
|
+
lesson,
|
696
|
+
tutorialDir + "/exercises"
|
697
|
+
)
|
698
|
+
)
|
699
|
+
const readmeContents = await Promise.all(lessonsPromises)
|
700
|
+
|
701
|
+
let imagesArray: any[] = []
|
702
|
+
|
703
|
+
for (const content of readmeContents) {
|
704
|
+
imagesArray = [...imagesArray, ...extractImagesFromMarkdown(content)]
|
705
|
+
}
|
706
|
+
|
707
|
+
console.log("📷 Generating images...")
|
708
|
+
|
709
|
+
const imagePromises = imagesArray.map(
|
710
|
+
async (image: { alt: string; url: string }) => {
|
711
|
+
return processImage(
|
712
|
+
bucket,
|
713
|
+
tutorialDir,
|
714
|
+
image.url,
|
715
|
+
image.alt,
|
716
|
+
rigoToken
|
717
|
+
)
|
718
|
+
}
|
719
|
+
)
|
720
|
+
await Promise.all(imagePromises)
|
721
|
+
|
722
|
+
return res.json({
|
723
|
+
message: "Course created",
|
724
|
+
slug: slugify(syllabus.courseInfo.title),
|
725
|
+
})
|
726
|
+
})
|
727
|
+
|
431
728
|
app.post("/actions/publish/:slug", async (req, res) => {
|
432
729
|
try {
|
433
730
|
const { slug } = req.params
|
@@ -4,19 +4,12 @@ import useStore from "../../utils/store"
|
|
4
4
|
import { interactiveCreation } from "../../utils/rigo"
|
5
5
|
import {
|
6
6
|
parseLesson,
|
7
|
-
uploadFileToBucket,
|
8
7
|
useConsumableCall,
|
9
8
|
validateTokens,
|
10
|
-
extractImagesFromMarkdown,
|
11
9
|
checkParams,
|
12
10
|
loginWithToken,
|
11
|
+
createCourse,
|
13
12
|
} from "../../utils/lib"
|
14
|
-
import {
|
15
|
-
createLearnJson,
|
16
|
-
processExercise,
|
17
|
-
slugify,
|
18
|
-
processImage,
|
19
|
-
} from "../../utils/creatorUtils"
|
20
13
|
|
21
14
|
import Loader from "../Loader"
|
22
15
|
import { TMessage } from "../Message"
|
@@ -26,10 +19,10 @@ import toast from "react-hot-toast"
|
|
26
19
|
import { ContentIndex } from "./ContentIndex"
|
27
20
|
import { Sidebar } from "./Sidebar"
|
28
21
|
import Login from "../Login"
|
29
|
-
import { eventBus } from "../../utils/eventBus"
|
30
22
|
import { useNavigate } from "react-router"
|
31
23
|
import PreviewGenerator from "../PreviewGenerator"
|
32
24
|
import { ParamsChecker } from "../ParamsChecker"
|
25
|
+
import { slugify } from "../../utils/creatorUtils"
|
33
26
|
|
34
27
|
const SyllabusEditor: React.FC = () => {
|
35
28
|
const navigate = useNavigate()
|
@@ -138,7 +131,6 @@ const SyllabusEditor: React.FC = () => {
|
|
138
131
|
toast.error("Please provide a title for the course")
|
139
132
|
return
|
140
133
|
}
|
141
|
-
setIsGenerating(true)
|
142
134
|
|
143
135
|
let tokenToUse = auth.rigoToken
|
144
136
|
const onValidRigoToken = (rigotoken: string) => {
|
@@ -159,54 +151,19 @@ const SyllabusEditor: React.FC = () => {
|
|
159
151
|
return
|
160
152
|
}
|
161
153
|
setIsGenerating(true)
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
)
|
172
|
-
)
|
173
|
-
const readmeContents = await Promise.all(lessonsPromises)
|
174
|
-
|
175
|
-
let imagesArray: any[] = []
|
176
|
-
|
177
|
-
for (const content of readmeContents) {
|
178
|
-
imagesArray = [...imagesArray, ...extractImagesFromMarkdown(content)]
|
179
|
-
}
|
180
|
-
|
181
|
-
eventBus.emit("course-generation", {
|
182
|
-
message: "📷 Generating images...",
|
183
|
-
})
|
184
|
-
|
185
|
-
const imagePromises = imagesArray.map(
|
186
|
-
async (image: { alt: string; url: string }) => {
|
187
|
-
return processImage(tutorialDir, image.url, image.alt, tokenToUse)
|
188
|
-
}
|
189
|
-
)
|
190
|
-
await Promise.all(imagePromises)
|
191
|
-
|
192
|
-
const learnJson = createLearnJson(syllabus.courseInfo)
|
193
|
-
await uploadFileToBucket(
|
194
|
-
JSON.stringify(learnJson),
|
195
|
-
"courses/" + slugify(syllabus.courseInfo.title) + "/learn.json"
|
196
|
-
)
|
197
|
-
|
198
|
-
cleanHistory()
|
199
|
-
|
200
|
-
window.location.href = `/preview/${slugify(
|
201
|
-
syllabus.courseInfo.title
|
202
|
-
)}?token=${auth.bcToken}`
|
203
|
-
// setIsGenerating(false)
|
154
|
+
createCourse(syllabus, tokenToUse, auth.bcToken)
|
155
|
+
|
156
|
+
setTimeout(() => {
|
157
|
+
cleanHistory()
|
158
|
+
setIsGenerating(false)
|
159
|
+
window.location.href = `/preview/${slugify(
|
160
|
+
syllabus.courseInfo.title || ""
|
161
|
+
)}?token=${auth.bcToken}`
|
162
|
+
}, 1000)
|
204
163
|
}
|
205
164
|
|
206
165
|
if (!syllabus) return null
|
207
166
|
|
208
|
-
// console.log(auth.user)
|
209
|
-
|
210
167
|
return isGenerating ? (
|
211
168
|
<>
|
212
169
|
<Loader
|
@@ -1,24 +1,24 @@
|
|
1
|
-
import { Bucket } from "@google-cloud/storage"
|
2
|
-
import { Storage } from "@google-cloud/storage"
|
3
|
-
import * as path from "path"
|
4
|
-
|
5
|
-
/**
|
6
|
-
* Sube contenido de texto directamente a un bucket de GCS.
|
7
|
-
* @param bucket Instancia de Bucket
|
8
|
-
* @param destination Ruta destino dentro del bucket (incluye carpeta/nombre)
|
9
|
-
* @param textContent Contenido textual a subir
|
10
|
-
*/
|
11
|
-
export async function uploadTextToBucket(
|
12
|
-
bucket: Bucket,
|
13
|
-
destination: string,
|
14
|
-
textContent: string
|
15
|
-
): Promise<void> {
|
16
|
-
const file = bucket.file(destination)
|
17
|
-
|
18
|
-
await file.save(textContent, {
|
19
|
-
resumable: false,
|
20
|
-
contentType: "text/plain",
|
21
|
-
})
|
22
|
-
|
23
|
-
console.log(`✅ Texto subido a gs://${bucket.name}/${destination}`)
|
24
|
-
}
|
1
|
+
import { Bucket } from "@google-cloud/storage"
|
2
|
+
import { Storage } from "@google-cloud/storage"
|
3
|
+
import * as path from "path"
|
4
|
+
|
5
|
+
/**
|
6
|
+
* Sube contenido de texto directamente a un bucket de GCS.
|
7
|
+
* @param bucket Instancia de Bucket
|
8
|
+
* @param destination Ruta destino dentro del bucket (incluye carpeta/nombre)
|
9
|
+
* @param textContent Contenido textual a subir
|
10
|
+
*/
|
11
|
+
export async function uploadTextToBucket(
|
12
|
+
bucket: Bucket,
|
13
|
+
destination: string,
|
14
|
+
textContent: string
|
15
|
+
): Promise<void> {
|
16
|
+
const file = bucket.file(destination)
|
17
|
+
|
18
|
+
await file.save(textContent, {
|
19
|
+
resumable: false,
|
20
|
+
contentType: "text/plain",
|
21
|
+
})
|
22
|
+
|
23
|
+
console.log(`✅ Texto subido a gs://${bucket.name}/${destination}`)
|
24
|
+
}
|