198 lines
8.0 KiB
PHP
198 lines
8.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Models\Story;
|
|
use App\Models\StoryAuthor;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Str;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Migrate legacy interview records into the new Stories system.
|
|
*
|
|
* Usage:
|
|
* php artisan stories:migrate-legacy
|
|
* php artisan stories:migrate-legacy --dry-run
|
|
* php artisan stories:migrate-legacy --legacy-connection=legacy --chunk=100
|
|
*
|
|
* Idempotent: running multiple times will not duplicate records.
|
|
* Legacy records are identified via `legacy_interview_id` column on stories table.
|
|
*/
|
|
final class MigrateStoriesCommand extends Command
|
|
{
|
|
protected $signature = 'stories:migrate-legacy
|
|
{--chunk=50 : number of records to process per batch}
|
|
{--dry-run : preview migration without persisting changes}
|
|
{--legacy-connection= : DB connection name for legacy database (default: uses default connection)}
|
|
{--legacy-table=interviews : legacy interviews table name}
|
|
';
|
|
|
|
protected $description = 'Migrate legacy interview records into the new nova Stories system (idempotent)';
|
|
|
|
public function handle(): int
|
|
{
|
|
$chunk = max(1, (int) $this->option('chunk'));
|
|
$dryRun = (bool) $this->option('dry-run');
|
|
$legacyConn = $this->option('legacy-connection') ?: null;
|
|
$table = (string) $this->option('legacy-table');
|
|
|
|
$this->info('Nova Stories — legacy interview migration');
|
|
$this->info("Table: {$table} | Chunk: {$chunk} | Dry-run: " . ($dryRun ? 'YES' : 'NO'));
|
|
$this->newLine();
|
|
|
|
try {
|
|
$db = $legacyConn ? DB::connection($legacyConn) : DB::connection();
|
|
// Quick existence check
|
|
$db->table($table)->limit(1)->get();
|
|
} catch (Throwable $e) {
|
|
$this->error("Cannot access table `{$table}`: " . $e->getMessage());
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$inserted = 0;
|
|
$skipped = 0;
|
|
$failed = 0;
|
|
|
|
$db->table($table)->orderBy('id')->chunkById($chunk, function ($rows) use (
|
|
$dryRun, &$inserted, &$skipped, &$failed
|
|
) {
|
|
foreach ($rows as $row) {
|
|
$legacyId = (int) ($row->id ?? 0);
|
|
|
|
if (! $legacyId) {
|
|
$skipped++;
|
|
continue;
|
|
}
|
|
|
|
// Idempotency: skip if already migrated
|
|
if (Story::where('legacy_interview_id', $legacyId)->exists()) {
|
|
$skipped++;
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
// ── Resolve / create author ──────────────────────────────
|
|
$authorName = $this->coerceString($row->username ?? $row->author ?? $row->uname ?? '');
|
|
$authorAvatar = $this->coerceString($row->icon ?? $row->avatar ?? '');
|
|
|
|
$author = null;
|
|
if ($authorName) {
|
|
$author = StoryAuthor::firstOrCreate(
|
|
['name' => $authorName],
|
|
['avatar' => $authorAvatar ?: null]
|
|
);
|
|
}
|
|
|
|
// ── Build slug ───────────────────────────────────────────
|
|
$rawTitle = $this->coerceString(
|
|
$row->headline ?? $row->title ?? $row->subject ?? ''
|
|
) ?: 'interview-' . $legacyId;
|
|
|
|
$slugBase = Str::slug(Str::limit($rawTitle, 180));
|
|
$slug = $slugBase ?: 'interview-' . $legacyId;
|
|
|
|
// Ensure uniqueness
|
|
$slug = $this->uniqueSlug($slug);
|
|
|
|
// ── Excerpt ──────────────────────────────────────────────
|
|
$fullContent = $this->coerceString(
|
|
$row->content ?? $row->tekst ?? $row->body ?? $row->text ?? ''
|
|
);
|
|
|
|
$excerpt = $this->coerceString($row->excerpt ?? $row->intro ?? $row->lead ?? '');
|
|
if (! $excerpt && $fullContent) {
|
|
$excerpt = Str::limit(strip_tags($fullContent), 200);
|
|
}
|
|
|
|
// ── Cover image ──────────────────────────────────────────
|
|
$coverRaw = $this->coerceString($row->pic ?? $row->image ?? $row->cover ?? $row->photo ?? '');
|
|
$coverImage = $coverRaw ? 'legacy/interviews/' . ltrim($coverRaw, '/') : null;
|
|
|
|
// ── Published date ───────────────────────────────────────
|
|
$publishedAt = null;
|
|
foreach (['datum', 'published_at', 'date', 'created_at'] as $field) {
|
|
$val = $row->{$field} ?? null;
|
|
if ($val) {
|
|
$ts = strtotime((string) $val);
|
|
if ($ts) {
|
|
$publishedAt = date('Y-m-d H:i:s', $ts);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($dryRun) {
|
|
$this->line(" [DRY-RUN] Would import: #{$legacyId} → {$slug}");
|
|
$inserted++;
|
|
continue;
|
|
}
|
|
|
|
Story::create([
|
|
'slug' => $slug,
|
|
'title' => Str::limit($rawTitle, 255),
|
|
'excerpt' => $excerpt ?: null,
|
|
'content' => $fullContent ?: null,
|
|
'cover_image' => $coverImage,
|
|
'author_id' => $author?->id,
|
|
'views' => max(0, (int) ($row->views ?? $row->hits ?? 0)),
|
|
'featured' => false,
|
|
'status' => 'published',
|
|
'published_at' => $publishedAt,
|
|
'legacy_interview_id' => $legacyId,
|
|
]);
|
|
|
|
$this->line(" Imported: #{$legacyId} → {$slug}");
|
|
$inserted++;
|
|
|
|
} catch (Throwable $e) {
|
|
$failed++;
|
|
$this->warn(" FAILED #{$legacyId}: " . $e->getMessage());
|
|
Log::warning("stories:migrate-legacy failed for id={$legacyId}", ['error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
});
|
|
|
|
$this->newLine();
|
|
$this->info("Migration complete.");
|
|
$this->table(
|
|
['Inserted', 'Skipped (existing)', 'Failed'],
|
|
[[$inserted, $skipped, $failed]]
|
|
);
|
|
|
|
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
private function coerceString(mixed $value, string $default = ''): string
|
|
{
|
|
if ($value === null) {
|
|
return $default;
|
|
}
|
|
$str = trim((string) $value);
|
|
return $str !== '' ? $str : $default;
|
|
}
|
|
|
|
/**
|
|
* Ensure the slug is unique, appending a numeric suffix if needed.
|
|
*/
|
|
private function uniqueSlug(string $slug): string
|
|
{
|
|
if (! Story::where('slug', $slug)->exists()) {
|
|
return $slug;
|
|
}
|
|
|
|
$i = 2;
|
|
do {
|
|
$candidate = $slug . '-' . $i++;
|
|
} while (Story::where('slug', $candidate)->exists());
|
|
|
|
return $candidate;
|
|
}
|
|
}
|