@learnpack/learnpack 5.0.328 → 5.0.331

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.
@@ -0,0 +1,203 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HistoryManager = void 0;
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+ class HistoryManager {
7
+ constructor(redisClient) {
8
+ this.TTL = "432000"; // 5 days in seconds
9
+ this.LIMIT = 5; // Keep last 5 states
10
+ this.redis = redisClient;
11
+ // Load Lua scripts
12
+ const luaDir = path.join(__dirname, "../lua");
13
+ this.saveStateLua = fs.readFileSync(path.join(luaDir, "saveState.lua"), "utf8");
14
+ this.undoLua = fs.readFileSync(path.join(luaDir, "undo.lua"), "utf8");
15
+ this.redoLua = fs.readFileSync(path.join(luaDir, "redo.lua"), "utf8");
16
+ }
17
+ /**
18
+ * Builds Redis keys for an exercise in a specific language
19
+ * @param courseSlug - The course slug identifier
20
+ * @param exerciseSlug - The exercise slug identifier
21
+ * @param lang - The language code
22
+ * @returns Object containing undo, redo, and version keys
23
+ */
24
+ getKeys(courseSlug, exerciseSlug, lang) {
25
+ const baseKey = `${courseSlug}:${exerciseSlug}:${lang}`;
26
+ return {
27
+ undo: `undo:${baseKey}`,
28
+ redo: `redo:${baseKey}`,
29
+ version: `undo:${baseKey}:version`,
30
+ };
31
+ }
32
+ /**
33
+ * Saves a new state in history
34
+ * @param courseSlug - The course slug identifier
35
+ * @param exerciseSlug - The exercise slug identifier
36
+ * @param lang - The language code
37
+ * @param content - The content of the state
38
+ * @param versionId - The version ID
39
+ * @returns Object containing success, newVersion, and error
40
+ */
41
+ async saveState(courseSlug, exerciseSlug, lang, content, versionId) {
42
+ const keys = this.getKeys(courseSlug, exerciseSlug, lang);
43
+ const state = {
44
+ content,
45
+ timestamp: Date.now(),
46
+ };
47
+ const stateJson = JSON.stringify(state);
48
+ try {
49
+ // Official Redis client syntax: eval(script, { keys: [...], arguments: [...] })
50
+ const result = await this.redis.eval(this.saveStateLua, {
51
+ keys: [keys.undo, keys.redo, keys.version],
52
+ arguments: [
53
+ stateJson,
54
+ versionId,
55
+ this.TTL.toString(),
56
+ this.LIMIT.toString(),
57
+ ],
58
+ });
59
+ // Check if it's an error response (table with err field)
60
+ if (result && typeof result === "object" && result.err) {
61
+ return { success: false, error: result.err };
62
+ }
63
+ // Success response is a string (the new version)
64
+ const newVersion = typeof result === "string" ? result : String(result);
65
+ return { success: true, newVersion };
66
+ }
67
+ catch (error) {
68
+ console.error("⏮️ HISTORY: Error saving state to Redis:", error);
69
+ return { success: false, error: error.message };
70
+ }
71
+ }
72
+ /**
73
+ * Performs an undo operation
74
+ * @param courseSlug - The course slug identifier
75
+ * @param exerciseSlug - The exercise slug identifier
76
+ * @param lang - The language code
77
+ * @param currentContent - The current content of the state
78
+ * @param versionId - The version ID
79
+ * @returns Object containing success, newVersion, and error
80
+ */
81
+ async undo(courseSlug, exerciseSlug, lang, currentContent, versionId) {
82
+ const keys = this.getKeys(courseSlug, exerciseSlug, lang);
83
+ const currentState = {
84
+ content: currentContent,
85
+ timestamp: Date.now(),
86
+ };
87
+ try {
88
+ const result = await this.redis.eval(this.undoLua, {
89
+ keys: [keys.undo, keys.redo, keys.version],
90
+ arguments: [
91
+ JSON.stringify(currentState),
92
+ versionId,
93
+ this.TTL.toString(),
94
+ ],
95
+ });
96
+ // Check if it's an error response (table with err field)
97
+ if (result && typeof result === "object" && result.err) {
98
+ return { success: false, error: result.err };
99
+ }
100
+ // Success response is an array: [newVersion, previousState]
101
+ if (!Array.isArray(result) || result.length < 2) {
102
+ console.error("⏮️ HISTORY: Invalid response from undo script:", result);
103
+ return { success: false, error: "INVALID_RESPONSE" };
104
+ }
105
+ const [newVersion, previousStateJson] = result;
106
+ if (!previousStateJson) {
107
+ console.error("⏮️ HISTORY: No state returned from undo script");
108
+ return { success: false, error: "NO_STATE_RETURNED" };
109
+ }
110
+ const previousState = JSON.parse(previousStateJson);
111
+ return {
112
+ success: true,
113
+ newVersion: String(newVersion),
114
+ content: previousState.content,
115
+ };
116
+ }
117
+ catch (error) {
118
+ console.error("⏮️ HISTORY: Error performing undo:", error);
119
+ return { success: false, error: error.message };
120
+ }
121
+ }
122
+ /**
123
+ * Performs a redo operation
124
+ * @param courseSlug - The course slug identifier
125
+ * @param exerciseSlug - The exercise slug identifier
126
+ * @param lang - The language code
127
+ * @param currentContent - The current content of the state
128
+ * @param versionId - The version ID
129
+ * @returns Object containing success, newVersion, and error
130
+ */
131
+ async redo(courseSlug, exerciseSlug, lang, currentContent, versionId) {
132
+ const keys = this.getKeys(courseSlug, exerciseSlug, lang);
133
+ const currentState = {
134
+ content: currentContent,
135
+ timestamp: Date.now(),
136
+ };
137
+ try {
138
+ const result = await this.redis.eval(this.redoLua, {
139
+ keys: [keys.undo, keys.redo, keys.version],
140
+ arguments: [
141
+ JSON.stringify(currentState),
142
+ versionId,
143
+ this.TTL.toString(),
144
+ ],
145
+ });
146
+ // Check if it's an error response (table with err field)
147
+ if (result && typeof result === "object" && result.err) {
148
+ return { success: false, error: result.err };
149
+ }
150
+ // Success response is an array: [newVersion, futureState]
151
+ if (!Array.isArray(result) || result.length < 2) {
152
+ console.error("⏮️ HISTORY: Invalid response from redo script:", result);
153
+ return { success: false, error: "INVALID_RESPONSE" };
154
+ }
155
+ const [newVersion, futureStateJson] = result;
156
+ if (!futureStateJson) {
157
+ console.error("⏮️ HISTORY: No state returned from redo script");
158
+ return { success: false, error: "NO_STATE_RETURNED" };
159
+ }
160
+ const futureState = JSON.parse(futureStateJson);
161
+ return {
162
+ success: true,
163
+ newVersion: String(newVersion),
164
+ content: futureState.content,
165
+ };
166
+ }
167
+ catch (error) {
168
+ console.error("⏮️ HISTORY: Error performing redo:", error);
169
+ return { success: false, error: error.message };
170
+ }
171
+ }
172
+ /**
173
+ * Gets the history status (can undo/redo, current version)
174
+ * @param courseSlug - The course slug identifier
175
+ * @param exerciseSlug - The exercise slug identifier
176
+ * @param lang - The language code
177
+ * @returns Object containing canUndo, canRedo, and version
178
+ */
179
+ async getHistoryStatus(courseSlug, exerciseSlug, lang) {
180
+ const keys = this.getKeys(courseSlug, exerciseSlug, lang);
181
+ try {
182
+ const [undoLen, redoLen, version] = await Promise.all([
183
+ this.redis.lLen(keys.undo),
184
+ this.redis.lLen(keys.redo),
185
+ this.redis.get(keys.version),
186
+ ]);
187
+ return {
188
+ canUndo: undoLen >= 1, // Need at least 1 (we save state BEFORE changes)
189
+ canRedo: redoLen > 0,
190
+ version: version || "0",
191
+ };
192
+ }
193
+ catch (error) {
194
+ console.error("⏮️ HISTORY: Error getting history status:", error);
195
+ return {
196
+ canUndo: false,
197
+ canRedo: false,
198
+ version: "0",
199
+ };
200
+ }
201
+ }
202
+ }
203
+ exports.HistoryManager = HistoryManager;
@@ -0,0 +1,76 @@
1
+ import type { Bucket } from "@google-cloud/storage";
2
+ import type { HistoryManager } from "./historyManager";
3
+ export interface SaveReadmeWithHistoryOptions {
4
+ courseSlug: string;
5
+ exerciseSlug: string;
6
+ lang: string;
7
+ fileName: string;
8
+ newContent: string;
9
+ previousContent?: string;
10
+ currentVersion?: string;
11
+ throwOnConflict?: boolean;
12
+ }
13
+ export interface SaveReadmeResult {
14
+ success: boolean;
15
+ newVersion: string;
16
+ error?: string;
17
+ }
18
+ /**
19
+ * Service to handle README file updates with automatic history tracking
20
+ * Encapsulates the logic of saving state to history before updating files
21
+ */
22
+ export declare class ReadmeHistoryService {
23
+ private historyManager;
24
+ private bucket;
25
+ /**
26
+ * Constructor for the ReadmeHistoryService
27
+ * @param historyManager - The history manager to use
28
+ * @param bucket - The bucket to use
29
+ */
30
+ constructor(historyManager: HistoryManager | null, bucket: Bucket);
31
+ /**
32
+ * Saves a README file to GCS with automatic history tracking
33
+ *
34
+ * Workflow:
35
+ * 1. If previousContent is not provided, downloads it from GCS
36
+ * 2. If currentVersion is not provided, gets it from history
37
+ * 3. Saves the PREVIOUS state to history (for undo)
38
+ * 4. Saves the NEW content to GCS
39
+ *
40
+ * @param options - Options for the saveReadmeWithHistory method
41
+ * @returns Result with success status and new version
42
+ */
43
+ saveReadmeWithHistory(options: SaveReadmeWithHistoryOptions): Promise<SaveReadmeResult>;
44
+ /**
45
+ * Gets the previous content of a README from GCS
46
+ * Returns undefined if file doesn't exist (first version)
47
+ * @param courseSlug - The slug of the course
48
+ * @param exerciseSlug - The slug of the exercise
49
+ * @param fileName - The name of the file
50
+ * @returns The previous content of the file or undefined if it doesn't exist
51
+ */
52
+ private getPreviousContent;
53
+ /**
54
+ * Gets the current version from history
55
+ * @param courseSlug - The slug of the course
56
+ * @param exerciseSlug - The slug of the exercise
57
+ * @param lang - The language of the file
58
+ * @returns The current version of the file
59
+ */
60
+ private getCurrentVersion;
61
+ /**
62
+ * Saves content to Google Cloud Storage
63
+ * @param courseSlug - The slug of the course
64
+ * @param exerciseSlug - The slug of the exercise
65
+ * @param fileName - The name of the file
66
+ * @param content - The content to save
67
+ * @returns void
68
+ */
69
+ private saveToGCS;
70
+ /**
71
+ * Helper to get README extension from language code
72
+ * @param lang - The language of the file
73
+ * @returns The name of the README file
74
+ */
75
+ static getReadmeFileName(lang: string): string;
76
+ }
@@ -0,0 +1,191 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ReadmeHistoryService = void 0;
4
+ /**
5
+ * Service to handle README file updates with automatic history tracking
6
+ * Encapsulates the logic of saving state to history before updating files
7
+ */
8
+ class ReadmeHistoryService {
9
+ /**
10
+ * Constructor for the ReadmeHistoryService
11
+ * @param historyManager - The history manager to use
12
+ * @param bucket - The bucket to use
13
+ */
14
+ constructor(historyManager, bucket) {
15
+ this.historyManager = historyManager;
16
+ this.bucket = bucket;
17
+ }
18
+ /**
19
+ * Saves a README file to GCS with automatic history tracking
20
+ *
21
+ * Workflow:
22
+ * 1. If previousContent is not provided, downloads it from GCS
23
+ * 2. If currentVersion is not provided, gets it from history
24
+ * 3. Saves the PREVIOUS state to history (for undo)
25
+ * 4. Saves the NEW content to GCS
26
+ *
27
+ * @param options - Options for the saveReadmeWithHistory method
28
+ * @returns Result with success status and new version
29
+ */
30
+ async saveReadmeWithHistory(options) {
31
+ const { courseSlug, exerciseSlug, lang, fileName, newContent, previousContent: providedPreviousContent, currentVersion: providedVersion, throwOnConflict = false, } = options;
32
+ // Guard: Only process README files
33
+ if (!fileName.startsWith("README")) {
34
+ console.warn(`⚠️ ReadmeHistoryService called for non-README file: ${fileName}`);
35
+ return { success: false, newVersion: "0", error: "NOT_README_FILE" };
36
+ }
37
+ // Guard: History manager not available
38
+ if (!this.historyManager) {
39
+ console.warn("⚠️ History manager not available, skipping history");
40
+ // Still save the file without history
41
+ await this.saveToGCS(courseSlug, exerciseSlug, fileName, newContent);
42
+ return {
43
+ success: false,
44
+ newVersion: "0",
45
+ error: "HISTORY_NOT_AVAILABLE",
46
+ };
47
+ }
48
+ try {
49
+ // Step 1: Get previous content if not provided
50
+ const previousContent = providedPreviousContent !== null && providedPreviousContent !== void 0 ? providedPreviousContent : (await this.getPreviousContent(courseSlug, exerciseSlug, fileName));
51
+ // If there's no previous content, we can't save to history
52
+ // (this is the first version of the file)
53
+ if (!previousContent) {
54
+ console.log(`📝 HISTORY: First version of ${fileName} for ${lang}, skipping history`);
55
+ await this.saveToGCS(courseSlug, exerciseSlug, fileName, newContent);
56
+ return {
57
+ success: false,
58
+ newVersion: "0",
59
+ error: "NO_PREVIOUS_CONTENT",
60
+ };
61
+ }
62
+ // Step 2: Get current version if not provided
63
+ const currentVersion = providedVersion !== null && providedVersion !== void 0 ? providedVersion : (await this.getCurrentVersion(courseSlug, exerciseSlug, lang));
64
+ // Step 3: Save to history BEFORE updating the file
65
+ const historyResult = await this.historyManager.saveState(courseSlug, exerciseSlug, lang, previousContent, // Save the content BEFORE the change
66
+ currentVersion);
67
+ // Step 4: Handle history save result
68
+ if (!historyResult.success) {
69
+ // VERSION_CONFLICT: Another user/process modified the file
70
+ if (historyResult.error === "VERSION_CONFLICT") {
71
+ const message = "Version conflict detected. Content may have been modified by another process.";
72
+ if (throwOnConflict) {
73
+ throw new Error(message);
74
+ }
75
+ else {
76
+ console.error(`⏮️ HISTORY: ${message}`);
77
+ // Still save the file despite conflict
78
+ await this.saveToGCS(courseSlug, exerciseSlug, fileName, newContent);
79
+ return {
80
+ success: false,
81
+ newVersion: currentVersion,
82
+ error: "VERSION_CONFLICT",
83
+ };
84
+ }
85
+ }
86
+ // Other errors: log but don't fail
87
+ console.error(`⏮️ HISTORY: Failed to save history (non-critical): ${historyResult.error}`);
88
+ await this.saveToGCS(courseSlug, exerciseSlug, fileName, newContent);
89
+ return {
90
+ success: false,
91
+ newVersion: currentVersion,
92
+ error: historyResult.error,
93
+ };
94
+ }
95
+ // Step 5: History saved successfully, now save the new content
96
+ await this.saveToGCS(courseSlug, exerciseSlug, fileName, newContent);
97
+ console.log(`✅ HISTORY: Saved ${fileName} (${lang}) - version ${historyResult.newVersion}`);
98
+ return {
99
+ success: true,
100
+ newVersion: historyResult.newVersion,
101
+ };
102
+ }
103
+ catch (error) {
104
+ const errorMessage = error.message || "Unknown error";
105
+ console.error(`❌ HISTORY: Error in saveReadmeWithHistory: ${errorMessage}`);
106
+ // Try to save the file anyway (best effort)
107
+ try {
108
+ await this.saveToGCS(courseSlug, exerciseSlug, fileName, newContent);
109
+ }
110
+ catch (saveError) {
111
+ console.error("❌ Failed to save file after history error:", saveError);
112
+ }
113
+ return {
114
+ success: false,
115
+ newVersion: "0",
116
+ error: errorMessage,
117
+ };
118
+ }
119
+ }
120
+ /**
121
+ * Gets the previous content of a README from GCS
122
+ * Returns undefined if file doesn't exist (first version)
123
+ * @param courseSlug - The slug of the course
124
+ * @param exerciseSlug - The slug of the exercise
125
+ * @param fileName - The name of the file
126
+ * @returns The previous content of the file or undefined if it doesn't exist
127
+ */
128
+ async getPreviousContent(courseSlug, exerciseSlug, fileName) {
129
+ try {
130
+ const filePath = `courses/${courseSlug}/exercises/${exerciseSlug}/${fileName}`;
131
+ const [content] = await this.bucket.file(filePath).download();
132
+ return content.toString();
133
+ }
134
+ catch (error) {
135
+ // File doesn't exist (404) - this is normal for first save
136
+ if (error.code === 404) {
137
+ return undefined;
138
+ }
139
+ console.error(`⚠️ Error downloading previous content: ${error.message}`);
140
+ return undefined;
141
+ }
142
+ }
143
+ /**
144
+ * Gets the current version from history
145
+ * @param courseSlug - The slug of the course
146
+ * @param exerciseSlug - The slug of the exercise
147
+ * @param lang - The language of the file
148
+ * @returns The current version of the file
149
+ */
150
+ async getCurrentVersion(courseSlug, exerciseSlug, lang) {
151
+ if (!this.historyManager) {
152
+ return "0";
153
+ }
154
+ try {
155
+ const status = await this.historyManager.getHistoryStatus(courseSlug, exerciseSlug, lang);
156
+ return status.version;
157
+ }
158
+ catch (error) {
159
+ console.error("⚠️ Error getting current version, defaulting to '0':", error);
160
+ return "0";
161
+ }
162
+ }
163
+ /**
164
+ * Saves content to Google Cloud Storage
165
+ * @param courseSlug - The slug of the course
166
+ * @param exerciseSlug - The slug of the exercise
167
+ * @param fileName - The name of the file
168
+ * @param content - The content to save
169
+ * @returns void
170
+ */
171
+ async saveToGCS(courseSlug, exerciseSlug, fileName, content) {
172
+ const filePath = `courses/${courseSlug}/exercises/${exerciseSlug}/${fileName}`;
173
+ const file = this.bucket.file(filePath);
174
+ await file.save(content, {
175
+ resumable: false,
176
+ });
177
+ }
178
+ /**
179
+ * Helper to get README extension from language code
180
+ * @param lang - The language of the file
181
+ * @returns The name of the README file
182
+ */
183
+ static getReadmeFileName(lang) {
184
+ if (lang === "en") {
185
+ return "README.md";
186
+ }
187
+ // es -> README.es.md, fr -> README.fr.md
188
+ return `README.${lang}.md`;
189
+ }
190
+ }
191
+ exports.ReadmeHistoryService = ReadmeHistoryService;
@@ -15,6 +15,7 @@ type TAssetMissing = {
15
15
  slug: string;
16
16
  title: string;
17
17
  lang: string;
18
+ graded: boolean;
18
19
  url: string;
19
20
  description: string;
20
21
  learnpack_deploy_url: string;
package/lib/utils/api.js CHANGED
@@ -341,7 +341,7 @@ const createAsset = async (token, asset) => {
341
341
  readme_url: null,
342
342
  difficulty: null,
343
343
  duration: null,
344
- graded: true,
344
+ graded: asset.graded,
345
345
  gitpod: true,
346
346
  category: asset.category,
347
347
  owner: asset.owner,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@learnpack/learnpack",
3
3
  "description": "Seamlessly build, sell and/or take interactive & auto-graded tutorials, start learning now or build a new tutorial to your audience.",
4
- "version": "5.0.328",
4
+ "version": "5.0.331",
5
5
  "author": "Alejandro Sanchez @alesanchezr",
6
6
  "contributors": [
7
7
  {
@@ -62,6 +62,7 @@
62
62
  "node-persist": "^3.1.0",
63
63
  "ora": "^8.2.0",
64
64
  "prompts": "^2.3.2",
65
+ "redis": "^5.10.0",
65
66
  "rimraf": "^6.0.1",
66
67
  "shelljs": "^0.8.4",
67
68
  "socket.io": "^4.4.1",
@@ -47,6 +47,7 @@ export const handleAssetCreation = async (
47
47
  slug: slug,
48
48
  title: learnJson.title[selectedLang],
49
49
  lang: selectedLang,
50
+ graded: true,
50
51
  description: learnJson.description[selectedLang],
51
52
  learnpack_deploy_url: learnpackDeployUrl,
52
53
  technologies: learnJson.technologies.map((tech: string) =>
@@ -78,6 +79,7 @@ export const handleAssetCreation = async (
78
79
 
79
80
  Console.info("Asset exists, updating it")
80
81
  const asset = await api.updateAsset(sessionPayload.token, slug, {
82
+ graded: true,
81
83
  learnpack_deploy_url: learnpackDeployUrl,
82
84
  title: learnJson.title[selectedLang],
83
85
  category: category,