@learnpack/learnpack 5.0.329 → 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.
- 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/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
package/lib/commands/serve.d.ts
CHANGED
|
@@ -17,9 +17,11 @@ export declare const createLearnJson: (courseInfo: FormState) => {
|
|
|
17
17
|
preview: string;
|
|
18
18
|
};
|
|
19
19
|
export declare const processImage: (url: string, description: string, rigoToken: string, courseSlug: string) => Promise<boolean>;
|
|
20
|
-
|
|
20
|
+
declare class ServeCommand extends SessionCommand {
|
|
21
21
|
static description: string;
|
|
22
|
+
private redis;
|
|
22
23
|
static flags: any;
|
|
23
24
|
init(): Promise<void>;
|
|
24
25
|
run(): Promise<void>;
|
|
25
26
|
}
|
|
27
|
+
export default ServeCommand;
|
package/lib/commands/serve.js
CHANGED
|
@@ -34,6 +34,9 @@ const publish_1 = require("./publish");
|
|
|
34
34
|
const export_1 = require("../utils/export");
|
|
35
35
|
const errorHandler_1 = require("../utils/errorHandler");
|
|
36
36
|
const jsdom_1 = require("jsdom");
|
|
37
|
+
const redis_1 = require("redis");
|
|
38
|
+
const historyManager_1 = require("../managers/historyManager");
|
|
39
|
+
const readmeHistoryService_1 = require("../managers/readmeHistoryService");
|
|
37
40
|
const frontMatter = require("front-matter");
|
|
38
41
|
if (process.env.NEW_RELIC_ENABLED === "true") {
|
|
39
42
|
require("newrelic");
|
|
@@ -466,7 +469,7 @@ const getTitleFromHTML = (html) => {
|
|
|
466
469
|
const titleMatch = html.match(titleRegex);
|
|
467
470
|
return titleMatch ? titleMatch[1] : null;
|
|
468
471
|
};
|
|
469
|
-
async function processSyncTranslationsSequentially(courseSlug, exerciseSlug, notificationId, sourceReadmeContent, targetLanguages, rigoToken, bucket) {
|
|
472
|
+
async function processSyncTranslationsSequentially(courseSlug, exerciseSlug, notificationId, sourceReadmeContent, targetLanguages, rigoToken, bucket, historyManager) {
|
|
470
473
|
var _a, _b, _c, _d, _e, _f;
|
|
471
474
|
try {
|
|
472
475
|
// Process translations sequentially (no race conditions)
|
|
@@ -492,12 +495,25 @@ async function processSyncTranslationsSequentially(courseSlug, exerciseSlug, not
|
|
|
492
495
|
if (!((_a = translationResult.parsed) === null || _a === void 0 ? void 0 : _a.translation)) {
|
|
493
496
|
throw new Error("Translation result is empty");
|
|
494
497
|
}
|
|
495
|
-
// Save translated README
|
|
496
|
-
const
|
|
498
|
+
// Save translated README with history tracking
|
|
499
|
+
const fileName = readmeHistoryService_1.ReadmeHistoryService.getReadmeFileName(targetLang);
|
|
500
|
+
const readmeHistoryService = new readmeHistoryService_1.ReadmeHistoryService(historyManager, bucket);
|
|
497
501
|
// eslint-disable-next-line no-await-in-loop
|
|
498
|
-
await
|
|
499
|
-
|
|
500
|
-
|
|
502
|
+
const historyResult = await readmeHistoryService.saveReadmeWithHistory({
|
|
503
|
+
courseSlug,
|
|
504
|
+
exerciseSlug,
|
|
505
|
+
lang: targetLang,
|
|
506
|
+
fileName,
|
|
507
|
+
newContent: translationResult.parsed.translation,
|
|
508
|
+
// Don't pass previousContent or currentVersion - let service fetch them
|
|
509
|
+
throwOnConflict: false, // Don't fail sync on version conflicts
|
|
510
|
+
});
|
|
511
|
+
if (historyResult.success) {
|
|
512
|
+
console.log(`🔄 SYNC: Saved ${targetLang} translation with history (version ${historyResult.newVersion})`);
|
|
513
|
+
}
|
|
514
|
+
else {
|
|
515
|
+
console.warn(`🔄 SYNC: Translation saved but history failed for ${targetLang}: ${historyResult.error}`);
|
|
516
|
+
}
|
|
501
517
|
// Update progress in syllabus
|
|
502
518
|
// eslint-disable-next-line no-await-in-loop
|
|
503
519
|
const syllabus = await getSyllabus(courseSlug, bucket);
|
|
@@ -586,6 +602,10 @@ async function processSyncTranslationsSequentially(courseSlug, exerciseSlug, not
|
|
|
586
602
|
}
|
|
587
603
|
}
|
|
588
604
|
class ServeCommand extends SessionCommand_1.default {
|
|
605
|
+
constructor() {
|
|
606
|
+
super(...arguments);
|
|
607
|
+
this.redis = null;
|
|
608
|
+
}
|
|
589
609
|
async init() {
|
|
590
610
|
const { flags } = this.parse(ServeCommand);
|
|
591
611
|
}
|
|
@@ -608,6 +628,55 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
608
628
|
else {
|
|
609
629
|
console.log("INFO: HOST is set to", host);
|
|
610
630
|
}
|
|
631
|
+
// Initialize Redis (official client)
|
|
632
|
+
const redisUrl = process.env.REDIS_URL || "redis://localhost:6379";
|
|
633
|
+
if (process.env.NODE_ENV === "production" &&
|
|
634
|
+
redisUrl === "redis://localhost:6379") {
|
|
635
|
+
console.error("❌ REDIS_URL not configured for production environment!");
|
|
636
|
+
console.warn("⚠️ History features will be unavailable");
|
|
637
|
+
this.redis = null;
|
|
638
|
+
}
|
|
639
|
+
else {
|
|
640
|
+
try {
|
|
641
|
+
const useTLS = redisUrl.startsWith("rediss://");
|
|
642
|
+
this.redis = (0, redis_1.createClient)({
|
|
643
|
+
url: redisUrl,
|
|
644
|
+
socket: {
|
|
645
|
+
tls: useTLS,
|
|
646
|
+
rejectUnauthorized: useTLS,
|
|
647
|
+
reconnectStrategy: (retries) => {
|
|
648
|
+
if (retries > 10) {
|
|
649
|
+
console.error("❌ Too many Redis reconnection attempts");
|
|
650
|
+
return new Error("Too many retries");
|
|
651
|
+
}
|
|
652
|
+
// Exponential backoff: 50ms, 100ms, 200ms, ...
|
|
653
|
+
const delay = Math.min(retries * 50, 2000);
|
|
654
|
+
return delay;
|
|
655
|
+
},
|
|
656
|
+
},
|
|
657
|
+
});
|
|
658
|
+
this.redis.on("error", (err) => {
|
|
659
|
+
console.error("❌ Redis error:", err);
|
|
660
|
+
});
|
|
661
|
+
this.redis.on("connect", () => {
|
|
662
|
+
console.log("🔄 Connecting to Redis...");
|
|
663
|
+
});
|
|
664
|
+
this.redis.on("ready", () => {
|
|
665
|
+
console.log("✅ Connected to Redis");
|
|
666
|
+
});
|
|
667
|
+
// Connect the client
|
|
668
|
+
await this.redis.connect();
|
|
669
|
+
}
|
|
670
|
+
catch (redisError) {
|
|
671
|
+
console.error("❌ Failed to connect to Redis:", redisError);
|
|
672
|
+
console.warn("⚠️ History features (undo/redo) will be unavailable");
|
|
673
|
+
this.redis = null;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
// Create HistoryManager instance if Redis is available
|
|
677
|
+
const historyManager = this.redis ?
|
|
678
|
+
new historyManager_1.HistoryManager(this.redis) :
|
|
679
|
+
null;
|
|
611
680
|
// async function listFilesWithPrefix(prefix: string) {
|
|
612
681
|
// const [files] = await bucket.getFiles({ prefix })
|
|
613
682
|
// return files
|
|
@@ -1783,11 +1852,14 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
1783
1852
|
res.set("Content-Disposition", contentDisposition);
|
|
1784
1853
|
fileStream.pipe(res);
|
|
1785
1854
|
});
|
|
1786
|
-
app.put("/exercise/:slug/file/:fileName", express.
|
|
1855
|
+
app.put("/exercise/:slug/file/:fileName", express.json(), (0, errorHandler_1.asyncHandler)(async (req, res) => {
|
|
1787
1856
|
var _a, _b;
|
|
1788
1857
|
const { slug, fileName } = req.params;
|
|
1789
1858
|
const query = req.query;
|
|
1790
1859
|
const courseSlug = query.slug;
|
|
1860
|
+
const lang = query.lang || "en";
|
|
1861
|
+
// Headers for history management
|
|
1862
|
+
const versionId = req.headers["x-history-version"] || "0";
|
|
1791
1863
|
console.log(`PUT /exercise/${slug}/file/${fileName}`);
|
|
1792
1864
|
// Validate required parameters
|
|
1793
1865
|
if (!courseSlug) {
|
|
@@ -1796,16 +1868,53 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
1796
1868
|
if (!fileName || !slug) {
|
|
1797
1869
|
throw new errorHandler_1.ValidationError("File name and exercise slug are required");
|
|
1798
1870
|
}
|
|
1871
|
+
// Extract content from body (can be text or JSON)
|
|
1872
|
+
let fileContent;
|
|
1873
|
+
let contentToSaveInHistory;
|
|
1874
|
+
if (typeof req.body === "string") {
|
|
1875
|
+
// Old format: plain text
|
|
1876
|
+
fileContent = req.body;
|
|
1877
|
+
}
|
|
1878
|
+
else if (req.body && typeof req.body === "object") {
|
|
1879
|
+
// New format: JSON with content and historyContent
|
|
1880
|
+
fileContent = req.body.content;
|
|
1881
|
+
contentToSaveInHistory = req.body.historyContent;
|
|
1882
|
+
}
|
|
1883
|
+
else {
|
|
1884
|
+
throw new errorHandler_1.ValidationError("Invalid request body format");
|
|
1885
|
+
}
|
|
1799
1886
|
try {
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1887
|
+
let newVersion = versionId;
|
|
1888
|
+
let created = false;
|
|
1889
|
+
// Use ReadmeHistoryService for README files with history support
|
|
1890
|
+
if (fileName.startsWith("README")) {
|
|
1891
|
+
const readmeHistoryService = new readmeHistoryService_1.ReadmeHistoryService(historyManager, bucket);
|
|
1892
|
+
const historyResult = await readmeHistoryService.saveReadmeWithHistory({
|
|
1893
|
+
courseSlug,
|
|
1894
|
+
exerciseSlug: slug,
|
|
1895
|
+
lang,
|
|
1896
|
+
fileName,
|
|
1897
|
+
newContent: fileContent,
|
|
1898
|
+
previousContent: contentToSaveInHistory,
|
|
1899
|
+
currentVersion: versionId,
|
|
1900
|
+
throwOnConflict: true, // Throw error on version conflicts for manual edits
|
|
1901
|
+
});
|
|
1902
|
+
newVersion = historyResult.newVersion;
|
|
1903
|
+
created = true; // File was saved by the service
|
|
1904
|
+
}
|
|
1905
|
+
else {
|
|
1906
|
+
// For non-README files, save directly without history
|
|
1907
|
+
const file = bucket.file(`courses/${courseSlug}/exercises/${slug}/${fileName}`);
|
|
1908
|
+
await file.save(fileContent, {
|
|
1909
|
+
resumable: false,
|
|
1910
|
+
});
|
|
1911
|
+
const [exists] = await file.exists();
|
|
1912
|
+
created = exists;
|
|
1913
|
+
}
|
|
1914
|
+
res.json({
|
|
1807
1915
|
message: "File updated",
|
|
1808
1916
|
created,
|
|
1917
|
+
version: newVersion,
|
|
1809
1918
|
});
|
|
1810
1919
|
}
|
|
1811
1920
|
catch (error) {
|
|
@@ -1826,6 +1935,188 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
1826
1935
|
throw error;
|
|
1827
1936
|
}
|
|
1828
1937
|
}));
|
|
1938
|
+
// Remove code_challenge_proposals from all README files of an exercise
|
|
1939
|
+
app.post("/exercise/:slug/remove-code-proposals", (0, errorHandler_1.asyncHandler)(async (req, res) => {
|
|
1940
|
+
var _a, _b;
|
|
1941
|
+
const { slug } = req.params;
|
|
1942
|
+
const { courseSlug } = req.body;
|
|
1943
|
+
// Validate required parameters
|
|
1944
|
+
if (!courseSlug) {
|
|
1945
|
+
throw new errorHandler_1.ValidationError("courseSlug is required in request body");
|
|
1946
|
+
}
|
|
1947
|
+
console.log(`🧹 Cleaning code_challenge_proposals from exercise: ${slug} in course: ${courseSlug}`);
|
|
1948
|
+
try {
|
|
1949
|
+
const exerciseDir = `courses/${courseSlug}/exercises/${slug}`;
|
|
1950
|
+
// Get all README files (all languages)
|
|
1951
|
+
const [readmeFiles] = await bucket.getFiles({
|
|
1952
|
+
prefix: `${exerciseDir}/README`,
|
|
1953
|
+
});
|
|
1954
|
+
if (readmeFiles.length === 0) {
|
|
1955
|
+
return res.status(404).json({
|
|
1956
|
+
success: false,
|
|
1957
|
+
error: "No README files found for this exercise",
|
|
1958
|
+
});
|
|
1959
|
+
}
|
|
1960
|
+
let cleanedCount = 0;
|
|
1961
|
+
const processedFiles = [];
|
|
1962
|
+
// Process each README file
|
|
1963
|
+
for (const readmeFile of readmeFiles) {
|
|
1964
|
+
try {
|
|
1965
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1966
|
+
const [readmeContent] = await readmeFile.download();
|
|
1967
|
+
const readmeText = readmeContent.toString();
|
|
1968
|
+
// Remove code_challenge_proposal blocks, EXCEPT those in GENERATING state
|
|
1969
|
+
// The GENERATING state is identified by "GENERATING(" marker
|
|
1970
|
+
// Pattern: ```code_challenge_proposal ... ``` (but not if contains GENERATING)
|
|
1971
|
+
const codeProposalRegex = /(\r?\n)?```code_challenge_proposal(?![\S\s]*?GENERATING\()[\S\s]*?```(\r?\n)?/g;
|
|
1972
|
+
const updatedReadme = readmeText.replace(codeProposalRegex, "");
|
|
1973
|
+
// Only update if there were actual changes
|
|
1974
|
+
if (updatedReadme.trim() !== readmeText.trim()) {
|
|
1975
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1976
|
+
await readmeFile.save(updatedReadme.trim(), {
|
|
1977
|
+
resumable: false,
|
|
1978
|
+
});
|
|
1979
|
+
cleanedCount++;
|
|
1980
|
+
processedFiles.push(readmeFile.name);
|
|
1981
|
+
console.log(`✅ Cleaned proposals from: ${readmeFile.name}`);
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
catch (readmeError) {
|
|
1985
|
+
console.error(`❌ Error cleaning README ${readmeFile.name}:`, readmeError);
|
|
1986
|
+
// Continue processing other files even if one fails
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
const message = cleanedCount > 0 ?
|
|
1990
|
+
`Successfully cleaned ${cleanedCount} README file(s)` :
|
|
1991
|
+
"No code challenge proposals found to clean";
|
|
1992
|
+
console.log(`✅ ${message}`);
|
|
1993
|
+
res.json({
|
|
1994
|
+
success: true,
|
|
1995
|
+
message,
|
|
1996
|
+
cleanedCount,
|
|
1997
|
+
processedFiles,
|
|
1998
|
+
});
|
|
1999
|
+
}
|
|
2000
|
+
catch (error) {
|
|
2001
|
+
console.error("❌ Error during code challenge proposal cleanup:", error);
|
|
2002
|
+
// Handle rate limit errors
|
|
2003
|
+
if (error.code === 429 ||
|
|
2004
|
+
((_a = error.message) === null || _a === void 0 ? void 0 : _a.includes("rate limit")) ||
|
|
2005
|
+
((_b = error.message) === null || _b === void 0 ? void 0 : _b.includes("rateLimitExceeded"))) {
|
|
2006
|
+
throw new errorHandler_1.ConflictError("Storage rate limit exceeded. Please try again in a few moments.", {
|
|
2007
|
+
code: "STORAGE_RATE_LIMIT",
|
|
2008
|
+
retryAfter: 60,
|
|
2009
|
+
});
|
|
2010
|
+
}
|
|
2011
|
+
throw error;
|
|
2012
|
+
}
|
|
2013
|
+
}));
|
|
2014
|
+
// GET /exercise/:slug/history/status
|
|
2015
|
+
// Get history status (can undo/redo, current version)
|
|
2016
|
+
app.get("/exercise/:slug/history/status", (0, errorHandler_1.asyncHandler)(async (req, res) => {
|
|
2017
|
+
const { slug } = req.params;
|
|
2018
|
+
const courseSlug = req.query.slug;
|
|
2019
|
+
const lang = req.query.lang || "en";
|
|
2020
|
+
if (!courseSlug) {
|
|
2021
|
+
throw new errorHandler_1.ValidationError("Course slug is required");
|
|
2022
|
+
}
|
|
2023
|
+
if (!historyManager) {
|
|
2024
|
+
return res.json({
|
|
2025
|
+
canUndo: false,
|
|
2026
|
+
canRedo: false,
|
|
2027
|
+
version: "0",
|
|
2028
|
+
available: false,
|
|
2029
|
+
});
|
|
2030
|
+
}
|
|
2031
|
+
const status = await historyManager.getHistoryStatus(courseSlug, slug, lang);
|
|
2032
|
+
res.json(Object.assign(Object.assign({}, status), { available: true }));
|
|
2033
|
+
}));
|
|
2034
|
+
// POST /exercise/:slug/history/undo
|
|
2035
|
+
// Undo the last change
|
|
2036
|
+
app.post("/exercise/:slug/history/undo", express.json(), (0, errorHandler_1.asyncHandler)(async (req, res) => {
|
|
2037
|
+
const { slug } = req.params;
|
|
2038
|
+
const { courseSlug, currentContent, versionId, lang } = req.body;
|
|
2039
|
+
if (!courseSlug || !currentContent || !versionId) {
|
|
2040
|
+
throw new errorHandler_1.ValidationError("Missing required fields: courseSlug, currentContent, versionId");
|
|
2041
|
+
}
|
|
2042
|
+
const language = lang || "en";
|
|
2043
|
+
if (!historyManager) {
|
|
2044
|
+
console.error("⏮️ HISTORY: History manager not available");
|
|
2045
|
+
throw new errorHandler_1.InternalServerError("History service not available");
|
|
2046
|
+
}
|
|
2047
|
+
const result = await historyManager.undo(courseSlug, slug, language, currentContent, versionId);
|
|
2048
|
+
if (!result.success) {
|
|
2049
|
+
if (result.error === "VERSION_CONFLICT") {
|
|
2050
|
+
throw new errorHandler_1.ConflictError("Version conflict. Please refresh and try again.");
|
|
2051
|
+
}
|
|
2052
|
+
if (result.error === "NO_UNDO_AVAILABLE") {
|
|
2053
|
+
throw new errorHandler_1.ValidationError("No more changes to undo");
|
|
2054
|
+
}
|
|
2055
|
+
throw new errorHandler_1.InternalServerError(`Undo operation failed: ${result.error}`);
|
|
2056
|
+
}
|
|
2057
|
+
if (!result.content) {
|
|
2058
|
+
throw new errorHandler_1.InternalServerError("No content returned from undo operation");
|
|
2059
|
+
}
|
|
2060
|
+
// Save the restored content to GCS
|
|
2061
|
+
const fileName = `README${(0, creatorUtilities_1.getReadmeExtension)(language)}`;
|
|
2062
|
+
const file = bucket.file(`courses/${courseSlug}/exercises/${slug}/${fileName}`);
|
|
2063
|
+
try {
|
|
2064
|
+
await file.save(result.content, {
|
|
2065
|
+
resumable: false,
|
|
2066
|
+
});
|
|
2067
|
+
}
|
|
2068
|
+
catch (gcsError) {
|
|
2069
|
+
console.error("⏮️ HISTORY: Failed to save restored content to GCS:", gcsError);
|
|
2070
|
+
throw new errorHandler_1.InternalServerError("Failed to save restored content");
|
|
2071
|
+
}
|
|
2072
|
+
res.json({
|
|
2073
|
+
content: result.content,
|
|
2074
|
+
version: result.newVersion,
|
|
2075
|
+
});
|
|
2076
|
+
}));
|
|
2077
|
+
// POST /exercise/:slug/history/redo
|
|
2078
|
+
// Redo the last undone change
|
|
2079
|
+
app.post("/exercise/:slug/history/redo", express.json(), (0, errorHandler_1.asyncHandler)(async (req, res) => {
|
|
2080
|
+
const { slug } = req.params;
|
|
2081
|
+
const { courseSlug, currentContent, versionId, lang } = req.body;
|
|
2082
|
+
if (!courseSlug || !currentContent || !versionId) {
|
|
2083
|
+
throw new errorHandler_1.ValidationError("Missing required fields: courseSlug, currentContent, versionId");
|
|
2084
|
+
}
|
|
2085
|
+
const language = lang || "en";
|
|
2086
|
+
if (!historyManager) {
|
|
2087
|
+
console.error("⏮️ HISTORY: History manager not available");
|
|
2088
|
+
throw new errorHandler_1.InternalServerError("History service not available");
|
|
2089
|
+
}
|
|
2090
|
+
const result = await historyManager.redo(courseSlug, slug, language, currentContent, versionId);
|
|
2091
|
+
if (!result.success) {
|
|
2092
|
+
if (result.error === "VERSION_CONFLICT") {
|
|
2093
|
+
throw new errorHandler_1.ConflictError("Version conflict. Please refresh and try again.");
|
|
2094
|
+
}
|
|
2095
|
+
if (result.error === "NO_REDO_AVAILABLE") {
|
|
2096
|
+
throw new errorHandler_1.ValidationError("No more changes to redo");
|
|
2097
|
+
}
|
|
2098
|
+
throw new errorHandler_1.InternalServerError(`Redo operation failed: ${result.error}`);
|
|
2099
|
+
}
|
|
2100
|
+
if (!result.content) {
|
|
2101
|
+
throw new errorHandler_1.InternalServerError("No content returned from redo operation");
|
|
2102
|
+
}
|
|
2103
|
+
// Save the restored content to GCS
|
|
2104
|
+
const fileName = `README${(0, creatorUtilities_1.getReadmeExtension)(language)}`;
|
|
2105
|
+
const file = bucket.file(`courses/${courseSlug}/exercises/${slug}/${fileName}`);
|
|
2106
|
+
try {
|
|
2107
|
+
await file.save(result.content, {
|
|
2108
|
+
resumable: false,
|
|
2109
|
+
});
|
|
2110
|
+
}
|
|
2111
|
+
catch (gcsError) {
|
|
2112
|
+
console.error("⏮️ HISTORY: Failed to save restored content to GCS:", gcsError);
|
|
2113
|
+
throw new errorHandler_1.InternalServerError("Failed to save restored content");
|
|
2114
|
+
}
|
|
2115
|
+
res.json({
|
|
2116
|
+
content: result.content,
|
|
2117
|
+
version: result.newVersion,
|
|
2118
|
+
});
|
|
2119
|
+
}));
|
|
1829
2120
|
// Create a new step for a course
|
|
1830
2121
|
app.post("/course/:slug/create-step", async (req, res) => {
|
|
1831
2122
|
console.log("POST /course/:slug/create-step");
|
|
@@ -2372,7 +2663,7 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
2372
2663
|
targetLanguages: targetLanguages.length,
|
|
2373
2664
|
});
|
|
2374
2665
|
// Process translations sequentially (no race conditions)
|
|
2375
|
-
processSyncTranslationsSequentially(courseSlug, exerciseSlug, notificationId, sourceReadmeContent, targetLanguages, rigoToken, bucket).catch(error => {
|
|
2666
|
+
processSyncTranslationsSequentially(courseSlug, exerciseSlug, notificationId, sourceReadmeContent, targetLanguages, rigoToken, bucket, historyManager).catch(error => {
|
|
2376
2667
|
console.error("Error in sync translation processing:", error);
|
|
2377
2668
|
// Update notification with critical error
|
|
2378
2669
|
getSyllabus(courseSlug, bucket).then(syl => {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { RedisClientType } from "redis";
|
|
2
|
+
export interface HistoryResult {
|
|
3
|
+
success: boolean;
|
|
4
|
+
newVersion?: string;
|
|
5
|
+
content?: string;
|
|
6
|
+
error?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface HistoryStatus {
|
|
9
|
+
canUndo: boolean;
|
|
10
|
+
canRedo: boolean;
|
|
11
|
+
version: string;
|
|
12
|
+
}
|
|
13
|
+
export declare class HistoryManager {
|
|
14
|
+
private redis;
|
|
15
|
+
private saveStateLua;
|
|
16
|
+
private undoLua;
|
|
17
|
+
private redoLua;
|
|
18
|
+
private readonly TTL;
|
|
19
|
+
private readonly LIMIT;
|
|
20
|
+
constructor(redisClient: RedisClientType);
|
|
21
|
+
/**
|
|
22
|
+
* Builds Redis keys for an exercise in a specific language
|
|
23
|
+
* @param courseSlug - The course slug identifier
|
|
24
|
+
* @param exerciseSlug - The exercise slug identifier
|
|
25
|
+
* @param lang - The language code
|
|
26
|
+
* @returns Object containing undo, redo, and version keys
|
|
27
|
+
*/
|
|
28
|
+
private getKeys;
|
|
29
|
+
/**
|
|
30
|
+
* Saves a new state in history
|
|
31
|
+
* @param courseSlug - The course slug identifier
|
|
32
|
+
* @param exerciseSlug - The exercise slug identifier
|
|
33
|
+
* @param lang - The language code
|
|
34
|
+
* @param content - The content of the state
|
|
35
|
+
* @param versionId - The version ID
|
|
36
|
+
* @returns Object containing success, newVersion, and error
|
|
37
|
+
*/
|
|
38
|
+
saveState(courseSlug: string, exerciseSlug: string, lang: string, content: string, versionId: string): Promise<HistoryResult>;
|
|
39
|
+
/**
|
|
40
|
+
* Performs an undo operation
|
|
41
|
+
* @param courseSlug - The course slug identifier
|
|
42
|
+
* @param exerciseSlug - The exercise slug identifier
|
|
43
|
+
* @param lang - The language code
|
|
44
|
+
* @param currentContent - The current content of the state
|
|
45
|
+
* @param versionId - The version ID
|
|
46
|
+
* @returns Object containing success, newVersion, and error
|
|
47
|
+
*/
|
|
48
|
+
undo(courseSlug: string, exerciseSlug: string, lang: string, currentContent: string, versionId: string): Promise<HistoryResult>;
|
|
49
|
+
/**
|
|
50
|
+
* Performs a redo operation
|
|
51
|
+
* @param courseSlug - The course slug identifier
|
|
52
|
+
* @param exerciseSlug - The exercise slug identifier
|
|
53
|
+
* @param lang - The language code
|
|
54
|
+
* @param currentContent - The current content of the state
|
|
55
|
+
* @param versionId - The version ID
|
|
56
|
+
* @returns Object containing success, newVersion, and error
|
|
57
|
+
*/
|
|
58
|
+
redo(courseSlug: string, exerciseSlug: string, lang: string, currentContent: string, versionId: string): Promise<HistoryResult>;
|
|
59
|
+
/**
|
|
60
|
+
* Gets the history status (can undo/redo, current version)
|
|
61
|
+
* @param courseSlug - The course slug identifier
|
|
62
|
+
* @param exerciseSlug - The exercise slug identifier
|
|
63
|
+
* @param lang - The language code
|
|
64
|
+
* @returns Object containing canUndo, canRedo, and version
|
|
65
|
+
*/
|
|
66
|
+
getHistoryStatus(courseSlug: string, exerciseSlug: string, lang: string): Promise<HistoryStatus>;
|
|
67
|
+
}
|
|
@@ -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;
|