502 lines
19 KiB
PHP
502 lines
19 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->ensureCategoryExists((int) $row->cid);
|
|
|
|
$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;
|
|
}
|
|
|
|
/** @var array<int, bool> */
|
|
protected array $ensuredCategories = [];
|
|
|
|
/**
|
|
* 老库里可能存在已删除分类的孤儿文章,为其补一个占位分类以满足外键约束。
|
|
*/
|
|
protected function ensureCategoryExists(int $cid): void
|
|
{
|
|
if ($cid <= 0 || isset($this->ensuredCategories[$cid])) {
|
|
return;
|
|
}
|
|
|
|
$this->ensuredCategories[$cid] = true;
|
|
|
|
if (Category::query()->whereKey($cid)->exists()) {
|
|
return;
|
|
}
|
|
|
|
$this->upsertWithId(Category::class, $cid, [
|
|
'name' => "已删除分类 {$cid}",
|
|
'display_order' => 9999,
|
|
'articles_count' => 0,
|
|
]);
|
|
$this->warn("Category #{$cid} missing in source; placeholder created for orphan articles.");
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|