@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.
- package/lib/commands/publish.js +2 -0
- 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/lib/utils/api.d.ts +1 -0
- package/lib/utils/api.js +1 -1
- package/package.json +2 -1
- package/src/commands/publish.ts +2 -0
- 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/src/utils/api.ts +2 -1
package/src/commands/serve.ts
CHANGED
|
@@ -75,6 +75,10 @@ import {
|
|
|
75
75
|
NotFoundError,
|
|
76
76
|
} from "../utils/errorHandler"
|
|
77
77
|
import { JSDOM } from "jsdom"
|
|
78
|
+
import { createClient } from "redis"
|
|
79
|
+
import type { RedisClientType } from "redis"
|
|
80
|
+
import { HistoryManager } from "../managers/historyManager"
|
|
81
|
+
import { ReadmeHistoryService } from "../managers/readmeHistoryService"
|
|
78
82
|
|
|
79
83
|
const frontMatter = require("front-matter")
|
|
80
84
|
|
|
@@ -796,7 +800,8 @@ async function processSyncTranslationsSequentially(
|
|
|
796
800
|
sourceReadmeContent: string,
|
|
797
801
|
targetLanguages: string[],
|
|
798
802
|
rigoToken: string,
|
|
799
|
-
bucket: Bucket
|
|
803
|
+
bucket: Bucket,
|
|
804
|
+
historyManager: HistoryManager | null
|
|
800
805
|
) {
|
|
801
806
|
try {
|
|
802
807
|
// Process translations sequentially (no race conditions)
|
|
@@ -829,14 +834,33 @@ async function processSyncTranslationsSequentially(
|
|
|
829
834
|
throw new Error("Translation result is empty")
|
|
830
835
|
}
|
|
831
836
|
|
|
832
|
-
// Save translated README
|
|
833
|
-
const
|
|
834
|
-
|
|
835
|
-
|
|
837
|
+
// Save translated README with history tracking
|
|
838
|
+
const fileName = ReadmeHistoryService.getReadmeFileName(targetLang)
|
|
839
|
+
const readmeHistoryService = new ReadmeHistoryService(
|
|
840
|
+
historyManager,
|
|
841
|
+
bucket
|
|
842
|
+
)
|
|
843
|
+
|
|
836
844
|
// eslint-disable-next-line no-await-in-loop
|
|
837
|
-
await
|
|
838
|
-
|
|
839
|
-
|
|
845
|
+
const historyResult = await readmeHistoryService.saveReadmeWithHistory({
|
|
846
|
+
courseSlug,
|
|
847
|
+
exerciseSlug,
|
|
848
|
+
lang: targetLang,
|
|
849
|
+
fileName,
|
|
850
|
+
newContent: translationResult.parsed.translation,
|
|
851
|
+
// Don't pass previousContent or currentVersion - let service fetch them
|
|
852
|
+
throwOnConflict: false, // Don't fail sync on version conflicts
|
|
853
|
+
})
|
|
854
|
+
|
|
855
|
+
if (historyResult.success) {
|
|
856
|
+
console.log(
|
|
857
|
+
`🔄 SYNC: Saved ${targetLang} translation with history (version ${historyResult.newVersion})`
|
|
858
|
+
)
|
|
859
|
+
} else {
|
|
860
|
+
console.warn(
|
|
861
|
+
`🔄 SYNC: Translation saved but history failed for ${targetLang}: ${historyResult.error}`
|
|
862
|
+
)
|
|
863
|
+
}
|
|
840
864
|
|
|
841
865
|
// Update progress in syllabus
|
|
842
866
|
// eslint-disable-next-line no-await-in-loop
|
|
@@ -969,9 +993,11 @@ async function processSyncTranslationsSequentially(
|
|
|
969
993
|
}
|
|
970
994
|
}
|
|
971
995
|
|
|
972
|
-
|
|
996
|
+
class ServeCommand extends SessionCommand {
|
|
973
997
|
static description = "Runs a small server to build tutorials"
|
|
974
998
|
|
|
999
|
+
private redis: ReturnType<typeof createClient> | null = null
|
|
1000
|
+
|
|
975
1001
|
static flags = {
|
|
976
1002
|
...SessionCommand.flags,
|
|
977
1003
|
port: flags.string({ char: "p", description: "server port" }),
|
|
@@ -1015,6 +1041,64 @@ export default class ServeCommand extends SessionCommand {
|
|
|
1015
1041
|
console.log("INFO: HOST is set to", host)
|
|
1016
1042
|
}
|
|
1017
1043
|
|
|
1044
|
+
// Initialize Redis (official client)
|
|
1045
|
+
const redisUrl = process.env.REDIS_URL || "redis://localhost:6379"
|
|
1046
|
+
|
|
1047
|
+
if (
|
|
1048
|
+
process.env.NODE_ENV === "production" &&
|
|
1049
|
+
redisUrl === "redis://localhost:6379"
|
|
1050
|
+
) {
|
|
1051
|
+
console.error("❌ REDIS_URL not configured for production environment!")
|
|
1052
|
+
console.warn("⚠️ History features will be unavailable")
|
|
1053
|
+
this.redis = null
|
|
1054
|
+
} else {
|
|
1055
|
+
try {
|
|
1056
|
+
const useTLS = redisUrl.startsWith("rediss://")
|
|
1057
|
+
|
|
1058
|
+
this.redis = createClient({
|
|
1059
|
+
url: redisUrl,
|
|
1060
|
+
socket: {
|
|
1061
|
+
tls: useTLS,
|
|
1062
|
+
rejectUnauthorized: useTLS,
|
|
1063
|
+
reconnectStrategy: (retries: number) => {
|
|
1064
|
+
if (retries > 10) {
|
|
1065
|
+
console.error("❌ Too many Redis reconnection attempts")
|
|
1066
|
+
return new Error("Too many retries")
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// Exponential backoff: 50ms, 100ms, 200ms, ...
|
|
1070
|
+
const delay = Math.min(retries * 50, 2000)
|
|
1071
|
+
return delay
|
|
1072
|
+
},
|
|
1073
|
+
},
|
|
1074
|
+
})
|
|
1075
|
+
|
|
1076
|
+
this.redis.on("error", (err: Error) => {
|
|
1077
|
+
console.error("❌ Redis error:", err)
|
|
1078
|
+
})
|
|
1079
|
+
|
|
1080
|
+
this.redis.on("connect", () => {
|
|
1081
|
+
console.log("🔄 Connecting to Redis...")
|
|
1082
|
+
})
|
|
1083
|
+
|
|
1084
|
+
this.redis.on("ready", () => {
|
|
1085
|
+
console.log("✅ Connected to Redis")
|
|
1086
|
+
})
|
|
1087
|
+
|
|
1088
|
+
// Connect the client
|
|
1089
|
+
await this.redis.connect()
|
|
1090
|
+
} catch (redisError) {
|
|
1091
|
+
console.error("❌ Failed to connect to Redis:", redisError)
|
|
1092
|
+
console.warn("⚠️ History features (undo/redo) will be unavailable")
|
|
1093
|
+
this.redis = null
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// Create HistoryManager instance if Redis is available
|
|
1098
|
+
const historyManager = this.redis ?
|
|
1099
|
+
new HistoryManager(this.redis as RedisClientType) :
|
|
1100
|
+
null
|
|
1101
|
+
|
|
1018
1102
|
// async function listFilesWithPrefix(prefix: string) {
|
|
1019
1103
|
// const [files] = await bucket.getFiles({ prefix })
|
|
1020
1104
|
// return files
|
|
@@ -2573,11 +2657,15 @@ export default class ServeCommand extends SessionCommand {
|
|
|
2573
2657
|
|
|
2574
2658
|
app.put(
|
|
2575
2659
|
"/exercise/:slug/file/:fileName",
|
|
2576
|
-
express.
|
|
2660
|
+
express.json(),
|
|
2577
2661
|
asyncHandler(async (req, res) => {
|
|
2578
2662
|
const { slug, fileName } = req.params
|
|
2579
2663
|
const query = req.query
|
|
2580
2664
|
const courseSlug = query.slug as string
|
|
2665
|
+
const lang = (query.lang as string) || "en"
|
|
2666
|
+
|
|
2667
|
+
// Headers for history management
|
|
2668
|
+
const versionId = (req.headers["x-history-version"] as string) || "0"
|
|
2581
2669
|
|
|
2582
2670
|
console.log(`PUT /exercise/${slug}/file/${fileName}`)
|
|
2583
2671
|
|
|
@@ -2592,21 +2680,64 @@ export default class ServeCommand extends SessionCommand {
|
|
|
2592
2680
|
throw new ValidationError("File name and exercise slug are required")
|
|
2593
2681
|
}
|
|
2594
2682
|
|
|
2683
|
+
// Extract content from body (can be text or JSON)
|
|
2684
|
+
let fileContent: string
|
|
2685
|
+
let contentToSaveInHistory: string | undefined
|
|
2686
|
+
|
|
2687
|
+
if (typeof req.body === "string") {
|
|
2688
|
+
// Old format: plain text
|
|
2689
|
+
fileContent = req.body
|
|
2690
|
+
} else if (req.body && typeof req.body === "object") {
|
|
2691
|
+
// New format: JSON with content and historyContent
|
|
2692
|
+
fileContent = req.body.content
|
|
2693
|
+
contentToSaveInHistory = req.body.historyContent
|
|
2694
|
+
} else {
|
|
2695
|
+
throw new ValidationError("Invalid request body format")
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2595
2698
|
try {
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
`courses/${courseSlug}/exercises/${slug}/${fileName}`
|
|
2599
|
-
)
|
|
2699
|
+
let newVersion = versionId
|
|
2700
|
+
let created = false
|
|
2600
2701
|
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2702
|
+
// Use ReadmeHistoryService for README files with history support
|
|
2703
|
+
if (fileName.startsWith("README")) {
|
|
2704
|
+
const readmeHistoryService = new ReadmeHistoryService(
|
|
2705
|
+
historyManager,
|
|
2706
|
+
bucket
|
|
2707
|
+
)
|
|
2708
|
+
|
|
2709
|
+
const historyResult =
|
|
2710
|
+
await readmeHistoryService.saveReadmeWithHistory({
|
|
2711
|
+
courseSlug,
|
|
2712
|
+
exerciseSlug: slug,
|
|
2713
|
+
lang,
|
|
2714
|
+
fileName,
|
|
2715
|
+
newContent: fileContent,
|
|
2716
|
+
previousContent: contentToSaveInHistory,
|
|
2717
|
+
currentVersion: versionId,
|
|
2718
|
+
throwOnConflict: true, // Throw error on version conflicts for manual edits
|
|
2719
|
+
})
|
|
2720
|
+
|
|
2721
|
+
newVersion = historyResult.newVersion
|
|
2722
|
+
created = true // File was saved by the service
|
|
2723
|
+
} else {
|
|
2724
|
+
// For non-README files, save directly without history
|
|
2725
|
+
const file = bucket.file(
|
|
2726
|
+
`courses/${courseSlug}/exercises/${slug}/${fileName}`
|
|
2727
|
+
)
|
|
2728
|
+
|
|
2729
|
+
await file.save(fileContent, {
|
|
2730
|
+
resumable: false,
|
|
2731
|
+
})
|
|
2604
2732
|
|
|
2605
|
-
|
|
2733
|
+
const [exists] = await file.exists()
|
|
2734
|
+
created = exists
|
|
2735
|
+
}
|
|
2606
2736
|
|
|
2607
|
-
res.
|
|
2737
|
+
res.json({
|
|
2608
2738
|
message: "File updated",
|
|
2609
2739
|
created,
|
|
2740
|
+
version: newVersion,
|
|
2610
2741
|
})
|
|
2611
2742
|
} catch (error: any) {
|
|
2612
2743
|
// Handle Google Cloud Storage rate limit errors (429)
|
|
@@ -2637,6 +2768,304 @@ export default class ServeCommand extends SessionCommand {
|
|
|
2637
2768
|
}
|
|
2638
2769
|
})
|
|
2639
2770
|
)
|
|
2771
|
+
|
|
2772
|
+
// Remove code_challenge_proposals from all README files of an exercise
|
|
2773
|
+
app.post(
|
|
2774
|
+
"/exercise/:slug/remove-code-proposals",
|
|
2775
|
+
asyncHandler(async (req, res) => {
|
|
2776
|
+
const { slug } = req.params
|
|
2777
|
+
const { courseSlug } = req.body
|
|
2778
|
+
|
|
2779
|
+
// Validate required parameters
|
|
2780
|
+
if (!courseSlug) {
|
|
2781
|
+
throw new ValidationError("courseSlug is required in request body")
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
console.log(
|
|
2785
|
+
`🧹 Cleaning code_challenge_proposals from exercise: ${slug} in course: ${courseSlug}`
|
|
2786
|
+
)
|
|
2787
|
+
|
|
2788
|
+
try {
|
|
2789
|
+
const exerciseDir = `courses/${courseSlug}/exercises/${slug}`
|
|
2790
|
+
|
|
2791
|
+
// Get all README files (all languages)
|
|
2792
|
+
const [readmeFiles] = await bucket.getFiles({
|
|
2793
|
+
prefix: `${exerciseDir}/README`,
|
|
2794
|
+
})
|
|
2795
|
+
|
|
2796
|
+
if (readmeFiles.length === 0) {
|
|
2797
|
+
return res.status(404).json({
|
|
2798
|
+
success: false,
|
|
2799
|
+
error: "No README files found for this exercise",
|
|
2800
|
+
})
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
let cleanedCount = 0
|
|
2804
|
+
const processedFiles: string[] = []
|
|
2805
|
+
|
|
2806
|
+
// Process each README file
|
|
2807
|
+
for (const readmeFile of readmeFiles) {
|
|
2808
|
+
try {
|
|
2809
|
+
// eslint-disable-next-line no-await-in-loop
|
|
2810
|
+
const [readmeContent] = await readmeFile.download()
|
|
2811
|
+
const readmeText = readmeContent.toString()
|
|
2812
|
+
|
|
2813
|
+
// Remove code_challenge_proposal blocks, EXCEPT those in GENERATING state
|
|
2814
|
+
// The GENERATING state is identified by "GENERATING(" marker
|
|
2815
|
+
// Pattern: ```code_challenge_proposal ... ``` (but not if contains GENERATING)
|
|
2816
|
+
const codeProposalRegex =
|
|
2817
|
+
/(\r?\n)?```code_challenge_proposal(?![\S\s]*?GENERATING\()[\S\s]*?```(\r?\n)?/g
|
|
2818
|
+
|
|
2819
|
+
const updatedReadme = readmeText.replace(codeProposalRegex, "")
|
|
2820
|
+
|
|
2821
|
+
// Only update if there were actual changes
|
|
2822
|
+
if (updatedReadme.trim() !== readmeText.trim()) {
|
|
2823
|
+
// eslint-disable-next-line no-await-in-loop
|
|
2824
|
+
await readmeFile.save(updatedReadme.trim(), {
|
|
2825
|
+
resumable: false,
|
|
2826
|
+
})
|
|
2827
|
+
|
|
2828
|
+
cleanedCount++
|
|
2829
|
+
processedFiles.push(readmeFile.name)
|
|
2830
|
+
console.log(`✅ Cleaned proposals from: ${readmeFile.name}`)
|
|
2831
|
+
}
|
|
2832
|
+
} catch (readmeError) {
|
|
2833
|
+
console.error(
|
|
2834
|
+
`❌ Error cleaning README ${readmeFile.name}:`,
|
|
2835
|
+
readmeError
|
|
2836
|
+
)
|
|
2837
|
+
// Continue processing other files even if one fails
|
|
2838
|
+
}
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
const message =
|
|
2842
|
+
cleanedCount > 0 ?
|
|
2843
|
+
`Successfully cleaned ${cleanedCount} README file(s)` :
|
|
2844
|
+
"No code challenge proposals found to clean"
|
|
2845
|
+
|
|
2846
|
+
console.log(`✅ ${message}`)
|
|
2847
|
+
|
|
2848
|
+
res.json({
|
|
2849
|
+
success: true,
|
|
2850
|
+
message,
|
|
2851
|
+
cleanedCount,
|
|
2852
|
+
processedFiles,
|
|
2853
|
+
})
|
|
2854
|
+
} catch (error: any) {
|
|
2855
|
+
console.error(
|
|
2856
|
+
"❌ Error during code challenge proposal cleanup:",
|
|
2857
|
+
error
|
|
2858
|
+
)
|
|
2859
|
+
|
|
2860
|
+
// Handle rate limit errors
|
|
2861
|
+
if (
|
|
2862
|
+
error.code === 429 ||
|
|
2863
|
+
error.message?.includes("rate limit") ||
|
|
2864
|
+
error.message?.includes("rateLimitExceeded")
|
|
2865
|
+
) {
|
|
2866
|
+
throw new ConflictError(
|
|
2867
|
+
"Storage rate limit exceeded. Please try again in a few moments.",
|
|
2868
|
+
{
|
|
2869
|
+
code: "STORAGE_RATE_LIMIT",
|
|
2870
|
+
retryAfter: 60,
|
|
2871
|
+
}
|
|
2872
|
+
)
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2875
|
+
throw error
|
|
2876
|
+
}
|
|
2877
|
+
})
|
|
2878
|
+
)
|
|
2879
|
+
|
|
2880
|
+
// GET /exercise/:slug/history/status
|
|
2881
|
+
// Get history status (can undo/redo, current version)
|
|
2882
|
+
app.get(
|
|
2883
|
+
"/exercise/:slug/history/status",
|
|
2884
|
+
asyncHandler(async (req, res) => {
|
|
2885
|
+
const { slug } = req.params
|
|
2886
|
+
const courseSlug = req.query.slug as string
|
|
2887
|
+
const lang = (req.query.lang as string) || "en"
|
|
2888
|
+
|
|
2889
|
+
if (!courseSlug) {
|
|
2890
|
+
throw new ValidationError("Course slug is required")
|
|
2891
|
+
}
|
|
2892
|
+
|
|
2893
|
+
if (!historyManager) {
|
|
2894
|
+
return res.json({
|
|
2895
|
+
canUndo: false,
|
|
2896
|
+
canRedo: false,
|
|
2897
|
+
version: "0",
|
|
2898
|
+
available: false,
|
|
2899
|
+
})
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
const status = await historyManager.getHistoryStatus(
|
|
2903
|
+
courseSlug,
|
|
2904
|
+
slug,
|
|
2905
|
+
lang
|
|
2906
|
+
)
|
|
2907
|
+
|
|
2908
|
+
res.json({
|
|
2909
|
+
...status,
|
|
2910
|
+
available: true,
|
|
2911
|
+
})
|
|
2912
|
+
})
|
|
2913
|
+
)
|
|
2914
|
+
|
|
2915
|
+
// POST /exercise/:slug/history/undo
|
|
2916
|
+
// Undo the last change
|
|
2917
|
+
app.post(
|
|
2918
|
+
"/exercise/:slug/history/undo",
|
|
2919
|
+
express.json(),
|
|
2920
|
+
asyncHandler(async (req, res) => {
|
|
2921
|
+
const { slug } = req.params
|
|
2922
|
+
const { courseSlug, currentContent, versionId, lang } = req.body
|
|
2923
|
+
|
|
2924
|
+
if (!courseSlug || !currentContent || !versionId) {
|
|
2925
|
+
throw new ValidationError(
|
|
2926
|
+
"Missing required fields: courseSlug, currentContent, versionId"
|
|
2927
|
+
)
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2930
|
+
const language = lang || "en"
|
|
2931
|
+
|
|
2932
|
+
if (!historyManager) {
|
|
2933
|
+
console.error("⏮️ HISTORY: History manager not available")
|
|
2934
|
+
throw new InternalServerError("History service not available")
|
|
2935
|
+
}
|
|
2936
|
+
|
|
2937
|
+
const result = await historyManager.undo(
|
|
2938
|
+
courseSlug,
|
|
2939
|
+
slug,
|
|
2940
|
+
language,
|
|
2941
|
+
currentContent,
|
|
2942
|
+
versionId
|
|
2943
|
+
)
|
|
2944
|
+
|
|
2945
|
+
if (!result.success) {
|
|
2946
|
+
if (result.error === "VERSION_CONFLICT") {
|
|
2947
|
+
throw new ConflictError(
|
|
2948
|
+
"Version conflict. Please refresh and try again."
|
|
2949
|
+
)
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
if (result.error === "NO_UNDO_AVAILABLE") {
|
|
2953
|
+
throw new ValidationError("No more changes to undo")
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
throw new InternalServerError(
|
|
2957
|
+
`Undo operation failed: ${result.error}`
|
|
2958
|
+
)
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
if (!result.content) {
|
|
2962
|
+
throw new InternalServerError(
|
|
2963
|
+
"No content returned from undo operation"
|
|
2964
|
+
)
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
// Save the restored content to GCS
|
|
2968
|
+
const fileName = `README${getReadmeExtension(language)}`
|
|
2969
|
+
const file = bucket.file(
|
|
2970
|
+
`courses/${courseSlug}/exercises/${slug}/${fileName}`
|
|
2971
|
+
)
|
|
2972
|
+
|
|
2973
|
+
try {
|
|
2974
|
+
await file.save(result.content, {
|
|
2975
|
+
resumable: false,
|
|
2976
|
+
})
|
|
2977
|
+
} catch (gcsError) {
|
|
2978
|
+
console.error(
|
|
2979
|
+
"⏮️ HISTORY: Failed to save restored content to GCS:",
|
|
2980
|
+
gcsError
|
|
2981
|
+
)
|
|
2982
|
+
throw new InternalServerError("Failed to save restored content")
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2985
|
+
res.json({
|
|
2986
|
+
content: result.content,
|
|
2987
|
+
version: result.newVersion,
|
|
2988
|
+
})
|
|
2989
|
+
})
|
|
2990
|
+
)
|
|
2991
|
+
|
|
2992
|
+
// POST /exercise/:slug/history/redo
|
|
2993
|
+
// Redo the last undone change
|
|
2994
|
+
app.post(
|
|
2995
|
+
"/exercise/:slug/history/redo",
|
|
2996
|
+
express.json(),
|
|
2997
|
+
asyncHandler(async (req, res) => {
|
|
2998
|
+
const { slug } = req.params
|
|
2999
|
+
const { courseSlug, currentContent, versionId, lang } = req.body
|
|
3000
|
+
|
|
3001
|
+
if (!courseSlug || !currentContent || !versionId) {
|
|
3002
|
+
throw new ValidationError(
|
|
3003
|
+
"Missing required fields: courseSlug, currentContent, versionId"
|
|
3004
|
+
)
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
const language = lang || "en"
|
|
3008
|
+
|
|
3009
|
+
if (!historyManager) {
|
|
3010
|
+
console.error("⏮️ HISTORY: History manager not available")
|
|
3011
|
+
throw new InternalServerError("History service not available")
|
|
3012
|
+
}
|
|
3013
|
+
|
|
3014
|
+
const result = await historyManager.redo(
|
|
3015
|
+
courseSlug,
|
|
3016
|
+
slug,
|
|
3017
|
+
language,
|
|
3018
|
+
currentContent,
|
|
3019
|
+
versionId
|
|
3020
|
+
)
|
|
3021
|
+
|
|
3022
|
+
if (!result.success) {
|
|
3023
|
+
if (result.error === "VERSION_CONFLICT") {
|
|
3024
|
+
throw new ConflictError(
|
|
3025
|
+
"Version conflict. Please refresh and try again."
|
|
3026
|
+
)
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
if (result.error === "NO_REDO_AVAILABLE") {
|
|
3030
|
+
throw new ValidationError("No more changes to redo")
|
|
3031
|
+
}
|
|
3032
|
+
|
|
3033
|
+
throw new InternalServerError(
|
|
3034
|
+
`Redo operation failed: ${result.error}`
|
|
3035
|
+
)
|
|
3036
|
+
}
|
|
3037
|
+
|
|
3038
|
+
if (!result.content) {
|
|
3039
|
+
throw new InternalServerError(
|
|
3040
|
+
"No content returned from redo operation"
|
|
3041
|
+
)
|
|
3042
|
+
}
|
|
3043
|
+
|
|
3044
|
+
// Save the restored content to GCS
|
|
3045
|
+
const fileName = `README${getReadmeExtension(language)}`
|
|
3046
|
+
const file = bucket.file(
|
|
3047
|
+
`courses/${courseSlug}/exercises/${slug}/${fileName}`
|
|
3048
|
+
)
|
|
3049
|
+
|
|
3050
|
+
try {
|
|
3051
|
+
await file.save(result.content, {
|
|
3052
|
+
resumable: false,
|
|
3053
|
+
})
|
|
3054
|
+
} catch (gcsError) {
|
|
3055
|
+
console.error(
|
|
3056
|
+
"⏮️ HISTORY: Failed to save restored content to GCS:",
|
|
3057
|
+
gcsError
|
|
3058
|
+
)
|
|
3059
|
+
throw new InternalServerError("Failed to save restored content")
|
|
3060
|
+
}
|
|
3061
|
+
|
|
3062
|
+
res.json({
|
|
3063
|
+
content: result.content,
|
|
3064
|
+
version: result.newVersion,
|
|
3065
|
+
})
|
|
3066
|
+
})
|
|
3067
|
+
)
|
|
3068
|
+
|
|
2640
3069
|
// Create a new step for a course
|
|
2641
3070
|
app.post("/course/:slug/create-step", async (req, res) => {
|
|
2642
3071
|
console.log("POST /course/:slug/create-step")
|
|
@@ -3417,7 +3846,8 @@ export default class ServeCommand extends SessionCommand {
|
|
|
3417
3846
|
sourceReadmeContent,
|
|
3418
3847
|
targetLanguages,
|
|
3419
3848
|
rigoToken,
|
|
3420
|
-
bucket
|
|
3849
|
+
bucket,
|
|
3850
|
+
historyManager
|
|
3421
3851
|
).catch(error => {
|
|
3422
3852
|
console.error("Error in sync translation processing:", error)
|
|
3423
3853
|
|
|
@@ -4668,3 +5098,5 @@ export default class ServeCommand extends SessionCommand {
|
|
|
4668
5098
|
})
|
|
4669
5099
|
}
|
|
4670
5100
|
}
|
|
5101
|
+
|
|
5102
|
+
export default ServeCommand
|
package/src/lua/redo.lua
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
-- Lua script to redo the last undone change
|
|
2
|
+
-- Implements Memento pattern with optimistic locking
|
|
3
|
+
--
|
|
4
|
+
-- KEYS[1]: undo:{course}:{exercise}:{lang}
|
|
5
|
+
-- KEYS[2]: redo:{course}:{exercise}:{lang}
|
|
6
|
+
-- KEYS[3]: undo:{course}:{exercise}:{lang}:version
|
|
7
|
+
-- ARGV[1]: current_state_json (state before redo, to save in undo)
|
|
8
|
+
-- ARGV[2]: version_id (current client version for optimistic locking)
|
|
9
|
+
-- ARGV[3]: ttl (432000 seconds = 5 days)
|
|
10
|
+
|
|
11
|
+
-- Get current version
|
|
12
|
+
local current_version = redis.call('GET', KEYS[3])
|
|
13
|
+
|
|
14
|
+
-- Verify version (optimistic locking)
|
|
15
|
+
if current_version ~= false and current_version ~= ARGV[2] then
|
|
16
|
+
return {err = 'VERSION_CONFLICT'}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
-- Verify there are states to redo
|
|
20
|
+
local redo_length = redis.call('LLEN', KEYS[2])
|
|
21
|
+
if redo_length == 0 then
|
|
22
|
+
return {err = 'NO_REDO_AVAILABLE'}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
-- Get future state (the most recent in redo stack)
|
|
26
|
+
local future_state = redis.call('LPOP', KEYS[2])
|
|
27
|
+
|
|
28
|
+
if not future_state then
|
|
29
|
+
return {err = 'NO_FUTURE_STATE'}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
-- Save current state in undo stack
|
|
33
|
+
redis.call('LPUSH', KEYS[1], ARGV[1])
|
|
34
|
+
|
|
35
|
+
-- Maintain state limit in undo (last 5)
|
|
36
|
+
redis.call('LTRIM', KEYS[1], 0, 4)
|
|
37
|
+
|
|
38
|
+
-- Renew TTL of both stacks
|
|
39
|
+
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))
|
|
40
|
+
redis.call('EXPIRE', KEYS[2], tonumber(ARGV[3]))
|
|
41
|
+
|
|
42
|
+
-- Increment version and renew its TTL
|
|
43
|
+
local new_version = redis.call('INCR', KEYS[3])
|
|
44
|
+
redis.call('EXPIRE', KEYS[3], tonumber(ARGV[3]))
|
|
45
|
+
|
|
46
|
+
-- Return array [newVersion, futureState] for compatibility with Redis client
|
|
47
|
+
return {tostring(new_version), future_state}
|
|
48
|
+
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
-- Lua script to save a new state in history
|
|
2
|
+
-- Implements Memento pattern with optimistic locking
|
|
3
|
+
-- NOTE: We save the state BEFORE changes, so users can undo to it
|
|
4
|
+
--
|
|
5
|
+
-- KEYS[1]: undo:{course}:{exercise}:{lang}
|
|
6
|
+
-- KEYS[2]: redo:{course}:{exercise}:{lang}
|
|
7
|
+
-- KEYS[3]: undo:{course}:{exercise}:{lang}:version
|
|
8
|
+
-- ARGV[1]: state_json (complete README BEFORE changes - serialized)
|
|
9
|
+
-- ARGV[2]: version_id (current client version for optimistic locking)
|
|
10
|
+
-- ARGV[3]: ttl (432000 seconds = 5 days)
|
|
11
|
+
-- ARGV[4]: limit (5 states maximum)
|
|
12
|
+
|
|
13
|
+
-- Get current version
|
|
14
|
+
local current_version = redis.call('GET', KEYS[3])
|
|
15
|
+
|
|
16
|
+
-- Verify version (optimistic locking)
|
|
17
|
+
-- If the sent version doesn't match the current one, reject
|
|
18
|
+
if current_version ~= false and current_version ~= ARGV[2] then
|
|
19
|
+
return {err = 'VERSION_CONFLICT'}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
-- Save new state in undo stack (at the beginning)
|
|
23
|
+
redis.call('LPUSH', KEYS[1], ARGV[1])
|
|
24
|
+
|
|
25
|
+
-- Invalidate redo stack (new change invalidates the "alternative future")
|
|
26
|
+
redis.call('DEL', KEYS[2])
|
|
27
|
+
|
|
28
|
+
-- Keep only the last N states (0 to limit-1)
|
|
29
|
+
redis.call('LTRIM', KEYS[1], 0, tonumber(ARGV[4]) - 1)
|
|
30
|
+
|
|
31
|
+
-- Renew TTL of undo stack
|
|
32
|
+
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))
|
|
33
|
+
|
|
34
|
+
-- Increment version and renew its TTL
|
|
35
|
+
local new_version = redis.call('INCR', KEYS[3])
|
|
36
|
+
redis.call('EXPIRE', KEYS[3], tonumber(ARGV[3]))
|
|
37
|
+
|
|
38
|
+
-- Return new version (as string for compatibility with Redis client)
|
|
39
|
+
return tostring(new_version)
|
|
40
|
+
|
package/src/lua/undo.lua
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
-- Lua script to undo the last change
|
|
2
|
+
-- Implements Memento pattern with optimistic locking
|
|
3
|
+
-- NOTE: Stack contains states BEFORE changes, so index 0 is what we want to restore
|
|
4
|
+
--
|
|
5
|
+
-- KEYS[1]: undo:{course}:{exercise}:{lang}
|
|
6
|
+
-- KEYS[2]: redo:{course}:{exercise}:{lang}
|
|
7
|
+
-- KEYS[3]: undo:{course}:{exercise}:{lang}:version
|
|
8
|
+
-- ARGV[1]: current_state_json (current state to save in redo for potential redo)
|
|
9
|
+
-- ARGV[2]: version_id (current client version for optimistic locking)
|
|
10
|
+
-- ARGV[3]: ttl (432000 seconds = 5 days)
|
|
11
|
+
|
|
12
|
+
-- Get current version
|
|
13
|
+
local current_version = redis.call('GET', KEYS[3])
|
|
14
|
+
|
|
15
|
+
-- Verify version (optimistic locking)
|
|
16
|
+
if current_version ~= false and current_version ~= ARGV[2] then
|
|
17
|
+
return {err = 'VERSION_CONFLICT'}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
-- Verify there are states to undo
|
|
21
|
+
-- We need at least 1 element (the state before the current change)
|
|
22
|
+
local undo_length = redis.call('LLEN', KEYS[1])
|
|
23
|
+
if undo_length < 1 then
|
|
24
|
+
return {err = 'NO_UNDO_AVAILABLE'}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
-- Get the saved state (index 0 - the state before current changes)
|
|
28
|
+
local previous_state = redis.call('LINDEX', KEYS[1], 0)
|
|
29
|
+
|
|
30
|
+
if not previous_state then
|
|
31
|
+
return {err = 'NO_PREVIOUS_STATE'}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
-- Move current state to redo stack
|
|
35
|
+
redis.call('LPUSH', KEYS[2], ARGV[1])
|
|
36
|
+
|
|
37
|
+
-- Remove current state from undo stack (index 0)
|
|
38
|
+
redis.call('LPOP', KEYS[1])
|
|
39
|
+
|
|
40
|
+
-- Renew TTL of both stacks
|
|
41
|
+
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))
|
|
42
|
+
redis.call('EXPIRE', KEYS[2], tonumber(ARGV[3]))
|
|
43
|
+
|
|
44
|
+
-- Increment version and renew its TTL
|
|
45
|
+
local new_version = redis.call('INCR', KEYS[3])
|
|
46
|
+
redis.call('EXPIRE', KEYS[3], tonumber(ARGV[3]))
|
|
47
|
+
|
|
48
|
+
-- Return array [newVersion, previousState] for compatibility with Redis client
|
|
49
|
+
return {tostring(new_version), previous_state}
|
|
50
|
+
|