*/ protected array $missingUserIds = []; public function __construct() { parent::__construct(); $this->logPath = storage_path('logs/forum_migration.log'); } public function handle(): int { $this->info('Starting forum migration'); $this->log('Starting forum migration'); $dry = $this->option('dry-run'); $only = $this->option('only'); $chunk = (int)$this->option('chunk'); $this->limit = $this->option('limit') !== null ? max(0, (int) $this->option('limit')) : null; $only = $only === 'attachments' ? 'gallery' : $only; if ($only && !in_array($only, ['categories', 'threads', 'posts', 'gallery', 'repair-orphans'], true)) { $this->error('Invalid --only value. Allowed: categories, threads, posts, gallery (or attachments), repair-orphans.'); return 1; } if ($chunk < 1) { $chunk = 500; } try { if (!$only || $only === 'categories') { $this->migrateCategories($dry); } if (!$only || $only === 'threads') { $this->migrateThreads($dry, $chunk); } if (!$only || $only === 'posts') { $this->migratePosts($dry, $chunk); } if (!$only || $only === 'gallery') { $this->migrateGallery($dry, $chunk); } if ($this->option('repair-orphans') || $only === 'repair-orphans') { $this->repairOrphanPosts($dry); } if ($this->option('report')) { $this->generateReport(); } $this->info('Forum migration finished'); $this->log('Forum migration finished'); return 0; } catch (Exception $e) { $this->error('Migration failed: ' . $e->getMessage()); $this->log('Migration failed: ' . $e->getMessage()); return 1; } } protected function migrateCategories(bool $dry) { $this->info('Migrating categories'); $legacy = DB::connection('legacy'); $roots = $legacy->table('forum_topics') ->select('root_id') ->distinct() ->where('root_id', '>', 0) ->orderBy('root_id') ->pluck('root_id'); if ($this->limit !== null && $this->limit > 0) { $roots = $roots->take($this->limit); } $this->info('Found ' . $roots->count() . ' legacy root ids'); foreach ($roots as $rootId) { $row = $legacy->table('forum_topics')->where('topic_id', $rootId)->first(); $name = $row->topic ?? 'Category ' . $rootId; $slug = Str::slug(substr($name, 0, 150)); $this->line("-> root {$rootId}: {$name}"); if ($dry) { $this->log("[dry] create category {$name} ({$slug})"); continue; } DB::transaction(function () use ($rootId, $name, $slug) { ForumCategory::updateOrCreate( ['id' => $rootId], ['name' => $name, 'slug' => $slug] ); }, 3); } $this->info('Categories migrated'); } protected function migrateThreads(bool $dry, int $chunk) { $this->info('Migrating threads'); $legacy = DB::connection('legacy'); $query = $legacy->table('forum_topics')->orderBy('topic_id'); $total = $query->count(); if ($this->limit !== null && $this->limit > 0) { $total = min($total, $this->limit); } $this->info("Total threads to process: {$total}"); $bar = $this->output->createProgressBar($total); $bar->start(); $processed = 0; $limit = $this->limit; // chunk by legacy primary key `topic_id` $query->chunkById($chunk, function ($rows) use ($dry, $bar, &$processed, $limit) { foreach ($rows as $r) { if ($limit !== null && $limit > 0 && $processed >= $limit) { return false; } $bar->advance(); $processed++; $data = [ 'id' => $r->topic_id, 'category_id' => $this->resolveCategoryId($r->root_id ?? null, $r->topic_id), // resolve user id or assign to system user (1) when missing or not found 'user_id' => $this->resolveUserId($r->user_id ?? null), 'title' => $r->topic, 'slug' => $this->uniqueSlug(Str::slug(substr($r->topic,0,200)) ?: 'thread-'.$r->topic_id, $r->topic_id), 'content' => $r->preview ?? '', 'views' => $r->views ?? 0, 'is_locked' => isset($r->open) ? !((bool)$r->open) : false, 'is_pinned' => false, 'visibility' => $this->mapPrivilegeToVisibility($r->privilege ?? 0), 'last_post_at' => $this->normalizeDate($r->last_update ?? null), ]; if ($dry) { $this->log('[dry] thread: ' . $r->topic_id . ' - ' . $r->topic); continue; } DB::transaction(function () use ($data) { ForumThread::updateOrCreate(['id' => $data['id']], $data); }, 3); } }, 'topic_id'); $bar->finish(); $this->line(''); $this->info('Threads migrated'); } protected function migratePosts(bool $dry, int $chunk) { $this->info('Migrating posts'); $legacy = DB::connection('legacy'); $query = $legacy->table('forum_posts')->orderBy('post_id'); $total = $query->count(); if ($this->limit !== null && $this->limit > 0) { $total = min($total, $this->limit); } $this->info("Total posts to process: {$total}"); $bar = $this->output->createProgressBar($total); $bar->start(); $processed = 0; $limit = $this->limit; // legacy forum_posts uses `post_id` as primary key $query->chunkById($chunk, function ($rows) use ($dry, $bar, &$processed, $limit) { foreach ($rows as $r) { if ($limit !== null && $limit > 0 && $processed >= $limit) { return false; } $bar->advance(); $processed++; $data = [ 'id' => $r->post_id, 'thread_id' => $r->topic_id, 'user_id' => $r->user_id ?? null, 'content' => $this->convertLegacyMessage($r->message ?? ''), 'is_edited' => isset($r->isupdated) ? (bool)$r->isupdated : false, 'edited_at' => $r->updated ?? null, ]; if ($dry) { $this->log('[dry] post: ' . $r->post_id); continue; } DB::transaction(function () use ($data) { ForumPost::updateOrCreate(['id' => $data['id']], $data); }, 3); } }, 'post_id'); $bar->finish(); $this->line(''); $this->info('Posts migrated'); } protected function mapPrivilegeToVisibility($priv) { // legacy privilege: 0 public, 1 members, 4 staff? adjust mapping conservatively if ($priv >= 4) return 'staff'; if ($priv >= 1) return 'members'; return 'public'; } protected function normalizeDate($val) { if (empty($val)) return null; $s = trim((string)$val); // legacy sometimes contains sentinel invalid dates like -0001-11-30 or zero dates if (strpos($s, '-0001') !== false) return null; if (strpos($s, '0000-00-00') !== false) return null; if (strtotime($s) === false) return null; return date('Y-m-d H:i:s', strtotime($s)); } protected function uniqueSlug(string $base, int $id) { $slug = $base; $i = 0; while (ForumThread::where('slug', $slug)->where('id', '<>', $id)->exists()) { $i++; $slug = $base . '-' . $id; // if somehow still exists, append counter if (ForumThread::where('slug', $slug)->where('id', '<>', $id)->exists()) { $slug = $base . '-' . $id . '-' . $i; } } return $slug; } protected function resolveCategoryId($rootId, $topicId) { // prefer explicit rootId if (!empty($rootId)) { // ensure category exists if (ForumCategory::where('id', $rootId)->exists()) return $rootId; } // if this topic itself is a category if (ForumCategory::where('id', $topicId)->exists()) return $topicId; // fallback: use first available category $first = ForumCategory::first(); if ($first) return $first->id; // as last resort, create Uncategorized $cat = ForumCategory::create(['name' => 'Uncategorized', 'slug' => 'uncategorized']); return $cat->id; } protected function resolveUserId($userId) { if (empty($userId)) { return $this->resolveDeletedUserId(); } // check users table in default connection if (DB::table('users')->where('id', $userId)->exists()) { return $userId; } $uid = (int) $userId; if ($uid > 0 && !in_array($uid, $this->missingUserIds, true)) { $this->missingUserIds[] = $uid; } return $this->resolveDeletedUserId(); } protected function resolveDeletedUserId(): int { if ($this->deletedUserId !== null) { return $this->deletedUserId; } $userOne = User::query()->find(1); if ($userOne) { $this->deletedUserId = 1; return $this->deletedUserId; } $fallback = User::query()->orderBy('id')->first(); if ($fallback) { $this->deletedUserId = (int) $fallback->id; return $this->deletedUserId; } $created = User::query()->create([ 'name' => 'Deleted User', 'email' => 'deleted-user+forum@skinbase.local', 'password' => Hash::make(Str::random(64)), 'role' => 'user', ]); $this->deletedUserId = (int) $created->id; return $this->deletedUserId; } protected function convertLegacyMessage($msg) { $converter = new BbcodeConverter(); return $converter->convert($msg); } protected function repairOrphanPosts(bool $dry): void { $this->info('Repairing orphan posts'); $orphansQuery = ForumPost::query()->whereDoesntHave('thread')->orderBy('id'); $orphanCount = (clone $orphansQuery)->count(); if ($orphanCount === 0) { $this->info('No orphan posts found.'); return; } $this->warn("Found {$orphanCount} orphan posts."); $repairThread = $this->resolveOrCreateOrphanRepairThread($dry); if ($repairThread === null) { $this->warn('Unable to resolve/create repair thread in dry-run mode. Reporting only.'); (clone $orphansQuery)->limit(20)->get(['id', 'thread_id', 'user_id', 'created_at'])->each(function (ForumPost $post): void { $this->line("- orphan post id={$post->id} thread_id={$post->thread_id} user_id={$post->user_id}"); }); return; } $this->line("Repair target thread: {$repairThread->id} ({$repairThread->slug})"); if ($dry) { $this->info("[dry] Would reassign {$orphanCount} orphan posts to thread {$repairThread->id}"); (clone $orphansQuery)->limit(20)->get(['id', 'thread_id', 'user_id', 'created_at'])->each(function (ForumPost $post) use ($repairThread): void { $this->log("[dry] orphan post {$post->id}: {$post->thread_id} -> {$repairThread->id}"); }); return; } $updated = 0; (clone $orphansQuery)->chunkById(500, function ($posts) use ($repairThread, &$updated): void { DB::transaction(function () use ($posts, $repairThread, &$updated): void { /** @var ForumPost $post */ foreach ($posts as $post) { $post->thread_id = $repairThread->id; $post->is_edited = true; $post->edited_at = $post->edited_at ?: now(); $post->save(); $updated++; } }, 3); }, 'id'); $latestPostAt = ForumPost::query() ->where('thread_id', $repairThread->id) ->max('created_at'); if ($latestPostAt) { $repairThread->last_post_at = $latestPostAt; $repairThread->save(); } $this->info("Repaired orphan posts: {$updated}"); $this->log("Repaired orphan posts: {$updated} -> thread {$repairThread->id}"); } protected function resolveOrCreateOrphanRepairThread(bool $dry): ?ForumThread { $slug = 'migration-orphaned-posts'; $existing = ForumThread::query()->where('slug', $slug)->first(); if ($existing) { return $existing; } $category = ForumCategory::query()->ordered()->first(); if (!$category && !$dry) { $category = ForumCategory::query()->create([ 'name' => 'Migration Repairs', 'slug' => 'migration-repairs', 'parent_id' => null, 'position' => 9999, ]); } if (!$category) { return null; } if ($dry) { return new ForumThread([ 'id' => 0, 'slug' => $slug, 'category_id' => $category->id, ]); } return ForumThread::query()->create([ 'category_id' => $category->id, 'user_id' => $this->resolveDeletedUserId(), 'title' => 'Migration: Orphaned Posts Recovery', 'slug' => $slug, 'content' => 'Automatic recovery thread for legacy posts whose source thread no longer exists after migration.', 'views' => 0, 'is_locked' => false, 'is_pinned' => false, 'visibility' => 'staff', 'last_post_at' => now(), ]); } protected function generateReport() { $this->info('Generating migration report'); $legacy = DB::connection('legacy'); $legacyCounts = [ 'categories' => $legacy->table('forum_topics')->where('root_id','>',0)->distinct('root_id')->count('root_id'), 'threads' => $legacy->table('forum_topics')->count(), 'posts' => $legacy->table('forum_posts')->count(), ]; $newCounts = [ 'categories' => ForumCategory::count(), 'threads' => ForumThread::count(), 'posts' => ForumPost::count(), 'attachments' => DB::table('forum_attachments')->count(), ]; $orphans = ForumPost::query() ->whereDoesntHave('thread') ->count(); $legacyThreadsWithLastUpdate = $legacy->table('forum_topics')->whereNotNull('last_update')->count(); $newThreadsWithLastPost = ForumThread::query()->whereNotNull('last_post_at')->count(); $legacyPostsWithPostDate = $legacy->table('forum_posts')->whereNotNull('post_date')->count(); $newPostsWithCreatedAt = ForumPost::query()->whereNotNull('created_at')->count(); $report = [ 'missing_users_count' => count($this->missingUserIds), 'missing_users' => $this->missingUserIds, 'orphan_posts' => $orphans, 'timestamp_mismatches' => [ 'threads_last_post_gap' => max(0, $legacyThreadsWithLastUpdate - $newThreadsWithLastPost), 'posts_created_at_gap' => max(0, $legacyPostsWithPostDate - $newPostsWithCreatedAt), ], ]; $this->info('Legacy counts: ' . json_encode($legacyCounts)); $this->info('New counts: ' . json_encode($newCounts)); $this->info('Report: ' . json_encode($report)); $this->log('Report: legacy=' . json_encode($legacyCounts) . ' new=' . json_encode($newCounts) . ' extra=' . json_encode($report)); } protected function log(string $msg) { $line = '[' . date('c') . '] ' . $msg . "\n"; file_put_contents($this->logPath, $line, FILE_APPEND | LOCK_EX); } protected function migrateGallery(bool $dry, int $chunk) { $this->info('Migrating gallery (forum_topics_gallery → forum_attachments)'); $legacy = DB::connection('legacy'); if (!$legacy->getSchemaBuilder()->hasTable('forum_topics_gallery')) { $this->info('No legacy forum_topics_gallery table found, skipping'); return; } $query = $legacy->table('forum_topics_gallery')->orderBy('id'); $total = $query->count(); if ($this->limit !== null && $this->limit > 0) { $total = min($total, $this->limit); } $this->info("Total gallery items to process: {$total}"); $bar = $this->output->createProgressBar($total); $bar->start(); $processed = 0; $limit = $this->limit; $query->chunkById($chunk, function ($rows) use ($dry, $bar, &$processed, $limit) { foreach ($rows as $r) { if ($limit !== null && $limit > 0 && $processed >= $limit) { return false; } $bar->advance(); $processed++; // expected legacy fields: id, name, category (topic id), folder, datum, description $topicId = $r->category ?? ($r->topic_id ?? null); $fileName = $r->name ?? null; if (empty($topicId) || empty($fileName)) { $this->log('Skipping gallery row with missing topic or name: ' . json_encode($r)); continue; } $nid = floor($topicId / 100); $relativePath = "files/news/{$nid}/{$topicId}/{$fileName}"; $publicPath = public_path($relativePath); $fileSize = null; $mimeType = null; $width = null; $height = null; if (file_exists($publicPath)) { $fileSize = filesize($publicPath); $img = @getimagesize($publicPath); if ($img !== false) { $width = $img[0]; $height = $img[1]; $mimeType = $img['mime'] ?? null; } else { $finfo = finfo_open(FILEINFO_MIME_TYPE); $mimeType = finfo_file($finfo, $publicPath); finfo_close($finfo); } } // find legacy first post id for this topic $legacy = DB::connection('legacy'); $firstPostId = $legacy->table('forum_posts') ->where('topic_id', $topicId) ->orderBy('post_date') ->value('post_id'); // map to new forum_posts id (we preserved ids when migrating) $postId = null; if ($firstPostId && \App\Models\ForumPost::where('id', $firstPostId)->exists()) { $postId = $firstPostId; } else { // fallback: find any post in new DB for thread $post = \App\Models\ForumPost::where('thread_id', $topicId)->orderBy('created_at')->first(); if ($post) $postId = $post->id; } if (empty($postId)) { $this->log('No target post found for gallery item: topic ' . $topicId . ' file ' . $fileName); continue; } if ($dry) { $this->log("[dry] attach {$relativePath} -> post {$postId}"); continue; } DB::transaction(function () use ($postId, $relativePath, $fileSize, $mimeType, $width, $height) { \App\Models\ForumAttachment::query()->updateOrCreate( [ 'post_id' => $postId, 'file_path' => $relativePath, ], [ 'file_size' => $fileSize ?? 0, 'mime_type' => $mimeType, 'width' => $width, 'height' => $height, 'updated_at' => now(), ] ); }, 3); } }, 'id'); $bar->finish(); $this->line(''); $this->info('Gallery migrated'); } }