feat: increase gallery grid from 4 to 5 columns per row on desktopfeat: increase gallery grid from 4 to 5 columns per row on desktop

This commit is contained in:
2026-02-25 19:11:23 +01:00
parent 5c97488e80
commit 0032aec02f
131 changed files with 15674 additions and 597 deletions

View File

@@ -0,0 +1,436 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use App\Services\TagService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Generate AI tags for artworks using a local LM Studio vision model.
*
* Usage:
* php artisan artworks:ai-tag
* php artisan artworks:ai-tag --after-id=1000 --chunk=20 --dry-run
* php artisan artworks:ai-tag --limit=100 --skip-tagged
* php artisan artworks:ai-tag --artwork-id=242 # process a single artwork by ID
* php artisan artworks:ai-tag --artwork-id=242 --dump-curl # print equivalent curl command (no API call made)
* php artisan artworks:ai-tag --artwork-id=242 --debug # print CDN URL, file size, magic bytes and data-URI prefix
* php artisan artworks:ai-tag --url=http://192.168.1.5:8200 --model=google/gemma-3-4b
*/
final class AiTagArtworksCommand extends Command
{
protected $signature = 'artworks:ai-tag
{--artwork-id= : Process only this single artwork ID (bypasses public/approved scope)}
{--after-id=0 : Skip artworks with ID this value (useful for resuming)}
{--limit= : Stop after processing this many artworks}
{--chunk=50 : DB chunk size}
{--dry-run : Print tags but do not persist them}
{--skip-tagged : Skip artworks that already have at least one AI tag}
{--url-only : Send CDN URL instead of base64 (only works if LM Studio can reach the CDN)}
{--dump-curl : Print the equivalent curl command for the API call and skip the actual request}
{--debug : Print CDN URL, file size, magic bytes and data-URI prefix for each image}
{--url= : LM Studio base URL (overrides config/env)}
{--model= : Model identifier (overrides config/env)}
{--clear-ai-tags : Delete existing AI tags for each artwork before re-tagging}
';
protected $description = 'Generate tags for artworks via a local LM Studio vision model';
// -------------------------------------------------------------------------
// Prompt
// -------------------------------------------------------------------------
private const SYSTEM_PROMPT = <<<'PROMPT'
You are an expert at analysing visual artwork and generating concise, descriptive tags.
PROMPT;
private const USER_PROMPT = <<<'PROMPT'
Analyse the artwork image and return a JSON array of relevant tags.
Cover: art style, subject/theme, dominant colours, mood, technique, and medium where visible.
Rules:
- Return ONLY a valid JSON array of lowercase strings no markdown, no explanation.
- Each tag must be 14 words, no punctuation except hyphens.
- Between 6 and 12 tags total.
Example output:
["digital painting","fantasy","portrait","dark tones","glowing eyes","detailed","dramatic lighting"]
PROMPT;
// -------------------------------------------------------------------------
public function __construct(private readonly TagService $tagService)
{
parent::__construct();
}
public function handle(): int
{
$artworkId = $this->option('artwork-id') !== null ? (int) $this->option('artwork-id') : null;
$afterId = max(0, (int) $this->option('after-id'));
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
$chunk = max(1, min((int) $this->option('chunk'), 200));
$dryRun = (bool) $this->option('dry-run');
$skipTagged = (bool) $this->option('skip-tagged');
$dumpCurl = (bool) $this->option('dump-curl');
$verbose = (bool) $this->option('debug');
$useBase64 = !(bool) $this->option('url-only');
$clearAiTags = (bool) $this->option('clear-ai-tags');
$baseUrl = rtrim((string) ($this->option('url') ?: config('vision.lm_studio.base_url')), '/');
$model = (string) ($this->option('model') ?: config('vision.lm_studio.model'));
$maxTags = (int) config('vision.lm_studio.max_tags', 12);
$this->info("LM Studio : {$baseUrl}");
$this->info("Model : {$model}");
$this->info("Image mode : " . ($useBase64 ? 'base64 (default)' : 'CDN URL (--url-only)'));
$this->info("Dry run : " . ($dryRun ? 'YES' : 'no'));
$this->info("Clear AI : " . ($clearAiTags ? 'YES — existing AI tags deleted first' : 'no'));
if ($artworkId !== null) {
$this->info("Artwork ID : {$artworkId} (single-artwork mode)");
}
$this->line('');
// Single-artwork mode: bypass public/approved scope so any artwork can be tested.
if ($artworkId !== null) {
$artwork = Artwork::withTrashed()->find($artworkId);
if ($artwork === null) {
$this->error("Artwork #{$artworkId} not found.");
return self::FAILURE;
}
$limit = 1;
$query = Artwork::withTrashed()->where('id', $artworkId);
} else {
$query = Artwork::query()
->public()
->where('id', '>', $afterId)
->whereNotNull('hash')
->whereNotNull('thumb_ext')
->orderBy('id');
if ($skipTagged) {
// Exclude artworks that already have an AI-sourced tag in the pivot.
$query->whereDoesntHave('tags', fn ($q) => $q->where('artwork_tag.source', 'ai'));
}
}
$processed = 0;
$tagged = 0;
$skipped = 0;
$errors = 0;
$query->chunkById($chunk, function ($artworks) use (
&$processed, &$tagged, &$skipped, &$errors,
$limit, $dryRun, $dumpCurl, $verbose, $useBase64, $baseUrl, $model, $maxTags, $clearAiTags,
) {
foreach ($artworks as $artwork) {
if ($limit !== null && $processed >= $limit) {
return false; // stop iteration
}
$processed++;
$imageUrl = $artwork->thumbUrl('md');
if ($imageUrl === null) {
$this->warn(" [#{$artwork->id}] No thumb URL — skip");
$skipped++;
continue;
}
$this->line(" [#{$artwork->id}] {$artwork->title}");
// Remove AI tags first if requested.
if ($clearAiTags) {
$aiTagIds = DB::table('artwork_tag')
->where('artwork_id', $artwork->id)
->where('source', 'ai')
->pluck('tag_id')
->all();
if ($aiTagIds !== []) {
if (!$dryRun) {
$this->tagService->detachTags($artwork, $aiTagIds);
}
$this->line(' ✂ Cleared ' . count($aiTagIds) . ' existing AI tag(s)' . ($dryRun ? ' (dry-run)' : ''));
}
}
if ($verbose) {
$this->line(" CDN URL : {$imageUrl}");
}
try {
$tags = $this->fetchTags($baseUrl, $model, $imageUrl, $useBase64, $maxTags, $dumpCurl, $verbose);
} catch (Throwable $e) {
$this->error(" ✗ API error: " . $e->getMessage());
// Show first 120 chars of the response body for easier debugging.
if (str_contains($e->getMessage(), 'status code')) {
$this->line(" (use --dry-run to test without saving)");
}
Log::error('artworks:ai-tag API error', [
'artwork_id' => $artwork->id,
'error' => $e->getMessage(),
]);
$errors++;
continue;
}
if ($tags === []) {
$this->warn(" ✗ No tags returned");
$skipped++;
continue;
}
$tagList = implode(', ', $tags);
$this->line("{$tagList}");
if (!$dryRun) {
$aiTagPayload = array_map(fn (string $t) => ['tag' => $t, 'confidence' => null], $tags);
try {
$this->tagService->attachAiTags($artwork, $aiTagPayload);
$tagged++;
} catch (Throwable $e) {
$this->error(" ✗ Save error: " . $e->getMessage());
Log::error('artworks:ai-tag save error', [
'artwork_id' => $artwork->id,
'error' => $e->getMessage(),
]);
$errors++;
}
} else {
$tagged++;
}
}
});
$this->line('');
$this->info("Done. processed={$processed} tagged={$tagged} skipped={$skipped} errors={$errors}");
return $errors > 0 ? self::FAILURE : self::SUCCESS;
}
// -------------------------------------------------------------------------
// LM Studio API call
// -------------------------------------------------------------------------
/**
* @return list<string>
*/
private function fetchTags(
string $baseUrl,
string $model,
string $imageUrl,
bool $useBase64,
int $maxTags,
bool $dumpCurl = false,
bool $verbose = false,
): array {
$imageContent = $useBase64
? $this->buildBase64ImageContent($imageUrl, $verbose)
: ['type' => 'image_url', 'image_url' => ['url' => $imageUrl]];
$payload = [
'model' => $model,
'temperature' => (float) config('vision.lm_studio.temperature', 0.3),
'max_tokens' => (int) config('vision.lm_studio.max_tokens', 300),
'messages' => [
[
'role' => 'system',
'content' => self::SYSTEM_PROMPT,
],
[
'role' => 'user',
'content' => [
$imageContent,
['type' => 'text', 'text' => self::USER_PROMPT],
],
],
],
];
$timeout = (int) config('vision.lm_studio.timeout', 60);
$connectTimeout = (int) config('vision.lm_studio.connect_timeout', 5);
$endpoint = "{$baseUrl}/v1/chat/completions";
// --dump-curl: write payload to a temp file and print the equivalent curl command.
if ($dumpCurl) {
$jsonPayload = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
// Truncate any base64 data URIs in the printed output so the terminal stays readable.
$printable = preg_replace(
'/("data:[^;]+;base64,)([A-Za-z0-9+\/=]{60})[A-Za-z0-9+\/=]+(")/',
'$1$2...[base64 truncated]$3',
$jsonPayload,
) ?? $jsonPayload;
// Write the full (untruncated) payload to a temp file for use with curl --data.
$tmpJson = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'sbtag_payload_' . uniqid() . '.json';
file_put_contents($tmpJson, $jsonPayload);
$this->line('');
$this->line('<fg=yellow>--- Payload (base64 truncated for display) ---</>');
$this->line($printable);
$this->line('');
$this->line('<fg=yellow>--- curl command (full payload in temp file) ---</>');
$this->line(
'curl -s -X POST ' . escapeshellarg($endpoint)
. ' -H ' . escapeshellarg('Content-Type: application/json')
. ' --data @' . escapeshellarg($tmpJson)
. ' | python -m json.tool'
);
$this->line('');
$this->info("Full JSON payload written to: {$tmpJson}");
// Return empty — no real API call is made.
return [];
}
$response = Http::timeout($timeout)
->connectTimeout($connectTimeout)
->post($endpoint, $payload)
->throw();
$body = $response->json();
$content = $body['choices'][0]['message']['content'] ?? '';
return $this->parseTags((string) $content, $maxTags);
}
/**
* Download the image using the system curl binary (raw bytes, no encoding surprises),
* base64-encode from the local file, then delete it.
*
* Using curl directly is more reliable than the Laravel Http client here because it
* avoids gzip/deflate decoding issues, chunked-transfer quirks, and header parsing
* edge cases that could corrupt the image bytes before encoding.
*
* @return array<string, mixed>
* @throws \RuntimeException if curl fails or the file is empty
*/
private function buildBase64ImageContent(string $imageUrl, bool $verbose = false): array
{
$ext = strtolower(pathinfo(parse_url($imageUrl, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION));
$mime = match ($ext) {
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
default => 'image/jpeg',
};
$tmpPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'sbtag_' . uniqid() . '.' . ($ext ?: 'jpg');
try {
exec(
'curl -s -f -L --max-time 30 -o ' . escapeshellarg($tmpPath) . ' ' . escapeshellarg($imageUrl),
$output,
$exitCode,
);
if ($exitCode !== 0 || !file_exists($tmpPath) || filesize($tmpPath) === 0) {
throw new \RuntimeException("curl failed to download image (exit={$exitCode}, size=" . (file_exists($tmpPath) ? filesize($tmpPath) : 'N/A') . "): {$imageUrl}");
}
$rawBytes = file_get_contents($tmpPath);
if ($rawBytes === false || $rawBytes === '') {
throw new \RuntimeException("file_get_contents returned empty after curl download: {$tmpPath}");
}
// LM Studio does not support WebP. Convert to JPEG via GD if needed.
if ($mime === 'image/webp') {
$convertedPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'sbtag_conv_' . uniqid() . '.jpg';
try {
if (!function_exists('imagecreatefromwebp')) {
throw new \RuntimeException('GD extension with WebP support is required to convert WebP images. Enable ext-gd with WebP support in php.ini.');
}
$img = imagecreatefromwebp($tmpPath);
if ($img === false) {
throw new \RuntimeException("GD failed to load WebP: {$tmpPath}");
}
imagejpeg($img, $convertedPath, 92);
imagedestroy($img);
$rawBytes = file_get_contents($convertedPath);
$mime = 'image/jpeg';
if ($verbose) {
$this->line(' Convert : WebP → JPEG (LM Studio does not accept WebP)');
}
} finally {
@unlink($convertedPath);
}
}
if ($verbose) {
$fileSize = filesize($tmpPath);
// Show first 8 bytes as hex to confirm it's a real image, not an HTML error page.
$magicHex = strtoupper(bin2hex(substr($rawBytes, 0, 8)));
$this->line(" File : {$tmpPath}");
$this->line(" Size : {$fileSize} bytes");
$this->line(" Magic : {$magicHex} (JPEG=FFD8FF, PNG=89504E47, WEBP=52494646)");
}
$base64 = base64_encode($rawBytes);
$dataUri = "data:{$mime};base64,{$base64}";
if ($verbose) {
$this->line(" MIME : {$mime}");
$this->line(" URI pfx : " . substr($dataUri, 0, 60) . '...');
}
} finally {
@unlink($tmpPath);
}
return ['type' => 'image_url', 'image_url' => ['url' => $dataUri]];
}
// -------------------------------------------------------------------------
// Response parsing
// -------------------------------------------------------------------------
/**
* Extract a JSON array from the model's response text.
*
* The model should return just the array, but may include surrounding text
* or markdown code fences, so we search for the first `[…]` block.
*
* @return list<string>
*/
private function parseTags(string $content, int $maxTags): array
{
$content = trim($content);
// Strip markdown code fences if present (```json … ```)
$content = preg_replace('/^```(?:json)?\s*/i', '', $content) ?? $content;
$content = preg_replace('/\s*```$/', '', $content) ?? $content;
// Extract the first JSON array from the text.
if (!preg_match('/(\[.*?\])/s', $content, $matches)) {
return [];
}
$decoded = json_decode($matches[1], true);
if (!is_array($decoded)) {
return [];
}
$tags = [];
foreach ($decoded as $item) {
if (!is_string($item)) {
continue;
}
$clean = trim(strtolower((string) $item));
if ($clean !== '') {
$tags[] = $clean;
}
}
// Respect the configured max-tags ceiling.
return array_slice(array_unique($tags), 0, $maxTags);
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\TagNormalizer;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* One-time (and idempotent) command to convert slug-style tag names to
* human-readable display names.
*
* A tag is considered "slug-style" when its name is identical to its slug
* (e.g. name="digital-art", slug="digital-art"). Tags that already have a
* custom name (user-edited) are left untouched.
*
* Usage:
* php artisan tags:fix-names
* php artisan tags:fix-names --dry-run
*/
final class FixTagNamesCommand extends Command
{
protected $signature = 'tags:fix-names
{--dry-run : Show what would change without writing to the database}
';
protected $description = 'Convert slug-style tag names (e.g. "digital-art") to readable names ("Digital Art")';
public function __construct(private readonly TagNormalizer $normalizer)
{
parent::__construct();
}
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->warn('DRY-RUN — no changes will be written.');
}
// Only fix rows where name === slug (those were created by the old code).
$rows = DB::table('tags')
->whereColumn('name', 'slug')
->orderBy('id')
->get(['id', 'name', 'slug']);
if ($rows->isEmpty()) {
$this->info('Nothing to fix — all tag names are already human-readable.');
return self::SUCCESS;
}
$this->info("Found {$rows->count()} tag(s) with slug-style names.");
$updated = 0;
$bar = $this->output->createProgressBar($rows->count());
$bar->start();
foreach ($rows as $row) {
$displayName = $this->normalizer->toDisplayName($row->slug);
if ($displayName === $row->name) {
$bar->advance();
continue; // Already correct (e.g. single-word tag "cars" → "Cars" — wait, that would differ)
}
if ($this->output->isVerbose()) {
$this->newLine();
$this->line(" {$row->slug}\"{$displayName}\"");
}
if (!$dryRun) {
DB::table('tags')
->where('id', $row->id)
->update(['name' => $displayName]);
}
$updated++;
$bar->advance();
}
$bar->finish();
$this->newLine(2);
$suffix = $dryRun ? ' (dry-run, nothing written)' : '';
$this->info("Updated {$updated} of {$rows->count()} tag(s){$suffix}.");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,288 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\ArtworkAward;
use App\Models\ArtworkAwardStat;
use App\Services\ArtworkAwardService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Migrates legacy `users_opinions` (projekti_old_skinbase) into `artwork_awards`.
*
* Score mapping (legacy score new medal):
* 4 gold (weight 3)
* 3 silver (weight 2)
* 2 bronze (weight 1)
* 1 skipped (too low to map meaningfully)
*
* Usage:
* php artisan awards:import-legacy
* php artisan awards:import-legacy --dry-run
* php artisan awards:import-legacy --chunk=500
* php artisan awards:import-legacy --skip-stats (skip final stats recalc)
*/
class ImportLegacyAwards extends Command
{
protected $signature = 'awards:import-legacy
{--dry-run : Preview only no writes to DB}
{--chunk=250 : Rows to process per batch}
{--skip-stats : Skip per-artwork stats recalculation at the end}
{--force : Overwrite existing awards instead of skipping duplicates}';
protected $description = 'Import legacy users_opinions into artwork_awards';
/** Maps legacy score value → medal string */
private const SCORE_MAP = [
4 => 'gold',
3 => 'silver',
2 => 'bronze',
];
public function handle(ArtworkAwardService $service): int
{
$dryRun = (bool) $this->option('dry-run');
$chunk = max(1, (int) $this->option('chunk'));
$skipStats = (bool) $this->option('skip-stats');
$force = (bool) $this->option('force');
if ($dryRun) {
$this->warn('[DRY-RUN] No data will be written.');
}
// Verify legacy connection is reachable
try {
DB::connection('legacy')->getPdo();
} catch (\Throwable $e) {
$this->error('Cannot connect to legacy database: ' . $e->getMessage());
return self::FAILURE;
}
if (! DB::connection('legacy')->getSchemaBuilder()->hasTable('users_opinions')) {
$this->error('Legacy table `users_opinions` not found.');
return self::FAILURE;
}
// Pre-load sets of valid artwork IDs and user IDs from the new DB
$this->info('Loading new-DB artwork and user ID sets…');
$validArtworkIds = DB::table('artworks')
->whereNull('deleted_at')
->pluck('id')
->flip() // flip so we can use isset() for O(1) lookup
->all();
$validUserIds = DB::table('users')
->whereNull('deleted_at')
->pluck('id')
->flip()
->all();
$this->info(sprintf(
'Found %d artworks and %d users in new DB.',
count($validArtworkIds),
count($validUserIds)
));
// Count legacy rows for progress bar
$total = DB::connection('legacy')
->table('users_opinions')
->count();
$this->info("Legacy rows to process: {$total}");
if ($total === 0) {
$this->warn('No legacy rows found. Nothing to do.');
return self::SUCCESS;
}
$stats = [
'imported' => 0,
'skipped_score' => 0,
'skipped_artwork' => 0,
'skipped_user' => 0,
'skipped_duplicate'=> 0,
'updated_force' => 0,
'errors' => 0,
];
$affectedArtworkIds = [];
$bar = $this->output->createProgressBar($total);
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% | imported: %imported% | skipped: %skipped%');
$bar->setMessage('0', 'imported');
$bar->setMessage('0', 'skipped');
$bar->start();
DB::connection('legacy')
->table('users_opinions')
->orderBy('opinion_id')
->chunk($chunk, function ($rows) use (
&$stats,
&$affectedArtworkIds,
$validArtworkIds,
$validUserIds,
$dryRun,
$force,
$bar
) {
$inserts = [];
$now = now();
foreach ($rows as $row) {
$artworkId = (int) $row->artwork_id;
$userId = (int) $row->author_id; // author_id = the voter
$score = (int) $row->score;
$postedAt = $row->post_date ?? $now;
// --- score → medal ---
$medal = self::SCORE_MAP[$score] ?? null;
if ($medal === null) {
$stats['skipped_score']++;
$bar->advance();
continue;
}
// --- Artwork must exist in new DB ---
if (! isset($validArtworkIds[$artworkId])) {
$stats['skipped_artwork']++;
$bar->advance();
continue;
}
// --- User must exist in new DB ---
if (! isset($validUserIds[$userId])) {
$stats['skipped_user']++;
$bar->advance();
continue;
}
if (! $dryRun) {
if ($force) {
// Upsert: update medal if row already exists
$affected = DB::table('artwork_awards')
->where('artwork_id', $artworkId)
->where('user_id', $userId)
->update([
'medal' => $medal,
'weight' => ArtworkAward::WEIGHTS[$medal],
'updated_at' => $now,
]);
if ($affected > 0) {
$stats['updated_force']++;
$affectedArtworkIds[$artworkId] = true;
$bar->advance();
continue;
}
} else {
// Skip if already exists
if (
DB::table('artwork_awards')
->where('artwork_id', $artworkId)
->where('user_id', $userId)
->exists()
) {
$stats['skipped_duplicate']++;
$bar->advance();
continue;
}
}
$inserts[] = [
'artwork_id' => $artworkId,
'user_id' => $userId,
'medal' => $medal,
'weight' => ArtworkAward::WEIGHTS[$medal],
'created_at' => $postedAt,
'updated_at' => $postedAt,
];
$affectedArtworkIds[$artworkId] = true;
}
$stats['imported']++;
$bar->advance();
}
// Bulk insert the batch (DB::table bypasses the observer intentionally;
// stats are recalculated in bulk at the end for performance)
if (! $dryRun && ! empty($inserts)) {
try {
DB::table('artwork_awards')->insert($inserts);
} catch (\Throwable $e) {
// Fallback: insert one-by-one to isolate constraint violations
foreach ($inserts as $row) {
try {
DB::table('artwork_awards')->insertOrIgnore([$row]);
} catch (\Throwable) {
$stats['errors']++;
}
}
}
}
$skippedTotal = $stats['skipped_score']
+ $stats['skipped_artwork']
+ $stats['skipped_user']
+ $stats['skipped_duplicate'];
$bar->setMessage((string) $stats['imported'], 'imported');
$bar->setMessage((string) $skippedTotal, 'skipped');
});
$bar->finish();
$this->newLine(2);
// -------------------------------------------------------------------------
// Recalculate stats for every affected artwork
// -------------------------------------------------------------------------
if (! $dryRun && ! $skipStats && ! empty($affectedArtworkIds)) {
$artworkCount = count($affectedArtworkIds);
$this->info("Recalculating award stats for {$artworkCount} artworks…");
$statsBar = $this->output->createProgressBar($artworkCount);
$statsBar->start();
foreach (array_keys($affectedArtworkIds) as $artworkId) {
try {
$service->recalcStats($artworkId);
} catch (\Throwable $e) {
$this->newLine();
$this->warn("Stats recalc failed for artwork #{$artworkId}: {$e->getMessage()}");
}
$statsBar->advance();
}
$statsBar->finish();
$this->newLine(2);
}
// -------------------------------------------------------------------------
// Summary
// -------------------------------------------------------------------------
$this->table(
['Result', 'Count'],
[
['Imported (new rows)', $stats['imported']],
['Forced updates', $stats['updated_force']],
['Skipped bad score', $stats['skipped_score']],
['Skipped artwork gone', $stats['skipped_artwork']],
['Skipped user gone', $stats['skipped_user']],
['Skipped duplicate', $stats['skipped_duplicate']],
['Errors', $stats['errors']],
]
);
if ($dryRun) {
$this->warn('[DRY-RUN] Nothing was written. Re-run without --dry-run to apply.');
} else {
$this->info('Migration complete.');
}
return $stats['errors'] > 0 ? self::FAILURE : self::SUCCESS;
}
}

View File

@@ -0,0 +1,266 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Migrates legacy `artworks_comments` (projekti_old_skinbase) into `artwork_comments`.
*
* Column mapping:
* legacy.comment_id artwork_comments.legacy_id (idempotency key)
* legacy.artwork_id artwork_comments.artwork_id
* legacy.user_id artwork_comments.user_id
* legacy.description artwork_comments.content
* legacy.date + .time artwork_comments.created_at / updated_at
*
* Ignored legacy columns: owner, author (username strings), owner_user_id
*
* Usage:
* php artisan comments:import-legacy
* php artisan comments:import-legacy --dry-run
* php artisan comments:import-legacy --chunk=1000
* php artisan comments:import-legacy --allow-guest-user=0 (import rows where user_id maps to 0 / not found, assigning a fallback user_id)
*/
class ImportLegacyComments extends Command
{
protected $signature = 'comments:import-legacy
{--dry-run : Preview only no writes to DB}
{--chunk=500 : Rows to process per batch}
{--skip-empty : Skip comments with empty/whitespace-only content}';
protected $description = 'Import legacy artworks_comments into artwork_comments';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$chunk = max(1, (int) $this->option('chunk'));
$skipEmpty = (bool) $this->option('skip-empty');
if ($dryRun) {
$this->warn('[DRY-RUN] No data will be written.');
}
// Verify legacy connection
try {
DB::connection('legacy')->getPdo();
} catch (\Throwable $e) {
$this->error('Cannot connect to legacy database: ' . $e->getMessage());
return self::FAILURE;
}
if (! DB::connection('legacy')->getSchemaBuilder()->hasTable('artworks_comments')) {
$this->error('Legacy table `artworks_comments` not found.');
return self::FAILURE;
}
if (! DB::getSchemaBuilder()->hasColumn('artwork_comments', 'legacy_id')) {
$this->error('Column `legacy_id` missing from `artwork_comments`. Run: php artisan migrate');
return self::FAILURE;
}
// Pre-load valid artwork IDs and user IDs from new DB for O(1) lookup
$this->info('Loading new-DB artwork and user ID sets…');
$validArtworkIds = DB::table('artworks')
->whereNull('deleted_at')
->pluck('id')
->flip()
->all();
$validUserIds = DB::table('users')
->whereNull('deleted_at')
->pluck('id')
->flip()
->all();
$this->info(sprintf(
'Found %d artworks and %d users in new DB.',
count($validArtworkIds),
count($validUserIds)
));
// Already-imported legacy IDs (to resume safely)
$this->info('Loading already-imported legacy_ids…');
$alreadyImported = DB::table('artwork_comments')
->whereNotNull('legacy_id')
->pluck('legacy_id')
->flip()
->all();
$this->info(sprintf('%d comments already imported (will be skipped).', count($alreadyImported)));
$total = DB::connection('legacy')->table('artworks_comments')->count();
$this->info("Legacy rows to process: {$total}");
if ($total === 0) {
$this->warn('No legacy rows found. Nothing to do.');
return self::SUCCESS;
}
$stats = [
'imported' => 0,
'skipped_duplicate' => 0,
'skipped_artwork' => 0,
'skipped_user' => 0,
'skipped_empty' => 0,
'errors' => 0,
];
$bar = $this->output->createProgressBar($total);
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% | imported: %imported% | skipped: %skipped%');
$bar->setMessage('0', 'imported');
$bar->setMessage('0', 'skipped');
$bar->start();
DB::connection('legacy')
->table('artworks_comments')
->orderBy('comment_id')
->chunk($chunk, function ($rows) use (
&$stats,
&$alreadyImported,
$validArtworkIds,
$validUserIds,
$dryRun,
$skipEmpty,
$bar
) {
$inserts = [];
$now = now();
foreach ($rows as $row) {
$legacyId = (int) $row->comment_id;
$artworkId = (int) $row->artwork_id;
$userId = (int) $row->user_id;
$content = trim((string) ($row->description ?? ''));
// --- Already imported ---
if (isset($alreadyImported[$legacyId])) {
$stats['skipped_duplicate']++;
$bar->advance();
continue;
}
// --- Content ---
if ($skipEmpty && $content === '') {
$stats['skipped_empty']++;
$bar->advance();
continue;
}
// Replace empty content with a placeholder so NOT NULL is satisfied
if ($content === '') {
$content = '[no content]';
}
// --- Artwork must exist ---
if (! isset($validArtworkIds[$artworkId])) {
$stats['skipped_artwork']++;
$bar->advance();
continue;
}
// --- User must exist ---
if (! isset($validUserIds[$userId])) {
$stats['skipped_user']++;
$bar->advance();
continue;
}
// --- Build timestamp from separate date + time columns ---
$createdAt = $this->buildTimestamp($row->date, $row->time, $now);
if (! $dryRun) {
$inserts[] = [
'legacy_id' => $legacyId,
'artwork_id' => $artworkId,
'user_id' => $userId,
'content' => $content,
'is_approved' => 1,
'created_at' => $createdAt,
'updated_at' => $createdAt,
'deleted_at' => null,
];
$alreadyImported[$legacyId] = true;
}
$stats['imported']++;
$bar->advance();
}
if (! $dryRun && ! empty($inserts)) {
try {
DB::table('artwork_comments')->insert($inserts);
} catch (\Throwable $e) {
// Fallback: row-by-row with ignore on unique violations
foreach ($inserts as $row) {
try {
DB::table('artwork_comments')->insertOrIgnore([$row]);
} catch (\Throwable) {
$stats['errors']++;
}
}
}
}
$skippedTotal = $stats['skipped_duplicate']
+ $stats['skipped_artwork']
+ $stats['skipped_user']
+ $stats['skipped_empty'];
$bar->setMessage((string) $stats['imported'], 'imported');
$bar->setMessage((string) $skippedTotal, 'skipped');
});
$bar->finish();
$this->newLine(2);
// -------------------------------------------------------------------------
// Summary
// -------------------------------------------------------------------------
$this->table(
['Result', 'Count'],
[
['Imported', $stats['imported']],
['Skipped already imported', $stats['skipped_duplicate']],
['Skipped artwork gone', $stats['skipped_artwork']],
['Skipped user gone', $stats['skipped_user']],
['Skipped empty content', $stats['skipped_empty']],
['Errors', $stats['errors']],
]
);
if ($dryRun) {
$this->warn('[DRY-RUN] Nothing was written. Re-run without --dry-run to apply.');
} else {
$this->info('Migration complete.');
}
return $stats['errors'] > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* Combine a legacy `date` (DATE) and `time` (TIME) column into a single datetime string.
* Falls back to $fallback when both are null.
*/
private function buildTimestamp(mixed $date, mixed $time, \Illuminate\Support\Carbon $fallback): string
{
if (! $date) {
return $fallback->toDateTimeString();
}
$datePart = substr((string) $date, 0, 10); // '2000-09-13'
$timePart = $time ? substr((string) $time, 0, 8) : '00:00:00'; // '09:34:27'
// Sanity-check: MySQL TIME can be negative or > 24h for intervals — clamp to midnight
if (! preg_match('/^\d{2}:\d{2}:\d{2}$/', $timePart) || $timePart < '00:00:00') {
$timePart = '00:00:00';
}
return $datePart . ' ' . $timePart;
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\ArtworkSearchIndexer;
use Illuminate\Console\Command;
class RebuildArtworkSearchIndex extends Command
{
protected $signature = 'artworks:search-rebuild {--chunk=500 : Number of artworks per chunk}';
protected $description = 'Re-queue all artworks for Meilisearch indexing (non-blocking, chunk-based).';
public function __construct(private readonly ArtworkSearchIndexer $indexer)
{
parent::__construct();
}
public function handle(): int
{
$chunk = (int) $this->option('chunk');
$this->info("Dispatching index jobs in chunks of {$chunk}");
$this->indexer->rebuildAll($chunk);
$this->info('All jobs dispatched. Workers will process them asynchronously.');
return self::SUCCESS;
}
}