diff --git a/composer.json b/composer.json
index b5b7a2f..64232c5 100644
--- a/composer.json
+++ b/composer.json
@@ -25,6 +25,7 @@
"require-dev": {
"filament/upgrade": "^4.0",
"larastan/larastan": "^3.0||^2.9",
+ "laravel/pao": "^1.0",
"laravel/pint": "^1.14",
"nunomaduro/collision": "^8.1.1||^7.10.0",
"orchestra/testbench": "^11.0.0||^10.0.0",
diff --git a/config/filament-lms.php b/config/filament-lms.php
index 39cda93..f492aef 100644
--- a/config/filament-lms.php
+++ b/config/filament-lms.php
@@ -154,4 +154,29 @@
|
*/
'resources' => [],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Common Cartridge / SCORM import
+ |--------------------------------------------------------------------------
+ */
+ /*
+ |--------------------------------------------------------------------------
+ | Embedded player completion guards
+ |--------------------------------------------------------------------------
+ */
+ 'embedded_player_min_session_seconds' => 90,
+ 'embedded_player_min_session_seconds_html5' => 300,
+
+ 'common_cartridge_import' => [
+ 'delete_after_success' => true,
+ 'storage_disk' => 'local',
+ 'storage_directory' => 'filament-lms/cartridge-imports',
+ 'default_import_path' => null,
+ 'retain_extracted_packages' => true,
+ 'packages_directory' => 'lms-scorm-packages',
+ ],
+
+ // Optional path to Node binary for Articulate slide JSON extraction
+ 'node_binary' => null,
];
diff --git a/database/migrations/add_embedded_player_to_lms_courses_table.php.stub b/database/migrations/add_embedded_player_to_lms_courses_table.php.stub
new file mode 100644
index 0000000..a95a833
--- /dev/null
+++ b/database/migrations/add_embedded_player_to_lms_courses_table.php.stub
@@ -0,0 +1,23 @@
+boolean('embedded_player')->default(false)->after('is_private');
+ $table->string('completion_mode', 32)->default('native')->after('embedded_player');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('lms_courses', function (Blueprint $table): void {
+ $table->dropColumn(['embedded_player', 'completion_mode']);
+ });
+ }
+};
diff --git a/database/migrations/add_player_slide_id_to_lms_steps_table.php.stub b/database/migrations/add_player_slide_id_to_lms_steps_table.php.stub
new file mode 100644
index 0000000..58aeb90
--- /dev/null
+++ b/database/migrations/add_player_slide_id_to_lms_steps_table.php.stub
@@ -0,0 +1,22 @@
+string('player_slide_id')->nullable()->after('text');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('lms_steps', function (Blueprint $table): void {
+ $table->dropColumn('player_slide_id');
+ });
+ }
+};
diff --git a/database/migrations/add_scorm_package_columns_to_lms_documents_table.php.stub b/database/migrations/add_scorm_package_columns_to_lms_documents_table.php.stub
new file mode 100644
index 0000000..3f41953
--- /dev/null
+++ b/database/migrations/add_scorm_package_columns_to_lms_documents_table.php.stub
@@ -0,0 +1,24 @@
+string('package_disk')->nullable()->after('name');
+ $table->string('package_path')->nullable()->after('package_disk');
+ $table->string('package_launch_path')->nullable()->after('package_path');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('lms_documents', function (Blueprint $table): void {
+ $table->dropColumn(['package_disk', 'package_path', 'package_launch_path']);
+ });
+ }
+};
diff --git a/dist/filament-lms.css b/dist/filament-lms.css
index 56a52ab..736c8f5 100644
--- a/dist/filament-lms.css
+++ b/dist/filament-lms.css
@@ -38,3 +38,134 @@
.step-material-container iframe[src*=".pdf"] {
min-height: 60vh;
}
+
+/* SCORM / HTML5 interactive packages: use full content width */
+.step-material-container--interactive {
+ width: 100%;
+ max-width: 100%;
+ margin: 0;
+ height: clamp(28rem, 80vh, 64rem);
+ min-height: 28rem;
+}
+
+.step-material-container--interactive iframe {
+ display: block;
+ width: 100%;
+ height: 100%;
+ border: 0;
+}
+
+/* Embedded player: hide Filament sidebar and use full width */
+body.lms-embedded-player .fi-sidebar {
+ display: none !important;
+}
+
+body.lms-embedded-player .fi-main-ctn {
+ margin-inline-start: 0 !important;
+}
+
+body.lms-embedded-player .fi-main {
+ max-width: 100%;
+}
+
+/* Knowledge checks: card-style radio options (single-choice Select fields on LMS tests). */
+.lms-test-form-wrapper .lms-knowledge-check-radio-field .fi-fo-radio {
+ counter-reset: lms-mcq-option;
+ display: flex;
+ flex-direction: column;
+ gap: 0.625rem;
+ width: 100%;
+}
+
+.lms-test-form-wrapper .lms-knowledge-check-radio-field .fi-fo-radio-label {
+ counter-increment: lms-mcq-option;
+ position: relative;
+ display: flex;
+ align-items: stretch;
+ gap: 0.75rem;
+ width: 100%;
+ margin: 0;
+ padding: 0.75rem 1rem;
+ border-radius: 0.5rem;
+ border: 1px solid rgb(229 231 235);
+ background-color: rgb(249 250 251);
+ cursor: pointer;
+ transition:
+ background-color 0.15s ease,
+ border-color 0.15s ease,
+ box-shadow 0.15s ease;
+}
+
+.dark .lms-test-form-wrapper .lms-knowledge-check-radio-field .fi-fo-radio-label {
+ border-color: rgb(55 65 81);
+ background-color: rgb(31 41 55 / 0.45);
+}
+
+.lms-test-form-wrapper .lms-knowledge-check-radio-field .fi-fo-radio-label:has(.fi-radio-input:checked) {
+ border-color: rgb(251 146 60);
+ background-color: rgb(255 247 237);
+ box-shadow: 0 0 0 1px rgb(251 146 60 / 0.35);
+}
+
+.dark .lms-test-form-wrapper .lms-knowledge-check-radio-field .fi-fo-radio-label:has(.fi-radio-input:checked) {
+ border-color: rgb(251 146 60);
+ background-color: rgb(124 45 18 / 0.28);
+}
+
+.lms-test-form-wrapper .lms-knowledge-check-radio-field .fi-fo-radio-label:hover {
+ border-color: rgb(209 213 219);
+}
+
+.dark .lms-test-form-wrapper .lms-knowledge-check-radio-field .fi-fo-radio-label:hover {
+ border-color: rgb(75 85 99);
+}
+
+.lms-test-form-wrapper .lms-knowledge-check-radio-field .fi-fo-radio-label-text {
+ display: flex;
+ align-items: center;
+ flex: 1;
+ gap: 0.75rem;
+ min-width: 0;
+}
+
+.lms-test-form-wrapper .lms-knowledge-check-radio-field .fi-fo-radio-label-text::before {
+ content: counter(lms-mcq-option, upper-alpha);
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2rem;
+ height: 2rem;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ font-weight: 700;
+ background-color: rgb(209 213 219);
+ color: rgb(255 255 255);
+}
+
+.dark .lms-test-form-wrapper .lms-knowledge-check-radio-field .fi-fo-radio-label-text::before {
+ background-color: rgb(75 85 99);
+}
+
+.lms-test-form-wrapper .lms-knowledge-check-radio-field .fi-fo-radio-label:has(.fi-radio-input:checked) .fi-fo-radio-label-text::before {
+ background-color: rgb(249 115 22);
+ color: rgb(255 255 255);
+}
+
+.lms-test-form-wrapper .lms-knowledge-check-radio-field .fi-radio-input {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.lms-test-form-wrapper .lms-knowledge-check-radio-field .fi-fo-radio-label-text p {
+ margin: 0;
+ font-size: 0.9375rem;
+ line-height: 1.45;
+}
diff --git a/resources/css/plugin.css b/resources/css/plugin.css
index 56a52ab..736c8f5 100644
--- a/resources/css/plugin.css
+++ b/resources/css/plugin.css
@@ -38,3 +38,134 @@
.step-material-container iframe[src*=".pdf"] {
min-height: 60vh;
}
+
+/* SCORM / HTML5 interactive packages: use full content width */
+.step-material-container--interactive {
+ width: 100%;
+ max-width: 100%;
+ margin: 0;
+ height: clamp(28rem, 80vh, 64rem);
+ min-height: 28rem;
+}
+
+.step-material-container--interactive iframe {
+ display: block;
+ width: 100%;
+ height: 100%;
+ border: 0;
+}
+
+/* Embedded player: hide Filament sidebar and use full width */
+body.lms-embedded-player .fi-sidebar {
+ display: none !important;
+}
+
+body.lms-embedded-player .fi-main-ctn {
+ margin-inline-start: 0 !important;
+}
+
+body.lms-embedded-player .fi-main {
+ max-width: 100%;
+}
+
+/* Knowledge checks: card-style radio options (single-choice Select fields on LMS tests). */
+.lms-test-form-wrapper .lms-knowledge-check-radio-field .fi-fo-radio {
+ counter-reset: lms-mcq-option;
+ display: flex;
+ flex-direction: column;
+ gap: 0.625rem;
+ width: 100%;
+}
+
+.lms-test-form-wrapper .lms-knowledge-check-radio-field .fi-fo-radio-label {
+ counter-increment: lms-mcq-option;
+ position: relative;
+ display: flex;
+ align-items: stretch;
+ gap: 0.75rem;
+ width: 100%;
+ margin: 0;
+ padding: 0.75rem 1rem;
+ border-radius: 0.5rem;
+ border: 1px solid rgb(229 231 235);
+ background-color: rgb(249 250 251);
+ cursor: pointer;
+ transition:
+ background-color 0.15s ease,
+ border-color 0.15s ease,
+ box-shadow 0.15s ease;
+}
+
+.dark .lms-test-form-wrapper .lms-knowledge-check-radio-field .fi-fo-radio-label {
+ border-color: rgb(55 65 81);
+ background-color: rgb(31 41 55 / 0.45);
+}
+
+.lms-test-form-wrapper .lms-knowledge-check-radio-field .fi-fo-radio-label:has(.fi-radio-input:checked) {
+ border-color: rgb(251 146 60);
+ background-color: rgb(255 247 237);
+ box-shadow: 0 0 0 1px rgb(251 146 60 / 0.35);
+}
+
+.dark .lms-test-form-wrapper .lms-knowledge-check-radio-field .fi-fo-radio-label:has(.fi-radio-input:checked) {
+ border-color: rgb(251 146 60);
+ background-color: rgb(124 45 18 / 0.28);
+}
+
+.lms-test-form-wrapper .lms-knowledge-check-radio-field .fi-fo-radio-label:hover {
+ border-color: rgb(209 213 219);
+}
+
+.dark .lms-test-form-wrapper .lms-knowledge-check-radio-field .fi-fo-radio-label:hover {
+ border-color: rgb(75 85 99);
+}
+
+.lms-test-form-wrapper .lms-knowledge-check-radio-field .fi-fo-radio-label-text {
+ display: flex;
+ align-items: center;
+ flex: 1;
+ gap: 0.75rem;
+ min-width: 0;
+}
+
+.lms-test-form-wrapper .lms-knowledge-check-radio-field .fi-fo-radio-label-text::before {
+ content: counter(lms-mcq-option, upper-alpha);
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2rem;
+ height: 2rem;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ font-weight: 700;
+ background-color: rgb(209 213 219);
+ color: rgb(255 255 255);
+}
+
+.dark .lms-test-form-wrapper .lms-knowledge-check-radio-field .fi-fo-radio-label-text::before {
+ background-color: rgb(75 85 99);
+}
+
+.lms-test-form-wrapper .lms-knowledge-check-radio-field .fi-fo-radio-label:has(.fi-radio-input:checked) .fi-fo-radio-label-text::before {
+ background-color: rgb(249 115 22);
+ color: rgb(255 255 255);
+}
+
+.lms-test-form-wrapper .lms-knowledge-check-radio-field .fi-radio-input {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.lms-test-form-wrapper .lms-knowledge-check-radio-field .fi-fo-radio-label-text p {
+ margin: 0;
+ font-size: 0.9375rem;
+ line-height: 1.45;
+}
diff --git a/resources/views/components/embedded-player-body-class.blade.php b/resources/views/components/embedded-player-body-class.blade.php
new file mode 100644
index 0000000..0d37b47
--- /dev/null
+++ b/resources/views/components/embedded-player-body-class.blade.php
@@ -0,0 +1,3 @@
+
diff --git a/resources/views/components/exit-lms.blade.php b/resources/views/components/exit-lms.blade.php
index 9fa6629..d252b86 100644
--- a/resources/views/components/exit-lms.blade.php
+++ b/resources/views/components/exit-lms.blade.php
@@ -1,3 +1,61 @@
-
- Exit LMS
-
+@php
+ use Illuminate\Support\Facades\Auth;
+ use Tapp\FilamentLms\Enums\CompletionMode;
+ use Tapp\FilamentLms\Models\Course;
+ use Tapp\FilamentLms\Services\ScormProgressService;
+
+ $courseSlug = request()->route('courseSlug');
+ $course = is_string($courseSlug)
+ ? Course::query()->where('slug', $courseSlug)->first()
+ : null;
+ $progressService = app(ScormProgressService::class);
+ $user = Auth::user();
+ $needsConfirm = $course
+ && $course->isEmbeddedPlayer()
+ && $course->completionMode() === CompletionMode::Html5
+ && $user
+ && ! $progressService->courseCompletedByUser($course, $user);
+ $canComplete = $needsConfirm && $user
+ ? $progressService->userMayConfirmCourseCompletion($course, $user)
+ : false;
+ $commitUrl = $needsConfirm
+ ? route('filament-lms.scorm-commit.store', ['course' => $course])
+ : null;
+@endphp
+
+@if ($needsConfirm)
+ @include('filament-lms::components.html5-exit-completion-script')
+
+ Exit LMS
+
+
+@else
+
+ Exit LMS
+
+@endif
diff --git a/resources/views/components/html5-exit-completion-script.blade.php b/resources/views/components/html5-exit-completion-script.blade.php
new file mode 100644
index 0000000..bcd9fd3
--- /dev/null
+++ b/resources/views/components/html5-exit-completion-script.blade.php
@@ -0,0 +1,41 @@
+
diff --git a/resources/views/components/html5-package-bridge-script.blade.php b/resources/views/components/html5-package-bridge-script.blade.php
new file mode 100644
index 0000000..774adc0
--- /dev/null
+++ b/resources/views/components/html5-package-bridge-script.blade.php
@@ -0,0 +1,44 @@
+
diff --git a/resources/views/components/html5-player-bridge.blade.php b/resources/views/components/html5-player-bridge.blade.php
new file mode 100644
index 0000000..51344a6
--- /dev/null
+++ b/resources/views/components/html5-player-bridge.blade.php
@@ -0,0 +1,69 @@
+@php
+ use Illuminate\Support\Facades\Auth;
+ use Tapp\FilamentLms\Services\ScormProgressService;
+
+ /** @var \Tapp\FilamentLms\Models\Course $course */
+ $user = Auth::user();
+ $canComplete = $user
+ ? app(ScormProgressService::class)->userMayConfirmCourseCompletion($course, $user)
+ : false;
+@endphp
+@include('filament-lms::components.html5-exit-completion-script')
+
diff --git a/resources/views/components/scorm-api-bridge.blade.php b/resources/views/components/scorm-api-bridge.blade.php
new file mode 100644
index 0000000..5afc74f
--- /dev/null
+++ b/resources/views/components/scorm-api-bridge.blade.php
@@ -0,0 +1,81 @@
+@php
+ /** @var \Tapp\FilamentLms\Models\Course $course */
+@endphp
+
diff --git a/resources/views/filament/pages/create-test-entry.blade.php b/resources/views/filament/pages/create-test-entry.blade.php
index 86dd868..975cc88 100644
--- a/resources/views/filament/pages/create-test-entry.blade.php
+++ b/resources/views/filament/pages/create-test-entry.blade.php
@@ -17,6 +17,6 @@
@endif
@else
- @livewire('tapp.filament-form-builder.livewire.filament-form.show', ['form' => $test->form, 'blockRedirect' => true])
+ @livewire('lms-test-form-show', ['form' => $test->form, 'blockRedirect' => true])
@endif
\ No newline at end of file
diff --git a/resources/views/livewire/document-step.blade.php b/resources/views/livewire/document-step.blade.php
index 181c14c..678f48d 100644
--- a/resources/views/livewire/document-step.blade.php
+++ b/resources/views/livewire/document-step.blade.php
@@ -10,19 +10,31 @@ class="flex-1 flex flex-col"
class="step-material-container rounded-lg border border-gray-300 cursor-pointer"
wire:click="download"
/>
+ @elseif ($document->hasScormPackage())
+
+
+
@else
-
@endif
-
- Download
-
+ @unless ($step->lesson->course->isEmbeddedPlayer())
+
+ Download
+
+ @endunless
-
+ @unless ($step->lesson->course->isEmbeddedPlayer())
+
+ @endunless
diff --git a/resources/views/livewire/lms-test-form-show.blade.php b/resources/views/livewire/lms-test-form-show.blade.php
new file mode 100644
index 0000000..d782934
--- /dev/null
+++ b/resources/views/livewire/lms-test-form-show.blade.php
@@ -0,0 +1,23 @@
+
diff --git a/routes/web.php b/routes/web.php
index 0dbaf5a..82e222a 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -2,6 +2,8 @@
use Illuminate\Support\Facades\Route;
use Tapp\FilamentLms\Http\Controllers\CertificateController;
+use Tapp\FilamentLms\Http\Controllers\ScormCommitController;
+use Tapp\FilamentLms\Http\Controllers\ScormPackageController;
/*
|--------------------------------------------------------------------------
@@ -20,4 +22,13 @@
->middleware('auth');
Route::get('lms/certificates/{course}/{user}', [CertificateController::class, 'show'])
->name('filament-lms::certificates.show');
+
+ Route::get('lms/scorm-package/{document}/{entry?}', [ScormPackageController::class, 'show'])
+ ->where('entry', '.*')
+ ->name('filament-lms.scorm-package.show')
+ ->middleware('auth');
+
+ Route::post('lms/scorm-commit/{course}', [ScormCommitController::class, 'store'])
+ ->name('filament-lms.scorm-commit.store')
+ ->middleware('auth');
});
diff --git a/src/Concerns/CourseLayout.php b/src/Concerns/CourseLayout.php
index 1cb6a0c..51494d8 100644
--- a/src/Concerns/CourseLayout.php
+++ b/src/Concerns/CourseLayout.php
@@ -5,20 +5,49 @@
use Filament\Support\Facades\FilamentView;
use Filament\View\PanelsRenderHook;
use Illuminate\Contracts\View\View;
+use Tapp\FilamentLms\Pages\Step as StepPage;
trait CourseLayout
{
- public function registerCourseLayout()
+ public function registerCourseLayout(): void
{
- FilamentView::registerRenderHook(
- PanelsRenderHook::SIDEBAR_NAV_START,
- fn (): View => view('filament-lms::components.nav-course-name', ['course' => $this->course]),
- );
+ if (! $this->course->isEmbeddedPlayer()) {
+ FilamentView::registerRenderHook(
+ PanelsRenderHook::SIDEBAR_NAV_START,
+ fn (): View => view('filament-lms::components.nav-course-name', ['course' => $this->course]),
+ );
+ }
FilamentView::registerRenderHook(
PanelsRenderHook::TOPBAR_AFTER,
fn (): View => view('filament-lms::components.topbar-course-progress', ['course' => $this->course]),
);
+ if ($this->course->isEmbeddedPlayer() && $this instanceof StepPage) {
+ FilamentView::registerRenderHook(
+ PanelsRenderHook::BODY_START,
+ fn (): View => view('filament-lms::components.embedded-player-body-class'),
+ );
+
+ if ($this->shouldRegisterScormBridge()) {
+ FilamentView::registerRenderHook(
+ PanelsRenderHook::BODY_END,
+ fn (): View => view('filament-lms::components.scorm-api-bridge', [
+ 'course' => $this->course,
+ 'commitUrl' => route('filament-lms.scorm-commit.store', ['course' => $this->course]),
+ ]),
+ );
+ }
+
+ if ($this->shouldRegisterHtml5Bridge()) {
+ FilamentView::registerRenderHook(
+ PanelsRenderHook::BODY_END,
+ fn (): View => view('filament-lms::components.html5-player-bridge', [
+ 'course' => $this->course,
+ 'commitUrl' => route('filament-lms.scorm-commit.store', ['course' => $this->course]),
+ ]),
+ );
+ }
+ }
}
}
diff --git a/src/Console/Commands/BackfillEmbeddedPlayerCourses.php b/src/Console/Commands/BackfillEmbeddedPlayerCourses.php
new file mode 100644
index 0000000..b1addc5
--- /dev/null
+++ b/src/Console/Commands/BackfillEmbeddedPlayerCourses.php
@@ -0,0 +1,147 @@
+whereNotNull('package_path')
+ ->where('package_path', '!=', '')
+ ->pluck('id');
+
+ if ($documentIds->isEmpty()) {
+ $this->warn('No documents with retained packages found.');
+
+ return self::SUCCESS;
+ }
+
+ $query = Course::query()
+ ->whereHas('steps', function ($stepQuery) use ($documentIds): void {
+ $stepQuery
+ ->where('material_type', 'document')
+ ->whereIn('material_id', $documentIds);
+ });
+
+ if ($courseId = $this->option('course-id')) {
+ $query->whereKey($courseId);
+ }
+
+ $updated = 0;
+ foreach ($query->with(['steps.material'])->get() as $course) {
+ $completionMode = $this->resolveCompletionModeForCourse($course);
+ $course->update([
+ 'embedded_player' => true,
+ 'completion_mode' => $completionMode,
+ ]);
+
+ $this->updateDocumentLaunchPaths($course);
+
+ $updated++;
+ $this->line("Updated course [{$course->id}] {$course->name} ({$completionMode->value})");
+ }
+
+ $this->info("Updated {$updated} course(s).");
+
+ return self::SUCCESS;
+ }
+
+ private function resolveCompletionModeForCourse(Course $course): CompletionMode
+ {
+ foreach ($course->steps as $step) {
+ $document = $step->material;
+ if (! $document instanceof Document || ! $document->hasScormPackage()) {
+ continue;
+ }
+
+ if ($this->packageHasImsManifest($document)) {
+ return CompletionMode::Scorm12;
+ }
+ }
+
+ if ($course->completion_mode !== CompletionMode::Native) {
+ return $course->completion_mode;
+ }
+
+ return CompletionMode::Html5;
+ }
+
+ private function updateDocumentLaunchPaths(Course $course): void
+ {
+ foreach ($course->steps as $step) {
+ $document = $step->material;
+ if (! $document instanceof Document || ! $document->hasScormPackage()) {
+ continue;
+ }
+
+ if (! $this->packageFileExists($document, self::SCORM_DRIVER_LAUNCH)) {
+ continue;
+ }
+
+ if ($document->package_launch_path === self::SCORM_DRIVER_LAUNCH) {
+ continue;
+ }
+
+ $document->update(['package_launch_path' => self::SCORM_DRIVER_LAUNCH]);
+ $this->line(" → document [{$document->id}] launch path set to ".self::SCORM_DRIVER_LAUNCH);
+ }
+ }
+
+ private function packageHasImsManifest(Document $document): bool
+ {
+ return $this->packageFileExists($document, 'imsmanifest.xml');
+ }
+
+ private function packageFileExists(Document $document, string $relativePath): bool
+ {
+ $packageRoot = $this->resolvePackageRoot($document);
+ if ($packageRoot === null) {
+ return false;
+ }
+
+ $fullPath = $packageRoot.'/'.ltrim(str_replace('\\', '/', $relativePath), '/');
+ $realPackageRoot = realpath($packageRoot);
+ $realFile = realpath($fullPath);
+
+ if ($realPackageRoot === false || $realFile === false) {
+ return false;
+ }
+
+ return str_starts_with($realFile, $realPackageRoot) && is_file($realFile);
+ }
+
+ private function resolvePackageRoot(Document $document): ?string
+ {
+ $packagePath = $document->package_path;
+ if ($packagePath === null || $packagePath === '') {
+ return null;
+ }
+
+ $disk = $document->package_disk ?: (string) config('filament-lms.common_cartridge_import.storage_disk', 'local');
+ $configuredRoot = Storage::disk($disk)->path($packagePath);
+
+ if (is_dir($configuredRoot)) {
+ return $configuredRoot;
+ }
+
+ $legacyRoot = storage_path('app/'.$packagePath);
+
+ return is_dir($legacyRoot) ? $legacyRoot : null;
+ }
+}
diff --git a/src/Console/Commands/ImportCartridgesCommand.php b/src/Console/Commands/ImportCartridgesCommand.php
new file mode 100644
index 0000000..a42d045
--- /dev/null
+++ b/src/Console/Commands/ImportCartridgesCommand.php
@@ -0,0 +1,127 @@
+resolveUserId();
+ if ($userId === null) {
+ $this->error('Could not resolve a user ID. Pass --user-id= or configure filament-lms.user_model.');
+
+ return self::FAILURE;
+ }
+
+ $tenantId = $this->option('tenant-id');
+ $files = $this->resolveZipFiles();
+ if ($files === []) {
+ $this->error('No ZIP files found to import.');
+
+ return self::FAILURE;
+ }
+
+ $this->info('Importing '.count($files).' package(s)...');
+ $this->line('Manual follow-up items:');
+ foreach (CommonCartridgeImportService::manualImportGaps() as $gap) {
+ $this->line(' - '.$gap);
+ }
+
+ foreach ($files as $absolutePath) {
+ $this->line('Processing: '.basename($absolutePath));
+ if ($this->option('sync')) {
+ ImportCommonCartridgeJob::dispatchSync($absolutePath, $userId, $tenantId);
+ } else {
+ ImportCommonCartridgeJob::dispatch($absolutePath, $userId, $tenantId);
+ }
+ }
+
+ if ($this->option('sync')) {
+ $this->info('Import completed synchronously.');
+ } else {
+ $this->info('Import job(s) queued. Ensure a queue worker is running.');
+ }
+
+ return self::SUCCESS;
+ }
+
+ /**
+ * @return list
+ */
+ private function resolveZipFiles(): array
+ {
+ $fileOption = $this->option('file');
+ if (is_string($fileOption) && $fileOption !== '') {
+ if (is_file($fileOption)) {
+ return [$fileOption];
+ }
+
+ $directory = $this->resolveImportDirectory();
+ $candidate = $directory.'/'.basename($fileOption);
+ if (is_file($candidate)) {
+ return [$candidate];
+ }
+
+ return [];
+ }
+
+ $directory = $this->resolveImportDirectory();
+ if (! is_dir($directory)) {
+ return [];
+ }
+
+ return collect(File::files($directory))
+ ->filter(fn ($file) => mb_strtolower($file->getExtension()) === 'zip')
+ ->map(fn ($file) => $file->getPathname())
+ ->values()
+ ->all();
+ }
+
+ private function resolveImportDirectory(): string
+ {
+ $path = $this->option('path');
+
+ if (is_string($path) && $path !== '') {
+ return $path;
+ }
+
+ $configured = config('filament-lms.common_cartridge_import.default_import_path');
+
+ return is_string($configured) && $configured !== ''
+ ? $configured
+ : database_path('trainings');
+ }
+
+ private function resolveUserId(): ?int
+ {
+ $userId = $this->option('user-id');
+ if (is_numeric($userId)) {
+ return (int) $userId;
+ }
+
+ $userModel = config('filament-lms.user_model');
+ if (! $userModel) {
+ return null;
+ }
+
+ $user = $userModel::query()->first();
+
+ return $user?->getKey();
+ }
+}
diff --git a/src/Enums/CompletionMode.php b/src/Enums/CompletionMode.php
new file mode 100644
index 0000000..d8da994
--- /dev/null
+++ b/src/Enums/CompletionMode.php
@@ -0,0 +1,12 @@
+query = $query;
}
- public function query()
+ public function query(): EloquentBuilder|QueryBuilder
{
- return CourseProgressQueryService::buildQuery();
+ return $this->query;
}
public function headings(): array
diff --git a/src/FilamentLmsServiceProvider.php b/src/FilamentLmsServiceProvider.php
index 742e2bb..f4dfa93 100644
--- a/src/FilamentLmsServiceProvider.php
+++ b/src/FilamentLmsServiceProvider.php
@@ -12,6 +12,8 @@
use Spatie\LaravelPackageTools\PackageServiceProvider;
use Tapp\FilamentLibrary\Models\LibraryItem;
use Tapp\FilamentLms\Console\Commands\BackfillCourseCompletedAt;
+use Tapp\FilamentLms\Console\Commands\BackfillEmbeddedPlayerCourses;
+use Tapp\FilamentLms\Console\Commands\ImportCartridgesCommand;
use Tapp\FilamentLms\Livewire\DocumentStep;
use Tapp\FilamentLms\Livewire\FormStep;
use Tapp\FilamentLms\Livewire\ImageStep;
@@ -56,8 +58,13 @@ public function configurePackage(Package $package): void
'change_name_to_text_on_lms_tests_table',
'create_lms_credit_categories_table',
'create_lms_course_credit_category_table',
+ 'add_scorm_package_columns_to_lms_documents_table',
+ 'add_embedded_player_to_lms_courses_table',
+ 'add_player_slide_id_to_lms_steps_table',
])
->hasCommand(BackfillCourseCompletedAt::class)
+ ->hasCommand(BackfillEmbeddedPlayerCourses::class)
+ ->hasCommand(ImportCartridgesCommand::class)
->hasInstallCommand(function (InstallCommand $command) {
$command
->publishMigrations()
diff --git a/src/Http/Controllers/ScormCommitController.php b/src/Http/Controllers/ScormCommitController.php
new file mode 100644
index 0000000..050c5e3
--- /dev/null
+++ b/src/Http/Controllers/ScormCommitController.php
@@ -0,0 +1,53 @@
+isEmbeddedPlayer(), 404);
+
+ $user = Auth::user();
+ abort_unless($user !== null && Course::accessibleTo($user)->whereKey($course->id)->exists(), 403);
+
+ $validated = $request->validate([
+ 'lesson_status' => ['nullable', 'string', 'max:64'],
+ 'lesson_location' => ['nullable', 'string', 'max:255'],
+ 'suspend_data' => ['nullable', 'string'],
+ 'score' => ['nullable', 'string', 'max:64'],
+ 'html5_complete' => ['nullable', 'boolean'],
+ 'html5_progress' => ['nullable', 'boolean'],
+ 'initialized' => ['nullable', 'boolean'],
+ 'finished' => ['nullable', 'boolean'],
+ ]);
+
+ $service = app(ScormProgressService::class);
+
+ if (! empty($validated['html5_complete'])) {
+ $result = $service->attemptManualCourseCompletion($course, $user);
+
+ return response()->json($result, $result['ok'] ? 200 : 422);
+ }
+
+ $service->processCommit($course, $user, [
+ 'lesson_status' => $validated['lesson_status'] ?? null,
+ 'lesson_location' => $validated['lesson_location'] ?? null,
+ 'suspend_data' => $validated['suspend_data'] ?? null,
+ 'score' => $validated['score'] ?? null,
+ 'html5_progress' => $validated['html5_progress'] ?? false,
+ ]);
+
+ return response()->json(['ok' => true]);
+ }
+}
diff --git a/src/Http/Controllers/ScormPackageController.php b/src/Http/Controllers/ScormPackageController.php
new file mode 100644
index 0000000..46329b4
--- /dev/null
+++ b/src/Http/Controllers/ScormPackageController.php
@@ -0,0 +1,142 @@
+hasScormPackage(), 404);
+
+ $course = $this->resolveCourseForDocument($document);
+ abort_unless($course !== null, 404);
+
+ $user = Auth::user();
+ abort_unless($user !== null && Course::accessibleTo($user)->whereKey($course->id)->exists(), 403);
+
+ $packageRoot = $this->resolvePackageRoot($document);
+ abort_if($packageRoot === null, 404);
+
+ // Path-based URLs (e.g. /lms/scorm-package/1/index.html) let relative asset paths resolve correctly.
+ $relativePath = $entry ?? (string) $request->query('entry', $document->getScormLaunchPath());
+ $relativePath = ltrim(str_replace('\\', '/', $relativePath), '/');
+ abort_if(str_contains($relativePath, '..'), 404);
+
+ $fullPath = $packageRoot.'/'.$relativePath;
+ $realPackageRoot = realpath($packageRoot);
+ $realFile = realpath($fullPath);
+ abort_if($realPackageRoot === false || $realFile === false, 404);
+ abort_unless(str_starts_with($realFile, $realPackageRoot), 404);
+ abort_unless(is_file($realFile), 404);
+
+ $mimeType = $this->mimeType($realFile);
+ $headers = $this->headersForMimeType($mimeType);
+
+ if ($course->isEmbeddedPlayer()
+ && $course->completionMode() === CompletionMode::Html5
+ && in_array($mimeType, ['text/html'], true)) {
+ $content = file_get_contents($realFile);
+ if (is_string($content)) {
+ $content = $this->injectHtml5BridgeIntoHtml($content);
+
+ return response($content, 200, $headers);
+ }
+ }
+
+ return response()->file($realFile, $headers);
+ }
+
+ private function injectHtml5BridgeIntoHtml(string $content): string
+ {
+ $script = view('filament-lms::components.html5-package-bridge-script')->render();
+
+ if (str_contains($content, '