diff --git a/app/Filament/Components/Actions/ExportScheduleAction.php b/app/Filament/Components/Actions/ExportScheduleAction.php new file mode 100644 index 000000000..f2e085cc3 --- /dev/null +++ b/app/Filament/Components/Actions/ExportScheduleAction.php @@ -0,0 +1,34 @@ +label(trans('filament-actions::export.modal.actions.export.label')); + + $this->authorize(fn () => auth()->user()->can(Permission::ACTION_SCHEDULE_READ, $server)); + + $this->action(fn (ScheduleExporterService $service, Schedule $schedule) => response()->streamDownload(function () use ($service, $schedule) { + echo $service->handle($schedule); + }, 'schedule-' . str($schedule->name)->kebab()->lower()->trim() . '.json')); + } +} diff --git a/app/Filament/Components/Actions/ImportScheduleAction.php b/app/Filament/Components/Actions/ImportScheduleAction.php new file mode 100644 index 000000000..37e934c0e --- /dev/null +++ b/app/Filament/Components/Actions/ImportScheduleAction.php @@ -0,0 +1,121 @@ +label(trans('filament-actions::import.modal.actions.import.label')); + + $this->authorize(fn () => auth()->user()->can(Permission::ACTION_SCHEDULE_CREATE, $server)); + + $this->form([ + Tabs::make('Tabs') + ->contained(false) + ->tabs([ + Tab::make(trans('admin/schedule.import.file')) + ->icon('tabler-file-upload') + ->schema([ + FileUpload::make('files') + ->label(trans('admin/schedule.model_label')) + ->hint(trans('admin/schedule.import.schedule_help')) + ->acceptedFileTypes(['application/json']) + ->preserveFilenames() + ->previewable(false) + ->storeFiles(false) + ->multiple(true), + ]), + Tab::make(trans('admin/schedule.import.url')) + ->icon('tabler-world-upload') + ->schema([ + Repeater::make('urls') + ->label('') + ->itemLabel(fn (array $state) => str($state['url'])->afterLast('/schedule-')->before('.json')->headline()) + ->hint(trans('admin/schedule.import.url_help')) + ->addActionLabel(trans('admin/schedule.import.add_url')) + ->grid(2) + ->reorderable(false) + ->addable(true) + ->deletable(fn (array $state) => count($state) > 1) + ->schema([ + TextInput::make('url') + ->live() + ->label(trans('admin/schedule.import.url')) + ->url() + ->endsWith('.json') + ->validationAttribute(trans('admin/schedule.import.url')), + ]), + ]), + ]), + ]); + + $this->action(function (array $data, ScheduleImporterService $service) use ($server) { + $schedules = array_merge(collect($data['urls'])->flatten()->whereNotNull()->unique()->all(), Arr::wrap($data['files'])); + if (empty($schedules)) { + return; + } + + [$success, $failed] = [collect(), collect()]; + + foreach ($schedules as $schedule) { + if ($schedule instanceof TemporaryUploadedFile) { + $name = str($schedule->getClientOriginalName())->afterLast('schedule-')->before('.json')->headline(); + $method = 'fromFile'; + } else { + $schedule = str($schedule); + $schedule = $schedule->contains('github.com') ? $schedule->replaceFirst('blob', 'raw') : $schedule; + $name = $schedule->afterLast('/schedule-')->before('.json')->headline(); + $method = 'fromUrl'; + } + try { + $service->$method($schedule, $server); + $success->push($name); + } catch (Exception $exception) { + $failed->push($name); + report($exception); + } + } + + if ($failed->count() > 0) { + Notification::make() + ->title(trans('admin/schedule.import.import_failed')) + ->body($failed->join(', ')) + ->danger() + ->send(); + } + if ($success->count() > 0) { + Notification::make() + ->title(trans('admin/schedule.import.import_success')) + ->body($success->join(', ')) + ->success() + ->send(); + } + }); + } +} diff --git a/app/Filament/Server/Resources/ScheduleResource/Pages/EditSchedule.php b/app/Filament/Server/Resources/ScheduleResource/Pages/EditSchedule.php index d18750ff8..38cad99df 100644 --- a/app/Filament/Server/Resources/ScheduleResource/Pages/EditSchedule.php +++ b/app/Filament/Server/Resources/ScheduleResource/Pages/EditSchedule.php @@ -3,6 +3,7 @@ namespace App\Filament\Server\Resources\ScheduleResource\Pages; use App\Facades\Activity; +use App\Filament\Components\Actions\ExportScheduleAction; use App\Filament\Server\Resources\ScheduleResource; use App\Models\Schedule; use App\Traits\Filament\CanCustomizeHeaderActions; @@ -50,6 +51,7 @@ class EditSchedule extends EditRecord ->property('name', $record->name) ->log(); }), + ExportScheduleAction::make(), $this->getSaveFormAction()->formId('form')->label('Save'), $this->getCancelFormAction()->formId('form'), ]; diff --git a/app/Filament/Server/Resources/ScheduleResource/Pages/ListSchedules.php b/app/Filament/Server/Resources/ScheduleResource/Pages/ListSchedules.php index 1d2f75f49..512aeecc3 100644 --- a/app/Filament/Server/Resources/ScheduleResource/Pages/ListSchedules.php +++ b/app/Filament/Server/Resources/ScheduleResource/Pages/ListSchedules.php @@ -2,6 +2,7 @@ namespace App\Filament\Server\Resources\ScheduleResource\Pages; +use App\Filament\Components\Actions\ImportScheduleAction; use App\Filament\Server\Resources\ScheduleResource; use App\Traits\Filament\CanCustomizeHeaderActions; use App\Traits\Filament\CanCustomizeHeaderWidgets; @@ -23,6 +24,7 @@ class ListSchedules extends ListRecords return [ CreateAction::make() ->label('New Schedule'), + ImportScheduleAction::make(), ]; } diff --git a/app/Services/Schedules/Sharing/ScheduleExporterService.php b/app/Services/Schedules/Sharing/ScheduleExporterService.php new file mode 100644 index 000000000..7d6608b2b --- /dev/null +++ b/app/Services/Schedules/Sharing/ScheduleExporterService.php @@ -0,0 +1,39 @@ + $schedule->name, + 'is_active' => $schedule->is_active, + 'only_when_online' => $schedule->only_when_online, + 'cron_minute' => $schedule->cron_minute, + 'cron_hour' => $schedule->cron_hour, + 'cron_day_of_month' => $schedule->cron_day_of_month, + 'cron_month' => $schedule->cron_month, + 'cron_day_of_week' => $schedule->cron_day_of_week, + + 'tasks' => $schedule->tasks->map(function (Task $task) { + return [ + 'sequence_id' => $task->sequence_id, + 'action' => $task->action, + 'payload' => $task->payload, + 'time_offset' => $task->time_offset, + 'continue_on_failure' => $task->continue_on_failure, + ]; + }), + ]; + + return json_encode($data, JSON_PRETTY_PRINT); + } +} diff --git a/app/Services/Schedules/Sharing/ScheduleImporterService.php b/app/Services/Schedules/Sharing/ScheduleImporterService.php new file mode 100644 index 000000000..5d5e37b2f --- /dev/null +++ b/app/Services/Schedules/Sharing/ScheduleImporterService.php @@ -0,0 +1,78 @@ +getError() !== UPLOAD_ERR_OK) { + throw new InvalidFileUploadException('The selected file was not uploaded successfully'); + } + + try { + $parsed = json_decode($file->getContent(), true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + throw new InvalidFileUploadException('Could not read JSON file: ' . $exception->getMessage()); + } + + return $this->connection->transaction(function () use ($server, $parsed) { + $minute = Arr::get($parsed, 'cron_minute', '0'); + $hour = Arr::get($parsed, 'cron_hour', '0'); + $dayOfMonth = Arr::get($parsed, 'cron_day_of_month', '*'); + $month = Arr::get($parsed, 'cron_month', '*'); + $dayOfWeek = Arr::get($parsed, 'cron_day_of_week', '*'); + + $schedule = Schedule::create([ + 'server_id' => $server->id, + 'name' => Arr::get($parsed, 'name'), + 'is_active' => Arr::get($parsed, 'is_active'), + 'only_when_online' => Arr::get($parsed, 'only_when_online'), + 'cron_minute' => $minute, + 'cron_hour' => $hour, + 'cron_day_of_month' => $dayOfMonth, + 'cron_month' => $month, + 'cron_day_of_week' => $dayOfWeek, + 'next_run_at' => Utilities::getScheduleNextRunDate($minute, $hour, $dayOfMonth, $month, $dayOfWeek), + ]); + + foreach (Arr::get($parsed, 'tasks', []) as $task) { + Task::create([ + 'schedule_id' => $schedule->id, + 'sequence_id' => Arr::get($task, 'sequence_id'), + 'action' => Arr::get($task, 'action'), + 'payload' => Arr::get($task, 'payload'), + 'time_offset' => Arr::get($task, 'time_offset'), + 'continue_on_failure' => Arr::get($task, 'continue_on_failure'), + ]); + } + + return $schedule; + }); + } + + public function fromUrl(string $url, Server $server): Schedule + { + $info = pathinfo($url); + $tmpDir = TemporaryDirectory::make()->deleteWhenDestroyed(); + $tmpPath = $tmpDir->path($info['basename']); + + if (!file_put_contents($tmpPath, file_get_contents($url))) { + throw new InvalidFileUploadException('Could not write temporary file.'); + } + + return $this->fromFile(new UploadedFile($tmpPath, $info['basename'], 'application/json'), $server); + } +} diff --git a/lang/en/admin/schedule.php b/lang/en/admin/schedule.php new file mode 100644 index 000000000..79e6b449b --- /dev/null +++ b/lang/en/admin/schedule.php @@ -0,0 +1,15 @@ + 'Schedule', + 'model_label_plural' => 'Schedule', + 'import' => [ + 'file' => 'File', + 'url' => 'URL', + 'schedule_help' => 'This should be the raw .json file ( schedule-daily-restart.json )', + 'url_help' => 'URLs must point directly to the raw .json file', + 'add_url' => 'New URL', + 'import_failed' => 'Import Failed', + 'import_success' => 'Import Success', + ], +];