Files
larablog/app/Console/Commands/SablogImportCommand.php
T
ak 263b98b218 Initial baseline: LaraBlog core with plugin commerce surface.
Captures the current working tree after theme slots, ArticleAccess, and the payment / paid-content plugins so subsequent work has a reviewable git history.
2026-08-12 01:15:38 +08:00

474 lines
18 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Domain\Blog\ContentFormat;
use App\Domain\Blog\ContentRenderer;
use App\Models\Article;
use App\Models\Attachment;
use App\Models\Category;
use App\Models\Comment;
use App\Models\Link;
use App\Models\Stylevar;
use App\Models\Tag;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Throwable;
class SablogImportCommand extends Command
{
protected $signature = 'sablog:import
{--connection=sablog : Laravel DB connection name for the source sablog database}
{--prefix=sablog_ : Source table prefix}
{--attachments= : Absolute path to sablog attachments directory}
{--mode=raw : Import mode: raw (keep HTML) or markdown (convert + rewrite attach embeds)}
{--encoding=auto : utf8|gbk|auto}
{--disk=attachments : Target attachments disk}
{--attachments-url-prefix=attachments : Legacy URL prefix}
{--retry-failed : Retry attachment rows that failed previously}
{--dry-run : Do not write to the target database or object storage}';
protected $description = 'Import sablog content. Modes: raw (HTML) or markdown (converted).';
/** @var array<string, int> */
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<Model> $modelClass
* @param array<string, mixed> $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;
}
}