@learnpack/learnpack 5.0.329 → 5.0.332
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/lib/commands/publish.js +76 -9
- package/lib/commands/serve.d.ts +3 -1
- package/lib/commands/serve.js +306 -15
- package/lib/managers/historyManager.d.ts +67 -0
- package/lib/managers/historyManager.js +203 -0
- package/lib/managers/readmeHistoryService.d.ts +76 -0
- package/lib/managers/readmeHistoryService.js +191 -0
- package/package.json +2 -1
- package/src/commands/publish.ts +117 -15
- package/src/commands/serve.ts +452 -20
- package/src/lua/redo.lua +48 -0
- package/src/lua/saveState.lua +40 -0
- package/src/lua/undo.lua +50 -0
- package/src/managers/historyManager.ts +281 -0
- package/src/managers/readmeHistoryService.ts +284 -0
- package/src/ui/_app/app.css +1 -1
- package/src/ui/_app/app.js +2090 -2090
- package/src/ui/app.tar.gz +0 -0
|
@@ -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;
|
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.
|
|
4
|
+
"version": "5.0.332",
|
|
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",
|
package/src/commands/publish.ts
CHANGED
|
@@ -19,10 +19,41 @@ import api, { getConsumable, RIGOBOT_HOST, TAcademy } from "../utils/api"
|
|
|
19
19
|
import * as prompts from "prompts"
|
|
20
20
|
import { isValidRigoToken } from "../utils/rigoActions"
|
|
21
21
|
import { minutesToISO8601Duration } from "../utils/misc"
|
|
22
|
-
import { slugify } from "../utils/creatorUtilities"
|
|
22
|
+
import { getReadmeExtension, slugify } from "../utils/creatorUtilities"
|
|
23
23
|
|
|
24
24
|
const uploadZipEndpont = RIGOBOT_HOST + "/v1/learnpack/upload"
|
|
25
25
|
|
|
26
|
+
const getAvailableLangs = (learnJson: any): string[] => {
|
|
27
|
+
const langs = Object.keys(learnJson?.title || {})
|
|
28
|
+
return langs.filter((l) => typeof l === "string" && l.length > 0)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const getDefaultLang = (learnJson: any): string => {
|
|
32
|
+
const availableLangs = getAvailableLangs(learnJson)
|
|
33
|
+
if (availableLangs.length === 0) return "en"
|
|
34
|
+
return availableLangs.includes("en") ? "en" : availableLangs[0]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const getLocalizedValue = (
|
|
38
|
+
translations: Record<string, any> | undefined,
|
|
39
|
+
lang: string,
|
|
40
|
+
fallbackLangs: string[] = ["en", "us"]
|
|
41
|
+
): string => {
|
|
42
|
+
if (!translations || typeof translations !== "object") return ""
|
|
43
|
+
|
|
44
|
+
const direct = translations[lang]
|
|
45
|
+
if (typeof direct === "string" && direct.trim().length > 0) return direct
|
|
46
|
+
|
|
47
|
+
for (const fb of fallbackLangs) {
|
|
48
|
+
const v = translations[fb]
|
|
49
|
+
if (typeof v === "string" && v.trim().length > 0) return v
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const firstKey = Object.keys(translations)[0]
|
|
53
|
+
const first = firstKey ? translations[firstKey] : ""
|
|
54
|
+
return typeof first === "string" ? first : ""
|
|
55
|
+
}
|
|
56
|
+
|
|
26
57
|
export const handleAssetCreation = async (
|
|
27
58
|
sessionPayload: { token: string; rigobotToken: string },
|
|
28
59
|
learnJson: any,
|
|
@@ -36,7 +67,19 @@ export const handleAssetCreation = async (
|
|
|
36
67
|
try {
|
|
37
68
|
const user = await api.validateToken(sessionPayload.token)
|
|
38
69
|
|
|
39
|
-
|
|
70
|
+
const assetTitle = getLocalizedValue(learnJson?.title, selectedLang)
|
|
71
|
+
const assetDescription = getLocalizedValue(
|
|
72
|
+
learnJson?.description,
|
|
73
|
+
selectedLang
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if (!assetTitle) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`Missing learn.json title for language "${selectedLang}"`
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let slug = slugify(assetTitle).slice(0, 47)
|
|
40
83
|
slug = `${slug}-${selectedLang}`
|
|
41
84
|
|
|
42
85
|
const { exists } = await api.doesAssetExists(sessionPayload.token, slug)
|
|
@@ -45,10 +88,10 @@ export const handleAssetCreation = async (
|
|
|
45
88
|
Console.info("Asset does not exist in this academy, creating it")
|
|
46
89
|
const asset = await api.createAsset(sessionPayload.token, {
|
|
47
90
|
slug: slug,
|
|
48
|
-
title:
|
|
91
|
+
title: assetTitle,
|
|
49
92
|
lang: selectedLang,
|
|
50
93
|
graded: true,
|
|
51
|
-
description:
|
|
94
|
+
description: assetDescription,
|
|
52
95
|
learnpack_deploy_url: learnpackDeployUrl,
|
|
53
96
|
technologies: learnJson.technologies.map((tech: string) =>
|
|
54
97
|
tech.toLowerCase().replace(/\s+/g, "-")
|
|
@@ -81,9 +124,9 @@ export const handleAssetCreation = async (
|
|
|
81
124
|
const asset = await api.updateAsset(sessionPayload.token, slug, {
|
|
82
125
|
graded: true,
|
|
83
126
|
learnpack_deploy_url: learnpackDeployUrl,
|
|
84
|
-
title:
|
|
127
|
+
title: assetTitle,
|
|
85
128
|
category: category,
|
|
86
|
-
description:
|
|
129
|
+
description: assetDescription,
|
|
87
130
|
all_translations,
|
|
88
131
|
})
|
|
89
132
|
try {
|
|
@@ -106,6 +149,58 @@ export const handleAssetCreation = async (
|
|
|
106
149
|
}
|
|
107
150
|
}
|
|
108
151
|
|
|
152
|
+
const createMultiLangAssetFromDisk = async (
|
|
153
|
+
sessionPayload: { token: string; rigobotToken: string },
|
|
154
|
+
learnJson: any,
|
|
155
|
+
deployUrl: string
|
|
156
|
+
) => {
|
|
157
|
+
const availableLangs = getAvailableLangs(learnJson)
|
|
158
|
+
|
|
159
|
+
if (availableLangs.length === 0) {
|
|
160
|
+
Console.error(
|
|
161
|
+
"No languages found in learn.json.title. Add at least one language (e.g. title.en)."
|
|
162
|
+
)
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const all_translations: string[] = []
|
|
167
|
+
for (const lang of availableLangs) {
|
|
168
|
+
const readmePath = path.join(
|
|
169
|
+
process.cwd(),
|
|
170
|
+
`README${getReadmeExtension(lang)}`
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
let indexReadmeString = ""
|
|
174
|
+
try {
|
|
175
|
+
if (fs.existsSync(readmePath)) {
|
|
176
|
+
indexReadmeString = fs.readFileSync(readmePath, "utf-8")
|
|
177
|
+
}
|
|
178
|
+
} catch (error) {
|
|
179
|
+
Console.error("Error reading index readme:", error)
|
|
180
|
+
indexReadmeString = ""
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const b64IndexReadme = Buffer.from(indexReadmeString).toString("base64")
|
|
184
|
+
|
|
185
|
+
// eslint-disable-next-line no-await-in-loop
|
|
186
|
+
const asset = await handleAssetCreation(
|
|
187
|
+
sessionPayload,
|
|
188
|
+
learnJson,
|
|
189
|
+
lang,
|
|
190
|
+
deployUrl,
|
|
191
|
+
b64IndexReadme,
|
|
192
|
+
all_translations
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
if (!asset) {
|
|
196
|
+
Console.debug("Could not create/update asset for lang", lang)
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
all_translations.push(asset.slug)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
109
204
|
const runAudit = (strict: boolean) => {
|
|
110
205
|
try {
|
|
111
206
|
Console.info("Running learnpack audit before publishing...")
|
|
@@ -362,9 +457,13 @@ class BuildCommand extends SessionCommand {
|
|
|
362
457
|
if (fs.existsSync(indexHtmlPath)) {
|
|
363
458
|
let indexHtmlContent = fs.readFileSync(indexHtmlPath, "utf-8")
|
|
364
459
|
|
|
365
|
-
const
|
|
460
|
+
const selectedLang = getDefaultLang(learnJson)
|
|
461
|
+
const description =
|
|
462
|
+
getLocalizedValue(learnJson?.description, selectedLang) ||
|
|
463
|
+
"LearnPack is awesome!"
|
|
366
464
|
const title =
|
|
367
|
-
learnJson
|
|
465
|
+
getLocalizedValue(learnJson?.title, selectedLang) ||
|
|
466
|
+
"LearnPack: Interactive Learning as a Service"
|
|
368
467
|
|
|
369
468
|
const previewUrl =
|
|
370
469
|
learnJson.preview ||
|
|
@@ -386,9 +485,15 @@ class BuildCommand extends SessionCommand {
|
|
|
386
485
|
|
|
387
486
|
if (fs.existsSync(manifestPWA)) {
|
|
388
487
|
let manifestPWAContent = fs.readFileSync(manifestPWA, "utf-8")
|
|
488
|
+
const selectedLang = getDefaultLang(learnJson)
|
|
489
|
+
const courseTitle =
|
|
490
|
+
getLocalizedValue(learnJson?.title, selectedLang) ||
|
|
491
|
+
getLocalizedValue(learnJson?.title, "us") ||
|
|
492
|
+
getLocalizedValue(learnJson?.title, "en") ||
|
|
493
|
+
"LearnPack"
|
|
389
494
|
manifestPWAContent = manifestPWAContent.replace(
|
|
390
495
|
"{{course_title}}",
|
|
391
|
-
|
|
496
|
+
courseTitle
|
|
392
497
|
)
|
|
393
498
|
|
|
394
499
|
const courseShortName = { answer: "testing-tutorial" }
|
|
@@ -454,13 +559,10 @@ class BuildCommand extends SessionCommand {
|
|
|
454
559
|
fs.unlinkSync(zipFilePath)
|
|
455
560
|
this.removeDirectory(buildDir)
|
|
456
561
|
|
|
457
|
-
await
|
|
458
|
-
sessionPayload,
|
|
562
|
+
await createMultiLangAssetFromDisk(
|
|
563
|
+
{ token: sessionPayload.token, rigobotToken: rigoToken },
|
|
459
564
|
learnJson,
|
|
460
|
-
|
|
461
|
-
res.data.url,
|
|
462
|
-
"",
|
|
463
|
-
[]
|
|
565
|
+
res.data.url
|
|
464
566
|
)
|
|
465
567
|
} catch (error) {
|
|
466
568
|
if (axios.isAxiosError(error)) {
|