@hed-hog/lms 0.0.366 → 0.0.370

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 (169) hide show
  1. package/dist/certificate/certificate.controller.d.ts +1 -1
  2. package/dist/certificate/certificate.controller.d.ts.map +1 -1
  3. package/dist/certificate/certificate.controller.js +4 -2
  4. package/dist/certificate/certificate.controller.js.map +1 -1
  5. package/dist/certificate/certificate.service.d.ts +50 -0
  6. package/dist/certificate/certificate.service.d.ts.map +1 -1
  7. package/dist/certificate/certificate.service.js +73 -0
  8. package/dist/certificate/certificate.service.js.map +1 -1
  9. package/dist/course/course-ai-usage.service.d.ts +58 -0
  10. package/dist/course/course-ai-usage.service.d.ts.map +1 -0
  11. package/dist/course/course-ai-usage.service.js +176 -0
  12. package/dist/course/course-ai-usage.service.js.map +1 -0
  13. package/dist/course/course-audio-transcription.service.d.ts +65 -1
  14. package/dist/course/course-audio-transcription.service.d.ts.map +1 -1
  15. package/dist/course/course-audio-transcription.service.js +381 -29
  16. package/dist/course/course-audio-transcription.service.js.map +1 -1
  17. package/dist/course/course-export-scorm12.service.d.ts +3 -0
  18. package/dist/course/course-export-scorm12.service.d.ts.map +1 -1
  19. package/dist/course/course-export-scorm12.service.js +141 -6
  20. package/dist/course/course-export-scorm12.service.js.map +1 -1
  21. package/dist/course/course-export.service.d.ts.map +1 -1
  22. package/dist/course/course-export.service.js +2 -1
  23. package/dist/course/course-export.service.js.map +1 -1
  24. package/dist/course/course-lesson.controller.d.ts +25 -3
  25. package/dist/course/course-lesson.controller.d.ts.map +1 -1
  26. package/dist/course/course-lesson.controller.js +71 -8
  27. package/dist/course/course-lesson.controller.js.map +1 -1
  28. package/dist/course/course-structure.controller.d.ts +26 -5
  29. package/dist/course/course-structure.controller.d.ts.map +1 -1
  30. package/dist/course/course-structure.controller.js +31 -1
  31. package/dist/course/course-structure.controller.js.map +1 -1
  32. package/dist/course/course-structure.service.d.ts +37 -5
  33. package/dist/course/course-structure.service.d.ts.map +1 -1
  34. package/dist/course/course-structure.service.js +165 -20
  35. package/dist/course/course-structure.service.js.map +1 -1
  36. package/dist/course/course-transcription-translation.service.d.ts +31 -0
  37. package/dist/course/course-transcription-translation.service.d.ts.map +1 -0
  38. package/dist/course/course-transcription-translation.service.js +227 -0
  39. package/dist/course/course-transcription-translation.service.js.map +1 -0
  40. package/dist/course/course-video-agent-pipeline.service.js +7 -7
  41. package/dist/course/course-video-agent-pipeline.service.js.map +1 -1
  42. package/dist/course/course.module.d.ts.map +1 -1
  43. package/dist/course/course.module.js +4 -0
  44. package/dist/course/course.module.js.map +1 -1
  45. package/dist/course/dto/create-course-bulk-job.dto.d.ts +2 -1
  46. package/dist/course/dto/create-course-bulk-job.dto.d.ts.map +1 -1
  47. package/dist/course/dto/create-course-bulk-job.dto.js +6 -1
  48. package/dist/course/dto/create-course-bulk-job.dto.js.map +1 -1
  49. package/dist/course/dto/create-course-export.dto.d.ts +1 -0
  50. package/dist/course/dto/create-course-export.dto.d.ts.map +1 -1
  51. package/dist/course/dto/create-course-export.dto.js +6 -0
  52. package/dist/course/dto/create-course-export.dto.js.map +1 -1
  53. package/dist/course/lms-bulk-upload-automation.service.d.ts.map +1 -1
  54. package/dist/course/lms-bulk-upload-automation.service.js +26 -13
  55. package/dist/course/lms-bulk-upload-automation.service.js.map +1 -1
  56. package/dist/course/lms-bulk-upload.controller.d.ts +3 -0
  57. package/dist/course/lms-bulk-upload.controller.d.ts.map +1 -1
  58. package/dist/course/lms-bulk-upload.service.d.ts +3 -0
  59. package/dist/course/lms-bulk-upload.service.d.ts.map +1 -1
  60. package/dist/course/lms-bulk-upload.service.js +48 -29
  61. package/dist/course/lms-bulk-upload.service.js.map +1 -1
  62. package/dist/course/subtitle.util.d.ts +46 -0
  63. package/dist/course/subtitle.util.d.ts.map +1 -0
  64. package/dist/course/subtitle.util.js +206 -0
  65. package/dist/course/subtitle.util.js.map +1 -0
  66. package/dist/enterprise/training/training-student.service.d.ts +27 -0
  67. package/dist/enterprise/training/training-student.service.d.ts.map +1 -1
  68. package/dist/enterprise/training/training-student.service.js +197 -10
  69. package/dist/enterprise/training/training-student.service.js.map +1 -1
  70. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.d.ts +3 -1
  71. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.d.ts.map +1 -1
  72. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.js +19 -5
  73. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.js.map +1 -1
  74. package/dist/lesson-xp-map/lesson-xp-map.module.d.ts.map +1 -1
  75. package/dist/lesson-xp-map/lesson-xp-map.module.js +2 -1
  76. package/dist/lesson-xp-map/lesson-xp-map.module.js.map +1 -1
  77. package/dist/libraries/lms/tsconfig.tsbuildinfo +1 -1
  78. package/dist/lms.module.d.ts.map +1 -1
  79. package/dist/lms.module.js +4 -0
  80. package/dist/lms.module.js.map +1 -1
  81. package/dist/platforma/platforma-performance.service.js +121 -121
  82. package/dist/platforma/platforma-video.service.d.ts +8 -0
  83. package/dist/platforma/platforma-video.service.d.ts.map +1 -1
  84. package/dist/platforma/platforma-video.service.js +45 -2
  85. package/dist/platforma/platforma-video.service.js.map +1 -1
  86. package/dist/platforma/platforma.controller.d.ts +99 -1
  87. package/dist/platforma/platforma.controller.d.ts.map +1 -1
  88. package/dist/platforma/platforma.controller.js +111 -2
  89. package/dist/platforma/platforma.controller.js.map +1 -1
  90. package/dist/training/dto/create-training.dto.d.ts +9 -0
  91. package/dist/training/dto/create-training.dto.d.ts.map +1 -1
  92. package/dist/training/dto/create-training.dto.js +45 -1
  93. package/dist/training/dto/create-training.dto.js.map +1 -1
  94. package/dist/training/training.controller.d.ts +144 -0
  95. package/dist/training/training.controller.d.ts.map +1 -1
  96. package/dist/training/training.service.d.ts +149 -0
  97. package/dist/training/training.service.d.ts.map +1 -1
  98. package/dist/training/training.service.js +332 -167
  99. package/dist/training/training.service.js.map +1 -1
  100. package/hedhog/data/image_type.yaml +10 -0
  101. package/hedhog/data/route.yaml +251 -0
  102. package/hedhog/data/setting_group.yaml +97 -0
  103. package/hedhog/frontend/app/bulk-upload-sessions/page.tsx.ejs +139 -27
  104. package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +69 -57
  105. package/hedhog/frontend/app/courses/[id]/_components/CourseIssuedCertificatesCard.tsx.ejs +168 -0
  106. package/hedhog/frontend/app/courses/[id]/structure/_components/course-ai-costs-tab.tsx.ejs +191 -0
  107. package/hedhog/frontend/app/courses/[id]/structure/_components/course-export-sheet.tsx.ejs +81 -1
  108. package/hedhog/frontend/app/courses/[id]/structure/_components/course-exports-tab.tsx.ejs +12 -0
  109. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +69 -1
  110. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +267 -19
  111. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +114 -86
  112. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +239 -31
  113. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +344 -59
  114. package/hedhog/frontend/app/courses/[id]/structure/_components/lesson-video-preview.tsx.ejs +200 -0
  115. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +1 -0
  116. package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +3 -0
  117. package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +19 -7
  118. package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +4 -0
  119. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-ai-costs.ts.ejs +40 -0
  120. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-exports.ts.ejs +25 -0
  121. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +148 -0
  122. package/hedhog/frontend/app/courses/[id]/structure/_data/use-transcription-segments.ts.ejs +157 -8
  123. package/hedhog/frontend/app/courses/_components/CourseRowActions.tsx.ejs +1 -22
  124. package/hedhog/frontend/app/courses/page.tsx.ejs +26 -4
  125. package/hedhog/frontend/app/paths/page.tsx.ejs +612 -164
  126. package/hedhog/frontend/messages/en.json +23 -12
  127. package/hedhog/frontend/messages/pt.json +23 -12
  128. package/hedhog/query/triggers.sql +33 -0
  129. package/hedhog/table/course_ai_usage.yaml +46 -0
  130. package/hedhog/table/course_lesson.yaml +3 -0
  131. package/hedhog/table/course_lesson_answer.yaml +37 -0
  132. package/hedhog/table/course_lesson_transcription_segment.yaml +8 -0
  133. package/hedhog/table/learning_path.yaml +6 -0
  134. package/hedhog/table/learning_path_module.yaml +22 -0
  135. package/hedhog/table/learning_path_step.yaml +9 -6
  136. package/hedhog/table/lesson_view_event.yaml +66 -66
  137. package/package.json +9 -9
  138. package/src/certificate/certificate.controller.ts +2 -0
  139. package/src/certificate/certificate.service.ts +99 -0
  140. package/src/course/course-ai-usage.service.ts +221 -0
  141. package/src/course/course-audio-transcription.service.ts +471 -43
  142. package/src/course/course-export-scorm12.service.ts +149 -5
  143. package/src/course/course-export.service.ts +1 -0
  144. package/src/course/course-lesson.controller.ts +59 -6
  145. package/src/course/course-structure.controller.ts +16 -0
  146. package/src/course/course-structure.service.ts +184 -10
  147. package/src/course/course-transcription-translation.service.ts +293 -0
  148. package/src/course/course-video-agent-pipeline.service.ts +471 -471
  149. package/src/course/course.module.ts +4 -0
  150. package/src/course/dto/create-course-bulk-job.dto.ts +7 -3
  151. package/src/course/dto/create-course-export.dto.ts +6 -0
  152. package/src/course/ffmpeg.util.ts +65 -65
  153. package/src/course/lms-bulk-upload-automation.service.ts +29 -7
  154. package/src/course/lms-bulk-upload.service.ts +20 -1
  155. package/src/course/subtitle.util.ts +220 -0
  156. package/src/enterprise/training/training-student.service.ts +224 -4
  157. package/src/lesson-xp-map/lesson-xp-ai-calculation.service.ts +14 -0
  158. package/src/lesson-xp-map/lesson-xp-map.module.ts +2 -1
  159. package/src/lms.module.ts +4 -0
  160. package/src/platforma/dto/heartbeat.dto.ts +30 -30
  161. package/src/platforma/handlers/emit-certificate.handler.ts +117 -117
  162. package/src/platforma/handlers/lesson-heartbeat.handler.ts +343 -343
  163. package/src/platforma/platforma-heartbeat.service.ts +33 -33
  164. package/src/platforma/platforma-performance.service.ts +606 -606
  165. package/src/platforma/platforma-search.service.ts +48 -48
  166. package/src/platforma/platforma-video.service.ts +59 -3
  167. package/src/platforma/platforma.controller.ts +88 -0
  168. package/src/training/dto/create-training.dto.ts +36 -0
  169. package/src/training/training.service.ts +360 -163
@@ -4,6 +4,7 @@ import { CoreModule } from '@hed-hog/core';
4
4
  import { QueueModule } from '@hed-hog/queue';
5
5
  import { forwardRef, Module } from '@nestjs/common';
6
6
  import { InstructorModule } from '../instructor/instructor.module';
7
+ import { CourseAiUsageService } from './course-ai-usage.service';
7
8
  import { CourseLessonController } from './course-lesson.controller';
8
9
  import { CourseOperationsIntegrationService } from './course-operations-integration.service';
9
10
  import { CourseOperationsController } from './course-operations.controller';
@@ -24,6 +25,7 @@ import { LmsBulkUploadController } from './lms-bulk-upload.controller';
24
25
  import { LmsBulkUploadService } from './lms-bulk-upload.service';
25
26
  import { LmsOperationsTaskSubscriber } from './lms-operations-task.subscriber';
26
27
  import { LmsSettingController } from './lms-setting.controller';
28
+ import { PlatformaVideoService } from '../platforma/platforma-video.service';
27
29
 
28
30
  @Module({
29
31
  imports: [
@@ -42,6 +44,7 @@ import { LmsSettingController } from './lms-setting.controller';
42
44
  LmsBulkUploadController,
43
45
  ],
44
46
  providers: [
47
+ CourseAiUsageService,
45
48
  CourseExportService,
46
49
  CourseExportScorm12Service,
47
50
  CourseExportScorm12WorkerService,
@@ -56,6 +59,7 @@ import { LmsSettingController } from './lms-setting.controller';
56
59
  LmsBulkUploadService,
57
60
  LmsCoursesMcpTools,
58
61
  LmsOperationsTaskSubscriber,
62
+ PlatformaVideoService,
59
63
  ],
60
64
  exports: [
61
65
  forwardRef(() => CourseService),
@@ -1,10 +1,14 @@
1
- import { IsBoolean, IsIn, IsOptional } from 'class-validator';
1
+ import { IsBoolean, IsIn, IsInt, IsOptional } from 'class-validator';
2
2
 
3
3
  export class CreateCourseBulkJobDto {
4
- @IsIn(['transcription', 'xp_recalculation', 'video_processing'])
5
- jobType: 'transcription' | 'xp_recalculation' | 'video_processing';
4
+ @IsIn(['transcription', 'xp_recalculation', 'video_processing', 'translate_transcription'])
5
+ jobType: 'transcription' | 'xp_recalculation' | 'video_processing' | 'translate_transcription';
6
6
 
7
7
  @IsBoolean()
8
8
  @IsOptional()
9
9
  reprocessAlreadyProcessed?: boolean;
10
+
11
+ @IsInt()
12
+ @IsOptional()
13
+ targetLocaleId?: number;
10
14
  }
@@ -1,5 +1,6 @@
1
1
  import { Type } from 'class-transformer';
2
2
  import {
3
+ IsArray,
3
4
  IsIn,
4
5
  IsInt,
5
6
  IsOptional,
@@ -53,4 +54,9 @@ export class CreateCourseExportDto {
53
54
  @ValidateNested()
54
55
  @Type(() => ScormVisualSettingsDto)
55
56
  visualSettings?: ScormVisualSettingsDto;
57
+
58
+ @IsOptional()
59
+ @IsArray()
60
+ @IsInt({ each: true })
61
+ subtitleLocaleIds?: number[];
56
62
  }
@@ -1,65 +1,65 @@
1
- import { existsSync, readdirSync } from 'fs';
2
- import { join } from 'path';
3
-
4
- /**
5
- * Resolves the ffmpeg/ffprobe binaries across environments: explicit env override first,
6
- * then a WinGet (Gyan.FFmpeg) lookup on Windows, finally the bare command on PATH.
7
- * Mirrors the resolution baked into CourseVideoHlsService so the decomposed pipeline jobs
8
- * (extract / split) behave identically.
9
- */
10
- export function getFfmpegCommand(): string {
11
- const fromEnv = process.env.FFMPEG_PATH?.trim();
12
- if (fromEnv) return fromEnv;
13
- if (process.platform === 'win32') {
14
- const found = findWindowsBinary('ffmpeg');
15
- if (found) return found;
16
- }
17
- return 'ffmpeg';
18
- }
19
-
20
- export function getFfprobeCommand(): string {
21
- const fromEnv = process.env.FFPROBE_PATH?.trim();
22
- if (fromEnv) return fromEnv;
23
- const ffmpegEnv = process.env.FFMPEG_PATH?.trim();
24
- if (ffmpegEnv) {
25
- const candidate = ffmpegEnv.replace(/ffmpeg(\.exe)?$/i, (_, ext) => `ffprobe${ext ?? ''}`);
26
- if (existsSync(candidate)) return candidate;
27
- }
28
- if (process.platform === 'win32') {
29
- const found = findWindowsBinary('ffprobe');
30
- if (found) return found;
31
- }
32
- return 'ffprobe';
33
- }
34
-
35
- export function findWindowsBinary(name: 'ffmpeg' | 'ffprobe'): string | null {
36
- const localAppData = process.env.LOCALAPPDATA;
37
- if (!localAppData) return null;
38
- const packagesRoot = join(localAppData, 'Microsoft', 'WinGet', 'Packages');
39
- try {
40
- const packageDirs = readdirSync(packagesRoot, { withFileTypes: true })
41
- .filter((e) => e.isDirectory() && e.name.startsWith('Gyan.FFmpeg'))
42
- .map((e) => join(packagesRoot, e.name))
43
- .sort((a, b) => b.localeCompare(a));
44
-
45
- for (const dir of packageDirs) {
46
- const direct = join(dir, 'bin', `${name}.exe`);
47
- if (existsSync(direct)) return direct;
48
- try {
49
- const versionDirs = readdirSync(dir, { withFileTypes: true })
50
- .filter((e) => e.isDirectory() && e.name.toLowerCase().startsWith('ffmpeg-'))
51
- .map((e) => join(dir, e.name))
52
- .sort((a, b) => b.localeCompare(a));
53
- for (const vd of versionDirs) {
54
- const candidate = join(vd, 'bin', `${name}.exe`);
55
- if (existsSync(candidate)) return candidate;
56
- }
57
- } catch {
58
- /* skip */
59
- }
60
- }
61
- } catch {
62
- /* skip */
63
- }
64
- return null;
65
- }
1
+ import { existsSync, readdirSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ /**
5
+ * Resolves the ffmpeg/ffprobe binaries across environments: explicit env override first,
6
+ * then a WinGet (Gyan.FFmpeg) lookup on Windows, finally the bare command on PATH.
7
+ * Mirrors the resolution baked into CourseVideoHlsService so the decomposed pipeline jobs
8
+ * (extract / split) behave identically.
9
+ */
10
+ export function getFfmpegCommand(): string {
11
+ const fromEnv = process.env.FFMPEG_PATH?.trim();
12
+ if (fromEnv) return fromEnv;
13
+ if (process.platform === 'win32') {
14
+ const found = findWindowsBinary('ffmpeg');
15
+ if (found) return found;
16
+ }
17
+ return 'ffmpeg';
18
+ }
19
+
20
+ export function getFfprobeCommand(): string {
21
+ const fromEnv = process.env.FFPROBE_PATH?.trim();
22
+ if (fromEnv) return fromEnv;
23
+ const ffmpegEnv = process.env.FFMPEG_PATH?.trim();
24
+ if (ffmpegEnv) {
25
+ const candidate = ffmpegEnv.replace(/ffmpeg(\.exe)?$/i, (_, ext) => `ffprobe${ext ?? ''}`);
26
+ if (existsSync(candidate)) return candidate;
27
+ }
28
+ if (process.platform === 'win32') {
29
+ const found = findWindowsBinary('ffprobe');
30
+ if (found) return found;
31
+ }
32
+ return 'ffprobe';
33
+ }
34
+
35
+ export function findWindowsBinary(name: 'ffmpeg' | 'ffprobe'): string | null {
36
+ const localAppData = process.env.LOCALAPPDATA;
37
+ if (!localAppData) return null;
38
+ const packagesRoot = join(localAppData, 'Microsoft', 'WinGet', 'Packages');
39
+ try {
40
+ const packageDirs = readdirSync(packagesRoot, { withFileTypes: true })
41
+ .filter((e) => e.isDirectory() && e.name.startsWith('Gyan.FFmpeg'))
42
+ .map((e) => join(packagesRoot, e.name))
43
+ .sort((a, b) => b.localeCompare(a));
44
+
45
+ for (const dir of packageDirs) {
46
+ const direct = join(dir, 'bin', `${name}.exe`);
47
+ if (existsSync(direct)) return direct;
48
+ try {
49
+ const versionDirs = readdirSync(dir, { withFileTypes: true })
50
+ .filter((e) => e.isDirectory() && e.name.toLowerCase().startsWith('ffmpeg-'))
51
+ .map((e) => join(dir, e.name))
52
+ .sort((a, b) => b.localeCompare(a));
53
+ for (const vd of versionDirs) {
54
+ const candidate = join(vd, 'bin', `${name}.exe`);
55
+ if (existsSync(candidate)) return candidate;
56
+ }
57
+ } catch {
58
+ /* skip */
59
+ }
60
+ }
61
+ } catch {
62
+ /* skip */
63
+ }
64
+ return null;
65
+ }
@@ -271,7 +271,7 @@ export class LmsBulkUploadAutomationService implements OnModuleInit {
271
271
  if (Number.isInteger(Number(payload.uploadItemId)) && Number(payload.uploadItemId) > 0) {
272
272
  await this.prisma.$executeRawUnsafe(
273
273
  `UPDATE "lms_bulk_upload_item"
274
- SET "status" = 'done', "completed_at" = NOW(), "updated_at" = NOW()
274
+ SET "status" = 'received', "completed_at" = NOW(), "updated_at" = NOW()
275
275
  WHERE "id" = $1`,
276
276
  Number(payload.uploadItemId),
277
277
  ).catch(() => undefined);
@@ -377,7 +377,18 @@ export class LmsBulkUploadAutomationService implements OnModuleInit {
377
377
  }
378
378
 
379
379
  for (const lesson of session.course_lesson ?? []) {
380
- if (this.normalizeComparableText(lesson.title) !== parsed.lessonTitle) {
380
+ const lessonNumeric = parsed.lessonCode?.replace(/[^0-9]/g, '') ?? '';
381
+ const lessonOrderCode = String(
382
+ Math.max(Number(lesson.order ?? 0), 0),
383
+ ).padStart(2, '0');
384
+
385
+ const matchesByCode =
386
+ Boolean(lessonNumeric) && lessonNumeric === lessonOrderCode;
387
+ const matchesByTitle =
388
+ !parsed.lessonCode &&
389
+ this.normalizeComparableText(lesson.title) === parsed.lessonTitle;
390
+
391
+ if (!matchesByCode && !matchesByTitle) {
381
392
  continue;
382
393
  }
383
394
 
@@ -411,15 +422,26 @@ export class LmsBulkUploadAutomationService implements OnModuleInit {
411
422
  }
412
423
 
413
424
  const RESOLUTION_SUFFIXES = new Set(['1080', '720', '480', '360', '240', '4k', '2k', 'uhd', 'fhd', 'hd', 'sd']);
414
- let titleParts = parts.slice(2);
415
- if (titleParts.length > 1 && RESOLUTION_SUFFIXES.has(titleParts[titleParts.length - 1])) {
416
- titleParts = titleParts.slice(0, -1);
425
+
426
+ let rest = parts.slice(2);
427
+
428
+ // 4 segmentos: detecta o código da aula (ex.: "a01") como primeiro item de `rest`.
429
+ // Sem código da aula (formato antigo de 3 segmentos) -> lessonCode = null.
430
+ let lessonCode: string | null = null;
431
+ if (rest.length > 1 && /^a\d{2,}$/.test(rest[0])) {
432
+ lessonCode = rest[0];
433
+ rest = rest.slice(1);
434
+ }
435
+
436
+ if (rest.length > 1 && RESOLUTION_SUFFIXES.has(rest[rest.length - 1])) {
437
+ rest = rest.slice(0, -1);
417
438
  }
418
439
 
419
440
  return {
420
441
  sessionCode: this.normalizeComparableText(parts[0]),
421
442
  courseCode: this.normalizeComparableText(parts[1]),
422
- lessonTitle: this.normalizeComparableText(titleParts.join('_')),
443
+ lessonCode,
444
+ lessonTitle: this.normalizeComparableText(rest.join('_')),
423
445
  };
424
446
  }
425
447
 
@@ -624,7 +646,7 @@ export class LmsBulkUploadAutomationService implements OnModuleInit {
624
646
  SET "matched_course_id" = $2,
625
647
  "matched_session_id" = $3,
626
648
  "matched_lesson_id" = $4,
627
- "status" = 'received',
649
+ "status" = 'processing',
628
650
  "updated_at" = $5
629
651
  WHERE "id" = $1`,
630
652
  itemId,
@@ -726,6 +726,9 @@ export class LmsBulkUploadService {
726
726
  matched_session_title: string | null;
727
727
  matched_lesson_id: number | null;
728
728
  matched_lesson_title: string | null;
729
+ job_status: string | null;
730
+ job_attempts: number | null;
731
+ job_max_attempts: number | null;
729
732
  }>>(
730
733
  `SELECT
731
734
  i."id",
@@ -753,7 +756,10 @@ export class LmsBulkUploadService {
753
756
  ms."id" AS matched_session_id,
754
757
  ms."title" AS matched_session_title,
755
758
  ml."id" AS matched_lesson_id,
756
- ml."title" AS matched_lesson_title
759
+ ml."title" AS matched_lesson_title,
760
+ job."job_status",
761
+ job."job_attempts",
762
+ job."job_max_attempts"
757
763
  FROM "lms_bulk_upload_item" i
758
764
  JOIN "lms_bulk_upload_session" s ON s."id" = i."session_id"
759
765
  LEFT JOIN "user" u ON u."id" = s."user_id"
@@ -777,6 +783,16 @@ export class LmsBulkUploadService {
777
783
  ORDER BY ci."id" DESC
778
784
  LIMIT 1
779
785
  ) logo ON TRUE
786
+ LEFT JOIN LATERAL (
787
+ SELECT qj."status" AS job_status,
788
+ qj."attempts" AS job_attempts,
789
+ qj."max_attempts" AS job_max_attempts
790
+ FROM "queue_job" qj
791
+ WHERE qj."source_entity" = 'lms_bulk_upload_item'
792
+ AND qj."source_entity_id" = CAST(i."id" AS TEXT)
793
+ ORDER BY qj."id" DESC
794
+ LIMIT 1
795
+ ) job ON TRUE
780
796
  ${whereSql}
781
797
  ORDER BY i."updated_at" DESC
782
798
  LIMIT $${args.length + 1}
@@ -814,6 +830,9 @@ export class LmsBulkUploadService {
814
830
  matchedSessionTitle: row.matched_session_title,
815
831
  matchedLessonId: row.matched_lesson_id ? Number(row.matched_lesson_id) : null,
816
832
  matchedLessonTitle: row.matched_lesson_title,
833
+ jobStatus: row.job_status ?? null,
834
+ jobAttempts: row.job_attempts != null ? Number(row.job_attempts) : null,
835
+ jobMaxAttempts: row.job_max_attempts != null ? Number(row.job_max_attempts) : null,
817
836
  })),
818
837
  total,
819
838
  page,
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Subtitle helpers shared between the student player VTT endpoint and the
3
+ * SCORM export. Line breaking is a *presentation* concern: it is applied when
4
+ * the VTT is generated, never stored in the database, so that translations stay
5
+ * clean and the balancing adapts to each language's text length.
6
+ *
7
+ * Defaults follow the Netflix Timed Text Style Guide (pt-BR): max 42 chars per
8
+ * line, max 2 lines, balanced split that avoids ending a line on a short
9
+ * function word.
10
+ */
11
+
12
+ const FUNCTION_WORDS = new Set([
13
+ 'a', 'à', 'ao', 'aos', 'as', 'às', 'com', 'da', 'das', 'de', 'do', 'dos',
14
+ 'e', 'em', 'na', 'nas', 'no', 'nos', 'o', 'os', 'ou', 'para', 'pela',
15
+ 'pelas', 'pelo', 'pelos', 'por', 'que', 'se', 'um', 'uma', 'umas', 'uns',
16
+ ]);
17
+
18
+ /**
19
+ * Returns true when the text fits in at most two lines that each respect the
20
+ * per-line character limit, breaking only between words. Used while grouping
21
+ * subtitle cues so a cue never grows past what can be displayed in two lines
22
+ * (measuring only the total length is not enough — a long word near the middle
23
+ * can force the second line over the limit).
24
+ */
25
+ export function fitsInTwoLines(text: string, maxCharsPerLine = 42): boolean {
26
+ const clean = String(text ?? '').replace(/\s+/g, ' ').trim();
27
+ if (clean.length <= maxCharsPerLine) return true;
28
+
29
+ const words = clean.split(' ');
30
+ let line1 = '';
31
+ for (let i = 0; i < words.length - 1; i += 1) {
32
+ line1 = line1 ? `${line1} ${words[i]}` : words[i];
33
+ // Once line 1 overflows, later splits only make it longer — give up.
34
+ if (line1.length > maxCharsPerLine) break;
35
+ const line2 = words.slice(i + 1).join(' ');
36
+ if (line2.length <= maxCharsPerLine) return true;
37
+ }
38
+ return false;
39
+ }
40
+
41
+ /**
42
+ * Splits a single-line subtitle text into at most two balanced lines using a
43
+ * `\n`, respecting the per-line character limit. Returns the text unchanged
44
+ * when it already fits in a single line. Any existing whitespace (including
45
+ * stray newlines) is normalized before balancing, so it is safe to call on
46
+ * legacy segments too.
47
+ */
48
+ export function balanceSubtitleLines(
49
+ text: string,
50
+ maxCharsPerLine = 42,
51
+ ): string {
52
+ const clean = String(text ?? '').replace(/\s+/g, ' ').trim();
53
+ if (clean.length <= maxCharsPerLine) return clean;
54
+
55
+ const words = clean.split(' ');
56
+ let line1 = '';
57
+ let best: { idx: number; cost: number } | null = null;
58
+
59
+ for (let i = 0; i < words.length - 1; i += 1) {
60
+ line1 = line1 ? `${line1} ${words[i]}` : words[i];
61
+ // Once line 1 overflows it can only get longer — stop searching.
62
+ if (line1.length > maxCharsPerLine) break;
63
+
64
+ const line2 = words.slice(i + 1).join(' ');
65
+ let cost = Math.abs(line1.length - line2.length);
66
+ // Strongly penalize a second line that does not fit.
67
+ if (line2.length > maxCharsPerLine) cost += 1000;
68
+ // Avoid ending the first line on a short function word.
69
+ const tail = (words[i] ?? '').toLowerCase().replace(/[^\p{L}]/gu, '');
70
+ if (FUNCTION_WORDS.has(tail)) cost += 6;
71
+
72
+ if (best === null || cost < best.cost) {
73
+ best = { idx: i, cost };
74
+ }
75
+ }
76
+
77
+ if (!best) return clean;
78
+
79
+ const first = words.slice(0, best.idx + 1).join(' ');
80
+ const second = words.slice(best.idx + 1).join(' ');
81
+ return `${first}\n${second}`;
82
+ }
83
+
84
+ export type TimedWord = { word: string; start: number; end: number };
85
+
86
+ /**
87
+ * Normalizes a word for cross-transcript comparison: lowercase, strip accents
88
+ * and any non-letter/digit characters. Used only to decide whether two tokens
89
+ * are "the same" during alignment — the original (clean) text is preserved.
90
+ */
91
+ function normalizeToken(word: string): string {
92
+ return String(word ?? '')
93
+ .toLowerCase()
94
+ .normalize('NFD')
95
+ .replace(/[̀-ͯ]/g, '')
96
+ .replace(/[^a-z0-9]/g, '');
97
+ }
98
+
99
+ /**
100
+ * Merges the *text* of a high-quality transcript (e.g. gpt-4o-transcribe, which
101
+ * returns no timestamps) with the *timing* of a Whisper transcript (real word
102
+ * timestamps). The two word sequences are aligned via LCS over normalized
103
+ * tokens; each clean word inherits a timestamp:
104
+ * - matched → the timed word's start/end
105
+ * - inserted → interpolated across the gap between surrounding anchors
106
+ * - deleted → the timed word is dropped (its time is absorbed by neighbours)
107
+ *
108
+ * Falls back to the raw timed words when the texts diverge too much (match ratio
109
+ * below `minMatchRatio`) — a slightly worse text with correct timing beats a
110
+ * confident-but-wrong alignment. Pure function (no I/O) for easy unit testing.
111
+ */
112
+ export function alignTextToTimings(
113
+ timedWords: TimedWord[],
114
+ cleanText: string,
115
+ minMatchRatio = 0.5,
116
+ ): TimedWord[] {
117
+ const cleanWords = String(cleanText ?? '')
118
+ .split(/\s+/)
119
+ .map((w) => w.trim())
120
+ .filter(Boolean);
121
+
122
+ if (cleanWords.length === 0) return timedWords;
123
+ if (timedWords.length === 0) {
124
+ // No timing available — caller decides; return clean words with zero timing
125
+ // so they are not silently lost (buildSubtitleSegments tolerates it).
126
+ return cleanWords.map((word) => ({ word, start: 0, end: 0 }));
127
+ }
128
+
129
+ const a = timedWords.map((w) => normalizeToken(w.word));
130
+ const b = cleanWords.map((w) => normalizeToken(w));
131
+
132
+ // LCS table over normalized tokens.
133
+ const n = a.length;
134
+ const m = b.length;
135
+ const lcs: number[][] = Array.from({ length: n + 1 }, () =>
136
+ new Array<number>(m + 1).fill(0),
137
+ );
138
+ for (let i = n - 1; i >= 0; i--) {
139
+ for (let j = m - 1; j >= 0; j--) {
140
+ lcs[i][j] =
141
+ a[i] && a[i] === b[j]
142
+ ? lcs[i + 1][j + 1] + 1
143
+ : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
144
+ }
145
+ }
146
+
147
+ // Walk the table to record, for each clean word index, the matched timed word
148
+ // index (or -1 when unmatched).
149
+ const matchOf = new Array<number>(m).fill(-1);
150
+ let i = 0;
151
+ let j = 0;
152
+ let matches = 0;
153
+ while (i < n && j < m) {
154
+ if (a[i] && a[i] === b[j]) {
155
+ matchOf[j] = i;
156
+ matches++;
157
+ i++;
158
+ j++;
159
+ } else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
160
+ i++;
161
+ } else {
162
+ j++;
163
+ }
164
+ }
165
+
166
+ if (matches / m < minMatchRatio) {
167
+ return timedWords;
168
+ }
169
+
170
+ // Assign timestamps to every clean word.
171
+ const result: TimedWord[] = new Array(m);
172
+ for (let k = 0; k < m; k++) {
173
+ const ti = matchOf[k];
174
+ if (ti >= 0) {
175
+ result[k] = {
176
+ word: cleanWords[k],
177
+ start: timedWords[ti].start,
178
+ end: timedWords[ti].end,
179
+ };
180
+ } else {
181
+ result[k] = { word: cleanWords[k], start: NaN, end: NaN };
182
+ }
183
+ }
184
+
185
+ // Fill unmatched runs by interpolating between the surrounding anchors.
186
+ // Outer boundaries: leading words spread from the very first timed start,
187
+ // trailing words spread up to the very last timed end (so a final correction
188
+ // keeps the leftover audio time instead of collapsing onto the last anchor).
189
+ const audioStart = timedWords[0].start;
190
+ const audioEnd = timedWords[timedWords.length - 1].end;
191
+
192
+ let k = 0;
193
+ while (k < m) {
194
+ if (!Number.isNaN(result[k].start)) {
195
+ k++;
196
+ continue;
197
+ }
198
+ // Find the run [k, end) of unmatched words.
199
+ let end = k;
200
+ while (end < m && Number.isNaN(result[end].start)) end++;
201
+
202
+ const prevEnd = k > 0 ? result[k - 1].end : audioStart;
203
+ const nextStart = end < m ? result[end].start : audioEnd;
204
+ const span = Math.max(0, nextStart - prevEnd);
205
+ const count = end - k;
206
+ const step = count > 0 ? span / count : 0;
207
+
208
+ for (let r = 0; r < count; r++) {
209
+ const s = prevEnd + step * r;
210
+ result[k + r] = {
211
+ word: result[k + r].word,
212
+ start: s,
213
+ end: prevEnd + step * (r + 1),
214
+ };
215
+ }
216
+ k = end;
217
+ }
218
+
219
+ return result;
220
+ }