Nyavokevin 9e5cc5ab1a
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
add dark theme
2025-09-18 20:34:07 +03:00

153 lines
5.7 KiB
PHP

<?php
namespace App\Support;
use App\Models\Card;
class CardCsvImporter
{
/**
* Import cards from a CSV or XLSX file (upsert by name/title).
* Returns array [inserted, updated, skipped].
*/
public function import(string $file, string $delimiter = ',', bool $dryRun = false): array
{
if (!is_file($file)) {
throw new \InvalidArgumentException("File not found: {$file}");
}
[$headers, $rows] = $this->readRows($file, $delimiter);
if (empty($headers)) {
return [0, 0, 0];
}
// Normalize headers (lowercase, trimmed, remove BOM)
$headers = array_map(function ($h) {
$h = (string) ($h ?? '');
$h = preg_replace('/^\xEF\xBB\xBF/', '', $h); // strip UTF-8 BOM
return strtolower(trim($h));
}, $headers);
$idx = array_flip($headers);
$nameKey = array_key_exists('name', $idx) ? 'name' : (array_key_exists('title', $idx) ? 'title' : null);
if (!$nameKey) {
throw new \RuntimeException('CSV must contain a "name" or "title" header.');
}
$get = function (array $row, string $key) use ($idx): ?string {
if (!array_key_exists($key, $idx)) return null;
$value = $row[$idx[$key]] ?? null;
return is_string($value) ? trim($value) : (is_null($value) ? null : trim((string) $value));
};
$inserted = 0;
$updated = 0;
$skipped = 0;
foreach ($rows as $row) {
if (!is_array($row)) continue;
if (count(array_filter($row, fn($v) => $v !== null && $v !== '')) === 0) continue;
$name = $get($row, $nameKey);
if (!$name) { $skipped++; continue; }
$payload = [
'description' => (string) ($get($row, 'description') ?? ''),
'description_upright' => (string) ($get($row, 'description_upright') ?? ''),
'description_reversed' => (string) ($get($row, 'description_reversed') ?? ''),
'image_url' => $get($row, 'image_url') ?: null,
'symbolism' => $this->parseSymbolism($get($row, 'symbolism')),
];
if ($dryRun) {
continue;
}
$existing = Card::where('name', $name)->first();
if ($existing) {
$existing->fill($payload)->save();
$updated++;
} else {
Card::create(array_merge(['name' => $name], $payload));
$inserted++;
}
}
return [$inserted, $updated, $skipped];
}
/**
* Read headers and rows from CSV or XLSX.
* @return array{0: array<int,string>, 1: array<int,array<int,string|null>>>}
*/
private function readRows(string $file, string $delimiter): array
{
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
if (in_array($ext, ['xlsx', 'xls', 'ods'])) {
if (!class_exists('PhpOffice\\PhpSpreadsheet\\IOFactory')) {
throw new \RuntimeException('XLSX import requires phpoffice/phpspreadsheet. Run: composer require phpoffice/phpspreadsheet');
}
/** @var \PhpOffice\PhpSpreadsheet\Spreadsheet $spreadsheet */
$spreadsheet = \PhpOffice\PhpSpreadsheet\IOFactory::load($file);
$sheet = $spreadsheet->getActiveSheet();
$data = $sheet->toArray(null, true, true, false); // rows of arrays
if (empty($data)) return [[], []];
$headers = array_map(fn($v) => is_string($v) ? $v : (is_null($v) ? '' : (string) $v), array_shift($data));
$rows = array_map(function ($row) use ($headers) {
// Normalize row length to headers length
$row = array_map(fn($v) => is_string($v) ? $v : (is_null($v) ? null : (string) $v), $row);
if (count($row) < count($headers)) {
$row = array_pad($row, count($headers), null);
} elseif (count($row) > count($headers)) {
$row = array_slice($row, 0, count($headers));
}
return $row;
}, $data);
return [$headers, $rows];
}
// Default: treat as CSV/TSV/plain text
$csv = new \SplFileObject($file, 'r');
$csv->setFlags(\SplFileObject::READ_CSV | \SplFileObject::SKIP_EMPTY | \SplFileObject::DROP_NEW_LINE);
$csv->setCsvControl($delimiter);
if ($csv->eof()) return [[], []];
$headers = $csv->fgetcsv();
if (!$headers || !is_array($headers)) return [[], []];
$headers = array_map(fn($h) => is_string($h) ? $h : (is_null($h) ? '' : (string) $h), $headers);
$rows = [];
foreach ($csv as $row) {
if (!is_array($row)) continue;
$rows[] = $row;
}
return [$headers, $rows];
}
/**
* Parse symbolism string into an array suitable for the Card::casts ['symbolism' => 'array'].
* Accepts JSON arrays or delimited strings (separated by ;, |, •, or ,).
*/
public function parseSymbolism(?string $raw): array
{
$raw = trim((string) $raw);
if ($raw === '') return [];
// Try JSON first
if (str_starts_with($raw, '[') || str_starts_with($raw, '{')) {
$decoded = json_decode($raw, true);
if (json_last_error() === JSON_ERROR_NONE) {
if (is_array($decoded)) return array_values($decoded);
return [];
}
}
$parts = preg_split('/[;|•,]+/u', $raw);
$parts = array_map(fn($s) => trim($s), $parts);
$parts = array_filter($parts, fn($s) => $s !== '');
return array_values($parts);
}
}