Skip to content

Commit b537b94

Browse files
committed
Add comic import via CSV and JSON
Adds bulk import for comics, matching the pattern used by books (GoodReads CSV), movies (IMDb CSV), and anime (MAL XML/JSON). - ComicImportService: parses CSV and JSON, O(1) duplicate detection via comicvine_volume_id or title+publisher fallback - ComicImport Livewire component: file upload, preview, import flow - comic-import blade view: format selector, column specs, preview table - Route: /comics/import - phpunit.xml: add explicit pgsql test credentials matching docker-compose
1 parent 89377de commit b537b94

5 files changed

Lines changed: 595 additions & 0 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Livewire\Comics;
6+
7+
use App\Services\ComicImportService;
8+
use Illuminate\Support\Collection;
9+
use Illuminate\Support\Facades\Auth;
10+
use Livewire\Component;
11+
use Livewire\WithFileUploads;
12+
13+
class ComicImport extends Component
14+
{
15+
use WithFileUploads;
16+
17+
public $file;
18+
19+
public string $format = 'csv'; // csv or json
20+
21+
public bool $skipDuplicates = true;
22+
23+
public ?Collection $preview = null;
24+
25+
public ?array $importResult = null;
26+
27+
public bool $importing = false;
28+
29+
protected function rules(): array
30+
{
31+
$mimes = $this->format === 'json' ? 'json,txt' : 'csv,txt';
32+
33+
return [
34+
'file' => ['required', 'file', "mimes:{$mimes}", 'max:10240'],
35+
];
36+
}
37+
38+
public function updatedFile(): void
39+
{
40+
$this->validate();
41+
$this->generatePreview();
42+
}
43+
44+
public function updatedFormat(): void
45+
{
46+
$this->file = null;
47+
$this->preview = null;
48+
$this->importResult = null;
49+
}
50+
51+
protected function generatePreview(): void
52+
{
53+
$content = file_get_contents($this->file->getRealPath());
54+
55+
try {
56+
$service = new ComicImportService;
57+
58+
if ($this->format === 'json') {
59+
$comics = $service->parseJson($content);
60+
} else {
61+
$comics = $service->parseCSV($content);
62+
}
63+
64+
$this->preview = $comics->take(10);
65+
} catch (\InvalidArgumentException $e) {
66+
$this->addError('file', $e->getMessage());
67+
$this->file = null;
68+
$this->preview = null;
69+
}
70+
}
71+
72+
public function import(): void
73+
{
74+
$this->validate();
75+
$this->importing = true;
76+
77+
try {
78+
$content = file_get_contents($this->file->getRealPath());
79+
$service = new ComicImportService;
80+
81+
if ($this->format === 'json') {
82+
$comics = $service->parseJson($content);
83+
} else {
84+
$comics = $service->parseCSV($content);
85+
}
86+
87+
$this->importResult = $service->importComics(
88+
Auth::user(),
89+
$comics,
90+
$this->skipDuplicates
91+
);
92+
} catch (\Exception $e) {
93+
$this->importResult = [
94+
'imported' => 0,
95+
'skipped' => 0,
96+
'errors' => [$e->getMessage()],
97+
];
98+
} finally {
99+
$this->importing = false;
100+
$this->preview = null;
101+
$this->file = null;
102+
}
103+
}
104+
105+
public function resetForm(): void
106+
{
107+
$this->file = null;
108+
$this->preview = null;
109+
$this->importResult = null;
110+
$this->format = 'csv';
111+
}
112+
113+
public function render()
114+
{
115+
return view('livewire.comics.comic-import')->layout('layouts.app');
116+
}
117+
}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Services;
6+
7+
use App\Enums\ReadingStatus;
8+
use App\Models\Comic;
9+
use App\Models\User;
10+
use Carbon\Carbon;
11+
use Illuminate\Support\Collection;
12+
13+
class ComicImportService
14+
{
15+
public function parseCSV(string $content): Collection
16+
{
17+
$lines = explode("\n", $content);
18+
$headers = str_getcsv(array_shift($lines));
19+
20+
$comics = collect();
21+
22+
foreach ($lines as $line) {
23+
if (empty(trim($line))) {
24+
continue;
25+
}
26+
27+
$row = str_getcsv($line);
28+
29+
if (count($row) !== count($headers)) {
30+
continue;
31+
}
32+
33+
$data = array_combine($headers, $row);
34+
35+
$comics->push($this->mapRowToComic($data));
36+
}
37+
38+
return $comics;
39+
}
40+
41+
public function parseJson(string $content): Collection
42+
{
43+
$data = json_decode($content, true);
44+
45+
if (! is_array($data)) {
46+
throw new \InvalidArgumentException('Invalid JSON: expected an array of comics.');
47+
}
48+
49+
return collect($data)->map(fn (array $item) => $this->mapJsonToComic($item));
50+
}
51+
52+
protected function mapRowToComic(array $row): array
53+
{
54+
return [
55+
'title' => $row['Title'] ?? '',
56+
'publisher' => $row['Publisher'] ?? null,
57+
'start_year' => ! empty($row['Start Year']) ? (int) $row['Start Year'] : null,
58+
'issue_count' => ! empty($row['Issue Count']) ? (int) $row['Issue Count'] : null,
59+
'status' => $this->mapStatus($row['Status'] ?? ''),
60+
'rating' => $this->parseRating($row['Rating'] ?? ''),
61+
'date_started' => $this->parseDate($row['Date Started'] ?? ''),
62+
'date_finished' => $this->parseDate($row['Date Finished'] ?? ''),
63+
'notes' => $row['Notes'] ?? null,
64+
'review' => $row['Review'] ?? null,
65+
'creators' => $row['Creators'] ?? null,
66+
'characters' => $row['Characters'] ?? null,
67+
'comicvine_volume_id' => ! empty($row['ComicVine Volume ID']) ? $row['ComicVine Volume ID'] : null,
68+
];
69+
}
70+
71+
protected function mapJsonToComic(array $item): array
72+
{
73+
return [
74+
'title' => $item['title'] ?? '',
75+
'publisher' => $item['publisher'] ?? null,
76+
'start_year' => isset($item['start_year']) ? (int) $item['start_year'] : null,
77+
'issue_count' => isset($item['issue_count']) ? (int) $item['issue_count'] : null,
78+
'status' => $this->mapStatus($item['status'] ?? ''),
79+
'rating' => $this->parseRating((string) ($item['rating'] ?? '')),
80+
'date_started' => $this->parseDate($item['date_started'] ?? ''),
81+
'date_finished' => $this->parseDate($item['date_finished'] ?? ''),
82+
'notes' => $item['notes'] ?? null,
83+
'review' => $item['review'] ?? null,
84+
'creators' => $item['creators'] ?? null,
85+
'characters' => $item['characters'] ?? null,
86+
'comicvine_volume_id' => $item['comicvine_volume_id'] ?? null,
87+
'cover_url' => $item['cover_url'] ?? null,
88+
'description' => $item['description'] ?? null,
89+
'comicvine_url' => $item['comicvine_url'] ?? null,
90+
];
91+
}
92+
93+
protected function parseDate(?string $date): ?string
94+
{
95+
if (empty($date)) {
96+
return null;
97+
}
98+
99+
try {
100+
return Carbon::parse($date)->format('Y-m-d');
101+
} catch (\Exception) {
102+
return null;
103+
}
104+
}
105+
106+
protected function parseRating(?string $rating): ?int
107+
{
108+
if (empty($rating) || $rating === '0') {
109+
return null;
110+
}
111+
112+
$rating = (int) $rating;
113+
114+
return $rating >= 1 && $rating <= 5 ? $rating : null;
115+
}
116+
117+
protected function mapStatus(string $status): ReadingStatus
118+
{
119+
return match (strtolower(trim($status))) {
120+
'read' => ReadingStatus::Read,
121+
'reading' => ReadingStatus::Reading,
122+
default => ReadingStatus::WantToRead,
123+
};
124+
}
125+
126+
public function importComics(User $user, Collection $comics, bool $skipDuplicates = true): array
127+
{
128+
$imported = 0;
129+
$skipped = 0;
130+
$errors = [];
131+
132+
// Pre-load existing identifiers for batch duplicate detection
133+
$existingIds = [];
134+
if ($skipDuplicates) {
135+
$userComics = Comic::where('user_id', $user->id)
136+
->select('comicvine_volume_id', 'title', 'publisher')
137+
->get();
138+
$existingIds['comicvine'] = $userComics->pluck('comicvine_volume_id')->filter()->flip()->all();
139+
$existingIds['title_publisher'] = $userComics->map(function ($c) {
140+
return strtolower(trim($c->title)).':'.strtolower(trim($c->publisher ?? ''));
141+
})->flip()->all();
142+
}
143+
144+
foreach ($comics as $index => $comicData) {
145+
try {
146+
if (empty($comicData['title'])) {
147+
$errors[] = 'Row '.($index + 2).': Missing title';
148+
149+
continue;
150+
}
151+
152+
if ($skipDuplicates && $this->isDuplicateFromCache($comicData, $existingIds)) {
153+
$skipped++;
154+
155+
continue;
156+
}
157+
158+
$comicData['user_id'] = $user->id;
159+
$comicData['status'] = $comicData['status']->value;
160+
161+
Comic::create($comicData);
162+
$imported++;
163+
164+
// Update cache with newly imported comic
165+
if ($skipDuplicates) {
166+
if (! empty($comicData['comicvine_volume_id'])) {
167+
$existingIds['comicvine'][$comicData['comicvine_volume_id']] = true;
168+
}
169+
$key = strtolower(trim($comicData['title'])).':'.strtolower(trim($comicData['publisher'] ?? ''));
170+
$existingIds['title_publisher'][$key] = true;
171+
}
172+
} catch (\Exception $e) {
173+
$errors[] = 'Row '.($index + 2).': '.$e->getMessage();
174+
}
175+
}
176+
177+
return [
178+
'imported' => $imported,
179+
'skipped' => $skipped,
180+
'errors' => $errors,
181+
];
182+
}
183+
184+
protected function isDuplicateFromCache(array $comicData, array $existingIds): bool
185+
{
186+
if (! empty($comicData['comicvine_volume_id']) && isset($existingIds['comicvine'][$comicData['comicvine_volume_id']])) {
187+
return true;
188+
}
189+
190+
$key = strtolower(trim($comicData['title'])).':'.strtolower(trim($comicData['publisher'] ?? ''));
191+
if (isset($existingIds['title_publisher'][$key])) {
192+
return true;
193+
}
194+
195+
return false;
196+
}
197+
}

phpunit.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@
2424
<env name="BROADCAST_CONNECTION" value="null"/>
2525
<env name="CACHE_STORE" value="array"/>
2626
<env name="DB_CONNECTION" value="pgsql"/>
27+
<env name="DB_HOST" value="127.0.0.1"/>
28+
<env name="DB_PORT" value="5432"/>
29+
<env name="DB_DATABASE" value="teal"/>
30+
<env name="DB_USERNAME" value="teal"/>
31+
<env name="DB_PASSWORD" value="secret"/>
2732
<env name="MAIL_MAILER" value="array"/>
2833
<env name="QUEUE_CONNECTION" value="sync"/>
2934
<env name="SESSION_DRIVER" value="array"/>

0 commit comments

Comments
 (0)