Current state
This commit is contained in:
92
app/Console/Commands/ImportArtworkHashes.php
Normal file
92
app/Console/Commands/ImportArtworkHashes.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ImportArtworkHashes extends Command
|
||||
{
|
||||
protected $signature = 'artworks:import-hashes {file=artworks_hash_skinbase.csv} {--start=0} {--limit=0}';
|
||||
protected $description = 'Import artwork hash, file_ext and thumb_ext from CSV (id,hash,file_ext,thumb_ext)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$file = $this->argument('file');
|
||||
$path = base_path($file);
|
||||
|
||||
if (!is_readable($path)) {
|
||||
$this->error("CSV file not readable: {$path}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$handle = fopen($path, 'r');
|
||||
if (!$handle) {
|
||||
$this->error('Unable to open CSV file');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Read header
|
||||
$header = fgetcsv($handle);
|
||||
if ($header === false) {
|
||||
$this->error('CSV appears empty');
|
||||
fclose($handle);
|
||||
return 1;
|
||||
}
|
||||
|
||||
$start = (int) $this->option('start');
|
||||
$limit = (int) $this->option('limit');
|
||||
$rowIndex = 0;
|
||||
$bar = null;
|
||||
|
||||
// Optionally count lines for progress bar
|
||||
if ($limit === 0) {
|
||||
// We'll not determine total to keep memory low
|
||||
}
|
||||
|
||||
while (($row = fgetcsv($handle)) !== false) {
|
||||
$rowIndex++;
|
||||
if ($rowIndex <= $start) {
|
||||
continue;
|
||||
}
|
||||
if ($limit > 0 && ($rowIndex - $start) > $limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Expecting columns: id,hash,file_ext,thumb_ext
|
||||
$id = isset($row[0]) ? trim($row[0], "\" ") : null;
|
||||
$hash = isset($row[1]) ? trim($row[1], "\" ") : null;
|
||||
$file_ext = isset($row[2]) ? trim($row[2], "\" ") : null;
|
||||
$thumb_ext = isset($row[3]) ? trim($row[3], "\" ") : null;
|
||||
|
||||
if (empty($id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normalize null strings
|
||||
if (strtoupper($hash) === 'NULL') $hash = null;
|
||||
if (strtoupper($file_ext) === 'NULL') $file_ext = null;
|
||||
if (strtoupper($thumb_ext) === 'NULL') $thumb_ext = null;
|
||||
|
||||
try {
|
||||
DB::table('artworks')->where('id', $id)->limit(1)->update([
|
||||
'hash' => $hash,
|
||||
'file_ext' => $file_ext,
|
||||
'thumb_ext' => $thumb_ext,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// Log and continue on duplicate / other DB errors
|
||||
$this->error("Row {$rowIndex} (id={$id}) failed: " . $e->getMessage());
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($rowIndex % 500 === 0) {
|
||||
$this->info("Processed {$rowIndex} rows");
|
||||
}
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
$this->info('Import complete.');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
193
app/Console/Commands/ImportCategories.php
Normal file
193
app/Console/Commands/ImportCategories.php
Normal file
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ImportCategories extends Command
|
||||
{
|
||||
protected $signature = 'import:categories {path=database/artworks_categories.csv}';
|
||||
|
||||
protected $description = 'Import categories from legacy CSV into categories table';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$path = base_path($this->argument('path'));
|
||||
|
||||
if (!file_exists($path)) {
|
||||
$this->error("CSV file not found: {$path}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$file = new \SplFileObject($path);
|
||||
$file->setFlags(\SplFileObject::READ_CSV | \SplFileObject::SKIP_EMPTY);
|
||||
|
||||
$header = null;
|
||||
$rows = [];
|
||||
|
||||
foreach ($file as $index => $row) {
|
||||
if ($row === [null] || $row === false) {
|
||||
continue;
|
||||
}
|
||||
if ($index === 0) {
|
||||
$header = $row;
|
||||
continue;
|
||||
}
|
||||
|
||||
// normalize row length
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
// build a map of rows by category id to allow ancestor lookups
|
||||
$map = [];
|
||||
foreach ($rows as $r) {
|
||||
$rid = isset($r[0]) ? (int)$r[0] : null;
|
||||
if ($rid) {
|
||||
$map[$rid] = $r;
|
||||
}
|
||||
}
|
||||
|
||||
// Temporarily disable foreign key checks to allow inserting parents out of order
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS=0');
|
||||
|
||||
// truncate categories table to start fresh
|
||||
DB::table('categories')->truncate();
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
$this->info('Inserting categories...');
|
||||
|
||||
// helper to walk ancestor chain and find a valid content_type_id
|
||||
$findAncestorContentType = function ($startRoot) use ($map) {
|
||||
$seen = [];
|
||||
$cur = $startRoot;
|
||||
while ($cur && isset($map[$cur]) && !in_array($cur, $seen, true)) {
|
||||
$seen[] = $cur;
|
||||
$ancestor = $map[$cur];
|
||||
$candidate = isset($ancestor[7]) ? (int)$ancestor[7] : null;
|
||||
if (!empty($candidate)) {
|
||||
// verify the content type exists in DB
|
||||
if (DB::table('content_types')->where('id', $candidate)->exists()) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
$cur = isset($ancestor[8]) ? (int)$ancestor[8] : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// helper to collect ancestor chain rows for debugging dump
|
||||
$collectAncestorChain = function ($startRoot) use ($map) {
|
||||
$chain = [];
|
||||
$seen = [];
|
||||
$cur = $startRoot;
|
||||
while ($cur && isset($map[$cur]) && !in_array($cur, $seen, true)) {
|
||||
$seen[] = $cur;
|
||||
$r = $map[$cur];
|
||||
$chain[] = [
|
||||
'id' => $cur,
|
||||
'name' => $r[1] ?? null,
|
||||
'section_id' => isset($r[7]) ? (int)$r[7] : null,
|
||||
'rootid' => isset($r[8]) ? (int)$r[8] : null,
|
||||
'raw' => $r,
|
||||
];
|
||||
$cur = isset($r[8]) ? (int)$r[8] : null;
|
||||
}
|
||||
return $chain;
|
||||
};
|
||||
|
||||
foreach ($rows as $row) {
|
||||
// CSV format: category_id,category_name,num_artworks,description,official_webpage,picture,views,section_id,rootid
|
||||
$origId = isset($row[0]) ? (int)$row[0] : null;
|
||||
$name = isset($row[1]) ? trim($row[1]) : '';
|
||||
$description = isset($row[3]) && $row[3] !== '' ? $row[3] : null;
|
||||
$image = isset($row[5]) && $row[5] !== '' ? $row[5] : null;
|
||||
$contentTypeId = isset($row[7]) ? (int)$row[7] : null;
|
||||
$rootId = isset($row[8]) ? (int)$row[8] : null;
|
||||
|
||||
// If the CSV `rootid` corresponds to a content type, it's a root for that content type => parent_id NULL
|
||||
if (!empty($rootId) && DB::table('content_types')->where('id', $rootId)->exists()) {
|
||||
$parentId = null;
|
||||
} else {
|
||||
// If content_type_id == parent_id then parent_id should be NULL
|
||||
$parentId = ($contentTypeId === $rootId || $rootId === 0) ? null : $rootId;
|
||||
}
|
||||
|
||||
// resolve missing/invalid content_type_id from ancestor chain (parent/root)
|
||||
$ctExists = !empty($contentTypeId) && DB::table('content_types')->where('id', $contentTypeId)->exists();
|
||||
if (!$ctExists) {
|
||||
if (!empty($rootId)) {
|
||||
$resolved = $findAncestorContentType($rootId);
|
||||
if ($resolved) {
|
||||
$contentTypeId = $resolved;
|
||||
$this->info("Resolved content_type_id for category {$origId} from ancestor: {$contentTypeId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// after resolution, verify content_type exists
|
||||
if (empty($contentTypeId) || !DB::table('content_types')->where('id', $contentTypeId)->exists()) {
|
||||
$this->error("Missing content_type_id for category {$origId}. Dumping data and aborting import.");
|
||||
|
||||
$dump = [
|
||||
'row' => $row,
|
||||
'resolved_root' => $rootId,
|
||||
'ancestor_chain' => $rootId ? $collectAncestorChain($rootId) : [],
|
||||
];
|
||||
|
||||
$this->line(json_encode($dump, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
|
||||
throw new \RuntimeException('Aborting import: unresolved content_type_id for category ' . $origId);
|
||||
}
|
||||
|
||||
$slug = Str::slug($name) ?: 'cat-' . $origId;
|
||||
|
||||
// ensure uniqueness for slug within content_type
|
||||
$existing = DB::table('categories')
|
||||
->where('content_type_id', $contentTypeId)
|
||||
->where('slug', $slug)
|
||||
->exists();
|
||||
|
||||
if ($existing) {
|
||||
$slug = $slug . '-' . $origId;
|
||||
}
|
||||
|
||||
$now = now();
|
||||
|
||||
$data = [
|
||||
'id' => $origId,
|
||||
'content_type_id' => $contentTypeId,
|
||||
'parent_id' => $parentId,
|
||||
'name' => $name,
|
||||
'slug' => $slug,
|
||||
'description' => $description,
|
||||
'image' => $image,
|
||||
'is_active' => true,
|
||||
'sort_order' => 0,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
DB::table('categories')->insert($data);
|
||||
$this->line("Inserted category {$origId}: {$name}");
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
$this->info('Import completed.');
|
||||
return 0;
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
$this->error('Import failed: ' . $e->getMessage());
|
||||
return 1;
|
||||
} finally {
|
||||
// re-enable foreign key checks
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS=1');
|
||||
}
|
||||
}
|
||||
}
|
||||
291
app/Console/Commands/ImportLegacyArtworks.php
Normal file
291
app/Console/Commands/ImportLegacyArtworks.php
Normal file
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Import artworks from legacy `wallz` table and attach categories via `connected` table.
|
||||
*
|
||||
* Usage:
|
||||
* php artisan skinbase:import-legacy-artworks --chunk=500 --dry-run
|
||||
*/
|
||||
class ImportLegacyArtworks extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:import-legacy-artworks
|
||||
{--chunk=500 : chunk size for processing}
|
||||
{--limit= : maximum number of legacy rows to import}
|
||||
{--dry-run : do not persist any changes}
|
||||
{--legacy-connection=legacy : name of legacy DB connection}
|
||||
{--legacy-table=wallz : legacy artworks table name}
|
||||
{--connected-table=connected : legacy artwork->category table}
|
||||
';
|
||||
|
||||
protected $description = 'Import artworks from legacy DB (wallz) into new artworks table';
|
||||
|
||||
private function coerceUnsignedInt(mixed $value, int $default = 0): int
|
||||
{
|
||||
if ($value === null) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value ? 1 : 0;
|
||||
}
|
||||
|
||||
if (is_int($value)) {
|
||||
return max(0, $value);
|
||||
}
|
||||
|
||||
if (is_float($value)) {
|
||||
return max(0, (int) $value);
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
$trimmed = trim($value);
|
||||
if ($trimmed === '') {
|
||||
return $default;
|
||||
}
|
||||
if (is_numeric($trimmed)) {
|
||||
return max(0, (int) $trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
private function coerceString(mixed $value, string $default = ''): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$stringValue = trim((string) $value);
|
||||
|
||||
return $stringValue !== '' ? $stringValue : $default;
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$chunk = (int) $this->option('chunk');
|
||||
$limit = $this->option('limit') ? (int) $this->option('limit') : null;
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$legacyConn = $this->option('legacy-connection');
|
||||
$legacyTable = $this->option('legacy-table');
|
||||
$connectedTable = $this->option('connected-table');
|
||||
|
||||
$this->info("Starting import from {$legacyConn}.{$legacyTable} (chunk={$chunk})");
|
||||
|
||||
$query = DB::connection($legacyConn)->table($legacyTable)->orderBy('id');
|
||||
|
||||
$processed = 0;
|
||||
|
||||
$query->chunkById($chunk, function ($rows) use (&$processed, $limit, $dryRun, $legacyConn, $connectedTable) {
|
||||
foreach ($rows as $row) {
|
||||
if ($limit !== null && $processed >= $limit) {
|
||||
return false; // stop chunking
|
||||
}
|
||||
|
||||
$legacyId = $row->id ?? null;
|
||||
|
||||
$title = $row->name ?? $row->title ?? ($row->headline ?? ('legacy-' . ($legacyId ?? Str::random(6))));
|
||||
$description = $row->description ?? $row->desc ?? null;
|
||||
|
||||
$slugBase = Str::slug(substr((string) $title, 0, 120));
|
||||
// Use cleaned title slug directly. If no title, fallback to artwork-<id|random>.
|
||||
$slug = $slugBase ? $slugBase : 'artwork-' . ($legacyId ?? Str::random(8));
|
||||
|
||||
$publishedAt = null;
|
||||
if (! empty($row->datum)) {
|
||||
$publishedAt = date('Y-m-d H:i:s', strtotime($row->datum));
|
||||
} elseif (! empty($row->created_at)) {
|
||||
$publishedAt = $row->created_at;
|
||||
}
|
||||
|
||||
// File mapping — try common legacy fields. Normalize and ensure file_path is not null.
|
||||
$rawFileName = $row->pic ?? $row->picture ?? $row->file ?? $row->fname ?? null;
|
||||
$fileName = null;
|
||||
$filePath = '';
|
||||
if (! empty($rawFileName) && trim((string) $rawFileName) !== '') {
|
||||
$fileName = trim((string) $rawFileName);
|
||||
// store legacy path under legacy/ folder, but do not move files here — admin can handle file migration
|
||||
$filePath = 'legacy/uploads/' . ltrim($fileName, '/');
|
||||
}
|
||||
|
||||
// derive mime type if missing (use extension mapping), fallback to application/octet-stream
|
||||
$mime = $row->mimetype ?? $row->mime ?? null;
|
||||
if (empty($mime) && $fileName) {
|
||||
$ext = strtolower(pathinfo((string) $fileName, PATHINFO_EXTENSION));
|
||||
$map = [
|
||||
'jpg' => 'image/jpeg',
|
||||
'jpeg' => 'image/jpeg',
|
||||
'png' => 'image/png',
|
||||
'gif' => 'image/gif',
|
||||
'bmp' => 'image/bmp',
|
||||
'webp' => 'image/webp',
|
||||
'svg' => 'image/svg+xml',
|
||||
'ico' => 'image/x-icon',
|
||||
'zip' => 'application/zip',
|
||||
'pdf' => 'application/pdf',
|
||||
];
|
||||
|
||||
$mime = $map[$ext] ?? null;
|
||||
}
|
||||
if (empty($mime)) {
|
||||
$mime = 'application/octet-stream';
|
||||
}
|
||||
|
||||
$data = [
|
||||
'id' => $row->id ?? null,
|
||||
// NOTE: artworks.user_id is NOT NULL (no FK constraint, but column cannot be null)
|
||||
'user_id' => $row->user_id ?? 1,
|
||||
'title' => (string) $title,
|
||||
'slug' => (string) $slug,
|
||||
'description' => $description,
|
||||
'file_name' => $fileName,
|
||||
// ensure non-null file_path to satisfy NOT NULL DB constraints
|
||||
'file_path' => $filePath ?? '',
|
||||
// legacy DB sometimes has no filesize; default to 0 to satisfy NOT NULL
|
||||
'file_size' => isset($row->filesize) && $row->filesize !== null ? (int) $row->filesize : (isset($row->size) && $row->size !== null ? (int) $row->size : 0),
|
||||
'mime_type' => $mime,
|
||||
'width' => $row->width ?? null,
|
||||
'height' => $row->height ?? null,
|
||||
'is_public' => isset($row->visible) ? (bool) $row->visible : true,
|
||||
'is_approved' => isset($row->approved) ? (bool) $row->approved : true,
|
||||
'published_at' => $publishedAt,
|
||||
];
|
||||
|
||||
// Coerce required NOT NULL columns to safe defaults (legacy data can be messy)
|
||||
$data['user_id'] = $this->coerceUnsignedInt($data['user_id'], 1);
|
||||
$data['file_name'] = $this->coerceString($data['file_name'], 'legacy-' . ($legacyId ?? Str::random(8)));
|
||||
$data['file_path'] = $this->coerceString($data['file_path'], 'legacy/uploads/' . $data['file_name']);
|
||||
$data['mime_type'] = $this->coerceString($data['mime_type'], 'application/octet-stream');
|
||||
$data['file_size'] = $this->coerceUnsignedInt($data['file_size'], 0);
|
||||
$data['width'] = $this->coerceUnsignedInt($data['width'], 0);
|
||||
$data['height'] = $this->coerceUnsignedInt($data['height'], 0);
|
||||
|
||||
$this->line('Importing legacy id=' . ($legacyId ?? 'unknown') . ' title=' . $data['title']);
|
||||
|
||||
if ($dryRun) {
|
||||
$processed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$art = null;
|
||||
|
||||
DB::connection()->transaction(function () use (&$art, $data, $legacyId, $legacyConn, $connectedTable) {
|
||||
// create artwork (guard against unique slug collisions)
|
||||
$baseSlug = $data['slug'];
|
||||
$attempt = 0;
|
||||
$slug = $baseSlug;
|
||||
while (Artwork::where('slug', $slug)->exists()) {
|
||||
$attempt++;
|
||||
$slug = $baseSlug . '-' . $attempt;
|
||||
}
|
||||
$data['slug'] = $slug;
|
||||
|
||||
// Preserve legacy primary ID if available and safe to do so.
|
||||
if (! empty($legacyId) && is_numeric($legacyId) && (int) $legacyId > 0) {
|
||||
$preserveId = (int) $legacyId;
|
||||
if (Artwork::where('id', $preserveId)->exists()) {
|
||||
// Avoid overwriting an existing artwork with the same id.
|
||||
throw new \RuntimeException("Artwork with id {$preserveId} already exists; skipping import for this legacy id.");
|
||||
}
|
||||
$data['id'] = $preserveId;
|
||||
}
|
||||
|
||||
// If we need to preserve the legacy primary id, perform a raw insert
|
||||
// so auto-increment doesn't assign a different id. Otherwise use Eloquent.
|
||||
if (! empty($data['id'])) {
|
||||
$insert = $data;
|
||||
$ts = date('Y-m-d H:i:s');
|
||||
if (! array_key_exists('created_at', $insert)) {
|
||||
$insert['created_at'] = $ts;
|
||||
}
|
||||
if (! array_key_exists('updated_at', $insert)) {
|
||||
$insert['updated_at'] = $ts;
|
||||
}
|
||||
|
||||
DB::table('artworks')->insert($insert);
|
||||
$art = Artwork::find($insert['id']);
|
||||
} else {
|
||||
$art = Artwork::create($data);
|
||||
}
|
||||
|
||||
// attach categories if connected table exists
|
||||
if (DB::connection($legacyConn)->getSchemaBuilder()->hasTable($connectedTable)) {
|
||||
// attempt to find category ids from connected table; common column names: wallz_id, art_id, connected_id
|
||||
$rows = DB::connection($legacyConn)->table($connectedTable)
|
||||
->where(function ($q) use ($legacyId) {
|
||||
$q->where('wallz_id', $legacyId)
|
||||
->orWhere('art_id', $legacyId)
|
||||
->orWhere('item_id', $legacyId);
|
||||
})->get();
|
||||
|
||||
$categoryIds = [];
|
||||
foreach ($rows as $r) {
|
||||
$cid = $r->category_id ?? $r->cat_id ?? $r->category ?? null;
|
||||
if ($cid) {
|
||||
// try to find matching Category in new DB by id or slug
|
||||
if (is_numeric($cid) && \App\Models\Category::where('id', $cid)->exists()) {
|
||||
$categoryIds[] = (int) $cid;
|
||||
} else {
|
||||
// maybe legacy stores slug
|
||||
$cat = \App\Models\Category::where('slug', $cid)->first();
|
||||
if ($cat) {
|
||||
$categoryIds[] = $cat->id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($categoryIds)) {
|
||||
$art->categories()->syncWithoutDetaching(array_values(array_unique($categoryIds)));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Post-insert verification: if we attempted to preserve the legacy id,
|
||||
// confirm the row exists with that id. Log mapping if preservation failed.
|
||||
if (! empty($legacyId) && is_numeric($legacyId) && (int) $legacyId > 0) {
|
||||
$preserveId = (int) $legacyId;
|
||||
$exists = Artwork::where('id', $preserveId)->exists();
|
||||
if (! $exists) {
|
||||
// If $art was created but with a different id, log mapping for manual reconciliation
|
||||
if ($art instanceof Artwork) {
|
||||
Log::warning('Imported legacy artwork but failed to preserve id', [
|
||||
'legacy_id' => $preserveId,
|
||||
'created_id' => $art->id,
|
||||
'slug' => $art->slug ?? null,
|
||||
]);
|
||||
} else {
|
||||
Log::warning('Legacy artwork not found after import', ['legacy_id' => $preserveId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$processed++;
|
||||
} catch (Throwable $e) {
|
||||
$this->error('Failed to import legacy id=' . ($legacyId ?? 'unknown') . ': ' . $e->getMessage());
|
||||
Log::error('ImportLegacyArtworks error', [
|
||||
'legacy_id' => $legacyId,
|
||||
'error' => $e->getMessage(),
|
||||
'data' => $data ?? null,
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, 'id');
|
||||
|
||||
$this->info('Import complete. Processed: ' . $processed);
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
203
app/Console/Commands/ImportLegacyUsers.php
Normal file
203
app/Console/Commands/ImportLegacyUsers.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ImportLegacyUsers extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:import-legacy-users {--chunk=200 : Chunk size for processing} {--force-reset-all : Force reset passwords for all imported users}';
|
||||
protected $description = 'Import legacy users into the new auth schema per legacy_users_migration spec';
|
||||
|
||||
protected array $usedUsernames = [];
|
||||
protected array $usedEmails = [];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->usedUsernames = User::pluck('username', 'username')->filter()->all();
|
||||
$this->usedEmails = User::pluck('email', 'email')->filter()->all();
|
||||
|
||||
$chunk = (int) $this->option('chunk');
|
||||
$imported = 0;
|
||||
$skipped = 0;
|
||||
|
||||
if (! DB::connection('legacy')->getPdo()) {
|
||||
$this->error('Legacy DB connection "legacy" is not configured or reachable.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
DB::connection('legacy')
|
||||
->table('users')
|
||||
->chunkById($chunk, function ($rows) use (&$imported, &$skipped) {
|
||||
$ids = $rows->pluck('user_id')->all();
|
||||
$stats = DB::connection('legacy')
|
||||
->table('users_statistics')
|
||||
->whereIn('user_id', $ids)
|
||||
->get()
|
||||
->keyBy('user_id');
|
||||
|
||||
foreach ($rows as $row) {
|
||||
try {
|
||||
$this->importRow($row, $stats[$row->user_id] ?? null);
|
||||
$imported++;
|
||||
} catch (\Throwable $e) {
|
||||
$skipped++;
|
||||
$this->warn("Skip user_id {$row->user_id}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
}, 'user_id');
|
||||
|
||||
$this->info("Imported: {$imported}, Skipped: {$skipped}");
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function importRow($row, $statRow = null): void
|
||||
{
|
||||
$legacyId = (int) $row->user_id;
|
||||
$baseUsername = $this->sanitizeUsername($row->uname ?: ('user'.$legacyId));
|
||||
$username = $this->uniqueUsername($baseUsername);
|
||||
|
||||
$email = $this->prepareEmail($row->email ?? null, $username);
|
||||
|
||||
$legacyPassword = $row->password2 ?: $row->password ?: null;
|
||||
|
||||
// Optionally force-reset every imported user's password to a secure random value.
|
||||
if ($this->option('force-reset-all')) {
|
||||
$this->warn("Force-reset-all enabled: generating secure password for user_id {$row->user_id}.");
|
||||
$passwordHash = Hash::make(Str::random(64));
|
||||
} else {
|
||||
// Force-reset known weak default passwords (e.g. "abc123").
|
||||
if ($legacyPassword !== null && trim($legacyPassword) === 'abc123') {
|
||||
$this->warn("Weak password 'abc123' detected for user_id {$row->user_id}; forcing reset.");
|
||||
$passwordHash = Hash::make(Str::random(64));
|
||||
} else {
|
||||
$passwordHash = Hash::make($legacyPassword ?: Str::random(32));
|
||||
}
|
||||
}
|
||||
|
||||
$uploads = $this->sanitizeStatValue($statRow->uploads ?? 0);
|
||||
$downloads = $this->sanitizeStatValue($statRow->downloads ?? 0);
|
||||
$pageviews = $this->sanitizeStatValue($statRow->pageviews ?? 0);
|
||||
$awards = $this->sanitizeStatValue($statRow->awards ?? 0);
|
||||
|
||||
DB::transaction(function () use ($legacyId, $username, $email, $passwordHash, $row, $uploads, $downloads, $pageviews, $awards) {
|
||||
$now = now();
|
||||
|
||||
DB::table('users')->insert([
|
||||
'id' => $legacyId,
|
||||
'username' => $username,
|
||||
'name' => $row->real_name ?: $username,
|
||||
'email' => $email,
|
||||
'password' => $passwordHash,
|
||||
'is_active' => (int) ($row->active ?? 1) === 1,
|
||||
'needs_password_reset' => true,
|
||||
'role' => 'user',
|
||||
'legacy_password_algo' => null,
|
||||
'last_visit_at' => $row->LastVisit ?: null,
|
||||
'created_at' => $row->joinDate ?: $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
DB::table('user_profiles')->insert([
|
||||
'user_id' => $legacyId,
|
||||
'bio' => $row->about_me ?: $row->description ?: null,
|
||||
'avatar' => $row->picture ?: null,
|
||||
'cover_image' => $row->cover_art ?: null,
|
||||
'country' => $row->country ?: null,
|
||||
'country_code' => $row->country_code ? substr($row->country_code, 0, 2) : null,
|
||||
'language' => $row->lang ?: null,
|
||||
'birthdate' => $row->birth ?: null,
|
||||
'gender' => $row->gender ?: 'X',
|
||||
'website' => $row->web ?: null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
if (!empty($row->web)) {
|
||||
DB::table('user_social_links')->insert([
|
||||
'user_id' => $legacyId,
|
||||
'platform' => 'website',
|
||||
'url' => $row->web,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
DB::table('user_statistics')->insert([
|
||||
'user_id' => $legacyId,
|
||||
'uploads' => $uploads,
|
||||
'downloads' => $downloads,
|
||||
'pageviews' => $pageviews,
|
||||
'awards' => $awards,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure statistic values are safe for unsigned DB columns.
|
||||
*/
|
||||
protected function sanitizeStatValue($value): int
|
||||
{
|
||||
$n = is_numeric($value) ? (int) $value : 0;
|
||||
if ($n < 0) {
|
||||
return 0;
|
||||
}
|
||||
return $n;
|
||||
}
|
||||
|
||||
protected function sanitizeUsername(string $username): string
|
||||
{
|
||||
$username = strtolower(trim($username));
|
||||
$username = preg_replace('/[^a-z0-9._-]/', '-', $username) ?: 'user';
|
||||
return trim($username, '.-') ?: 'user';
|
||||
}
|
||||
|
||||
protected function uniqueUsername(string $base): string
|
||||
{
|
||||
$name = $base;
|
||||
$i = 1;
|
||||
while (isset($this->usedUsernames[$name]) || DB::table('users')->where('username', $name)->exists()) {
|
||||
$name = $base . '-' . $i;
|
||||
$i++;
|
||||
}
|
||||
$this->usedUsernames[$name] = $name;
|
||||
return $name;
|
||||
}
|
||||
|
||||
protected function prepareEmail(?string $legacyEmail, string $username): string
|
||||
{
|
||||
$legacyEmail = $legacyEmail ? strtolower(trim($legacyEmail)) : null;
|
||||
$baseLocal = $this->sanitizeEmailLocal($username);
|
||||
$domain = 'users.skinbase.org';
|
||||
|
||||
$email = $legacyEmail ?: ($baseLocal . '@' . $domain);
|
||||
$email = $this->uniqueEmail($email, $baseLocal, $domain);
|
||||
return $email;
|
||||
}
|
||||
|
||||
protected function uniqueEmail(string $email, string $baseLocal, string $domain): string
|
||||
{
|
||||
$i = 1;
|
||||
$local = explode('@', $email)[0];
|
||||
$current = $email;
|
||||
while (isset($this->usedEmails[$current]) || DB::table('users')->where('email', $current)->exists()) {
|
||||
$current = $local . $i . '@' . $domain;
|
||||
$i++;
|
||||
}
|
||||
$this->usedEmails[$current] = $current;
|
||||
return $current;
|
||||
}
|
||||
|
||||
protected function sanitizeEmailLocal(string $value): string
|
||||
{
|
||||
$local = strtolower(trim($value));
|
||||
$local = preg_replace('/[^a-z0-9._-]/', '-', $local) ?: 'user';
|
||||
return trim($local, '.-') ?: 'user';
|
||||
}
|
||||
}
|
||||
152
app/Console/Commands/MigrateFeaturedWorks.php
Normal file
152
app/Console/Commands/MigrateFeaturedWorks.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class MigrateFeaturedWorks extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'migrate:featured-works {--dry-run : Do not write any rows} {--limit=0 : Stop after inserting this many rows} {--legacy-connection=legacy : name of legacy DB connection} {--legacy-table=featured_works : legacy table name} {--start-id=0 : Start processing from this legacy featured_id} {--chunk=500 : Chunk size for processing}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Migrate rows from legacy featured_works into artwork_features safely';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$dryRun = $this->option('dry-run');
|
||||
$limit = (int) $this->option('limit');
|
||||
|
||||
$this->info('Starting migration from `featured_works` to `artwork_features`');
|
||||
if ($dryRun) {
|
||||
$this->info('Running in dry-run mode; no inserts will be performed.');
|
||||
}
|
||||
|
||||
$inserted = 0;
|
||||
$skipped = 0;
|
||||
$total = 0;
|
||||
|
||||
$startId = (int) $this->option('start-id');
|
||||
$chunk = (int) $this->option('chunk');
|
||||
|
||||
$mapping = [
|
||||
3 => 10, // Gold -> high priority
|
||||
2 => 20, // Silver
|
||||
1 => 30, // Bronze
|
||||
4 => 50, // Featured
|
||||
0 => 100 // default
|
||||
];
|
||||
|
||||
$legacyConn = $this->option('legacy-connection');
|
||||
$legacyTable = $this->option('legacy-table');
|
||||
|
||||
$this->info("Reading from legacy connection '{$legacyConn}' table '{$legacyTable}'");
|
||||
|
||||
$query = DB::connection($legacyConn)->table($legacyTable)->orderBy('featured_id');
|
||||
if ($startId > 0) {
|
||||
$this->info("Resuming from featured_id >= {$startId}");
|
||||
$query = $query->where('featured_id', '>=', $startId);
|
||||
}
|
||||
|
||||
$query->chunkById($chunk, function ($rows) use (&$inserted, &$skipped, &$total, $dryRun, $limit, $mapping) {
|
||||
foreach ($rows as $row) {
|
||||
$total++;
|
||||
|
||||
if ($limit > 0 && $inserted >= $limit) {
|
||||
return false; // stop chunking
|
||||
}
|
||||
|
||||
$artworkId = isset($row->artwork_id) ? (int) $row->artwork_id : 0;
|
||||
|
||||
if ($artworkId <= 0) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Verify artwork exists
|
||||
$exists = DB::table('artworks')->where('id', $artworkId)->exists();
|
||||
if (! $exists) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Avoid duplicate active feature for same artwork
|
||||
$already = DB::table('artwork_features')
|
||||
->where('artwork_id', $artworkId)
|
||||
->where('is_active', true)
|
||||
->exists();
|
||||
|
||||
if ($already) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine featured_at
|
||||
$postDate = $row->post_date ?? null;
|
||||
if (empty($postDate) || $postDate === '0000-00-00' || $postDate === '0000-00-00 00:00:00') {
|
||||
$featuredAt = Carbon::now();
|
||||
} else {
|
||||
try {
|
||||
$featuredAt = Carbon::parse($postDate);
|
||||
} catch (\Throwable $e) {
|
||||
$featuredAt = Carbon::now();
|
||||
}
|
||||
}
|
||||
|
||||
// Map priority from legacy 'type'
|
||||
$type = isset($row->type) ? (int) $row->type : 0;
|
||||
$priority = $mapping[$type] ?? 100;
|
||||
|
||||
// Validate created_by: only set if a valid user id exists in new users table
|
||||
$createdBy = isset($row->user_id) ? (int) $row->user_id : null;
|
||||
if ($createdBy <= 0 || ! DB::table('users')->where('id', $createdBy)->exists()) {
|
||||
$createdBy = null;
|
||||
}
|
||||
|
||||
$record = [
|
||||
'artwork_id' => $artworkId,
|
||||
'featured_at' => $featuredAt->toDateTimeString(),
|
||||
'expires_at' => null,
|
||||
'priority' => $priority,
|
||||
'label' => null,
|
||||
'note' => $row->description ?? null,
|
||||
'is_active' => 1,
|
||||
'created_by' => $createdBy,
|
||||
'created_at' => Carbon::now()->toDateTimeString(),
|
||||
'updated_at' => Carbon::now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line('[dry] Insert: artwork_id=' . $artworkId . ' featured_at=' . $record['featured_at'] . ' priority=' . $priority);
|
||||
$inserted++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
DB::table('artwork_features')->insert($record);
|
||||
$inserted++;
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Failed to insert artwork_id=' . $artworkId . ' : ' . $e->getMessage());
|
||||
$skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
// Return true to continue, false to stop chunking
|
||||
return ($limit > 0 && $inserted >= $limit) ? false : true;
|
||||
}, 'featured_id');
|
||||
|
||||
$this->info("Done. Processed: {$total}, Inserted: {$inserted}, Skipped: {$skipped}");
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
60
app/Console/Commands/ResetAllUserPasswords.php
Normal file
60
app/Console/Commands/ResetAllUserPasswords.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ResetAllUserPasswords extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'skinbase:reset-all-passwords {--chunk=500 : Chunk size for processing} {--yes : Skip confirmation}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Reset all user passwords to secure random values and flag for password reset.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! $this->option('yes') && ! $this->confirm('This will replace every user password with a secure random value and mark accounts as needing a reset. Continue?')) {
|
||||
$this->info('Aborted. No changes were made.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$chunk = (int) $this->option('chunk');
|
||||
$count = DB::table('users')->count();
|
||||
if ($count === 0) {
|
||||
$this->info('No users found.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$bar = $this->output->createProgressBar($count);
|
||||
$bar->start();
|
||||
|
||||
DB::table('users')->orderBy('id')->chunkById($chunk, function ($rows) use ($bar) {
|
||||
foreach ($rows as $row) {
|
||||
DB::table('users')->where('id', $row->id)->update([
|
||||
'password' => Hash::make(Str::random(64)),
|
||||
'needs_password_reset' => true,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$bar->advance();
|
||||
}
|
||||
}, 'id');
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine(2);
|
||||
$this->info('All user passwords reset and accounts flagged for password reset.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user