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,736 @@
1
+ import { safeParse, CourseSchema, ModuleSchema, LessonSchema, } from "./schemas.js";
2
+ // ============================================================================
3
+ // Tenant Extraction
4
+ // ============================================================================
5
+ /**
6
+ * Extracts the tenant ID from a LearningSuite URL.
7
+ * URL format: https://{subdomain}.learningsuite.io/...
8
+ */
9
+ export function extractTenantFromUrl(url) {
10
+ const urlObj = new URL(url);
11
+ const hostname = urlObj.hostname;
12
+ // Extract subdomain from learningsuite.io
13
+ const match = /^([^.]+)\.learningsuite\.io$/.exec(hostname);
14
+ if (!match?.[1]) {
15
+ return { subdomain: "", tenantId: null };
16
+ }
17
+ return {
18
+ subdomain: match[1],
19
+ tenantId: null, // Will be resolved by API
20
+ };
21
+ }
22
+ // ============================================================================
23
+ // Browser/API Automation
24
+ // ============================================================================
25
+ /* v8 ignore start */
26
+ /**
27
+ * Extracts the tenant ID from the page by inspecting network requests or localStorage.
28
+ */
29
+ export async function extractTenantId(page) {
30
+ return page.evaluate(() => {
31
+ // Try to find tenant ID in localStorage
32
+ for (let i = 0; i < localStorage.length; i++) {
33
+ const key = localStorage.key(i);
34
+ if (key) {
35
+ const value = localStorage.getItem(key);
36
+ if (value) {
37
+ // Look for tenant ID patterns
38
+ const match = /"tenantId":\s*"([^"]+)"/.exec(value);
39
+ if (match?.[1])
40
+ return match[1];
41
+ }
42
+ }
43
+ }
44
+ // Check meta tags or data attributes
45
+ const metaTenant = document.querySelector('meta[name="tenant-id"]');
46
+ if (metaTenant) {
47
+ return metaTenant.getAttribute("content");
48
+ }
49
+ // Check for tenant in script tags (common in SPAs)
50
+ const scripts = Array.from(document.querySelectorAll("script"));
51
+ for (const script of scripts) {
52
+ const content = script.textContent ?? "";
53
+ const tenantMatch = /tenantId['":\s]+['"]([a-z0-9]+)['"]/i.exec(content);
54
+ if (tenantMatch?.[1]) {
55
+ return tenantMatch[1];
56
+ }
57
+ }
58
+ return null;
59
+ });
60
+ }
61
+ /**
62
+ * Extracts tenant ID from page by looking at script content and link hrefs.
63
+ */
64
+ async function extractTenantIdFromPage(page) {
65
+ return page.evaluate(() => {
66
+ // Check for any API calls that contain the tenant ID
67
+ const apiPattern = /api\.learningsuite\.io\/([a-z0-9]+)\/graphql/;
68
+ const scripts = Array.from(document.querySelectorAll("script"));
69
+ for (const script of scripts) {
70
+ const src = script.src ?? "";
71
+ const content = script.textContent ?? "";
72
+ const match = apiPattern.exec(src) ?? apiPattern.exec(content);
73
+ if (match?.[1])
74
+ return match[1];
75
+ }
76
+ // Check network resource hints
77
+ const links = Array.from(document.querySelectorAll('link[href*="learningsuite"]'));
78
+ for (const link of links) {
79
+ const href = link.href ?? "";
80
+ const match = /\/([a-z0-9]{20,})\//.exec(href);
81
+ if (match?.[1])
82
+ return match[1];
83
+ }
84
+ return null;
85
+ });
86
+ }
87
+ /**
88
+ * Gets the auth token from localStorage.
89
+ */
90
+ export async function getAuthToken(page) {
91
+ return page.evaluate(() => {
92
+ // Look for common token storage patterns
93
+ const tokenKeys = ["accessToken", "token", "authToken", "jwt", "access_token"];
94
+ for (const key of tokenKeys) {
95
+ const value = localStorage.getItem(key);
96
+ if (value)
97
+ return value;
98
+ }
99
+ // Try sessionStorage
100
+ for (const key of tokenKeys) {
101
+ const value = sessionStorage.getItem(key);
102
+ if (value)
103
+ return value;
104
+ }
105
+ // Look in any localStorage key that might contain a token
106
+ for (let i = 0; i < localStorage.length; i++) {
107
+ const key = localStorage.key(i);
108
+ if (key?.toLowerCase().includes("auth") || key?.toLowerCase().includes("token")) {
109
+ const value = localStorage.getItem(key);
110
+ if (value) {
111
+ try {
112
+ const parsed = JSON.parse(value);
113
+ if (typeof parsed.accessToken === "string")
114
+ return parsed.accessToken;
115
+ if (typeof parsed.token === "string")
116
+ return parsed.token;
117
+ }
118
+ catch {
119
+ // If it's not JSON, it might be the token itself
120
+ if (value.length > 20 && !value.includes(" ")) {
121
+ return value;
122
+ }
123
+ }
124
+ }
125
+ }
126
+ }
127
+ return null;
128
+ });
129
+ }
130
+ /**
131
+ * Makes a GraphQL request to the LearningSuite API.
132
+ */
133
+ export async function graphqlRequest(page, tenantId, query, variables) {
134
+ const authToken = await getAuthToken(page);
135
+ const result = await page.evaluate(async ({ tenantId, query, variables, authToken }) => {
136
+ try {
137
+ const headers = {
138
+ "Content-Type": "application/json",
139
+ };
140
+ if (authToken) {
141
+ headers.Authorization = `Bearer ${authToken}`;
142
+ }
143
+ const response = await fetch(`https://api.learningsuite.io/${tenantId}/graphql`, {
144
+ method: "POST",
145
+ headers,
146
+ body: JSON.stringify({ query, variables }),
147
+ });
148
+ if (!response.ok) {
149
+ return { error: `HTTP ${response.status}`, status: response.status };
150
+ }
151
+ const data = await response.json();
152
+ return { data };
153
+ }
154
+ catch (error) {
155
+ return { error: String(error) };
156
+ }
157
+ }, { tenantId, query, variables, authToken });
158
+ if ("error" in result) {
159
+ // Note: LearningSuite uses persisted queries, so most custom queries will fail with HTTP 400.
160
+ // This is expected behavior - we fall back to DOM-based extraction.
161
+ return null;
162
+ }
163
+ return result.data;
164
+ }
165
+ /**
166
+ * Fetches all courses available to the user.
167
+ */
168
+ export async function fetchCourses(page, tenantId) {
169
+ const query = `
170
+ query GetMyCourses {
171
+ myCourses {
172
+ id
173
+ title
174
+ description
175
+ thumbnailUrl
176
+ progress
177
+ moduleCount
178
+ lessonCount
179
+ }
180
+ }
181
+ `;
182
+ const response = await graphqlRequest(page, tenantId, query);
183
+ if (!response?.data?.myCourses) {
184
+ // Try alternative query
185
+ const altQuery = `
186
+ query GetProducts {
187
+ products {
188
+ id
189
+ title
190
+ description
191
+ imageUrl
192
+ }
193
+ }
194
+ `;
195
+ const altResponse = await graphqlRequest(page, tenantId, altQuery);
196
+ if (altResponse?.data?.products) {
197
+ return altResponse.data.products
198
+ .map((p) => safeParse(CourseSchema, p, "fetchCourses.alt"))
199
+ .filter((c) => c !== null);
200
+ }
201
+ return [];
202
+ }
203
+ return response.data.myCourses
204
+ .map((c) => safeParse(CourseSchema, c, "fetchCourses"))
205
+ .filter((c) => c !== null);
206
+ }
207
+ /**
208
+ * Fetches modules for a course.
209
+ */
210
+ export async function fetchModules(page, tenantId, courseId) {
211
+ const query = `
212
+ query GetCourseModules($courseId: ID!) {
213
+ course(id: $courseId) {
214
+ modules {
215
+ id
216
+ title
217
+ description
218
+ position
219
+ isLocked
220
+ lessonCount
221
+ }
222
+ }
223
+ }
224
+ `;
225
+ const response = await graphqlRequest(page, tenantId, query, { courseId });
226
+ if (!response?.data?.course?.modules) {
227
+ // Try alternative query (chapters)
228
+ const altQuery = `
229
+ query GetCourseChapters($courseId: ID!) {
230
+ course(id: $courseId) {
231
+ chapters {
232
+ id
233
+ title
234
+ description
235
+ order
236
+ isLocked
237
+ }
238
+ }
239
+ }
240
+ `;
241
+ const altResponse = await graphqlRequest(page, tenantId, altQuery, { courseId });
242
+ if (altResponse?.data?.course?.chapters) {
243
+ return altResponse.data.course.chapters
244
+ .map((m) => safeParse(ModuleSchema, m, "fetchModules.alt"))
245
+ .filter((m) => m !== null);
246
+ }
247
+ return [];
248
+ }
249
+ return response.data.course.modules
250
+ .map((m) => safeParse(ModuleSchema, m, "fetchModules"))
251
+ .filter((m) => m !== null);
252
+ }
253
+ /**
254
+ * Fetches lessons for a module.
255
+ */
256
+ export async function fetchLessons(page, tenantId, courseId, moduleId) {
257
+ const query = `
258
+ query GetModuleLessons($courseId: ID!, $moduleId: ID!) {
259
+ course(id: $courseId) {
260
+ module(id: $moduleId) {
261
+ lessons {
262
+ id
263
+ title
264
+ description
265
+ position
266
+ isLocked
267
+ isCompleted
268
+ duration
269
+ contentType
270
+ }
271
+ }
272
+ }
273
+ }
274
+ `;
275
+ const response = await graphqlRequest(page, tenantId, query, { courseId, moduleId });
276
+ if (!response?.data?.course?.module?.lessons) {
277
+ // Try alternative query
278
+ const altQuery = `
279
+ query GetChapterLessons($chapterId: ID!) {
280
+ chapter(id: $chapterId) {
281
+ lessons {
282
+ id
283
+ title
284
+ description
285
+ order
286
+ isLocked
287
+ isCompleted
288
+ }
289
+ }
290
+ }
291
+ `;
292
+ const altResponse = await graphqlRequest(page, tenantId, altQuery, { chapterId: moduleId });
293
+ if (altResponse?.data?.chapter?.lessons) {
294
+ return altResponse.data.chapter.lessons
295
+ .map((l) => safeParse(LessonSchema, l, "fetchLessons.alt"))
296
+ .filter((l) => l !== null);
297
+ }
298
+ return [];
299
+ }
300
+ return response.data.course.module.lessons
301
+ .map((l) => safeParse(LessonSchema, l, "fetchLessons"))
302
+ .filter((l) => l !== null);
303
+ }
304
+ /**
305
+ * Extracts courses from the page DOM (fallback method).
306
+ */
307
+ export async function extractCoursesFromPage(page) {
308
+ return page.evaluate(() => {
309
+ const courses = [];
310
+ // Look for course cards/links
311
+ const courseElements = document.querySelectorAll('[class*="course"], [class*="Course"], [data-course-id], a[href*="/course/"]');
312
+ const seen = new Set();
313
+ for (const el of Array.from(courseElements)) {
314
+ // Try to extract course ID from data attribute or href
315
+ let id = el.dataset.courseId ?? "";
316
+ const href = el.href ?? "";
317
+ if (!id && href) {
318
+ const match = /\/course[s]?\/([^/]+)/.exec(href);
319
+ if (match?.[1])
320
+ id = match[1];
321
+ }
322
+ if (!id || seen.has(id))
323
+ continue;
324
+ seen.add(id);
325
+ // Extract title
326
+ const titleEl = el.querySelector("h2, h3, h4, [class*='title'], [class*='Title']");
327
+ const title = titleEl?.textContent?.trim() ?? `Course ${courses.length + 1}`;
328
+ // Extract description
329
+ const descEl = el.querySelector("p, [class*='description'], [class*='Description']");
330
+ const description = descEl?.textContent?.trim() ?? null;
331
+ // Extract thumbnail
332
+ const imgEl = el.querySelector("img");
333
+ const thumbnailUrl = imgEl?.src ?? null;
334
+ courses.push({
335
+ id,
336
+ title,
337
+ description,
338
+ thumbnailUrl,
339
+ moduleCount: 0,
340
+ lessonCount: 0,
341
+ });
342
+ }
343
+ return courses;
344
+ });
345
+ }
346
+ /**
347
+ * Builds the complete course structure for a LearningSuite course using DOM extraction.
348
+ * This is more reliable than GraphQL as the API structure may vary between instances.
349
+ */
350
+ export async function buildLearningSuiteCourseStructure(page, courseUrl, onProgress) {
351
+ // Extract domain and tenant info
352
+ const urlObj = new URL(courseUrl);
353
+ const domain = urlObj.hostname;
354
+ onProgress?.({ phase: "init" });
355
+ // Navigate to course page
356
+ await page.goto(courseUrl, { timeout: 30000 });
357
+ await page.waitForLoadState("networkidle").catch(() => { });
358
+ await page.waitForTimeout(3000);
359
+ // Extract tenant ID from page
360
+ const tenantId = (await extractTenantId(page)) ?? (await extractTenantIdFromPage(page));
361
+ if (!tenantId) {
362
+ console.error("Could not determine tenant ID for LearningSuite portal");
363
+ return null;
364
+ }
365
+ // Extract course ID from URL
366
+ // URL formats:
367
+ // - /student/course/{slug}/{id} (e.g., /student/course/einfuehrung-in-die-akademie/mgLFsjbW)
368
+ // - /course/{id}
369
+ let courseId = null;
370
+ // Try format: /student/course/{slug}/{id}
371
+ const twoPartMatch = /\/course\/[^/]+\/([^/?]+)/.exec(courseUrl);
372
+ if (twoPartMatch?.[1]) {
373
+ courseId = twoPartMatch[1];
374
+ }
375
+ else {
376
+ // Try format: /course/{id}
377
+ const onePartMatch = /\/course\/([^/?]+)/.exec(courseUrl);
378
+ if (onePartMatch?.[1]) {
379
+ courseId = onePartMatch[1];
380
+ }
381
+ }
382
+ if (!courseId) {
383
+ // Try to find course ID from current page URL
384
+ const pageUrl = page.url();
385
+ const pageTwoPartMatch = /\/course\/[^/]+\/([^/?]+)/.exec(pageUrl);
386
+ if (pageTwoPartMatch?.[1]) {
387
+ courseId = pageTwoPartMatch[1];
388
+ }
389
+ else {
390
+ const pageOnePartMatch = /\/course\/([^/?]+)/.exec(pageUrl);
391
+ if (pageOnePartMatch?.[1]) {
392
+ courseId = pageOnePartMatch[1];
393
+ }
394
+ }
395
+ if (!courseId) {
396
+ console.error("Could not extract course ID from URL:", courseUrl);
397
+ return null;
398
+ }
399
+ }
400
+ // Extract URL slug for later URL construction
401
+ const slugMatch = /\/course\/([^/]+)\/[^/]+/.exec(courseUrl);
402
+ const courseSlug = slugMatch?.[1] ?? courseId;
403
+ // Extract course details from DOM
404
+ onProgress?.({ phase: "course" });
405
+ const courseInfo = await page.evaluate(() => {
406
+ // LearningSuite has the course title in a header section
407
+ // The structure is: "KURS" label followed by the course name
408
+ let title = "";
409
+ // Look for elements containing "KURS" text and get the nearby title
410
+ const allElements = document.querySelectorAll("*");
411
+ for (const el of Array.from(allElements)) {
412
+ const text = el.textContent?.trim() ?? "";
413
+ if (text === "KURS" || text === "Kurs" || text === "COURSE") {
414
+ // Found the label, now find the title (usually a sibling or nearby element)
415
+ const parent = el.parentElement;
416
+ if (parent) {
417
+ const siblings = Array.from(parent.children);
418
+ for (const sib of siblings) {
419
+ const sibText = sib.textContent?.trim() ?? "";
420
+ if (sibText.length > 5 && sibText !== text && !sibText.includes("KURS")) {
421
+ title = sibText;
422
+ break;
423
+ }
424
+ }
425
+ }
426
+ if (title)
427
+ break;
428
+ }
429
+ }
430
+ // Alternative: find by URL slug
431
+ if (!title) {
432
+ const url = window.location.pathname;
433
+ const slugMatch = /\/course\/([^/]+)\//.exec(url);
434
+ if (slugMatch?.[1]) {
435
+ // Convert slug to title (replace hyphens with spaces, title case)
436
+ title = slugMatch[1].replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
437
+ }
438
+ }
439
+ // Final fallback: page title
440
+ if (!title) {
441
+ title = document.title.split(" - ")[0]?.trim() ?? "Unknown Course";
442
+ }
443
+ return { title, description: null };
444
+ });
445
+ const course = {
446
+ id: courseId,
447
+ title: courseInfo.title,
448
+ description: courseInfo.description,
449
+ thumbnailUrl: null,
450
+ moduleCount: 0,
451
+ lessonCount: 0,
452
+ };
453
+ onProgress?.({ phase: "course", courseName: course.title });
454
+ // Extract modules and lessons from DOM
455
+ onProgress?.({ phase: "modules" });
456
+ // First extract all modules from the course page
457
+ const modulesWithLessons = await extractModulesFromCoursePage(page, domain, courseSlug, courseId);
458
+ // Now iterate through each module to get its lessons
459
+ for (let i = 0; i < modulesWithLessons.length; i++) {
460
+ const module = modulesWithLessons[i];
461
+ if (!module)
462
+ continue;
463
+ // Skip locked modules
464
+ if (module.isLocked) {
465
+ continue;
466
+ }
467
+ onProgress?.({
468
+ phase: "modules",
469
+ currentModuleIndex: i + 1,
470
+ totalModules: modulesWithLessons.length,
471
+ currentModule: module.title,
472
+ });
473
+ // Navigate to the module by clicking on its title text
474
+ const moduleTitle = page.locator(`text="${module.title}"`).first();
475
+ if (await moduleTitle.isVisible().catch(() => false)) {
476
+ await moduleTitle.click();
477
+ await page.waitForLoadState("domcontentloaded").catch(() => { });
478
+ await page.waitForTimeout(2000);
479
+ // Extract module ID from URL (format: /t/{moduleId})
480
+ const currentUrl = page.url();
481
+ const moduleIdMatch = /\/t\/([^/]+)/.exec(currentUrl);
482
+ if (moduleIdMatch?.[1]) {
483
+ module.id = moduleIdMatch[1];
484
+ }
485
+ // Extract lessons directly from the module page
486
+ // Lessons are listed as links with format: /{courseId}/{lessonId}
487
+ const lessonsData = await page.evaluate((cId) => {
488
+ const links = document.querySelectorAll("a");
489
+ const lessons = [];
490
+ const seenIds = new Set();
491
+ for (const link of Array.from(links)) {
492
+ const href = link.href;
493
+ // Check if this is a lesson link (contains courseId but not /t/)
494
+ if (!href.includes(`/${cId}/`) || href.includes("/t/"))
495
+ continue;
496
+ // Extract lesson ID from URL
497
+ const parts = href.split("/");
498
+ const lessonId = parts[parts.length - 1];
499
+ if (!lessonId || seenIds.has(lessonId))
500
+ continue;
501
+ seenIds.add(lessonId);
502
+ // Extract title and duration from link text
503
+ const text = link.textContent?.replace(/\s+/g, " ").trim() ?? "";
504
+ if (text.length < 5)
505
+ continue;
506
+ // Parse title (before duration info)
507
+ let title = text;
508
+ let duration = "";
509
+ // Duration patterns: "X Minute(n)" or "X Sekunde(n)"
510
+ const durationMatch = /(\d+\s*(?:Minute|Sekunde)n?)/i.exec(text);
511
+ if (durationMatch) {
512
+ const durationIdx = text.indexOf(durationMatch[0]);
513
+ title = text.substring(0, durationIdx).trim();
514
+ duration = durationMatch[0];
515
+ }
516
+ // Check for completion checkmark
517
+ const hasCheckmark = link.querySelector('svg[data-icon="check"]') !== null;
518
+ if (title.length > 3) {
519
+ lessons.push({ title, lessonId, duration, isCompleted: hasCheckmark });
520
+ }
521
+ }
522
+ return lessons;
523
+ }, courseId);
524
+ if (lessonsData.length > 0) {
525
+ module.lessons = lessonsData.map((l, idx) => ({
526
+ id: l.lessonId,
527
+ title: l.title,
528
+ position: idx,
529
+ moduleId: module.id,
530
+ isLocked: false,
531
+ isCompleted: l.isCompleted,
532
+ }));
533
+ }
534
+ // Go back to the course page
535
+ await page.goto(courseUrl, { waitUntil: "domcontentloaded" }).catch(() => { });
536
+ await page.waitForTimeout(1500);
537
+ }
538
+ }
539
+ onProgress?.({ phase: "done" });
540
+ // Update totals
541
+ course.moduleCount = modulesWithLessons.length;
542
+ course.lessonCount = modulesWithLessons.reduce((sum, m) => sum + m.lessons.length, 0);
543
+ return {
544
+ course,
545
+ modules: modulesWithLessons,
546
+ tenantId,
547
+ domain,
548
+ courseSlug,
549
+ };
550
+ }
551
+ /**
552
+ * Extracts modules from the course page by analyzing text content.
553
+ * Modules are identified by their stats line: "X LEKTIONEN | Y MIN." or "ERSCHEINT BALD"
554
+ */
555
+ async function extractModulesFromCoursePage(page, _domain, _courseSlug, _courseId) {
556
+ // Wait for content to load
557
+ await page.waitForTimeout(2000);
558
+ // Extract modules by analyzing text content
559
+ const modulesData = await page.evaluate(() => {
560
+ const text = document.body.innerText;
561
+ const lines = text
562
+ .split("\n")
563
+ .map((s) => s.trim())
564
+ .filter((s) => s.length > 0);
565
+ const modules = [];
566
+ for (let i = 0; i < lines.length; i++) {
567
+ const line = lines[i] ?? "";
568
+ // Check for stats line pattern
569
+ const statsMatch = /(\d+)\s*LEKTIONEN?\s*\|\s*(\d+)\s*MIN/i.exec(line);
570
+ const isLocked = /ERSCHEINT\s*BALD|COMING\s*SOON/i.test(line);
571
+ if (statsMatch || isLocked) {
572
+ // The title is usually the line before the stats
573
+ const prevLine = lines[i - 1]?.trim() ?? "";
574
+ // Validate title
575
+ if (prevLine &&
576
+ prevLine.length > 3 &&
577
+ prevLine.length < 100 &&
578
+ !/^(\d+%|START|FORTSETZEN)$/i.test(prevLine)) {
579
+ modules.push({
580
+ title: prevLine,
581
+ lessonCount: statsMatch ? parseInt(statsMatch[1] ?? "0", 10) : 0,
582
+ duration: statsMatch ? (statsMatch[2] ?? "0") + " Min." : "",
583
+ isLocked,
584
+ });
585
+ }
586
+ }
587
+ }
588
+ return modules;
589
+ });
590
+ const modules = [];
591
+ for (let i = 0; i < modulesData.length; i++) {
592
+ const mod = modulesData[i];
593
+ if (!mod)
594
+ continue;
595
+ const description = mod.isLocked
596
+ ? "Erscheint bald"
597
+ : `${mod.lessonCount} Lektionen, ${mod.duration}`;
598
+ modules.push({
599
+ id: `module-${i}`, // Will be updated when we navigate to the module
600
+ title: mod.title,
601
+ description,
602
+ position: i,
603
+ isLocked: mod.isLocked,
604
+ lessons: [], // Will be populated when we enter the module
605
+ });
606
+ }
607
+ return modules;
608
+ }
609
+ /* v8 ignore stop */
610
+ // ============================================================================
611
+ // URL Utilities
612
+ // ============================================================================
613
+ /**
614
+ * Constructs the URL for a LearningSuite course.
615
+ */
616
+ export function getLearningSuiteCourseUrl(domain, courseSlug, courseId) {
617
+ return `https://${domain}/student/course/${courseSlug}/${courseId}`;
618
+ }
619
+ /**
620
+ * Constructs the URL for a LearningSuite lesson.
621
+ * URL format: /student/course/{slug}/{courseId}/{topicId}
622
+ * Note: The topicId (lessonId from module page) is enough - the server redirects to the full URL.
623
+ */
624
+ export function getLearningSuiteLessonUrl(domain, courseSlug, courseId, _moduleId, // Unused - kept for API compatibility
625
+ lessonId // This is actually the topicId
626
+ ) {
627
+ return `https://${domain}/student/course/${courseSlug}/${courseId}/${lessonId}`;
628
+ }
629
+ // ============================================================================
630
+ // Lesson Completion
631
+ // ============================================================================
632
+ /* v8 ignore start */
633
+ /**
634
+ * Marks a lesson as completed by clicking the "Abschließen" button.
635
+ * This unlocks the next lesson in sequence.
636
+ *
637
+ * @returns true if successfully completed, false otherwise
638
+ */
639
+ export async function markLessonComplete(page, lessonUrl) {
640
+ try {
641
+ // Navigate to the lesson if not already there
642
+ const currentUrl = page.url();
643
+ if (!currentUrl.includes(lessonUrl)) {
644
+ await page.goto(lessonUrl, { timeout: 30000 });
645
+ await page.waitForLoadState("networkidle").catch(() => { });
646
+ await page.waitForTimeout(2000);
647
+ }
648
+ // Find and click the complete button using evaluate
649
+ // (handles font rendering issues where "Abschließen" might appear as "Ab chließen")
650
+ const clicked = await page.evaluate(() => {
651
+ // Look for buttons with text containing variations of "Abschließen" or "Complete"
652
+ const buttons = Array.from(document.querySelectorAll("button"));
653
+ for (const button of buttons) {
654
+ const text = button.textContent?.toLowerCase().replace(/\s+/g, "") ?? "";
655
+ // Match: abschließen, abschliessen, complete, markascomplete
656
+ if (text.includes("abschließen") ||
657
+ text.includes("abschliessen") ||
658
+ text.includes("complete")) {
659
+ // Check if this is not already a "completed" state button
660
+ if (!text.includes("abgeschlossen") && !text.includes("completed")) {
661
+ button.click();
662
+ return true;
663
+ }
664
+ }
665
+ }
666
+ return false;
667
+ });
668
+ if (!clicked) {
669
+ return false;
670
+ }
671
+ // Wait for the API call to complete
672
+ await page.waitForTimeout(1500);
673
+ // If we clicked the button, consider it successful
674
+ // The server tracks completion via submitEventsNew GraphQL mutation
675
+ return true;
676
+ }
677
+ catch (error) {
678
+ console.error("Error marking lesson as complete:", error);
679
+ return false;
680
+ }
681
+ }
682
+ /**
683
+ * Auto-completes all accessible lessons in sequence to unlock subsequent content.
684
+ * This is useful when lessons are sequentially locked.
685
+ *
686
+ * @param page - Playwright page
687
+ * @param lessons - List of lessons to complete
688
+ * @param onProgress - Callback for progress updates
689
+ * @returns Number of lessons successfully completed
690
+ */
691
+ export async function autoCompleteLessons(page, lessons, onProgress) {
692
+ let completedCount = 0;
693
+ const unlocked = lessons.filter((l) => !l.isLocked);
694
+ for (const lesson of unlocked) {
695
+ onProgress?.(completedCount, unlocked.length, lesson.title);
696
+ const success = await markLessonComplete(page, lesson.url);
697
+ if (success) {
698
+ completedCount++;
699
+ }
700
+ else {
701
+ // If we can't complete a lesson, subsequent ones might still be locked
702
+ console.warn(`Could not complete lesson: ${lesson.title}`);
703
+ }
704
+ // Small delay between lessons
705
+ await page.waitForTimeout(500);
706
+ }
707
+ return completedCount;
708
+ }
709
+ /**
710
+ * Checks if a lesson page shows the lesson as completed.
711
+ */
712
+ export async function isLessonCompleted(page) {
713
+ return page.evaluate(() => {
714
+ // Look for completion indicators
715
+ const indicators = [
716
+ '[class*="completed"]',
717
+ '[class*="done"]',
718
+ '[data-completed="true"]',
719
+ 'button:has-text("Abgeschlossen")',
720
+ ];
721
+ for (const selector of indicators) {
722
+ if (document.querySelector(selector))
723
+ return true;
724
+ }
725
+ // Check if "Abschließen" button is gone or disabled
726
+ const completeButton = document.querySelector('button:has-text("Abschließen"), button:has-text("Complete")');
727
+ if (completeButton) {
728
+ return completeButton.disabled;
729
+ }
730
+ return false;
731
+ });
732
+ }
733
+ /* v8 ignore stop */
734
+ // Re-export shared utilities
735
+ export { slugify, createFolderName } from "../../shared/slug.js";
736
+ //# sourceMappingURL=navigator.js.map