mirror of
https://github.com/filamentphp/filament.git
synced 2026-10-24 12:04:06 +08:00
488 lines
17 KiB
PHP
488 lines
17 KiB
PHP
<?php
|
|
|
|
namespace Filament\Actions\Concerns;
|
|
|
|
use Closure;
|
|
use Filament\Actions\Action;
|
|
use Filament\Actions\Action as NotificationAction;
|
|
use Filament\Actions\Action as TableAction;
|
|
use Filament\Actions\ImportAction;
|
|
use Filament\Actions\ImportAction as ImportTableAction;
|
|
use Filament\Actions\Imports\ImportColumn;
|
|
use Filament\Actions\Imports\Importer;
|
|
use Filament\Actions\Imports\Jobs\ImportCsv;
|
|
use Filament\Actions\Imports\Models\Import;
|
|
use Filament\Forms\Components\FileUpload;
|
|
use Filament\Forms\Components\Select;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Schema\Components\Fieldset;
|
|
use Filament\Schema\Components\Utilities\Get;
|
|
use Filament\Schema\Components\Utilities\Set;
|
|
use Filament\Support\ChunkIterator;
|
|
use Filament\Support\Facades\FilamentIcon;
|
|
use Illuminate\Bus\PendingBatch;
|
|
use Illuminate\Contracts\Auth\Authenticatable;
|
|
use Illuminate\Contracts\Support\Htmlable;
|
|
use Illuminate\Filesystem\AwsS3V3Adapter;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\Bus;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Number;
|
|
use League\Csv\Info;
|
|
use League\Csv\Reader as CsvReader;
|
|
use League\Csv\Statement;
|
|
use League\Csv\Writer;
|
|
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
|
use SplTempFileObject;
|
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
|
|
trait CanImportRecords
|
|
{
|
|
/**
|
|
* @var class-string<Importer>
|
|
*/
|
|
protected string $importer;
|
|
|
|
protected ?string $job = null;
|
|
|
|
protected int | Closure $chunkSize = 100;
|
|
|
|
protected int | Closure | null $maxRows = null;
|
|
|
|
protected int | Closure | null $headerOffset = null;
|
|
|
|
protected string | Closure | null $csvDelimiter = null;
|
|
|
|
/**
|
|
* @var array<string, mixed> | Closure
|
|
*/
|
|
protected array | Closure $options = [];
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->label(fn (ImportAction | ImportTableAction $action): string => __('filament-actions::import.label', ['label' => $action->getPluralModelLabel()]));
|
|
|
|
$this->modalHeading(fn (ImportAction | ImportTableAction $action): string => __('filament-actions::import.modal.heading', ['label' => $action->getPluralModelLabel()]));
|
|
|
|
$this->modalDescription(fn (ImportAction | ImportTableAction $action): Htmlable => $action->getModalAction('downloadExample'));
|
|
|
|
$this->modalSubmitActionLabel(__('filament-actions::import.modal.actions.import.label'));
|
|
|
|
$this->groupedIcon(FilamentIcon::resolve('actions::import-action.grouped') ?? 'heroicon-m-arrow-up-tray');
|
|
|
|
$this->form(fn (ImportAction | ImportTableAction $action): array => array_merge([
|
|
FileUpload::make('file')
|
|
->label(__('filament-actions::import.modal.form.file.label'))
|
|
->placeholder(__('filament-actions::import.modal.form.file.placeholder'))
|
|
->acceptedFileTypes(['text/csv', 'text/x-csv', 'application/csv', 'application/x-csv', 'text/comma-separated-values', 'text/x-comma-separated-values', 'text/plain'])
|
|
->afterStateUpdated(function (Set $set, ?TemporaryUploadedFile $state) use ($action) {
|
|
if (! $state instanceof TemporaryUploadedFile) {
|
|
return;
|
|
}
|
|
|
|
$csvStream = $this->getUploadedFileStream($state);
|
|
|
|
if (! $csvStream) {
|
|
return;
|
|
}
|
|
|
|
$csvReader = CsvReader::createFromStream($csvStream);
|
|
|
|
if (filled($csvDelimiter = $this->getCsvDelimiter($csvReader))) {
|
|
$csvReader->setDelimiter($csvDelimiter);
|
|
}
|
|
|
|
$csvReader->setHeaderOffset($action->getHeaderOffset() ?? 0);
|
|
|
|
$csvColumns = $csvReader->getHeader();
|
|
|
|
$lowercaseCsvColumnValues = array_map('strtolower', $csvColumns);
|
|
$lowercaseCsvColumnKeys = array_combine(
|
|
$lowercaseCsvColumnValues,
|
|
$csvColumns,
|
|
);
|
|
|
|
$set('columnMap', array_reduce($action->getImporter()::getColumns(), function (array $carry, ImportColumn $column) use ($lowercaseCsvColumnKeys, $lowercaseCsvColumnValues) {
|
|
$carry[$column->getName()] = $lowercaseCsvColumnKeys[
|
|
Arr::first(
|
|
array_intersect(
|
|
$lowercaseCsvColumnValues,
|
|
$column->getGuesses(),
|
|
),
|
|
)
|
|
] ?? null;
|
|
|
|
return $carry;
|
|
}, []));
|
|
})
|
|
->storeFiles(false)
|
|
->visibility('private')
|
|
->required()
|
|
->hiddenLabel(),
|
|
Fieldset::make(__('filament-actions::import.modal.form.columns.label'))
|
|
->columns(1)
|
|
->inlineLabel()
|
|
->schema(function (Get $get) use ($action): array {
|
|
$csvFile = Arr::first((array) ($get('file') ?? []));
|
|
|
|
if (! $csvFile instanceof TemporaryUploadedFile) {
|
|
return [];
|
|
}
|
|
|
|
$csvStream = $this->getUploadedFileStream($csvFile);
|
|
|
|
if (! $csvStream) {
|
|
return [];
|
|
}
|
|
|
|
$csvReader = CsvReader::createFromStream($csvStream);
|
|
|
|
if (filled($csvDelimiter = $this->getCsvDelimiter($csvReader))) {
|
|
$csvReader->setDelimiter($csvDelimiter);
|
|
}
|
|
|
|
$csvReader->setHeaderOffset($action->getHeaderOffset() ?? 0);
|
|
|
|
$csvColumns = $csvReader->getHeader();
|
|
$csvColumnOptions = array_combine($csvColumns, $csvColumns);
|
|
|
|
return array_map(
|
|
fn (ImportColumn $column): Select => $column->getSelect()->options($csvColumnOptions),
|
|
$action->getImporter()::getColumns(),
|
|
);
|
|
})
|
|
->statePath('columnMap')
|
|
->visible(fn (Get $get): bool => Arr::first((array) ($get('file') ?? [])) instanceof TemporaryUploadedFile),
|
|
], $action->getImporter()::getOptionsFormComponents()));
|
|
|
|
$this->action(function (ImportAction | ImportTableAction $action, array $data) {
|
|
/** @var TemporaryUploadedFile $csvFile */
|
|
$csvFile = $data['file'];
|
|
|
|
$csvStream = $this->getUploadedFileStream($csvFile);
|
|
|
|
if (! $csvStream) {
|
|
return;
|
|
}
|
|
|
|
$csvReader = CsvReader::createFromStream($csvStream);
|
|
|
|
if (filled($csvDelimiter = $this->getCsvDelimiter($csvReader))) {
|
|
$csvReader->setDelimiter($csvDelimiter);
|
|
}
|
|
|
|
$csvReader->setHeaderOffset($action->getHeaderOffset() ?? 0);
|
|
$csvResults = Statement::create()->process($csvReader);
|
|
|
|
$totalRows = $csvResults->count();
|
|
$maxRows = $action->getMaxRows() ?? $totalRows;
|
|
|
|
if ($maxRows < $totalRows) {
|
|
Notification::make()
|
|
->title(__('filament-actions::import.notifications.max_rows.title'))
|
|
->body(trans_choice('filament-actions::import.notifications.max_rows.body', $maxRows, [
|
|
'count' => Number::format($maxRows),
|
|
]))
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$user = auth()->user();
|
|
|
|
$import = app(Import::class);
|
|
$import->user()->associate($user);
|
|
$import->file_name = $csvFile->getClientOriginalName();
|
|
$import->file_path = $csvFile->getRealPath();
|
|
$import->importer = $action->getImporter();
|
|
$import->total_rows = $totalRows;
|
|
$import->save();
|
|
|
|
$importChunkIterator = new ChunkIterator($csvResults->getRecords(), chunkSize: $action->getChunkSize());
|
|
|
|
/** @var array<array<array<string, string>>> $importChunks */
|
|
$importChunks = $importChunkIterator->get();
|
|
|
|
$job = $action->getJob();
|
|
|
|
$options = array_merge(
|
|
$action->getOptions(),
|
|
Arr::except($data, ['file', 'columnMap']),
|
|
);
|
|
|
|
// We do not want to send the loaded user relationship to the queue in job payloads,
|
|
// in case it contains attributes that are not serializable, such as binary columns.
|
|
$import->unsetRelation('user');
|
|
|
|
$importJobs = collect($importChunks)
|
|
->map(fn (array $importChunk): object => new ($job)(
|
|
$import,
|
|
rows: base64_encode(serialize($importChunk)),
|
|
columnMap: $data['columnMap'],
|
|
options: $options,
|
|
));
|
|
|
|
$importer = $import->getImporter(
|
|
columnMap: $data['columnMap'],
|
|
options: $options,
|
|
);
|
|
|
|
Bus::batch($importJobs->all())
|
|
->allowFailures()
|
|
->when(
|
|
filled($jobQueue = $importer->getJobQueue()),
|
|
fn (PendingBatch $batch) => $batch->onQueue($jobQueue),
|
|
)
|
|
->when(
|
|
filled($jobConnection = $importer->getJobConnection()),
|
|
fn (PendingBatch $batch) => $batch->onConnection($jobConnection),
|
|
)
|
|
->when(
|
|
filled($jobBatchName = $importer->getJobBatchName()),
|
|
fn (PendingBatch $batch) => $batch->name($jobBatchName),
|
|
)
|
|
->finally(function () use ($import) {
|
|
$import->touch('completed_at');
|
|
|
|
if (! $import->user instanceof Authenticatable) {
|
|
return;
|
|
}
|
|
|
|
$failedRowsCount = $import->getFailedRowsCount();
|
|
|
|
Notification::make()
|
|
->title(__('filament-actions::import.notifications.completed.title'))
|
|
->body($import->importer::getCompletedNotificationBody($import))
|
|
->when(
|
|
! $failedRowsCount,
|
|
fn (Notification $notification) => $notification->success(),
|
|
)
|
|
->when(
|
|
$failedRowsCount && ($failedRowsCount < $import->total_rows),
|
|
fn (Notification $notification) => $notification->warning(),
|
|
)
|
|
->when(
|
|
$failedRowsCount === $import->total_rows,
|
|
fn (Notification $notification) => $notification->danger(),
|
|
)
|
|
->when(
|
|
$failedRowsCount,
|
|
fn (Notification $notification) => $notification->actions([
|
|
NotificationAction::make('downloadFailedRowsCsv')
|
|
->label(trans_choice('filament-actions::import.notifications.completed.actions.download_failed_rows_csv.label', $failedRowsCount, [
|
|
'count' => Number::format($failedRowsCount),
|
|
]))
|
|
->color('danger')
|
|
->url(route('filament.imports.failed-rows.download', ['import' => $import], absolute: false), shouldOpenInNewTab: true)
|
|
->markAsRead(),
|
|
]),
|
|
)
|
|
->sendToDatabase($import->user);
|
|
})
|
|
->dispatch();
|
|
|
|
Notification::make()
|
|
->title($action->getSuccessNotificationTitle())
|
|
->body(trans_choice('filament-actions::import.notifications.started.body', $import->total_rows, [
|
|
'count' => Number::format($import->total_rows),
|
|
]))
|
|
->success()
|
|
->send();
|
|
});
|
|
|
|
$this->registerModalActions([
|
|
(match (true) {
|
|
$this instanceof TableAction => TableAction::class,
|
|
default => Action::class,
|
|
})::make('downloadExample')
|
|
->label(__('filament-actions::import.modal.actions.download_example.label'))
|
|
->link()
|
|
->action(function (): StreamedResponse {
|
|
$columns = $this->getImporter()::getColumns();
|
|
|
|
$csv = Writer::createFromFileObject(new SplTempFileObject());
|
|
|
|
if (filled($csvDelimiter = $this->getCsvDelimiter())) {
|
|
$csv->setDelimiter($csvDelimiter);
|
|
}
|
|
|
|
$csv->insertOne(array_map(
|
|
fn (ImportColumn $column): string => $column->getExampleHeader(),
|
|
$columns,
|
|
));
|
|
|
|
$example = array_map(
|
|
fn (ImportColumn $column) => $column->getExample(),
|
|
$columns,
|
|
);
|
|
|
|
if (array_filter(
|
|
$example,
|
|
fn ($value): bool => filled($value),
|
|
)) {
|
|
$csv->insertOne($example);
|
|
}
|
|
|
|
return response()->streamDownload(function () use ($csv) {
|
|
echo $csv->toString();
|
|
}, __('filament-actions::import.example_csv.file_name', ['importer' => (string) str($this->getImporter())->classBasename()->kebab()]), [
|
|
'Content-Type' => 'text/csv',
|
|
]);
|
|
}),
|
|
]);
|
|
|
|
$this->color('gray');
|
|
|
|
$this->modalWidth('xl');
|
|
|
|
$this->successNotificationTitle(__('filament-actions::import.notifications.started.title'));
|
|
|
|
$this->model(fn (ImportAction | ImportTableAction $action): string => $action->getImporter()::getModel());
|
|
}
|
|
|
|
/**
|
|
* @return resource | false
|
|
*/
|
|
public function getUploadedFileStream(TemporaryUploadedFile $file)
|
|
{
|
|
$filePath = $file->getRealPath();
|
|
|
|
if (config('filesystems.disks.' . config('filament.default_filesystem_disk') . '.driver') !== 's3') {
|
|
return fopen($filePath, mode: 'r');
|
|
}
|
|
|
|
/** @var AwsS3V3Adapter $s3Adapter */
|
|
$s3Adapter = Storage::disk('s3')->getAdapter();
|
|
|
|
invade($s3Adapter)->client->registerStreamWrapper(); /** @phpstan-ignore-line */
|
|
$fileS3Path = 's3://' . config('filesystems.disks.s3.bucket') . '/' . $filePath;
|
|
|
|
return fopen($fileS3Path, mode: 'r', context: stream_context_create([
|
|
's3' => [
|
|
'seekable' => true,
|
|
],
|
|
]));
|
|
}
|
|
|
|
public static function getDefaultName(): ?string
|
|
{
|
|
return 'import';
|
|
}
|
|
|
|
/**
|
|
* @param class-string<Importer> $importer
|
|
*/
|
|
public function importer(string $importer): static
|
|
{
|
|
$this->importer = $importer;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* @param class-string | null $job
|
|
*/
|
|
public function job(?string $job): static
|
|
{
|
|
$this->job = $job;
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function chunkSize(int | Closure $size): static
|
|
{
|
|
$this->chunkSize = $size;
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function maxRows(int | Closure | null $rows): static
|
|
{
|
|
$this->maxRows = $rows;
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function headerOffset(int | Closure | null $offset): static
|
|
{
|
|
$this->headerOffset = $offset;
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function csvDelimiter(string | Closure | null $delimiter): static
|
|
{
|
|
$this->csvDelimiter = $delimiter;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* @return class-string<Importer>
|
|
*/
|
|
public function getImporter(): string
|
|
{
|
|
return $this->importer;
|
|
}
|
|
|
|
/**
|
|
* @return class-string
|
|
*/
|
|
public function getJob(): string
|
|
{
|
|
return $this->job ?? ImportCsv::class;
|
|
}
|
|
|
|
public function getChunkSize(): int
|
|
{
|
|
return $this->evaluate($this->chunkSize);
|
|
}
|
|
|
|
public function getMaxRows(): ?int
|
|
{
|
|
return $this->evaluate($this->maxRows);
|
|
}
|
|
|
|
public function getHeaderOffset(): ?int
|
|
{
|
|
return $this->evaluate($this->headerOffset);
|
|
}
|
|
|
|
public function getCsvDelimiter(?CsvReader $reader = null): ?string
|
|
{
|
|
return $this->evaluate($this->csvDelimiter) ?? $this->guessCsvDelimiter($reader);
|
|
}
|
|
|
|
protected function guessCsvDelimiter(?CsvReader $reader = null): ?string
|
|
{
|
|
if (! $reader) {
|
|
return null;
|
|
}
|
|
|
|
$delimiterCounts = Info::getDelimiterStats($reader, delimiters: [',', ';', '|', "\t"], limit: 10);
|
|
|
|
return array_search(max($delimiterCounts), $delimiterCounts);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> | Closure $options
|
|
*/
|
|
public function options(array | Closure $options): static
|
|
{
|
|
$this->options = $options;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function getOptions(): array
|
|
{
|
|
return $this->evaluate($this->options);
|
|
}
|
|
}
|