*/ protected array $report = [ 'users' => 0, 'categories' => 0, 'articles' => 0, 'comments' => 0, 'tags' => 0, 'links' => 0, 'stylevars' => 0, 'attachments_ok' => 0, 'attachments_missing' => 0, 'attachments_failed' => 0, 'skipped_trackbacks' => 0, 'skipped_searchindex' => 0, 'skipped_sessions' => 0, ]; public function handle(ContentRenderer $renderer): int { $mode = strtolower((string) $this->option('mode')); if (! in_array($mode, ['raw', 'markdown'], true)) { $this->error('--mode must be raw or markdown'); return self::FAILURE; } $connection = (string) $this->option('connection'); $prefix = (string) $this->option('prefix'); $attachmentsRoot = $this->option('attachments'); $disk = (string) $this->option('disk'); $dryRun = (bool) $this->option('dry-run'); $encoding = (string) $this->option('encoding'); if (! config("database.connections.{$connection}")) { $this->error("Database connection [{$connection}] is not configured. Add it to config/database.php / .env (SABLOG_DB_*)."); return self::FAILURE; } $this->info("Import mode: {$mode}".($dryRun ? ' (dry-run)' : '')); try { $source = DB::connection($connection); $source->select('select 1'); } catch (Throwable $e) { $this->error('Cannot connect to sablog database: '.$e->getMessage()); return self::FAILURE; } $this->reportSkipped($source, $prefix); if (! $dryRun) { DB::transaction(function () use ($source, $prefix, $encoding, $mode, $renderer, $attachmentsRoot, $disk) { $this->importUsers($source, $prefix, $encoding); $this->importCategories($source, $prefix, $encoding); $this->importArticles($source, $prefix, $encoding, $mode, $renderer); $this->importComments($source, $prefix, $encoding); $this->importTags($source, $prefix, $encoding); $this->importLinks($source, $prefix, $encoding); $this->importStylevars($source, $prefix, $encoding); $this->importAttachments($source, $prefix, $attachmentsRoot, $disk); }); } else { $this->importUsers($source, $prefix, $encoding, true); $this->importCategories($source, $prefix, $encoding, true); $this->importArticles($source, $prefix, $encoding, $mode, $renderer, true); $this->countTable($source, $prefix.'comments', 'comments'); $this->countTable($source, $prefix.'tags', 'tags'); $this->countTable($source, $prefix.'links', 'links'); $this->countTable($source, $prefix.'stylevars', 'stylevars'); $this->countTable($source, $prefix.'attachments', 'attachments_ok'); } $this->table(array_keys($this->report), [array_values($this->report)]); $this->info('Done. Source database/files were not modified.'); return self::SUCCESS; } protected function reportSkipped($source, string $prefix): void { foreach ([ 'trackbacks' => 'skipped_trackbacks', 'trackbacklog' => 'skipped_trackbacks', 'searchindex' => 'skipped_searchindex', 'sessions' => 'skipped_sessions', ] as $table => $key) { $name = $prefix.$table; if ($this->sourceTableExists($source, $name)) { $this->report[$key] += (int) $source->table($name)->count(); } } } protected function importUsers($source, string $prefix, string $encoding, bool $countOnly = false): void { $table = $prefix.'users'; if (! $this->sourceTableExists($source, $table)) { return; } foreach ($source->table($table)->orderBy('userid')->cursor() as $row) { $this->report['users']++; if ($countOnly) { continue; } $this->upsertWithId(User::class, (int) $row->userid, [ 'name' => $this->decode((string) $row->username, $encoding), 'username' => $this->decode((string) $row->username, $encoding), 'email' => null, 'password' => Hash::make(Str::password(32)), 'password_legacy' => (string) $row->password, 'url' => $this->decode((string) ($row->url ?? ''), $encoding) ?: null, 'login_count' => (int) ($row->logincount ?? 0), 'login_ip' => $row->loginip ?? null, 'login_at' => $this->fromUnix($row->logintime ?? null), 'reg_ip' => $row->regip ?? null, 'created_at' => $this->fromUnix($row->regdateline ?? null) ?? now(), 'updated_at' => now(), ]); } } protected function importCategories($source, string $prefix, string $encoding, bool $countOnly = false): void { $table = $prefix.'categories'; if (! $this->sourceTableExists($source, $table)) { return; } foreach ($source->table($table)->orderBy('cid')->cursor() as $row) { $this->report['categories']++; if ($countOnly) { continue; } $this->upsertWithId(Category::class, (int) $row->cid, [ 'name' => $this->decode((string) $row->name, $encoding), 'display_order' => (int) ($row->displayorder ?? 0), 'articles_count' => (int) ($row->articles ?? 0), ]); } } protected function importArticles($source, string $prefix, string $encoding, string $mode, ContentRenderer $renderer, bool $countOnly = false): void { $table = $prefix.'articles'; if (! $this->sourceTableExists($source, $table)) { return; } foreach ($source->table($table)->orderBy('articleid')->cursor() as $row) { $this->report['articles']++; if ($countOnly) { continue; } $content = $this->decode((string) $row->content, $encoding); $format = ContentFormat::HTML; if ($mode === 'markdown') { $content = $renderer->convertImportedHtmlToMarkdown($content, (int) $row->articleid); $format = ContentFormat::MARKDOWN; } $legacyAttachments = null; if (! empty($row->attachments)) { $legacyAttachments = @unserialize($row->attachments); if ($legacyAttachments === false) { $legacyAttachments = ['raw' => $row->attachments]; } } $this->upsertWithId(Article::class, (int) $row->articleid, [ 'category_id' => (int) $row->cid, 'user_id' => (int) $row->uid, 'title' => $this->decode((string) $row->title, $encoding), 'content' => $content, 'content_format' => $format, 'description' => $this->decode((string) ($row->description ?? ''), $encoding) ?: null, 'keywords' => $this->decode((string) ($row->keywords ?? ''), $encoding) ?: null, 'published_at' => $this->fromUnix($row->dateline ?? null), 'views' => (int) ($row->views ?? 0), 'comments_count' => (int) ($row->comments ?? 0), 'stick' => (bool) ($row->stick ?? false), 'visible' => (bool) ($row->visible ?? true), 'close_comment' => (bool) ($row->closecomment ?? false), 'read_password' => ($row->readpassword ?? '') !== '' ? (string) $row->readpassword : null, 'legacy_attachments' => $legacyAttachments, ]); } } protected function importComments($source, string $prefix, string $encoding): void { $table = $prefix.'comments'; if (! $this->sourceTableExists($source, $table)) { return; } foreach ($source->table($table)->orderBy('commentid')->cursor() as $row) { $this->report['comments']++; // Avoid plugin hooks (e.g. AI moderation) rewriting imported statuses / flooding queues. Comment::withoutEvents(function () use ($row, $encoding): void { $this->upsertWithId(Comment::class, (int) $row->commentid, [ 'article_id' => (int) $row->articleid, 'author' => $this->decode((string) $row->author, $encoding), 'url' => $this->decode((string) ($row->url ?? ''), $encoding) ?: null, 'content' => $this->decode((string) $row->content, $encoding), 'ip' => $row->ipaddress ?? null, 'moderation_status' => ((int) ($row->visible ?? 0)) === 1 ? Comment::STATUS_APPROVED : Comment::STATUS_PENDING, 'published_at' => $this->fromUnix($row->dateline ?? null), ]); }); } } protected function importTags($source, string $prefix, string $encoding): void { $table = $prefix.'tags'; if (! $this->sourceTableExists($source, $table)) { return; } foreach ($source->table($table)->orderBy('tagid')->cursor() as $row) { $this->report['tags']++; $tag = $this->upsertWithId(Tag::class, (int) $row->tagid, [ 'name' => $this->decode((string) $row->tag, $encoding), 'use_count' => (int) ($row->usenum ?? 0), ]); $ids = preg_split('/\s*,\s*/', (string) ($row->aids ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: []; $articleIds = collect($ids)->map(fn ($id) => (int) $id)->filter()->unique()->values()->all(); $tag->articles()->sync($articleIds); } } protected function importLinks($source, string $prefix, string $encoding): void { $table = $prefix.'links'; if (! $this->sourceTableExists($source, $table)) { return; } foreach ($source->table($table)->orderBy('linkid')->cursor() as $row) { $this->report['links']++; $this->upsertWithId(Link::class, (int) $row->linkid, [ 'name' => $this->decode((string) $row->name, $encoding), 'url' => (string) $row->url, 'note' => $this->decode((string) ($row->note ?? ''), $encoding) ?: null, 'display_order' => (int) ($row->displayorder ?? 0), 'visible' => (bool) ($row->visible ?? true), ]); } } protected function importStylevars($source, string $prefix, string $encoding): void { $table = $prefix.'stylevars'; if (! $this->sourceTableExists($source, $table)) { return; } foreach ($source->table($table)->orderBy('stylevarid')->cursor() as $row) { $this->report['stylevars']++; $this->upsertWithId(Stylevar::class, (int) $row->stylevarid, [ 'title' => $this->decode((string) $row->title, $encoding), 'value' => $this->decode((string) ($row->value ?? ''), $encoding), 'visible' => (bool) ($row->visible ?? true), ]); } } protected function importAttachments($source, string $prefix, ?string $attachmentsRoot, string $disk): void { $table = $prefix.'attachments'; if (! $this->sourceTableExists($source, $table)) { return; } $retryFailed = (bool) $this->option('retry-failed'); foreach ($source->table($table)->orderBy('attachmentid')->cursor() as $row) { $id = (int) $row->attachmentid; $legacy = ltrim(str_replace('\\', '/', (string) $row->filepath), '/'); $existing = Attachment::query()->find($id); if ($existing && $existing->synced_at && ! $retryFailed) { $this->report['attachments_ok']++; continue; } $local = $attachmentsRoot ? rtrim($attachmentsRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $legacy) : null; $articleId = (int) ($row->articleid ?? 0) ?: null; $filename = (string) ($row->filename ?: basename($legacy)); $key = sprintf( 'attachments/%s/%d/%s.%s', $articleId ?: 'orphan', $id, substr(hash('sha256', $legacy.$id), 0, 16), pathinfo($filename, PATHINFO_EXTENSION) ?: 'bin' ); $payload = [ 'article_id' => $articleId, 'disk' => $disk, 'path' => $key, 'thumb_path' => null, 'filename' => $filename, 'mime' => $row->filetype ?: null, 'size' => (int) ($row->filesize ?? 0), 'checksum' => null, 'visibility' => Attachment::VISIBILITY_PUBLIC, 'legacy_filepath' => $legacy, 'downloads' => (int) ($row->downloads ?? 0), 'synced_at' => null, ]; if (! $local || ! is_file($local)) { $this->report['attachments_missing']++; $this->upsertWithId(Attachment::class, $id, $payload); continue; } try { $payload['checksum'] = hash_file('sha256', $local) ?: null; $payload['size'] = filesize($local) ?: $payload['size']; $payload['mime'] = mime_content_type($local) ?: $payload['mime']; $stream = fopen($local, 'r'); Storage::disk($disk)->put($key, $stream, ['visibility' => 'public']); if (is_resource($stream)) { fclose($stream); } $thumbLegacy = ltrim(str_replace('\\', '/', (string) ($row->thumb_filepath ?? '')), '/'); if ($attachmentsRoot && $thumbLegacy !== '') { $thumbLocal = rtrim($attachmentsRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $thumbLegacy); if (is_file($thumbLocal)) { $thumbKey = preg_replace('/(\.[^.]+)?$/', '_thumb$1', $key) ?: $key.'_thumb'; $thumbStream = fopen($thumbLocal, 'r'); Storage::disk($disk)->put($thumbKey, $thumbStream, ['visibility' => 'public']); if (is_resource($thumbStream)) { fclose($thumbStream); } $payload['thumb_path'] = $thumbKey; } } $payload['synced_at'] = now(); $this->upsertWithId(Attachment::class, $id, $payload); $this->report['attachments_ok']++; } catch (Throwable $e) { $this->report['attachments_failed']++; $this->warn("Attachment #{$id} failed: ".$e->getMessage()); $this->upsertWithId(Attachment::class, $id, $payload); } } } /** * @param class-string $modelClass * @param array $values */ protected function upsertWithId(string $modelClass, int $id, array $values): Model { /** @var Model $model */ $model = $modelClass::query()->find($id) ?? new $modelClass; $model->forceFill(['id' => $id] + $values)->save(); return $model; } protected function countTable($source, string $table, string $key): void { if ($this->sourceTableExists($source, $table)) { $this->report[$key] = (int) $source->table($table)->count(); } } protected function sourceTableExists($source, string $table): bool { try { return Schema::connection($source->getName())->hasTable($table); } catch (Throwable) { return false; } } protected function decode(string $value, string $encoding): string { if ($value === '') { return $value; } $encoding = strtolower($encoding); if ($encoding === 'utf8' || $encoding === 'utf-8') { return $value; } if ($encoding === 'gbk' || $encoding === 'gb2312') { return mb_convert_encoding($value, 'UTF-8', 'GBK'); } if (! mb_check_encoding($value, 'UTF-8')) { $converted = @mb_convert_encoding($value, 'UTF-8', 'GBK'); return $converted !== false ? $converted : $value; } return $value; } protected function fromUnix(mixed $timestamp): ?\Illuminate\Support\Carbon { $ts = (int) $timestamp; return $ts > 0 ? \Illuminate\Support\Carbon::createFromTimestamp($ts) : null; } }