@learnpack/learnpack 5.0.275 → 5.0.277

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 (95) hide show
  1. package/README.md +409 -409
  2. package/lib/commands/audit.js +15 -15
  3. package/lib/commands/breakToken.js +19 -19
  4. package/lib/commands/clean.js +3 -3
  5. package/lib/commands/init.js +41 -41
  6. package/lib/commands/logout.js +3 -3
  7. package/lib/commands/publish.js +5 -10
  8. package/lib/commands/serve.js +55 -2
  9. package/lib/creatorDist/assets/index-BfLyIQVh.js +10343 -10224
  10. package/lib/managers/config/index.js +77 -77
  11. package/lib/utils/api.d.ts +1 -1
  12. package/lib/utils/api.js +12 -9
  13. package/lib/utils/creatorUtilities.js +14 -14
  14. package/lib/utils/export/epub.d.ts +2 -0
  15. package/lib/utils/export/epub.js +298 -0
  16. package/lib/utils/export/index.d.ts +3 -0
  17. package/lib/utils/export/index.js +7 -0
  18. package/lib/utils/export/scorm.d.ts +2 -0
  19. package/lib/utils/export/scorm.js +84 -0
  20. package/lib/utils/export/shared.d.ts +4 -0
  21. package/lib/utils/export/shared.js +61 -0
  22. package/lib/utils/export/types.d.ts +15 -0
  23. package/lib/utils/export/types.js +2 -0
  24. package/package.json +2 -1
  25. package/src/commands/audit.ts +487 -487
  26. package/src/commands/breakToken.ts +67 -67
  27. package/src/commands/clean.ts +30 -30
  28. package/src/commands/init.ts +650 -650
  29. package/src/commands/logout.ts +38 -38
  30. package/src/commands/publish.ts +20 -25
  31. package/src/commands/serve.ts +69 -4
  32. package/src/commands/start.ts +333 -333
  33. package/src/commands/translate.ts +123 -123
  34. package/src/creator/README.md +54 -54
  35. package/src/creator/eslint.config.js +7 -7
  36. package/src/creator/src/components/syllabus/ContentIndex.tsx +312 -312
  37. package/src/creator/src/i18n.ts +28 -28
  38. package/src/creator/src/index.css +217 -217
  39. package/src/creator/src/locales/en.json +126 -126
  40. package/src/creator/src/locales/es.json +126 -126
  41. package/src/creator/src/utils/configTypes.ts +122 -122
  42. package/src/creator/src/utils/constants.ts +13 -13
  43. package/src/creator/src/utils/creatorUtils.ts +46 -46
  44. package/src/creator/src/utils/eventBus.ts +2 -2
  45. package/src/creator/src/utils/lib.ts +468 -468
  46. package/src/creator/src/utils/socket.ts +61 -61
  47. package/src/creator/src/utils/store.ts +222 -222
  48. package/src/creator/src/vite-env.d.ts +1 -1
  49. package/src/creator/vite.config.ts +13 -13
  50. package/src/creatorDist/assets/index-BfLyIQVh.js +10343 -10224
  51. package/src/managers/config/defaults.ts +49 -49
  52. package/src/managers/config/exercise.ts +364 -364
  53. package/src/managers/config/index.ts +775 -775
  54. package/src/managers/file.ts +236 -236
  55. package/src/managers/server/routes.ts +554 -554
  56. package/src/managers/session.ts +182 -182
  57. package/src/managers/telemetry.ts +188 -188
  58. package/src/models/action.ts +13 -13
  59. package/src/models/config-manager.ts +28 -28
  60. package/src/models/config.ts +106 -106
  61. package/src/models/creator.ts +47 -47
  62. package/src/models/exercise-obj.ts +30 -30
  63. package/src/models/session.ts +39 -39
  64. package/src/models/socket.ts +61 -61
  65. package/src/models/status.ts +16 -16
  66. package/src/ui/_app/app.css +1 -1
  67. package/src/ui/_app/app.js +400 -397
  68. package/src/ui/app.tar.gz +0 -0
  69. package/src/utils/BaseCommand.ts +56 -56
  70. package/src/utils/api.ts +53 -39
  71. package/src/utils/audit.ts +392 -392
  72. package/src/utils/checkNotInstalled.ts +267 -267
  73. package/src/utils/configBuilder.ts +82 -82
  74. package/src/utils/convertCreds.js +34 -34
  75. package/src/utils/creatorUtilities.ts +504 -504
  76. package/src/utils/export/README.md +178 -0
  77. package/src/utils/export/epub.ts +400 -0
  78. package/src/utils/export/index.ts +3 -0
  79. package/src/utils/export/scorm.ts +121 -0
  80. package/src/utils/export/shared.ts +61 -0
  81. package/src/utils/export/types.ts +17 -0
  82. package/src/utils/incrementVersion.js +74 -74
  83. package/src/utils/misc.ts +58 -58
  84. package/src/utils/rigoActions.ts +500 -500
  85. package/src/utils/sidebarGenerator.ts +195 -195
  86. package/src/utils/templates/epub/epub.css +133 -0
  87. package/src/utils/templates/isolated/exercises/01-hello-world/README.es.md +26 -26
  88. package/src/utils/templates/isolated/exercises/01-hello-world/README.md +26 -26
  89. package/src/utils/templates/scorm/adlcp_rootv1p2.xsd +110 -0
  90. package/src/utils/templates/scorm/config/api.js +175 -0
  91. package/src/utils/templates/scorm/config/index.html +210 -0
  92. package/src/utils/templates/scorm/ims_xml.xsd +1 -0
  93. package/src/utils/templates/scorm/imscp_rootv1p1p2.xsd +345 -0
  94. package/src/utils/templates/scorm/imsmanifest.xml +38 -0
  95. package/src/utils/templates/scorm/imsmd_rootv1p2p1.xsd +573 -0
@@ -1,650 +1,650 @@
1
- import { flags } from "@oclif/command"
2
- import BaseCommand from "../utils/BaseCommand"
3
- import ora from "ora"
4
-
5
- // eslint-disable-next-line
6
- import * as fs from "fs-extra"
7
- import * as prompts from "prompts"
8
- import cli from "cli-ux"
9
- import * as eta from "eta"
10
-
11
- import api from "../utils/api"
12
- import Console from "../utils/console"
13
- import { ValidationError } from "../utils/errors"
14
-
15
- import * as path from "path"
16
- import {
17
- // hasCreatorPermission,
18
- generateImage,
19
- downloadImage,
20
- interactiveCreation,
21
- createCodeFile,
22
- readmeCreator,
23
- createPreviewReadme,
24
- makeReadmeReadable,
25
- isValidRigoToken,
26
- } from "../utils/rigoActions"
27
- import { getConsumable } from "../utils/api"
28
- import {
29
- checkReadability,
30
- PackageInfo,
31
- getExInfo,
32
- extractImagesFromMarkdown,
33
- getFilenameFromUrl,
34
- makePackageInfo,
35
- estimateDuration,
36
- appendContentIndex,
37
- appendAIRules,
38
- slugify,
39
- } from "../utils/creatorUtilities"
40
- import SessionManager from "../managers/session"
41
-
42
- const durationByKind: Record<string, number> = {
43
- code: 5,
44
- quiz: 3,
45
- read: 2,
46
- }
47
-
48
- function estimateActivities(estimatedDuration: number) {
49
- const result: Record<string, number> = {}
50
-
51
- for (const kind of Object.keys(durationByKind)) {
52
- result[kind] = Math.floor(estimatedDuration / durationByKind[kind])
53
- }
54
-
55
- return result
56
- }
57
-
58
- const PARAMS = {
59
- expected_grade_level: "6",
60
- max_fkgl: 8,
61
- max_words: 200,
62
- max_rewrite_attempts: 3,
63
- max_title_length: 50,
64
- }
65
-
66
- const whichTargetAudienceAndEstimatedDuration = async () => {
67
- const res = await prompts([
68
- {
69
- type: "text",
70
- name: "targetAudience",
71
- message: "What is the target audience for this tutorial?",
72
- },
73
- {
74
- type: "number",
75
- name: "estimatedDuration",
76
- message: "What is the estimated duration for this tutorial in minutes?",
77
- },
78
- ])
79
- return {
80
- targetAudience: res.targetAudience,
81
- estimatedDuration: res.estimatedDuration,
82
- }
83
- }
84
-
85
- async function processExercise(
86
- rigoToken: string,
87
- steps: string[],
88
- packageContext: string,
89
- exercise: any,
90
- exercisesDir: string
91
- ): Promise<string> {
92
- const { exNumber, exTitle, kind, description } = getExInfo(exercise)
93
- const exerciseDir = path.join(exercisesDir, `${exNumber}-${exTitle}`)
94
-
95
- const readme = await readmeCreator(
96
- rigoToken,
97
- {
98
- title: `${exNumber} - ${exTitle}`,
99
- output_lang: "en",
100
- list_of_exercises: steps.join(","),
101
- tutorial_description: packageContext,
102
- lesson_description: description,
103
- kind: kind.toLowerCase(),
104
- last_lesson: "",
105
- },
106
- "learnpack-lesson-writer"
107
- )
108
-
109
- const duration = durationByKind[kind.toLowerCase()]
110
- let attempts = 0
111
- let readability = checkReadability(readme.parsed.content, 200, duration || 1)
112
-
113
- while (
114
- readability.fkglResult.fkgl > PARAMS.max_fkgl &&
115
- attempts < PARAMS.max_rewrite_attempts
116
- ) {
117
- Console.warning(
118
- `The lesson ${exTitle} has as readability score of ${
119
- readability.fkglResult.fkgl
120
- } . It exceeds the maximum of words per minute. Rewriting it... (Attempt ${
121
- attempts + 1
122
- })`
123
- )
124
-
125
- // eslint-disable-next-line
126
- const reducedReadme = await makeReadmeReadable(
127
- rigoToken,
128
- {
129
- lesson: readability.body,
130
- number_of_words: readability.minutes.toString(),
131
- expected_number_words: PARAMS.max_words.toString(),
132
- fkgl_results: JSON.stringify(readability.fkglResult),
133
- expected_grade_level: PARAMS.expected_grade_level,
134
- },
135
- "learnpack-lesson-writer"
136
- )
137
-
138
- // console.log("REDUCED README START", reducedReadme, "REDUCED README END")
139
-
140
- if (!reducedReadme) break
141
-
142
- readability = checkReadability(
143
- reducedReadme.parsed.content,
144
- PARAMS.max_words,
145
- duration || 1
146
- )
147
-
148
- attempts++
149
- }
150
-
151
- Console.success(
152
- `After ${attempts} attempts, the lesson ${exTitle} has a readability score of ${
153
- readability.fkglResult.fkgl
154
- } using FKGL. And it has ${readability.minutes.toFixed(
155
- 2
156
- )} minutes of reading time.`
157
- )
158
-
159
- const readmeFilename = "README.md"
160
- fs.writeFileSync(
161
- path.join(exerciseDir, readmeFilename),
162
- readability.newMarkdown
163
- )
164
-
165
- if (kind.toLowerCase() === "code") {
166
- const codeFile = await createCodeFile(rigoToken, {
167
- readme: readability.newMarkdown,
168
- tutorial_info: packageContext,
169
- })
170
-
171
- fs.writeFileSync(
172
- path.join(
173
- exerciseDir,
174
- `app.${codeFile.parsed.extension.replace(".", "")}`
175
- ),
176
- codeFile.parsed.content
177
- )
178
- }
179
-
180
- return readability.newMarkdown
181
- }
182
-
183
- const initializeInteractiveCreation = async (
184
- rigoToken: string,
185
- courseInfo: string
186
- ): Promise<{
187
- steps: string[]
188
- title: string
189
- description: string
190
- interactions: string
191
- difficulty: string
192
- duration: number
193
- }> => {
194
- let prevInteractions = ""
195
- let isReady = false
196
- let currentSteps = []
197
- let currentTitle = ""
198
- let currentDescription = ""
199
- let currentDifficulty = ""
200
-
201
- while (!isReady) {
202
- const spinner = ora("Thinking...").start()
203
- let wholeInfo = courseInfo
204
- wholeInfo += `
205
- Current title: ${currentTitle}
206
- Current description: ${currentDescription}
207
- `
208
- // eslint-disable-next-line
209
- const res = await interactiveCreation(rigoToken, {
210
- courseInfo: wholeInfo,
211
- prevInteractions: prevInteractions,
212
- })
213
- spinner.succeed("Done!")
214
- currentSteps = res.parsed.listOfSteps
215
- isReady = res.parsed.ready
216
- if (res.parsed.title && currentTitle !== res.parsed.title) {
217
- currentTitle = res.parsed.title
218
- }
219
-
220
- if (
221
- res.parsed.description &&
222
- currentDescription !== res.parsed.description
223
- ) {
224
- currentDescription = res.parsed.description
225
- }
226
-
227
- if (res.parsed.difficulty && currentDifficulty !== res.parsed.difficulty) {
228
- currentDifficulty = res.parsed.difficulty
229
- }
230
-
231
- if (!isReady) {
232
- console.log(currentSteps)
233
- Console.info(`AI: ${res.parsed.aiMessage}`)
234
- prevInteractions += `\nAI: ${res.parsed.aiMessage}`
235
- // eslint-disable-next-line
236
- const userMessage = await prompts([
237
- {
238
- type: "text",
239
- name: "userMessage",
240
- message: "Your message: ",
241
- },
242
- ])
243
- prevInteractions += `\nUser: ${userMessage.userMessage}`
244
- }
245
- }
246
-
247
- const duration = estimateDuration(currentSteps)
248
- return {
249
- steps: currentSteps,
250
- title: currentTitle,
251
- description: currentDescription,
252
- interactions: prevInteractions,
253
- difficulty: currentDifficulty,
254
- duration,
255
- }
256
- }
257
-
258
- process.emitWarning = (warning: string) => {
259
- Console.debug("A Warning was emitted by Node.js: ", warning)
260
- }
261
-
262
- const handleAILogic = async (tutorialDir: string, packageInfo: PackageInfo) => {
263
- fs.removeSync(path.join(tutorialDir, "exercises", "01-hello-world"))
264
-
265
- let sessionPayload = await SessionManager.getPayload()
266
-
267
- const sessionExists = sessionPayload && sessionPayload.rigobot
268
-
269
- const isValidToken =
270
- sessionExists && sessionPayload.rigobot.key ?
271
- await isValidRigoToken(sessionPayload.rigobot.key) :
272
- false
273
-
274
- const isValidBreathecodeToken =
275
- sessionExists && sessionPayload.token ?
276
- await api.validateToken(sessionPayload.token) :
277
- false
278
-
279
- if (!sessionExists || !isValidBreathecodeToken || !isValidToken) {
280
- Console.info(
281
- "Almost there! First you need to login with 4Geeks.com to use AI Generation tool for creators. You can create a new account here: https://4geeks.com/checkout?plan=4geeks-creator"
282
- )
283
- try {
284
- sessionPayload = await SessionManager.login()
285
- } catch (error) {
286
- Console.error("Error trying to authenticate")
287
- Console.error((error as TypeError).message || (error as string))
288
- }
289
- }
290
-
291
- const rigoToken = sessionPayload.rigobot.key
292
-
293
- const consumable = await getConsumable(sessionPayload.token, "ai-generation")
294
-
295
- if (consumable.count === 0) {
296
- Console.error(
297
- "It seems you cannot generate tutorials with AI. Make sure you creator subscription is up to date here: https://4geeks.com/profile/subscriptions. If you believe there is an issue you can always contact support@4geeks.com"
298
- )
299
- process.exit(1)
300
- }
301
-
302
- // const isCreator = await hasCreatorPermission(rigoToken)
303
- // if (!isCreator) {
304
- // Console.error(
305
- // "👀 Oops! You need to be a creator to use our specialized AI. Please contact support"
306
- // )
307
- // process.exit(1)
308
- // }
309
-
310
- Console.success("🎉 Let's begin this learning journey!")
311
-
312
- const { targetAudience, estimatedDuration } =
313
- await whichTargetAudienceAndEstimatedDuration()
314
- const contentIndex = await appendContentIndex()
315
- const airules = await appendAIRules()
316
-
317
- if (airules) {
318
- const rulesDir = path.join(tutorialDir, ".learn", "rules")
319
-
320
- // Crear el directorio si no existe
321
- fs.mkdirSync(rulesDir, { recursive: true })
322
-
323
- fs.writeFileSync(path.join(rulesDir, "airules.txt"), airules)
324
- }
325
-
326
- let packageContext = `
327
- \n
328
- Title: "${packageInfo.title.us}"
329
- Description: "${packageInfo.description.us}"
330
- Target Audience: "${targetAudience}"
331
- Estimated Duration: "${estimatedDuration} minutes"
332
-
333
- ${
334
- contentIndex ?
335
- `Content Index submitted by the user, use this to guide your creation. Keep in mind that your tutorial should contain these topics:
336
- ---
337
- ${contentIndex}
338
- ---
339
- ` :
340
- ""
341
- }
342
-
343
- This is the duration for each type of step, use it to estimate the number of steps to create:
344
- ${Object.entries(durationByKind)
345
- .map(([key, value]) => `${key}: ${value} minutes`)
346
- .join("\n")}
347
-
348
-
349
- Within the estimated duration, is possible to have the following activities:
350
- Format=
351
- Activity: Maximum number of steps for duration
352
-
353
- Estimated activities:
354
- ${JSON.stringify(estimateActivities(estimatedDuration))}
355
-
356
- You should create a tutorial that is engaging and fun to follow.
357
-
358
-
359
- ${
360
- airules ?
361
- `
362
- This is a list of rules you need to follow when creating the tutorial:
363
- ${airules}
364
- ` :
365
- ""
366
- }
367
- `
368
-
369
- const { steps, title, description, duration, difficulty } =
370
- await initializeInteractiveCreation(rigoToken, packageContext)
371
- packageInfo.title.us = title
372
- packageInfo.description.us = description
373
- packageInfo.duration = duration
374
- packageInfo.difficulty = difficulty
375
-
376
- packageContext = `
377
- Title: "${title}"
378
- Description: "${description}"
379
- Target Audience: "${targetAudience}"
380
- List of exercises: ${steps.join(", ")}
381
-
382
- AI Rules: ${airules}
383
- `
384
- const exercisesDir = path.join(tutorialDir, "exercises")
385
- fs.ensureDirSync(exercisesDir)
386
-
387
- Console.info("Creating lessons...")
388
- for (const [index, exercise] of steps.entries()) {
389
- const { exNumber, exTitle } = getExInfo(exercise)
390
- const exerciseDir = path.join(exercisesDir, `${exNumber}-${exTitle}`)
391
- fs.ensureDirSync(exerciseDir)
392
- }
393
-
394
- const exercisePromises = steps.map(exercise =>
395
- processExercise(rigoToken, steps, packageContext, exercise, exercisesDir)
396
- )
397
-
398
- const readmeContents = await Promise.all(exercisePromises)
399
- Console.success("Lessons created! 🎉")
400
-
401
- Console.info("Generating images for the lessons...")
402
- let imagesArray: any[] = []
403
-
404
- for (const content of readmeContents) {
405
- imagesArray = [...imagesArray, ...extractImagesFromMarkdown(content)]
406
- }
407
-
408
- const imagePromises = imagesArray.map(async (image: any) => {
409
- try {
410
- const filename = getFilenameFromUrl(image.url)
411
- const webhookUrl = `${process.env.HOST}/webhooks/${slugify(
412
- packageInfo.title.us
413
- )}/images/${filename}`
414
-
415
- const imagePath = path.join(tutorialDir, ".learn", "assets", filename)
416
-
417
- const res = await generateImage(rigoToken, {
418
- prompt: image.alt,
419
- callbackUrl: webhookUrl,
420
- })
421
- await downloadImage(res.image_url, imagePath)
422
- return true
423
- } catch {
424
- Console.error(`Error downloading image ${image.url}`)
425
-
426
- return false
427
- }
428
- })
429
- await Promise.all(imagePromises)
430
- Console.info(
431
- "Images generated successfully! 🎉 Your tutorial will be ready soon!"
432
- )
433
-
434
- Console.info("Creating preview readme...")
435
-
436
- await createPreviewReadme(tutorialDir, packageInfo, rigoToken, readmeContents)
437
- const imagePath = path.join(tutorialDir, "preview.png")
438
-
439
- const res = await generateImage(rigoToken, {
440
- prompt:
441
- "Generate a preview image for the tutorial. This is all the tutorial information: " +
442
- packageContext +
443
- "\n Generate only a basic preview image, add the tutorial Title as a text add the top middle, avoid adding any other text elements. Try to generate an image that related with the tutorial content.",
444
- callbackUrl: `${process.env.HOST}/webhooks/${slugify(
445
- packageInfo.title.us
446
- )}/images/preview.png`,
447
- })
448
-
449
- // await downloadImage(res.image_url, imagePath)
450
-
451
- return true
452
- }
453
-
454
- const getChoices = async (empty: boolean) => {
455
- const defaultChoices = {
456
- title: "My Interactive Tutorial",
457
- description: "",
458
- difficulty: "beginner",
459
- duration: 5,
460
- useAI: "no",
461
- grading: "isolated",
462
- }
463
-
464
- if (empty) {
465
- return defaultChoices
466
- }
467
-
468
- const choices = await prompts([
469
- {
470
- type: "select",
471
- name: "grading",
472
- message: "How are you going to grade students or yourself?",
473
- choices: [
474
- {
475
- title:
476
- "No grading: No feedback or testing whatsoever, similar to a an interactive book.",
477
- value: null,
478
- },
479
- {
480
- title: "Isolated: Each step is a new separate exercise",
481
- value: "isolated",
482
- },
483
- {
484
- title:
485
- "Step by step: Each step builds on top of each other like an incremental tutorial",
486
- value: "incremental",
487
- },
488
- ],
489
- },
490
- {
491
- type: "text",
492
-
493
- name: "title",
494
- initial: "My Interactive Tutorial",
495
- message: "Title for your tutorial? Press enter to leave as it is",
496
- validate: (value: string) => {
497
- if (value.length > PARAMS.max_title_length) {
498
- return `Title must be less than ${PARAMS.max_title_length} characters`
499
- }
500
-
501
- return true
502
- },
503
- },
504
- {
505
- type: "text",
506
- name: "description",
507
- initial: "",
508
- message: "Description for your tutorial? Press enter to leave blank",
509
- },
510
-
511
- {
512
- type: "select",
513
- name: "useAI",
514
- message:
515
- "Want a little bit of AI magic to help you? Our AI can craft the tutorial for you",
516
- choices: [
517
- {
518
- title: "Yes, please help me",
519
- value: "yes",
520
- },
521
- { title: "No, thanks, I prefer to do it manually", value: "no" },
522
- ],
523
- },
524
- ])
525
-
526
- const completeChoices = {
527
- ...choices,
528
- difficulty: "beginner",
529
- duration: 30,
530
- }
531
- return completeChoices
532
- }
533
-
534
- class InitComand extends BaseCommand {
535
- static description =
536
- "Create a new learning package: Book, Tutorial or Exercise"
537
-
538
- static flags = {
539
- ...BaseCommand.flags,
540
- grading: flags.help({ char: "h" }),
541
- }
542
-
543
- async run() {
544
- const { flags } = this.parse(InitComand)
545
-
546
- await alreadyInitialized()
547
-
548
- const choices = await getChoices(flags.yes)
549
-
550
- const packageInfo = makePackageInfo(choices)
551
-
552
- const tutorialDir = `./${packageInfo.slug}`
553
- fs.ensureDirSync(tutorialDir)
554
-
555
- const templatesDir = path.resolve(
556
- __dirname,
557
- "../../src/utils/templates/" + (choices.grading || "no-grading")
558
- )
559
- if (!fs.existsSync(templatesDir))
560
- throw ValidationError(`Template ${templatesDir} does not exists`)
561
- await fs.copySync(templatesDir, tutorialDir)
562
-
563
- if (choices.useAI === "yes") {
564
- await handleAILogic(tutorialDir, packageInfo)
565
- }
566
-
567
- const languages = ["en", "es"]
568
-
569
- // Creating README files
570
- for (const language of languages) {
571
- const readmeFilename = `README${language !== "en" ? `.${language}` : ""}`
572
- const readmeTemplatePath = path.resolve(
573
- templatesDir,
574
- `${readmeFilename}.ejs`
575
- )
576
-
577
- if (choices.useAI !== "yes") {
578
- const readmeObject = {
579
- title: packageInfo.title.us,
580
- description: packageInfo.description.us,
581
- grading: packageInfo.grading,
582
- difficulty: packageInfo.difficulty,
583
- duration: packageInfo.duration,
584
- }
585
- const readmeContent = eta.render(
586
- fs.readFileSync(readmeTemplatePath, "utf-8"),
587
- readmeObject
588
- )
589
- fs.writeFileSync(
590
- path.join(tutorialDir, `${readmeFilename}.md`),
591
- readmeContent
592
- )
593
- }
594
-
595
- if (fs.existsSync(path.join(tutorialDir, `${readmeFilename}.ejs`)))
596
- fs.removeSync(path.join(tutorialDir, `${readmeFilename}.ejs`))
597
- }
598
-
599
- cli.action.start("Initializing package")
600
-
601
- if (!fs.existsSync(path.join(tutorialDir, ".gitignore")))
602
- fs.copyFile(
603
- path.resolve(__dirname, "../../src/utils/templates/gitignore.txt"),
604
- path.join(tutorialDir, ".gitignore")
605
- )
606
-
607
- fs.writeFileSync(
608
- path.join(tutorialDir, "learn.json"),
609
- JSON.stringify(packageInfo, null, 2)
610
- )
611
-
612
- cli.action.stop()
613
- Console.success(`😋 Package initialized successfully in ${tutorialDir}`)
614
- Console.help(
615
- `Get inside the tutorial with the command: $ cd ${tutorialDir}`
616
- )
617
- Console.help(
618
- `Start the exercises by running the following command on your terminal: $ learnpack start`
619
- )
620
- process.exit(0)
621
- }
622
- }
623
-
624
- const alreadyInitialized = () =>
625
- new Promise((resolve, reject) => {
626
- fs.readdir("./", function (err: any, files: any) {
627
- files = files.filter((f: any) =>
628
- [".learn", "learn.json", "bc.json", ".breathecode"].includes(f)
629
- )
630
- if (err) {
631
- reject(ValidationError(err.message))
632
- throw ValidationError(err.message)
633
- } else if (files.length > 0) {
634
- reject(
635
- ValidationError(
636
- "It seems the package is already initialized because we've found the following files: " +
637
- files.join(",")
638
- )
639
- )
640
- throw ValidationError(
641
- "It seems the package is already initialized because we've found the following files: " +
642
- files.join(",")
643
- )
644
- }
645
-
646
- resolve(false)
647
- })
648
- })
649
-
650
- export default InitComand
1
+ import { flags } from "@oclif/command"
2
+ import BaseCommand from "../utils/BaseCommand"
3
+ import ora from "ora"
4
+
5
+ // eslint-disable-next-line
6
+ import * as fs from "fs-extra"
7
+ import * as prompts from "prompts"
8
+ import cli from "cli-ux"
9
+ import * as eta from "eta"
10
+
11
+ import api from "../utils/api"
12
+ import Console from "../utils/console"
13
+ import { ValidationError } from "../utils/errors"
14
+
15
+ import * as path from "path"
16
+ import {
17
+ // hasCreatorPermission,
18
+ generateImage,
19
+ downloadImage,
20
+ interactiveCreation,
21
+ createCodeFile,
22
+ readmeCreator,
23
+ createPreviewReadme,
24
+ makeReadmeReadable,
25
+ isValidRigoToken,
26
+ } from "../utils/rigoActions"
27
+ import { getConsumable } from "../utils/api"
28
+ import {
29
+ checkReadability,
30
+ PackageInfo,
31
+ getExInfo,
32
+ extractImagesFromMarkdown,
33
+ getFilenameFromUrl,
34
+ makePackageInfo,
35
+ estimateDuration,
36
+ appendContentIndex,
37
+ appendAIRules,
38
+ slugify,
39
+ } from "../utils/creatorUtilities"
40
+ import SessionManager from "../managers/session"
41
+
42
+ const durationByKind: Record<string, number> = {
43
+ code: 5,
44
+ quiz: 3,
45
+ read: 2,
46
+ }
47
+
48
+ function estimateActivities(estimatedDuration: number) {
49
+ const result: Record<string, number> = {}
50
+
51
+ for (const kind of Object.keys(durationByKind)) {
52
+ result[kind] = Math.floor(estimatedDuration / durationByKind[kind])
53
+ }
54
+
55
+ return result
56
+ }
57
+
58
+ const PARAMS = {
59
+ expected_grade_level: "6",
60
+ max_fkgl: 8,
61
+ max_words: 200,
62
+ max_rewrite_attempts: 3,
63
+ max_title_length: 50,
64
+ }
65
+
66
+ const whichTargetAudienceAndEstimatedDuration = async () => {
67
+ const res = await prompts([
68
+ {
69
+ type: "text",
70
+ name: "targetAudience",
71
+ message: "What is the target audience for this tutorial?",
72
+ },
73
+ {
74
+ type: "number",
75
+ name: "estimatedDuration",
76
+ message: "What is the estimated duration for this tutorial in minutes?",
77
+ },
78
+ ])
79
+ return {
80
+ targetAudience: res.targetAudience,
81
+ estimatedDuration: res.estimatedDuration,
82
+ }
83
+ }
84
+
85
+ async function processExercise(
86
+ rigoToken: string,
87
+ steps: string[],
88
+ packageContext: string,
89
+ exercise: any,
90
+ exercisesDir: string
91
+ ): Promise<string> {
92
+ const { exNumber, exTitle, kind, description } = getExInfo(exercise)
93
+ const exerciseDir = path.join(exercisesDir, `${exNumber}-${exTitle}`)
94
+
95
+ const readme = await readmeCreator(
96
+ rigoToken,
97
+ {
98
+ title: `${exNumber} - ${exTitle}`,
99
+ output_lang: "en",
100
+ list_of_exercises: steps.join(","),
101
+ tutorial_description: packageContext,
102
+ lesson_description: description,
103
+ kind: kind.toLowerCase(),
104
+ last_lesson: "",
105
+ },
106
+ "learnpack-lesson-writer"
107
+ )
108
+
109
+ const duration = durationByKind[kind.toLowerCase()]
110
+ let attempts = 0
111
+ let readability = checkReadability(readme.parsed.content, 200, duration || 1)
112
+
113
+ while (
114
+ readability.fkglResult.fkgl > PARAMS.max_fkgl &&
115
+ attempts < PARAMS.max_rewrite_attempts
116
+ ) {
117
+ Console.warning(
118
+ `The lesson ${exTitle} has as readability score of ${
119
+ readability.fkglResult.fkgl
120
+ } . It exceeds the maximum of words per minute. Rewriting it... (Attempt ${
121
+ attempts + 1
122
+ })`
123
+ )
124
+
125
+ // eslint-disable-next-line
126
+ const reducedReadme = await makeReadmeReadable(
127
+ rigoToken,
128
+ {
129
+ lesson: readability.body,
130
+ number_of_words: readability.minutes.toString(),
131
+ expected_number_words: PARAMS.max_words.toString(),
132
+ fkgl_results: JSON.stringify(readability.fkglResult),
133
+ expected_grade_level: PARAMS.expected_grade_level,
134
+ },
135
+ "learnpack-lesson-writer"
136
+ )
137
+
138
+ // console.log("REDUCED README START", reducedReadme, "REDUCED README END")
139
+
140
+ if (!reducedReadme) break
141
+
142
+ readability = checkReadability(
143
+ reducedReadme.parsed.content,
144
+ PARAMS.max_words,
145
+ duration || 1
146
+ )
147
+
148
+ attempts++
149
+ }
150
+
151
+ Console.success(
152
+ `After ${attempts} attempts, the lesson ${exTitle} has a readability score of ${
153
+ readability.fkglResult.fkgl
154
+ } using FKGL. And it has ${readability.minutes.toFixed(
155
+ 2
156
+ )} minutes of reading time.`
157
+ )
158
+
159
+ const readmeFilename = "README.md"
160
+ fs.writeFileSync(
161
+ path.join(exerciseDir, readmeFilename),
162
+ readability.newMarkdown
163
+ )
164
+
165
+ if (kind.toLowerCase() === "code") {
166
+ const codeFile = await createCodeFile(rigoToken, {
167
+ readme: readability.newMarkdown,
168
+ tutorial_info: packageContext,
169
+ })
170
+
171
+ fs.writeFileSync(
172
+ path.join(
173
+ exerciseDir,
174
+ `app.${codeFile.parsed.extension.replace(".", "")}`
175
+ ),
176
+ codeFile.parsed.content
177
+ )
178
+ }
179
+
180
+ return readability.newMarkdown
181
+ }
182
+
183
+ const initializeInteractiveCreation = async (
184
+ rigoToken: string,
185
+ courseInfo: string
186
+ ): Promise<{
187
+ steps: string[]
188
+ title: string
189
+ description: string
190
+ interactions: string
191
+ difficulty: string
192
+ duration: number
193
+ }> => {
194
+ let prevInteractions = ""
195
+ let isReady = false
196
+ let currentSteps = []
197
+ let currentTitle = ""
198
+ let currentDescription = ""
199
+ let currentDifficulty = ""
200
+
201
+ while (!isReady) {
202
+ const spinner = ora("Thinking...").start()
203
+ let wholeInfo = courseInfo
204
+ wholeInfo += `
205
+ Current title: ${currentTitle}
206
+ Current description: ${currentDescription}
207
+ `
208
+ // eslint-disable-next-line
209
+ const res = await interactiveCreation(rigoToken, {
210
+ courseInfo: wholeInfo,
211
+ prevInteractions: prevInteractions,
212
+ })
213
+ spinner.succeed("Done!")
214
+ currentSteps = res.parsed.listOfSteps
215
+ isReady = res.parsed.ready
216
+ if (res.parsed.title && currentTitle !== res.parsed.title) {
217
+ currentTitle = res.parsed.title
218
+ }
219
+
220
+ if (
221
+ res.parsed.description &&
222
+ currentDescription !== res.parsed.description
223
+ ) {
224
+ currentDescription = res.parsed.description
225
+ }
226
+
227
+ if (res.parsed.difficulty && currentDifficulty !== res.parsed.difficulty) {
228
+ currentDifficulty = res.parsed.difficulty
229
+ }
230
+
231
+ if (!isReady) {
232
+ console.log(currentSteps)
233
+ Console.info(`AI: ${res.parsed.aiMessage}`)
234
+ prevInteractions += `\nAI: ${res.parsed.aiMessage}`
235
+ // eslint-disable-next-line
236
+ const userMessage = await prompts([
237
+ {
238
+ type: "text",
239
+ name: "userMessage",
240
+ message: "Your message: ",
241
+ },
242
+ ])
243
+ prevInteractions += `\nUser: ${userMessage.userMessage}`
244
+ }
245
+ }
246
+
247
+ const duration = estimateDuration(currentSteps)
248
+ return {
249
+ steps: currentSteps,
250
+ title: currentTitle,
251
+ description: currentDescription,
252
+ interactions: prevInteractions,
253
+ difficulty: currentDifficulty,
254
+ duration,
255
+ }
256
+ }
257
+
258
+ process.emitWarning = (warning: string) => {
259
+ Console.debug("A Warning was emitted by Node.js: ", warning)
260
+ }
261
+
262
+ const handleAILogic = async (tutorialDir: string, packageInfo: PackageInfo) => {
263
+ fs.removeSync(path.join(tutorialDir, "exercises", "01-hello-world"))
264
+
265
+ let sessionPayload = await SessionManager.getPayload()
266
+
267
+ const sessionExists = sessionPayload && sessionPayload.rigobot
268
+
269
+ const isValidToken =
270
+ sessionExists && sessionPayload.rigobot.key ?
271
+ await isValidRigoToken(sessionPayload.rigobot.key) :
272
+ false
273
+
274
+ const isValidBreathecodeToken =
275
+ sessionExists && sessionPayload.token ?
276
+ await api.validateToken(sessionPayload.token) :
277
+ false
278
+
279
+ if (!sessionExists || !isValidBreathecodeToken || !isValidToken) {
280
+ Console.info(
281
+ "Almost there! First you need to login with 4Geeks.com to use AI Generation tool for creators. You can create a new account here: https://4geeks.com/checkout?plan=4geeks-creator"
282
+ )
283
+ try {
284
+ sessionPayload = await SessionManager.login()
285
+ } catch (error) {
286
+ Console.error("Error trying to authenticate")
287
+ Console.error((error as TypeError).message || (error as string))
288
+ }
289
+ }
290
+
291
+ const rigoToken = sessionPayload.rigobot.key
292
+
293
+ const consumable = await getConsumable(sessionPayload.token, "ai-generation")
294
+
295
+ if (consumable.count === 0) {
296
+ Console.error(
297
+ "It seems you cannot generate tutorials with AI. Make sure you creator subscription is up to date here: https://4geeks.com/profile/subscriptions. If you believe there is an issue you can always contact support@4geeks.com"
298
+ )
299
+ process.exit(1)
300
+ }
301
+
302
+ // const isCreator = await hasCreatorPermission(rigoToken)
303
+ // if (!isCreator) {
304
+ // Console.error(
305
+ // "👀 Oops! You need to be a creator to use our specialized AI. Please contact support"
306
+ // )
307
+ // process.exit(1)
308
+ // }
309
+
310
+ Console.success("🎉 Let's begin this learning journey!")
311
+
312
+ const { targetAudience, estimatedDuration } =
313
+ await whichTargetAudienceAndEstimatedDuration()
314
+ const contentIndex = await appendContentIndex()
315
+ const airules = await appendAIRules()
316
+
317
+ if (airules) {
318
+ const rulesDir = path.join(tutorialDir, ".learn", "rules")
319
+
320
+ // Crear el directorio si no existe
321
+ fs.mkdirSync(rulesDir, { recursive: true })
322
+
323
+ fs.writeFileSync(path.join(rulesDir, "airules.txt"), airules)
324
+ }
325
+
326
+ let packageContext = `
327
+ \n
328
+ Title: "${packageInfo.title.us}"
329
+ Description: "${packageInfo.description.us}"
330
+ Target Audience: "${targetAudience}"
331
+ Estimated Duration: "${estimatedDuration} minutes"
332
+
333
+ ${
334
+ contentIndex ?
335
+ `Content Index submitted by the user, use this to guide your creation. Keep in mind that your tutorial should contain these topics:
336
+ ---
337
+ ${contentIndex}
338
+ ---
339
+ ` :
340
+ ""
341
+ }
342
+
343
+ This is the duration for each type of step, use it to estimate the number of steps to create:
344
+ ${Object.entries(durationByKind)
345
+ .map(([key, value]) => `${key}: ${value} minutes`)
346
+ .join("\n")}
347
+
348
+
349
+ Within the estimated duration, is possible to have the following activities:
350
+ Format=
351
+ Activity: Maximum number of steps for duration
352
+
353
+ Estimated activities:
354
+ ${JSON.stringify(estimateActivities(estimatedDuration))}
355
+
356
+ You should create a tutorial that is engaging and fun to follow.
357
+
358
+
359
+ ${
360
+ airules ?
361
+ `
362
+ This is a list of rules you need to follow when creating the tutorial:
363
+ ${airules}
364
+ ` :
365
+ ""
366
+ }
367
+ `
368
+
369
+ const { steps, title, description, duration, difficulty } =
370
+ await initializeInteractiveCreation(rigoToken, packageContext)
371
+ packageInfo.title.us = title
372
+ packageInfo.description.us = description
373
+ packageInfo.duration = duration
374
+ packageInfo.difficulty = difficulty
375
+
376
+ packageContext = `
377
+ Title: "${title}"
378
+ Description: "${description}"
379
+ Target Audience: "${targetAudience}"
380
+ List of exercises: ${steps.join(", ")}
381
+
382
+ AI Rules: ${airules}
383
+ `
384
+ const exercisesDir = path.join(tutorialDir, "exercises")
385
+ fs.ensureDirSync(exercisesDir)
386
+
387
+ Console.info("Creating lessons...")
388
+ for (const [index, exercise] of steps.entries()) {
389
+ const { exNumber, exTitle } = getExInfo(exercise)
390
+ const exerciseDir = path.join(exercisesDir, `${exNumber}-${exTitle}`)
391
+ fs.ensureDirSync(exerciseDir)
392
+ }
393
+
394
+ const exercisePromises = steps.map(exercise =>
395
+ processExercise(rigoToken, steps, packageContext, exercise, exercisesDir)
396
+ )
397
+
398
+ const readmeContents = await Promise.all(exercisePromises)
399
+ Console.success("Lessons created! 🎉")
400
+
401
+ Console.info("Generating images for the lessons...")
402
+ let imagesArray: any[] = []
403
+
404
+ for (const content of readmeContents) {
405
+ imagesArray = [...imagesArray, ...extractImagesFromMarkdown(content)]
406
+ }
407
+
408
+ const imagePromises = imagesArray.map(async (image: any) => {
409
+ try {
410
+ const filename = getFilenameFromUrl(image.url)
411
+ const webhookUrl = `${process.env.HOST}/webhooks/${slugify(
412
+ packageInfo.title.us
413
+ )}/images/${filename}`
414
+
415
+ const imagePath = path.join(tutorialDir, ".learn", "assets", filename)
416
+
417
+ const res = await generateImage(rigoToken, {
418
+ prompt: image.alt,
419
+ callbackUrl: webhookUrl,
420
+ })
421
+ await downloadImage(res.image_url, imagePath)
422
+ return true
423
+ } catch {
424
+ Console.error(`Error downloading image ${image.url}`)
425
+
426
+ return false
427
+ }
428
+ })
429
+ await Promise.all(imagePromises)
430
+ Console.info(
431
+ "Images generated successfully! 🎉 Your tutorial will be ready soon!"
432
+ )
433
+
434
+ Console.info("Creating preview readme...")
435
+
436
+ await createPreviewReadme(tutorialDir, packageInfo, rigoToken, readmeContents)
437
+ const imagePath = path.join(tutorialDir, "preview.png")
438
+
439
+ const res = await generateImage(rigoToken, {
440
+ prompt:
441
+ "Generate a preview image for the tutorial. This is all the tutorial information: " +
442
+ packageContext +
443
+ "\n Generate only a basic preview image, add the tutorial Title as a text add the top middle, avoid adding any other text elements. Try to generate an image that related with the tutorial content.",
444
+ callbackUrl: `${process.env.HOST}/webhooks/${slugify(
445
+ packageInfo.title.us
446
+ )}/images/preview.png`,
447
+ })
448
+
449
+ // await downloadImage(res.image_url, imagePath)
450
+
451
+ return true
452
+ }
453
+
454
+ const getChoices = async (empty: boolean) => {
455
+ const defaultChoices = {
456
+ title: "My Interactive Tutorial",
457
+ description: "",
458
+ difficulty: "beginner",
459
+ duration: 5,
460
+ useAI: "no",
461
+ grading: "isolated",
462
+ }
463
+
464
+ if (empty) {
465
+ return defaultChoices
466
+ }
467
+
468
+ const choices = await prompts([
469
+ {
470
+ type: "select",
471
+ name: "grading",
472
+ message: "How are you going to grade students or yourself?",
473
+ choices: [
474
+ {
475
+ title:
476
+ "No grading: No feedback or testing whatsoever, similar to a an interactive book.",
477
+ value: null,
478
+ },
479
+ {
480
+ title: "Isolated: Each step is a new separate exercise",
481
+ value: "isolated",
482
+ },
483
+ {
484
+ title:
485
+ "Step by step: Each step builds on top of each other like an incremental tutorial",
486
+ value: "incremental",
487
+ },
488
+ ],
489
+ },
490
+ {
491
+ type: "text",
492
+
493
+ name: "title",
494
+ initial: "My Interactive Tutorial",
495
+ message: "Title for your tutorial? Press enter to leave as it is",
496
+ validate: (value: string) => {
497
+ if (value.length > PARAMS.max_title_length) {
498
+ return `Title must be less than ${PARAMS.max_title_length} characters`
499
+ }
500
+
501
+ return true
502
+ },
503
+ },
504
+ {
505
+ type: "text",
506
+ name: "description",
507
+ initial: "",
508
+ message: "Description for your tutorial? Press enter to leave blank",
509
+ },
510
+
511
+ {
512
+ type: "select",
513
+ name: "useAI",
514
+ message:
515
+ "Want a little bit of AI magic to help you? Our AI can craft the tutorial for you",
516
+ choices: [
517
+ {
518
+ title: "Yes, please help me",
519
+ value: "yes",
520
+ },
521
+ { title: "No, thanks, I prefer to do it manually", value: "no" },
522
+ ],
523
+ },
524
+ ])
525
+
526
+ const completeChoices = {
527
+ ...choices,
528
+ difficulty: "beginner",
529
+ duration: 30,
530
+ }
531
+ return completeChoices
532
+ }
533
+
534
+ class InitComand extends BaseCommand {
535
+ static description =
536
+ "Create a new learning package: Book, Tutorial or Exercise"
537
+
538
+ static flags = {
539
+ ...BaseCommand.flags,
540
+ grading: flags.help({ char: "h" }),
541
+ }
542
+
543
+ async run() {
544
+ const { flags } = this.parse(InitComand)
545
+
546
+ await alreadyInitialized()
547
+
548
+ const choices = await getChoices(flags.yes)
549
+
550
+ const packageInfo = makePackageInfo(choices)
551
+
552
+ const tutorialDir = `./${packageInfo.slug}`
553
+ fs.ensureDirSync(tutorialDir)
554
+
555
+ const templatesDir = path.resolve(
556
+ __dirname,
557
+ "../../src/utils/templates/" + (choices.grading || "no-grading")
558
+ )
559
+ if (!fs.existsSync(templatesDir))
560
+ throw ValidationError(`Template ${templatesDir} does not exists`)
561
+ await fs.copySync(templatesDir, tutorialDir)
562
+
563
+ if (choices.useAI === "yes") {
564
+ await handleAILogic(tutorialDir, packageInfo)
565
+ }
566
+
567
+ const languages = ["en", "es"]
568
+
569
+ // Creating README files
570
+ for (const language of languages) {
571
+ const readmeFilename = `README${language !== "en" ? `.${language}` : ""}`
572
+ const readmeTemplatePath = path.resolve(
573
+ templatesDir,
574
+ `${readmeFilename}.ejs`
575
+ )
576
+
577
+ if (choices.useAI !== "yes") {
578
+ const readmeObject = {
579
+ title: packageInfo.title.us,
580
+ description: packageInfo.description.us,
581
+ grading: packageInfo.grading,
582
+ difficulty: packageInfo.difficulty,
583
+ duration: packageInfo.duration,
584
+ }
585
+ const readmeContent = eta.render(
586
+ fs.readFileSync(readmeTemplatePath, "utf-8"),
587
+ readmeObject
588
+ )
589
+ fs.writeFileSync(
590
+ path.join(tutorialDir, `${readmeFilename}.md`),
591
+ readmeContent
592
+ )
593
+ }
594
+
595
+ if (fs.existsSync(path.join(tutorialDir, `${readmeFilename}.ejs`)))
596
+ fs.removeSync(path.join(tutorialDir, `${readmeFilename}.ejs`))
597
+ }
598
+
599
+ cli.action.start("Initializing package")
600
+
601
+ if (!fs.existsSync(path.join(tutorialDir, ".gitignore")))
602
+ fs.copyFile(
603
+ path.resolve(__dirname, "../../src/utils/templates/gitignore.txt"),
604
+ path.join(tutorialDir, ".gitignore")
605
+ )
606
+
607
+ fs.writeFileSync(
608
+ path.join(tutorialDir, "learn.json"),
609
+ JSON.stringify(packageInfo, null, 2)
610
+ )
611
+
612
+ cli.action.stop()
613
+ Console.success(`😋 Package initialized successfully in ${tutorialDir}`)
614
+ Console.help(
615
+ `Get inside the tutorial with the command: $ cd ${tutorialDir}`
616
+ )
617
+ Console.help(
618
+ `Start the exercises by running the following command on your terminal: $ learnpack start`
619
+ )
620
+ process.exit(0)
621
+ }
622
+ }
623
+
624
+ const alreadyInitialized = () =>
625
+ new Promise((resolve, reject) => {
626
+ fs.readdir("./", function (err: any, files: any) {
627
+ files = files.filter((f: any) =>
628
+ [".learn", "learn.json", "bc.json", ".breathecode"].includes(f)
629
+ )
630
+ if (err) {
631
+ reject(ValidationError(err.message))
632
+ throw ValidationError(err.message)
633
+ } else if (files.length > 0) {
634
+ reject(
635
+ ValidationError(
636
+ "It seems the package is already initialized because we've found the following files: " +
637
+ files.join(",")
638
+ )
639
+ )
640
+ throw ValidationError(
641
+ "It seems the package is already initialized because we've found the following files: " +
642
+ files.join(",")
643
+ )
644
+ }
645
+
646
+ resolve(false)
647
+ })
648
+ })
649
+
650
+ export default InitComand