offcourse 1.0.1 → 1.1.0

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.
Files changed (41) hide show
  1. package/README.md +107 -8
  2. package/dist/cli/commands/sync.js +4 -1
  3. package/dist/cli/commands/sync.js.map +1 -1
  4. package/dist/cli/commands/syncHighLevel.d.ts.map +1 -1
  5. package/dist/cli/commands/syncHighLevel.js +4 -1
  6. package/dist/cli/commands/syncHighLevel.js.map +1 -1
  7. package/dist/cli/commands/syncLearningSuite.d.ts +35 -0
  8. package/dist/cli/commands/syncLearningSuite.d.ts.map +1 -0
  9. package/dist/cli/commands/syncLearningSuite.js +765 -0
  10. package/dist/cli/commands/syncLearningSuite.js.map +1 -0
  11. package/dist/cli/index.js +38 -0
  12. package/dist/cli/index.js.map +1 -1
  13. package/dist/downloader/hlsDownloader.d.ts +10 -4
  14. package/dist/downloader/hlsDownloader.d.ts.map +1 -1
  15. package/dist/downloader/hlsDownloader.js +38 -16
  16. package/dist/downloader/hlsDownloader.js.map +1 -1
  17. package/dist/downloader/index.d.ts +4 -0
  18. package/dist/downloader/index.d.ts.map +1 -1
  19. package/dist/downloader/index.js +6 -6
  20. package/dist/downloader/index.js.map +1 -1
  21. package/dist/downloader/loomDownloader.d.ts +1 -1
  22. package/dist/downloader/loomDownloader.d.ts.map +1 -1
  23. package/dist/downloader/loomDownloader.js +9 -7
  24. package/dist/downloader/loomDownloader.js.map +1 -1
  25. package/dist/scraper/learningsuite/extractor.d.ts +50 -0
  26. package/dist/scraper/learningsuite/extractor.d.ts.map +1 -0
  27. package/dist/scraper/learningsuite/extractor.js +429 -0
  28. package/dist/scraper/learningsuite/extractor.js.map +1 -0
  29. package/dist/scraper/learningsuite/index.d.ts +4 -0
  30. package/dist/scraper/learningsuite/index.d.ts.map +1 -0
  31. package/dist/scraper/learningsuite/index.js +4 -0
  32. package/dist/scraper/learningsuite/index.js.map +1 -0
  33. package/dist/scraper/learningsuite/navigator.d.ts +122 -0
  34. package/dist/scraper/learningsuite/navigator.d.ts.map +1 -0
  35. package/dist/scraper/learningsuite/navigator.js +736 -0
  36. package/dist/scraper/learningsuite/navigator.js.map +1 -0
  37. package/dist/scraper/learningsuite/schemas.d.ts +270 -0
  38. package/dist/scraper/learningsuite/schemas.d.ts.map +1 -0
  39. package/dist/scraper/learningsuite/schemas.js +147 -0
  40. package/dist/scraper/learningsuite/schemas.js.map +1 -0
  41. package/package.json +1 -1
@@ -0,0 +1,765 @@
1
+ import chalk from "chalk";
2
+ import cliProgress from "cli-progress";
3
+ import ora from "ora";
4
+ import { join } from "node:path";
5
+ import { loadConfig } from "../../config/configManager.js";
6
+ import { downloadVideo } from "../../downloader/index.js";
7
+ import { getAuthenticatedSession, createLoginChecker } from "../../shared/auth.js";
8
+ import { buildLearningSuiteCourseStructure, createFolderName, extractLearningSuitePostContent, getLearningSuiteLessonUrl, slugify, } from "../../scraper/learningsuite/index.js";
9
+ import { createCourseDirectory, createModuleDirectory, getVideoPath, saveMarkdown, isLessonSynced, downloadFile, } from "../../storage/fileSystem.js";
10
+ /**
11
+ * Tracks if shutdown has been requested (Ctrl+C).
12
+ */
13
+ let isShuttingDown = false;
14
+ const cleanupResources = {};
15
+ /**
16
+ * Graceful shutdown handler.
17
+ */
18
+ function setupShutdownHandlers() {
19
+ const shutdown = async (signal) => {
20
+ if (isShuttingDown) {
21
+ console.log(chalk.red("\n\n⚠️ Force exit"));
22
+ process.exit(1);
23
+ }
24
+ isShuttingDown = true;
25
+ console.log(chalk.yellow(`\n\n⏹️ ${signal} received, shutting down gracefully...`));
26
+ try {
27
+ if (cleanupResources.browser) {
28
+ await cleanupResources.browser.close();
29
+ }
30
+ console.log(chalk.gray(" Cleanup complete."));
31
+ }
32
+ catch {
33
+ // Ignore cleanup errors
34
+ }
35
+ process.exit(0);
36
+ };
37
+ process.on("SIGINT", () => void shutdown("SIGINT"));
38
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
39
+ }
40
+ /**
41
+ * Check if we should continue processing or stop due to shutdown.
42
+ */
43
+ function shouldContinue() {
44
+ return !isShuttingDown;
45
+ }
46
+ /**
47
+ * Extracts the domain from a LearningSuite URL.
48
+ */
49
+ function extractDomain(url) {
50
+ try {
51
+ return new URL(url).hostname;
52
+ }
53
+ catch {
54
+ return url;
55
+ }
56
+ }
57
+ /**
58
+ * LearningSuite-specific login page checker.
59
+ * Note: We check for /auth at the end of path OR as a path segment to be safe.
60
+ */
61
+ export const isLearningSuiteLoginPage = createLoginChecker([
62
+ /\/auth(?:$|\/|\?)/, // /auth, /auth/, /auth?...
63
+ /\/login/,
64
+ /\/signin/,
65
+ /accounts\.google\.com/,
66
+ ]);
67
+ /**
68
+ * Verifies if the user has a valid LearningSuite session.
69
+ *
70
+ * LearningSuite stores authentication in various ways:
71
+ * 1. localStorage tokens (accessToken, jwt, etc.)
72
+ * 2. Cookies (session cookies)
73
+ * 3. URL-based redirect (if we're not on /auth, we're likely logged in)
74
+ */
75
+ async function hasValidLearningSuiteSession(page) {
76
+ const currentUrl = page.url();
77
+ // Primary check: If we're NOT on an auth page after navigation, we're logged in
78
+ // This is the most reliable indicator for LearningSuite
79
+ if (!isLearningSuiteLoginPage(currentUrl)) {
80
+ // Double-check we're on a student/course page (not an error page)
81
+ if (currentUrl.includes("/student") ||
82
+ currentUrl.includes("/course") ||
83
+ currentUrl.includes("/dashboard")) {
84
+ return true;
85
+ }
86
+ }
87
+ // Secondary check: Look for auth tokens in storage
88
+ const hasToken = await page.evaluate(() => {
89
+ // Check localStorage for various token patterns
90
+ const tokenKeys = ["accessToken", "token", "authToken", "jwt", "access_token", "id_token"];
91
+ for (const key of tokenKeys) {
92
+ if (localStorage.getItem(key))
93
+ return true;
94
+ }
95
+ // Check sessionStorage
96
+ for (const key of tokenKeys) {
97
+ if (sessionStorage.getItem(key))
98
+ return true;
99
+ }
100
+ // Check for any key containing 'auth' or 'token' or 'session'
101
+ for (let i = 0; i < localStorage.length; i++) {
102
+ const key = localStorage.key(i);
103
+ if (key) {
104
+ const keyLower = key.toLowerCase();
105
+ if (keyLower.includes("auth") ||
106
+ keyLower.includes("token") ||
107
+ keyLower.includes("session")) {
108
+ const value = localStorage.getItem(key);
109
+ // Make sure it's not empty
110
+ if (value && value.length > 10)
111
+ return true;
112
+ }
113
+ }
114
+ }
115
+ // Check for user-related keys (often set after login)
116
+ const userKeys = ["user", "currentUser", "userInfo", "profile"];
117
+ for (const key of userKeys) {
118
+ if (localStorage.getItem(key))
119
+ return true;
120
+ }
121
+ return false;
122
+ });
123
+ return hasToken;
124
+ }
125
+ /**
126
+ * Detects if a URL is a LearningSuite portal.
127
+ */
128
+ export function isLearningSuitePortal(url) {
129
+ return url.includes(".learningsuite.io");
130
+ }
131
+ /**
132
+ * Handles the sync-learningsuite command.
133
+ * Downloads all content from a LearningSuite portal.
134
+ */
135
+ export async function syncLearningSuiteCommand(url, options) {
136
+ setupShutdownHandlers();
137
+ console.log(chalk.blue("\n📚 LearningSuite Course Sync\n"));
138
+ const config = loadConfig();
139
+ const domain = extractDomain(url);
140
+ console.log(chalk.gray(` Portal: ${domain}`));
141
+ // Get authenticated session
142
+ const useHeadless = options.visible ? false : config.headless;
143
+ const spinner = ora("Connecting to LearningSuite...").start();
144
+ let browser;
145
+ let session;
146
+ try {
147
+ const result = await getAuthenticatedSession({
148
+ domain,
149
+ loginUrl: url,
150
+ isLoginPage: isLearningSuiteLoginPage,
151
+ verifySession: hasValidLearningSuiteSession,
152
+ }, { headless: useHeadless });
153
+ browser = result.browser;
154
+ session = result.session;
155
+ cleanupResources.browser = browser;
156
+ spinner.succeed("Connected to LearningSuite");
157
+ }
158
+ catch (error) {
159
+ spinner.fail("Failed to connect");
160
+ console.log(chalk.red("\n❌ Authentication failed.\n"));
161
+ console.log(chalk.gray(` Tried to authenticate with: ${url}`));
162
+ if (error instanceof Error) {
163
+ console.log(chalk.gray(` Error: ${error.message}`));
164
+ }
165
+ process.exit(1);
166
+ }
167
+ try {
168
+ // Check if shutdown was requested
169
+ if (!shouldContinue()) {
170
+ return;
171
+ }
172
+ console.log(chalk.blue("\n📖 Scanning course structure...\n"));
173
+ // Build course structure
174
+ let courseStructure = null;
175
+ let progressBar;
176
+ try {
177
+ courseStructure = await buildLearningSuiteCourseStructure(session.page, url, (progress) => {
178
+ if (progress.phase === "course" && progress.courseName) {
179
+ console.log(chalk.white(` Course: ${progress.courseName}`));
180
+ }
181
+ else if (progress.phase === "modules" && progress.totalModules) {
182
+ progressBar = new cliProgress.SingleBar({
183
+ format: " {bar} {percentage}% | {value}/{total} | {status}",
184
+ barCompleteChar: "█",
185
+ barIncompleteChar: "░",
186
+ barsize: 30,
187
+ hideCursor: true,
188
+ }, cliProgress.Presets.shades_grey);
189
+ progressBar.start(progress.totalModules, 0, { status: "Scanning modules..." });
190
+ }
191
+ else if (progress.phase === "lessons") {
192
+ if (progress.skippedLocked) {
193
+ progressBar?.increment({ status: `🔒 ${progress.currentModule ?? "Locked"}` });
194
+ }
195
+ else if (progress.lessonsFound !== undefined) {
196
+ progressBar?.increment({
197
+ status: `${progress.currentModule ?? "Module"} (${progress.lessonsFound} lessons)`,
198
+ });
199
+ }
200
+ else {
201
+ const moduleName = progress.currentModule ?? "";
202
+ const shortName = moduleName.length > 35 ? moduleName.substring(0, 32) + "..." : moduleName;
203
+ progressBar?.update(progress.currentModuleIndex ?? 0, { status: shortName });
204
+ }
205
+ }
206
+ else if (progress.phase === "done") {
207
+ progressBar?.stop();
208
+ }
209
+ });
210
+ }
211
+ catch (error) {
212
+ progressBar?.stop();
213
+ console.log(chalk.red(" Failed to scan course structure"));
214
+ if (error instanceof Error) {
215
+ console.log(chalk.gray(` Error: ${error.message}`));
216
+ }
217
+ throw error;
218
+ }
219
+ if (!courseStructure) {
220
+ console.log(chalk.red("\n❌ Could not extract course structure"));
221
+ console.log(chalk.gray(" This might mean:"));
222
+ console.log(chalk.gray(" - The portal is not a supported LearningSuite portal"));
223
+ console.log(chalk.gray(" - You don't have access to this course"));
224
+ console.log(chalk.gray(" - The portal structure has changed"));
225
+ await browser.close();
226
+ process.exit(1);
227
+ }
228
+ // Override course name if provided
229
+ if (options.courseName) {
230
+ courseStructure.course.title = options.courseName;
231
+ }
232
+ // Print summary
233
+ const totalLessons = courseStructure.modules.reduce((sum, mod) => sum + mod.lessons.length, 0);
234
+ const lockedModules = courseStructure.modules.filter((m) => m.isLocked).length;
235
+ const lockedLessons = courseStructure.modules.reduce((sum, mod) => sum + mod.lessons.filter((l) => l.isLocked).length, 0);
236
+ console.log();
237
+ const parts = [];
238
+ parts.push(`${courseStructure.modules.length} modules`);
239
+ parts.push(`${totalLessons} lessons`);
240
+ if (lockedModules > 0)
241
+ parts.push(chalk.yellow(`${lockedModules} modules locked`));
242
+ if (lockedLessons > 0)
243
+ parts.push(chalk.yellow(`${lockedLessons} lessons locked`));
244
+ console.log(` Found: ${parts.join(", ")}`);
245
+ if (lockedLessons > 0) {
246
+ console.log(chalk.gray(` 💡 Tip: Use 'offcourse complete <url>' to unlock lessons first`));
247
+ }
248
+ if (options.dryRun) {
249
+ printCourseStructure(courseStructure);
250
+ await browser.close();
251
+ return;
252
+ }
253
+ // Create course directory
254
+ const courseSlug = slugify(courseStructure.course.title);
255
+ const courseDir = await createCourseDirectory(config.outputDir, courseSlug);
256
+ console.log(chalk.gray(`\n📁 Output: ${courseDir}\n`));
257
+ // Process lessons
258
+ const videoTasks = [];
259
+ let contentExtracted = 0;
260
+ let skipped = 0;
261
+ let skippedLocked = 0;
262
+ let processed = 0;
263
+ // Calculate accessible lessons (excluding locked)
264
+ const accessibleLessonsCount = courseStructure.modules.reduce((sum, mod) => (mod.isLocked ? sum : sum + mod.lessons.filter((l) => !l.isLocked).length), 0);
265
+ // Apply limit
266
+ const lessonLimit = options.limit;
267
+ let totalToProcess = accessibleLessonsCount;
268
+ if (lessonLimit) {
269
+ totalToProcess = Math.min(accessibleLessonsCount, lessonLimit);
270
+ console.log(chalk.yellow(` Limiting to ${totalToProcess} lessons\n`));
271
+ }
272
+ // Phase 2: Extract content and queue downloads
273
+ const phase2Label = options.skipContent
274
+ ? `🎬 Scanning ${totalToProcess} lessons for videos...`
275
+ : `📝 Extracting content for ${totalToProcess} lessons...`;
276
+ console.log(chalk.blue(`\n${phase2Label}\n`));
277
+ const contentProgressBar = new cliProgress.SingleBar({
278
+ format: " {bar} {percentage}% | {value}/{total} | {status}",
279
+ barCompleteChar: "█",
280
+ barIncompleteChar: "░",
281
+ barsize: 30,
282
+ hideCursor: true,
283
+ }, cliProgress.Presets.shades_grey);
284
+ contentProgressBar.start(totalToProcess, 0, { status: "Starting..." });
285
+ for (const [modIndex, module] of courseStructure.modules.entries()) {
286
+ if (!shouldContinue())
287
+ break;
288
+ if (lessonLimit && processed >= lessonLimit)
289
+ break;
290
+ if (module.isLocked) {
291
+ continue;
292
+ }
293
+ const moduleDir = await createModuleDirectory(courseDir, modIndex, module.title);
294
+ for (const [lessonIndex, lesson] of module.lessons.entries()) {
295
+ if (!shouldContinue())
296
+ break;
297
+ if (lessonLimit && processed >= lessonLimit)
298
+ break;
299
+ // Skip locked lessons
300
+ if (lesson.isLocked) {
301
+ skippedLocked++;
302
+ continue;
303
+ }
304
+ const shortName = lesson.title.length > 40 ? lesson.title.substring(0, 37) + "..." : lesson.title;
305
+ contentProgressBar.update(processed, { status: shortName });
306
+ // Check if already synced
307
+ const syncStatus = await isLessonSynced(moduleDir, lessonIndex, lesson.title);
308
+ if (!options.skipContent && !syncStatus.content) {
309
+ try {
310
+ // Get full lesson URL
311
+ const lessonUrl = getLearningSuiteLessonUrl(courseStructure.domain, courseStructure.courseSlug ?? courseStructure.course.id, courseStructure.course.id, lesson.moduleId, // Use lesson's own moduleId (topicId) for correct URL
312
+ lesson.id);
313
+ // Extract content
314
+ const content = await extractLearningSuitePostContent(session.page, lessonUrl, courseStructure.tenantId, courseStructure.course.id, lesson.id);
315
+ if (content) {
316
+ // Save markdown
317
+ const markdown = formatLearningSuiteMarkdown(content.title, content.description, content.htmlContent);
318
+ await saveMarkdown(moduleDir, createFolderName(lessonIndex, lesson.title) + ".md", markdown);
319
+ // Download attachments
320
+ for (const attachment of content.attachments) {
321
+ if (attachment.url) {
322
+ const attachmentPath = join(moduleDir, `${createFolderName(lessonIndex, lesson.title)}-${attachment.name}`);
323
+ await downloadFile(attachment.url, attachmentPath);
324
+ }
325
+ }
326
+ // Queue video download
327
+ if (!options.skipVideos && !syncStatus.video && content.video?.url) {
328
+ videoTasks.push({
329
+ lessonId: lesson.id,
330
+ lessonName: lesson.title,
331
+ videoUrl: content.video.hlsUrl ?? content.video.url,
332
+ videoType: mapVideoType(content.video.type),
333
+ outputPath: getVideoPath(moduleDir, lessonIndex, lesson.title),
334
+ preferredQuality: options.quality,
335
+ });
336
+ }
337
+ contentExtracted++;
338
+ }
339
+ }
340
+ catch (error) {
341
+ console.error(`\nError extracting ${lesson.title}:`, error);
342
+ }
343
+ }
344
+ else {
345
+ skipped++;
346
+ // Still queue video if content was skipped but video not downloaded
347
+ if (!options.skipVideos && !syncStatus.video) {
348
+ try {
349
+ const lessonUrl = getLearningSuiteLessonUrl(courseStructure.domain, courseStructure.courseSlug ?? courseStructure.course.id, courseStructure.course.id, lesson.moduleId, // Use lesson's own moduleId (topicId) for correct URL
350
+ lesson.id);
351
+ const content = await extractLearningSuitePostContent(session.page, lessonUrl, courseStructure.tenantId, courseStructure.course.id, lesson.id);
352
+ if (content?.video?.url) {
353
+ videoTasks.push({
354
+ lessonId: lesson.id,
355
+ lessonName: lesson.title,
356
+ videoUrl: content.video.hlsUrl ?? content.video.url,
357
+ videoType: mapVideoType(content.video.type),
358
+ outputPath: getVideoPath(moduleDir, lessonIndex, lesson.title),
359
+ preferredQuality: options.quality,
360
+ });
361
+ }
362
+ }
363
+ catch {
364
+ // Skip if we can't get video URL
365
+ }
366
+ }
367
+ }
368
+ processed++;
369
+ contentProgressBar.update(processed, { status: shortName });
370
+ }
371
+ }
372
+ contentProgressBar.stop();
373
+ // Print content summary
374
+ console.log();
375
+ const contentParts = [];
376
+ if (contentExtracted > 0)
377
+ contentParts.push(chalk.green(`${contentExtracted} extracted`));
378
+ if (skipped > 0)
379
+ contentParts.push(chalk.gray(`${skipped} cached`));
380
+ if (skippedLocked > 0)
381
+ contentParts.push(chalk.yellow(`${skippedLocked} locked`));
382
+ console.log(` Content: ${contentParts.join(", ")}`);
383
+ // Phase 3: Download videos
384
+ if (!options.skipVideos && videoTasks.length > 0) {
385
+ // Extract cookies from session for authenticated video downloads
386
+ const browserCookies = await session.page.context().cookies();
387
+ const cookieString = browserCookies.map((c) => `${c.name}=${c.value}`).join("; ");
388
+ const refererUrl = `https://${courseStructure.domain}/`;
389
+ // Add cookies and referer to all video tasks
390
+ for (const task of videoTasks) {
391
+ task.cookies = cookieString;
392
+ task.referer = refererUrl;
393
+ }
394
+ await downloadVideos(videoTasks, config);
395
+ }
396
+ console.log(chalk.green("\n✅ Sync complete!\n"));
397
+ console.log(chalk.gray(` Output: ${courseDir}\n`));
398
+ }
399
+ finally {
400
+ await browser.close();
401
+ }
402
+ }
403
+ /**
404
+ * Maps LearningSuite video type to downloader video type.
405
+ */
406
+ function mapVideoType(type) {
407
+ switch (type) {
408
+ case "hls":
409
+ return "highlevel"; // Use HLS downloader
410
+ case "vimeo":
411
+ return "vimeo";
412
+ case "loom":
413
+ return "loom";
414
+ case "native":
415
+ return "native";
416
+ default:
417
+ return "native";
418
+ }
419
+ }
420
+ /**
421
+ * Downloads videos with progress display.
422
+ */
423
+ async function downloadVideos(videoTasks, config) {
424
+ const total = videoTasks.length;
425
+ console.log(chalk.blue(`\n🎬 Downloading ${total} videos...\n`));
426
+ const multibar = new cliProgress.MultiBar({
427
+ clearOnComplete: true,
428
+ hideCursor: true,
429
+ format: " {typeTag} {bar} {percentage}% | {lessonName}",
430
+ barCompleteChar: "█",
431
+ barIncompleteChar: "░",
432
+ barsize: 25,
433
+ autopadding: true,
434
+ }, cliProgress.Presets.shades_grey);
435
+ const overallBar = multibar.create(total, 0, {
436
+ typeTag: "[TOTAL]".padEnd(8),
437
+ lessonName: `0/${total} completed`,
438
+ });
439
+ let completed = 0;
440
+ let failed = 0;
441
+ const errors = [];
442
+ const activeBars = new Map();
443
+ const taskQueue = [...videoTasks];
444
+ const activePromises = new Set();
445
+ const processTask = async (task) => {
446
+ const typeTag = task.videoType ? `[${task.videoType.toUpperCase()}]` : "[VIDEO]";
447
+ const shortName = task.lessonName.length > 40 ? task.lessonName.substring(0, 37) + "..." : task.lessonName;
448
+ const bar = multibar.create(100, 0, {
449
+ typeTag: typeTag.padEnd(8),
450
+ lessonName: shortName,
451
+ });
452
+ activeBars.set(task.lessonName, bar);
453
+ try {
454
+ const result = await downloadVideo(task, (progress) => {
455
+ bar.update(Math.round(progress.percent));
456
+ });
457
+ if (!result.success) {
458
+ errors.push({ name: task.lessonName, error: result.error ?? "Download failed" });
459
+ failed++;
460
+ }
461
+ else {
462
+ completed++;
463
+ }
464
+ }
465
+ catch (error) {
466
+ const errorMsg = error instanceof Error ? error.message : String(error);
467
+ errors.push({ name: task.lessonName, error: errorMsg });
468
+ failed++;
469
+ }
470
+ finally {
471
+ multibar.remove(bar);
472
+ activeBars.delete(task.lessonName);
473
+ const done = completed + failed;
474
+ overallBar.update(done, {
475
+ lessonName: `${done}/${total} completed (${failed} failed)`,
476
+ });
477
+ }
478
+ };
479
+ while (taskQueue.length > 0 || activePromises.size > 0) {
480
+ while (taskQueue.length > 0 && activePromises.size < config.concurrency) {
481
+ const task = taskQueue.shift();
482
+ if (task) {
483
+ const promise = processTask(task).finally(() => {
484
+ activePromises.delete(promise);
485
+ });
486
+ activePromises.add(promise);
487
+ }
488
+ }
489
+ if (activePromises.size > 0) {
490
+ await Promise.race(activePromises);
491
+ }
492
+ }
493
+ multibar.stop();
494
+ // Print summary
495
+ console.log();
496
+ if (failed === 0) {
497
+ console.log(chalk.green(` ✓ ${completed} videos downloaded successfully`));
498
+ }
499
+ else {
500
+ console.log(chalk.yellow(` Videos: ${completed} downloaded, ${failed} failed`));
501
+ }
502
+ if (errors.length > 0) {
503
+ console.log(chalk.yellow("\n Failed downloads:"));
504
+ for (const error of errors) {
505
+ console.log(chalk.red(` - ${error.name}: ${error.error}`));
506
+ }
507
+ }
508
+ }
509
+ /**
510
+ * Format markdown content for LearningSuite lessons.
511
+ */
512
+ export function formatLearningSuiteMarkdown(title, description, htmlContent) {
513
+ const lines = [];
514
+ lines.push(`# ${title}`);
515
+ lines.push("");
516
+ if (description) {
517
+ lines.push(description);
518
+ lines.push("");
519
+ }
520
+ if (htmlContent) {
521
+ lines.push("---");
522
+ lines.push("");
523
+ // Simple HTML to text conversion
524
+ const text = htmlContent
525
+ .replace(/<br\s*\/?>/gi, "\n")
526
+ .replace(/<\/p>/gi, "\n\n")
527
+ .replace(/<\/div>/gi, "\n")
528
+ .replace(/<\/li>/gi, "\n")
529
+ .replace(/<li>/gi, "- ")
530
+ .replace(/<[^>]+>/g, "")
531
+ .replace(/&nbsp;/g, " ")
532
+ .replace(/&amp;/g, "&")
533
+ .replace(/&lt;/g, "<")
534
+ .replace(/&gt;/g, ">")
535
+ .replace(/&quot;/g, '"')
536
+ .trim();
537
+ lines.push(text);
538
+ lines.push("");
539
+ }
540
+ return lines.join("\n");
541
+ }
542
+ /**
543
+ * Print course structure (for dry-run mode).
544
+ */
545
+ function printCourseStructure(structure) {
546
+ console.log(chalk.cyan("\n📋 Course Structure\n"));
547
+ console.log(chalk.white(` ${structure.course.title}`));
548
+ console.log(chalk.gray(` Tenant: ${structure.tenantId}`));
549
+ console.log(chalk.gray(` Domain: ${structure.domain}`));
550
+ console.log();
551
+ for (const [i, module] of structure.modules.entries()) {
552
+ const lockedTag = module.isLocked ? chalk.yellow(" [LOCKED]") : "";
553
+ console.log(chalk.white(` ${String(i + 1).padStart(2)}. ${module.title}${lockedTag}`));
554
+ for (const [j, lesson] of module.lessons.slice(0, 5).entries()) {
555
+ const lessonLocked = lesson.isLocked ? chalk.yellow(" 🔒") : "";
556
+ console.log(chalk.gray(` ${String(j + 1).padStart(2)}. ${lesson.title}${lessonLocked}`));
557
+ }
558
+ if (module.lessons.length > 5) {
559
+ console.log(chalk.gray(` ... and ${module.lessons.length - 5} more`));
560
+ }
561
+ console.log();
562
+ }
563
+ }
564
+ /**
565
+ * Complete command - mark lessons as complete to unlock sequential content.
566
+ */
567
+ export async function completeLearningSuiteCommand(url, options) {
568
+ console.log(chalk.cyan("\n🔓 LearningSuite Complete\n"));
569
+ if (!isLearningSuitePortal(url)) {
570
+ console.error(chalk.red("Error: URL does not appear to be a LearningSuite portal"));
571
+ process.exit(1);
572
+ }
573
+ const domain = extractDomain(url);
574
+ console.log(chalk.gray(` Domain: ${domain}`));
575
+ // Get authenticated session
576
+ const useHeadless = !options.visible;
577
+ const spinner = ora("Connecting to LearningSuite...").start();
578
+ let browser;
579
+ let session;
580
+ try {
581
+ const result = await getAuthenticatedSession({
582
+ domain,
583
+ loginUrl: url,
584
+ isLoginPage: isLearningSuiteLoginPage,
585
+ verifySession: hasValidLearningSuiteSession,
586
+ }, { headless: useHeadless });
587
+ browser = result.browser;
588
+ session = result.session;
589
+ spinner.succeed("Connected to LearningSuite");
590
+ }
591
+ catch (error) {
592
+ spinner.fail("Failed to connect");
593
+ console.log(chalk.red("\n❌ Authentication failed.\n"));
594
+ if (error instanceof Error) {
595
+ console.log(chalk.gray(` Error: ${error.message}`));
596
+ }
597
+ process.exit(1);
598
+ }
599
+ try {
600
+ let iteration = 0;
601
+ let lastTotalLessons = 0;
602
+ let lastCompletedLessons = 0;
603
+ let grandTotalCompleted = 0;
604
+ const maxIterations = 10; // Safety limit
605
+ // Iterative loop: keep going until no new content is unlocked
606
+ while (iteration < maxIterations) {
607
+ iteration++;
608
+ console.log(chalk.blue(`\n📊 ${iteration === 1 ? "Scanning" : "Re-scanning"} course structure...\n`));
609
+ const courseStructure = await buildLearningSuiteCourseStructure(session.page, url);
610
+ if (!courseStructure) {
611
+ console.error(chalk.red("Failed to build course structure"));
612
+ await browser.close();
613
+ process.exit(1);
614
+ }
615
+ const totalLessons = courseStructure.modules.reduce((sum, mod) => sum + mod.lessons.length, 0);
616
+ const completedLessons = courseStructure.modules.reduce((sum, mod) => sum + mod.lessons.filter((l) => l.isCompleted).length, 0);
617
+ const lockedLessons = courseStructure.modules.reduce((sum, mod) => sum + mod.lessons.filter((l) => l.isLocked).length, 0);
618
+ const incompleteLessons = totalLessons - completedLessons;
619
+ const percentage = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0;
620
+ console.log(chalk.gray(` Found: ${totalLessons} lessons, ${completedLessons} completed (${percentage}%)`));
621
+ if (incompleteLessons > 0) {
622
+ console.log(chalk.gray(` Remaining: ${incompleteLessons} incomplete`));
623
+ }
624
+ if (lockedLessons > 0) {
625
+ console.log(chalk.yellow(` Note: ${lockedLessons} lessons still locked`));
626
+ }
627
+ // Check if anything changed since last iteration
628
+ if (iteration > 1) {
629
+ const newLessons = totalLessons - lastTotalLessons;
630
+ if (newLessons > 0) {
631
+ console.log(chalk.green(` 🆕 ${newLessons} new lessons unlocked!`));
632
+ }
633
+ else if (totalLessons === lastTotalLessons && completedLessons === lastCompletedLessons) {
634
+ console.log(chalk.gray(` No new content unlocked.`));
635
+ break;
636
+ }
637
+ }
638
+ // All done?
639
+ if (incompleteLessons === 0) {
640
+ console.log(chalk.green("\n✅ All lessons are completed!\n"));
641
+ break;
642
+ }
643
+ lastTotalLessons = totalLessons;
644
+ lastCompletedLessons = completedLessons;
645
+ console.log(chalk.blue(`\n🔓 Round ${iteration}: Completing lessons...\n`));
646
+ let roundCompleted = 0;
647
+ // Navigate to course page to find modules
648
+ const courseUrl = `https://${courseStructure.domain}/student/course/${courseStructure.courseSlug ?? courseStructure.course.id}/${courseStructure.course.id}`;
649
+ await session.page.goto(courseUrl, { waitUntil: "load" });
650
+ await session.page.waitForTimeout(2000);
651
+ // Start ALL unstarted modules in a loop using Playwright locator
652
+ let modulesStarted = 0;
653
+ const maxModuleStarts = 20; // Safety limit
654
+ while (modulesStarted < maxModuleStarts) {
655
+ // Find all elements containing "START" text (case-sensitive, exact match)
656
+ const startButtons = session.page.locator("text=START");
657
+ const startCount = await startButtons.count();
658
+ if (startCount === 0) {
659
+ break; // No more modules to start
660
+ }
661
+ console.log(chalk.gray(` Found ${startCount} unstarted module(s)...`));
662
+ try {
663
+ // Click the first START button
664
+ await startButtons.first().click({ timeout: 5000 });
665
+ modulesStarted++;
666
+ console.log(chalk.green(` ▶️ Started module ${modulesStarted}`));
667
+ // Wait for navigation
668
+ await session.page.waitForTimeout(3000);
669
+ // Go back to course page
670
+ await session.page.goto(courseUrl, { waitUntil: "load" });
671
+ await session.page.waitForTimeout(2000);
672
+ }
673
+ catch {
674
+ console.log(chalk.gray(` Could not click START`));
675
+ break;
676
+ }
677
+ }
678
+ if (modulesStarted > 0) {
679
+ console.log(chalk.green(` ✓ Started ${modulesStarted} modules\n`));
680
+ }
681
+ // Go through each module and complete its lessons
682
+ for (const mod of courseStructure.modules) {
683
+ // Skip modules that are 100% complete or locked
684
+ const moduleComplete = mod.lessons.every((l) => l.isCompleted);
685
+ const moduleLocked = mod.isLocked;
686
+ if (moduleComplete) {
687
+ continue; // Skip completed modules
688
+ }
689
+ if (moduleLocked) {
690
+ console.log(chalk.yellow(` ⏭️ Skipping locked module: ${mod.title}`));
691
+ continue;
692
+ }
693
+ console.log(chalk.cyan(` 📁 Processing: ${mod.title}`));
694
+ // Navigate to each incomplete lesson in this module
695
+ for (const lesson of mod.lessons) {
696
+ if (lesson.isCompleted) {
697
+ continue; // Skip completed lessons
698
+ }
699
+ if (lesson.isLocked) {
700
+ continue; // Skip locked lessons
701
+ }
702
+ // Navigate to the lesson
703
+ // URL format: /student/course/{slug}/{courseId}/{topicId}
704
+ // The topicId is the lesson.id - server auto-expands to full URL
705
+ const lessonUrl = `https://${courseStructure.domain}/student/course/${courseStructure.courseSlug ?? courseStructure.course.id}/${courseStructure.course.id}/${lesson.id}`;
706
+ try {
707
+ await session.page.goto(lessonUrl, { waitUntil: "load" });
708
+ await session.page.waitForTimeout(2000);
709
+ }
710
+ catch {
711
+ continue; // Skip if navigation fails
712
+ }
713
+ const shortName = lesson.title.length > 40 ? lesson.title.substring(0, 37) + "..." : lesson.title;
714
+ process.stdout.write(chalk.gray(` ⏳ ${shortName}...`));
715
+ // Check any unchecked checkboxes (for AGB etc.)
716
+ await session.page.evaluate(() => {
717
+ document.querySelectorAll('input[type="checkbox"]:not(:checked)').forEach((cb) => {
718
+ cb.click();
719
+ });
720
+ });
721
+ // Find and click the complete button
722
+ let completeButton = session.page.locator('button:has-text("Abschließen")');
723
+ let buttonCount = await completeButton.count();
724
+ if (buttonCount === 0) {
725
+ completeButton = session.page.locator('button:has-text("schlie")');
726
+ buttonCount = await completeButton.count();
727
+ }
728
+ if (buttonCount === 0) {
729
+ completeButton = session.page.locator("button.MuiButton-colorSuccess");
730
+ buttonCount = await completeButton.count();
731
+ }
732
+ if (buttonCount === 0) {
733
+ process.stdout.write(chalk.yellow(` no button\n`));
734
+ continue;
735
+ }
736
+ try {
737
+ await completeButton.first().click({ timeout: 5000 });
738
+ await session.page.waitForTimeout(1000);
739
+ roundCompleted++;
740
+ grandTotalCompleted++;
741
+ process.stdout.write(chalk.green(` ✓\n`));
742
+ }
743
+ catch {
744
+ process.stdout.write(chalk.yellow(` click failed\n`));
745
+ }
746
+ }
747
+ }
748
+ if (roundCompleted > 0) {
749
+ console.log(chalk.green(`\n ✓ Round ${iteration}: ${roundCompleted} lessons completed`));
750
+ }
751
+ else {
752
+ console.log(chalk.gray(`\n Round ${iteration}: No lessons completed in this round`));
753
+ }
754
+ }
755
+ // Final summary
756
+ if (grandTotalCompleted > 0) {
757
+ console.log(chalk.green(`\n🎉 Total: ${grandTotalCompleted} lessons marked as complete!\n`));
758
+ }
759
+ console.log(chalk.green("✅ Complete finished!\n"));
760
+ }
761
+ finally {
762
+ await browser.close();
763
+ }
764
+ }
765
+ //# sourceMappingURL=syncLearningSuite.js.map