@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.
@@ -33,6 +33,7 @@ const handleAssetCreation = async (sessionPayload, learnJson, selectedLang, lear
33
33
  slug: slug,
34
34
  title: learnJson.title[selectedLang],
35
35
  lang: selectedLang,
36
+ graded: true,
36
37
  description: learnJson.description[selectedLang],
37
38
  learnpack_deploy_url: learnpackDeployUrl,
38
39
  technologies: learnJson.technologies.map((tech) => tech.toLowerCase().replace(/\s+/g, "-")),
@@ -57,6 +58,7 @@ const handleAssetCreation = async (sessionPayload, learnJson, selectedLang, lear
57
58
  }
58
59
  console_1.default.info("Asset exists, updating it");
59
60
  const asset = await api_1.default.updateAsset(sessionPayload.token, slug, {
61
+ graded: true,
60
62
  learnpack_deploy_url: learnpackDeployUrl,
61
63
  title: learnJson.title[selectedLang],
62
64
  category: category,
@@ -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
- export default class ServeCommand extends SessionCommand {
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;
@@ -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 readmePath = `courses/${courseSlug}/exercises/${exerciseSlug}/README${(0, creatorUtilities_1.getReadmeExtension)(targetLang)}`;
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 bucket
499
- .file(readmePath)
500
- .save(translationResult.parsed.translation);
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.text(), (0, errorHandler_1.asyncHandler)(async (req, res) => {
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
- // Update the file in the bucket
1801
- const file = bucket.file(`courses/${courseSlug}/exercises/${slug}/${fileName}`);
1802
- await file.save(req.body, {
1803
- resumable: false,
1804
- });
1805
- const created = await file.exists();
1806
- res.send({
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
+ }