@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.
@@ -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 { translateExercise, isValidRigoToken } from "../utils/rigoActions"
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 frontMatter = require("front-matter")
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: "20mb" }),
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
- const tutorialDir = "courses/" + slugify(syllabus.courseInfo.title)
164
- const lessonsPromises = syllabus.lessons.map((lesson) =>
165
- processExercise(
166
- tokenToUse,
167
- syllabus.lessons,
168
- JSON.stringify(syllabus.courseInfo),
169
- lesson,
170
- tutorialDir + "/exercises"
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
+ }