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:
436
app/Console/Commands/AiTagArtworksCommand.php
Normal file
436
app/Console/Commands/AiTagArtworksCommand.php
Normal 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 1–4 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);
|
||||
}
|
||||
}
|
||||
92
app/Console/Commands/FixTagNamesCommand.php
Normal file
92
app/Console/Commands/FixTagNamesCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
288
app/Console/Commands/ImportLegacyAwards.php
Normal file
288
app/Console/Commands/ImportLegacyAwards.php
Normal 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;
|
||||
}
|
||||
}
|
||||
266
app/Console/Commands/ImportLegacyComments.php
Normal file
266
app/Console/Commands/ImportLegacyComments.php
Normal 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;
|
||||
}
|
||||
}
|
||||
30
app/Console/Commands/RebuildArtworkSearchIndex.php
Normal file
30
app/Console/Commands/RebuildArtworkSearchIndex.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use App\Console\Commands\BackfillArtworkEmbeddingsCommand;
|
||||
use App\Console\Commands\AggregateSimilarArtworkAnalyticsCommand;
|
||||
use App\Console\Commands\AggregateFeedAnalyticsCommand;
|
||||
use App\Console\Commands\EvaluateFeedWeightsCommand;
|
||||
use App\Console\Commands\AiTagArtworksCommand;
|
||||
use App\Console\Commands\CompareFeedAbCommand;
|
||||
use App\Uploads\Commands\CleanupUploadsCommand;
|
||||
|
||||
@@ -33,6 +34,7 @@ class Kernel extends ConsoleKernel
|
||||
AggregateFeedAnalyticsCommand::class,
|
||||
EvaluateFeedWeightsCommand::class,
|
||||
CompareFeedAbCommand::class,
|
||||
AiTagArtworksCommand::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user