Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('lms_tests', function (Blueprint $table): void {
$table->text('name')->change();
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('lms_tests', function (Blueprint $table): void {
$table->string('name')->change();
});
}
};
1 change: 1 addition & 0 deletions src/FilamentLmsServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ public function configurePackage(Package $package): void
'add_completed_at_to_lms_course_user_table',
'make_material_nullable_in_lms_steps_table',
'backfill_lms_course_user_completed_at_from_step_dates',
'change_name_to_text_on_lms_tests_table',
])
->hasCommand(BackfillCourseCompletedAt::class)
->hasInstallCommand(function (InstallCommand $command) {
Expand Down
200 changes: 200 additions & 0 deletions src/Imports/CourseStepsImport.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
<?php

declare(strict_types=1);

namespace Tapp\FilamentLms\Imports;

use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Maatwebsite\Excel\Concerns\ToCollection;
use Maatwebsite\Excel\Concerns\WithHeadingRow;
use Tapp\FilamentLms\Models\Course;
use Tapp\FilamentLms\Models\Image;
use Tapp\FilamentLms\Models\Lesson;
use Tapp\FilamentLms\Models\Link;
use Tapp\FilamentLms\Models\Step;
use Tapp\FilamentLms\Models\Video;
use Tapp\FilamentLms\Services\VideoUrlService;

class CourseStepsImport implements ToCollection, WithHeadingRow
{
public function __construct(
protected string $courseName
) {}

/**
* Extract URL from cell that may be "label (url)" or just "url".
*/
public static function extractUrl(mixed $value): ?string
{
if ($value === null || $value === '') {
return null;
}

$value = trim((string) $value);
if ($value === '') {
return null;
}

if (preg_match('/\((\s*https?:\/\/[^)]+)\s*\)/', $value, $matches)) {
return trim($matches[1]);
}

if (preg_match('/^https?:\/\//', $value)) {
return $value;
}

return null;
}

public function collection(Collection $rows): void
{
if ($rows->isEmpty()) {
return;
}

$first = $rows->first();
$headers = array_keys($first->toArray());
$hasStepNameColumn = in_array('step_name', $headers, true);
$hasScriptColumn = in_array('script', $headers, true);

if (! $hasStepNameColumn && ! in_array('lesson_name', $headers, true)) {
throw new \InvalidArgumentException('Unrecognized CSV format. Expected at least "Step Name" or "Lesson Name" column.');
}

DB::transaction(function () use ($rows, $hasStepNameColumn, $hasScriptColumn): void {
$this->importRows($rows, $hasStepNameColumn, $hasScriptColumn);
});
}

protected function importRows(Collection $rows, bool $hasStepNameColumn, bool $hasScriptColumn): void
{
$course = Course::create([
'name' => $this->courseName,
'slug' => Str::slug($this->courseName),
'external_id' => Str::slug($this->courseName, '_'),
]);

$lessonOrder = 0;
$lessons = [];
$defaultLesson = null;

foreach ($rows as $row) {
$stepName = $this->value($row, 'step_name') ?? $this->value($row, 'lesson_name');
if ($stepName === null || trim((string) $stepName) === '') {
continue;
}

$stepName = trim((string) $stepName);

if ($hasStepNameColumn) {
$lessonName = $this->value($row, 'lesson_name');
$lessonNameTrimmed = $lessonName !== null ? trim((string) $lessonName) : '';
$lessonName = $lessonNameTrimmed !== '' ? $lessonNameTrimmed : $this->courseName;
if (! isset($lessons[$lessonName])) {
$lessonOrder++;
$lessons[$lessonName] = Lesson::create([
'course_id' => $course->id,
'name' => $lessonName,
'slug' => Str::slug($lessonName),
'order' => $lessonOrder,
]);
}
$lesson = $lessons[$lessonName];
$videoUrl = $this->value($row, 'url');
$videoUrl = $videoUrl !== null && trim((string) $videoUrl) !== '' ? trim((string) $videoUrl) : null;
$text = null;
$imageUrl = null;
$linkUrl = null;
} else {
if ($defaultLesson === null) {
$defaultLesson = Lesson::create([
'course_id' => $course->id,
'name' => $this->courseName,
'slug' => Str::slug($this->courseName),
'order' => 1,
]);
}
$lesson = $defaultLesson;
$text = $hasScriptColumn ? $this->value($row, 'script') : null;
$text = $text !== null ? trim((string) $text) : null;
$videoUrl = self::extractUrl($this->value($row, 'video_audio_image'));
$imageUrl = self::extractUrl($this->value($row, 'slides_image'));
$linkUrlRaw = $this->value($row, 'url');
$linkUrl = $linkUrlRaw !== null && trim((string) $linkUrlRaw) !== ''
? self::extractUrl($linkUrlRaw) ?? trim((string) $linkUrlRaw)
: null;
}

[$materialId, $materialType] = $this->createMaterialForStep(
$stepName,
$videoUrl,
$imageUrl,
$linkUrl
);

$stepOrder = $lesson->steps()->count() + 1;
$stepSlug = $lesson->slug.'-'.Str::slug($stepName);

Step::create([
'lesson_id' => $lesson->id,
'order' => $stepOrder,
'name' => $stepName,
'slug' => $stepSlug,
'text' => $text,
'material_id' => $materialId,
'material_type' => $materialType,
]);
}
}

/**
* Create video, image, or link material (priority: video > image > link). Returns [material_id, material_type].
*
* @return array{0: int|null, 1: string|null}
*/
protected function createMaterialForStep(
string $stepName,
?string $videoUrl,
?string $imageUrl,
?string $linkUrl
): array {
if ($videoUrl !== null && $videoUrl !== '') {
$videoUrl = VideoUrlService::convertToEmbedUrl($videoUrl);
$video = Video::create([
'name' => $stepName,
'url' => $videoUrl,
]);

return [$video->id, 'video'];
}

if ($imageUrl !== null && $imageUrl !== '') {
$image = Image::create(['name' => $stepName]);
try {
$image->addMediaFromUrl($imageUrl)->toMediaCollection('image');
} catch (\Throwable) {
// If URL media add fails, step still has image record
}

return [$image->id, 'image'];
}

if ($linkUrl !== null && $linkUrl !== '') {
$link = Link::create([
'name' => $stepName,
'url' => $linkUrl,
]);

return [$link->id, 'link'];
}

return [null, null];
}

protected function value(Collection $row, string $key): mixed
{
return $row->get($key);
}
}
35 changes: 35 additions & 0 deletions src/Jobs/ImportCourseFromCsv.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace Tapp\FilamentLms\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\File;
use Maatwebsite\Excel\Facades\Excel;
use Tapp\FilamentLms\Imports\CourseStepsImport;

class ImportCourseFromCsv implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

public function __construct(
protected string $courseName,
protected string $filePath
) {}

public function handle(): void
{
if (! File::isReadable($this->filePath)) {
return;
}

Excel::import(new CourseStepsImport($this->courseName), $this->filePath);

File::delete($this->filePath);
}
}
12 changes: 9 additions & 3 deletions src/Models/Video.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,18 @@ public function step(): MorphTo
return $this->morphTo(Step::class);
}

public function getProviderAttribute()
public function getProviderAttribute(): string
{
if (str_contains($this->url, 'youtube')) {
$url = $this->url ?? '';

if (str_contains($url, 'youtube.com') || str_contains($url, 'youtube-nocookie.com') || str_contains($url, 'youtu.be')) {
return 'youtube';
}

return 'vimeo';
if (str_contains($url, 'vimeo.com') || str_contains($url, 'player.vimeo.com')) {
return 'vimeo';
}

return 'external';
}
}
67 changes: 67 additions & 0 deletions src/Resources/CourseResource/Pages/ListCourses.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,16 @@

namespace Tapp\FilamentLms\Resources\CourseResource\Pages;

use Filament\Actions\Action;
use Filament\Actions\CreateAction;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tapp\FilamentLms\Jobs\ImportCourseFromCsv;
use Tapp\FilamentLms\Resources\CourseResource;

class ListCourses extends ListRecords
Expand All @@ -13,6 +21,65 @@ class ListCourses extends ListRecords
protected function getHeaderActions(): array
{
return [
Action::make('import_course')
->label('Import Course')
->icon('heroicon-o-arrow-up-tray')
->schema([
FileUpload::make('file')
->label('CSV file')
->required()
->storeFiles(false)
->acceptedFileTypes([
'text/csv',
'application/csv',
'text/plain',
'application/vnd.ms-excel',
])
->maxSize(10240)
->helperText('Upload a CSV with columns: "Step Name", "Lesson Name", "Url", "Script", "Slides (Image)", "Video (Audio + Image)"'),
TextInput::make('course_name')
->label('Course name')
->required()
->maxLength(255)
->helperText('Name of the new course.'),
])
->action(function (array $data): void {
$file = $data['file'];
$courseName = trim($data['course_name']);

if (! $file instanceof UploadedFile) {
Notification::make()
->title('Import failed')
->body('Could not read the uploaded file.')
->danger()
->send();

return;
}

$storedPath = $file->storeAs(
'filament-lms/course-imports',
Str::uuid().'.csv'
);

if ($storedPath === false) {
Notification::make()
->title('Import failed')
->body('Could not store the uploaded file.')
->danger()
->send();

return;
}

ImportCourseFromCsv::dispatch($courseName, Storage::path($storedPath));

Notification::make()
->title('Import queued')
->body("Course \"{$courseName}\" will be imported in the background. You can continue working while the import runs.")
->success()
->send();
}),
CreateAction::make(),
];
}
Expand Down
Loading