feat(cms): import legacy article documents into document library

This commit is contained in:
2026-02-11 09:15:19 +08:00
parent 4e7ef92d0b
commit ec2b1d21f4
2 changed files with 520 additions and 0 deletions

View File

@@ -0,0 +1,390 @@
<?php
namespace App\Console\Commands;
use App\Models\Article;
use App\Models\ArticleAttachment;
use App\Models\AuditLog;
use App\Models\Document;
use App\Models\DocumentCategory;
use App\Models\DocumentVersion;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Ramsey\Uuid\Uuid;
class ImportArticleDocuments extends Command
{
protected $signature = 'articles:import-documents
{--category-slug= : Fallback document category slug}
{--fallback-user-id=1 : Fallback user ID when source article has no valid creator}
{--include-unpublished : Also import draft/archived article documents}
{--mark-archived : Mark source articles as archived after successful import}
{--limit=0 : Limit number of articles to import (0 means no limit)}
{--dry-run : Preview actions without writing data}';
protected $description = 'Import legacy article documents into document library for unified CRUD management';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$includeUnpublished = (bool) $this->option('include-unpublished');
$markArchived = (bool) $this->option('mark-archived');
$limit = max((int) $this->option('limit'), 0);
$fallbackUser = User::find((int) $this->option('fallback-user-id'));
if (! $fallbackUser) {
$this->error('Fallback user not found.');
return self::FAILURE;
}
$fallbackCategory = $this->resolveFallbackCategory($this->option('category-slug'));
if (! $fallbackCategory) {
$this->error('No document category found. Please create one or pass --category-slug.');
return self::FAILURE;
}
$query = Article::query()
->with(['attachments', 'categories', 'creator'])
->where('content_type', Article::CONTENT_TYPE_DOCUMENT);
if (! $includeUnpublished) {
$query->where('status', Article::STATUS_PUBLISHED);
}
if ($limit > 0) {
$query->limit($limit);
}
$articles = $query->orderBy('id')->get();
if ($articles->isEmpty()) {
$this->info('No article documents matched the import criteria.');
return self::SUCCESS;
}
$this->info('Article document import started');
$this->line('Matched articles: '.$articles->count());
$this->line('Fallback category: '.$fallbackCategory->slug);
$this->line('Dry run: '.($dryRun ? 'yes' : 'no'));
$this->line('Mark source archived: '.($markArchived ? 'yes' : 'no'));
$this->newLine();
$imported = 0;
$skipped = 0;
$failed = 0;
$archivedSources = 0;
foreach ($articles as $article) {
try {
$result = $this->importSingleArticle(
article: $article,
fallbackCategory: $fallbackCategory,
fallbackUser: $fallbackUser,
dryRun: $dryRun,
markArchived: $markArchived
);
if ($result === 'imported') {
$imported++;
} elseif ($result === 'archived_source') {
$imported++;
$archivedSources++;
} else {
$skipped++;
}
} catch (\Throwable $e) {
$failed++;
$this->error("{$article->slug}: {$e->getMessage()}");
}
}
$this->newLine();
$this->info('Import finished');
$this->line("Imported: {$imported}");
$this->line("Skipped: {$skipped}");
$this->line("Failed: {$failed}");
if ($markArchived) {
$this->line("Source articles archived: {$archivedSources}");
}
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
private function importSingleArticle(
Article $article,
DocumentCategory $fallbackCategory,
User $fallbackUser,
bool $dryRun,
bool $markArchived
): string {
$publicUuid = $this->deterministicPublicUuid($article);
$existing = Document::where('public_uuid', $publicUuid)->first();
if ($existing) {
$this->warn("{$article->slug}: already imported as document #{$existing->id}, skipping.");
return 'skipped';
}
$category = $this->resolveCategoryForArticle($article, $fallbackCategory);
$actor = $article->creator ?: $fallbackUser;
$accessLevel = $this->normalizeAccessLevel($article->access_level);
$documentStatus = $article->status === Article::STATUS_PUBLISHED ? 'active' : 'archived';
$description = $this->resolveDescription($article);
if ($dryRun) {
$this->line("{$article->slug}: would import to category={$category->slug}, access={$accessLevel}, attachments={$article->attachments->count()}");
return 'skipped';
}
DB::transaction(function () use (
$article,
$publicUuid,
$category,
$actor,
$accessLevel,
$documentStatus,
$description,
$markArchived
): void {
$document = Document::create([
'document_category_id' => $category->id,
'title' => $article->title,
'document_number' => null,
'description' => $description,
'public_uuid' => $publicUuid,
'access_level' => $accessLevel,
'status' => $documentStatus,
'created_by_user_id' => $actor->id,
'last_updated_by_user_id' => $actor->id,
'version_count' => 0,
'archived_at' => $documentStatus === 'archived' ? now() : null,
]);
$versionIds = [];
$orderedAttachments = $article->attachments->sortBy([
fn (ArticleAttachment $attachment) => $attachment->created_at?->timestamp ?? 0,
fn (ArticleAttachment $attachment) => $attachment->id,
])->values();
foreach ($orderedAttachments as $attachment) {
$copied = $this->copyAttachmentToPrivate($article, $attachment);
if (! $copied) {
continue;
}
$version = DocumentVersion::create([
'document_id' => $document->id,
'version_number' => $this->versionNumberFromIndex(count($versionIds)),
'version_notes' => $attachment->description ?: 'Imported from legacy article attachment',
'is_current' => false,
'file_path' => $copied['file_path'],
'original_filename' => $copied['original_filename'],
'mime_type' => $copied['mime_type'],
'file_size' => $copied['file_size'],
'file_hash' => $copied['file_hash'],
'uploaded_by_user_id' => $actor->id,
'uploaded_at' => $attachment->created_at ?? $article->updated_at ?? now(),
'created_at' => $attachment->created_at ?? $article->updated_at ?? now(),
'updated_at' => $attachment->updated_at ?? $attachment->created_at ?? now(),
]);
$versionIds[] = $version->id;
}
if ($versionIds === []) {
$markdownVersion = $this->createMarkdownVersion($article, $document, $actor);
$versionIds[] = $markdownVersion->id;
}
$currentVersionId = end($versionIds) ?: null;
if (! $currentVersionId) {
throw new \RuntimeException("Failed to create version for article {$article->slug}");
}
DocumentVersion::where('document_id', $document->id)->update(['is_current' => false]);
DocumentVersion::where('id', $currentVersionId)->update(['is_current' => true]);
$document->forceFill([
'current_version_id' => $currentVersionId,
'version_count' => count($versionIds),
'created_at' => $article->published_at ?? $article->created_at ?? now(),
'updated_at' => $article->updated_at ?? $article->published_at ?? now(),
])->save();
if ($markArchived && $article->status !== Article::STATUS_ARCHIVED) {
$article->update([
'status' => Article::STATUS_ARCHIVED,
'archived_at' => now(),
'last_updated_by_user_id' => $actor->id,
]);
}
AuditLog::create([
'user_id' => $actor->id,
'action' => 'article.document_imported',
'description' => "Imported article document {$article->slug} -> document #{$document->id}",
'auditable_type' => Document::class,
'auditable_id' => $document->id,
'metadata' => [
'source_article_id' => $article->id,
'source_article_slug' => $article->slug,
'imported_version_count' => count($versionIds),
],
'ip_address' => '127.0.0.1',
]);
});
$this->info("{$article->slug}: imported.");
return $markArchived ? 'archived_source' : 'imported';
}
private function resolveFallbackCategory(?string $categorySlug): ?DocumentCategory
{
if ($categorySlug) {
return DocumentCategory::where('slug', $categorySlug)->first();
}
return DocumentCategory::where('slug', 'organization-public-disclosure')->first()
?: DocumentCategory::orderBy('sort_order')->orderBy('id')->first();
}
private function resolveCategoryForArticle(Article $article, DocumentCategory $fallbackCategory): DocumentCategory
{
foreach ($article->categories as $category) {
$matched = DocumentCategory::where('slug', $category->slug)->first();
if ($matched) {
return $matched;
}
}
return $fallbackCategory;
}
private function deterministicPublicUuid(Article $article): string
{
return Uuid::uuid5(Uuid::NAMESPACE_URL, "legacy-article-document:{$article->id}:{$article->slug}")->toString();
}
private function resolveDescription(Article $article): ?string
{
$description = $article->summary ?: $article->meta_description;
if ($description) {
return $description;
}
$plain = trim(strip_tags($article->content));
if ($plain === '') {
return null;
}
return Str::limit($plain, 400);
}
private function normalizeAccessLevel(?string $accessLevel): string
{
return in_array($accessLevel, ['public', 'members', 'admin', 'board'], true)
? $accessLevel
: 'members';
}
private function versionNumberFromIndex(int $index): string
{
if ($index === 0) {
return '1.0';
}
return '1.'.$index;
}
/**
* @return array{file_path:string, original_filename:string, mime_type:string, file_size:int, file_hash:string}|null
*/
private function copyAttachmentToPrivate(Article $article, ArticleAttachment $attachment): ?array
{
if (! Storage::disk('public')->exists($attachment->file_path)) {
$this->warn(" - {$article->slug}: attachment #{$attachment->id} file missing ({$attachment->file_path}), skipped.");
return null;
}
$bytes = Storage::disk('public')->get($attachment->file_path);
$extension = pathinfo($attachment->original_filename, PATHINFO_EXTENSION);
$safeName = $this->sanitizeFilename(pathinfo($attachment->original_filename, PATHINFO_FILENAME) ?: 'attachment');
$targetPath = 'documents/imported/articles/'.$article->id.'/'.Str::uuid().'-'.$safeName.($extension ? '.'.$extension : '');
Storage::disk('private')->put($targetPath, $bytes);
return [
'file_path' => $targetPath,
'original_filename' => $attachment->original_filename,
'mime_type' => $attachment->mime_type ?: 'application/octet-stream',
'file_size' => strlen($bytes),
'file_hash' => hash('sha256', $bytes),
];
}
private function createMarkdownVersion(Article $article, Document $document, User $actor): DocumentVersion
{
$content = $this->buildMarkdownFromArticle($article);
$targetPath = 'documents/imported/articles/'.$article->id.'/'.Str::uuid().'-'.$article->slug.'.md';
Storage::disk('private')->put($targetPath, $content);
return DocumentVersion::create([
'document_id' => $document->id,
'version_number' => '1.0',
'version_notes' => 'Imported from legacy article content (no file attachments)',
'is_current' => false,
'file_path' => $targetPath,
'original_filename' => $article->slug.'.md',
'mime_type' => 'text/markdown',
'file_size' => strlen($content),
'file_hash' => hash('sha256', $content),
'uploaded_by_user_id' => $actor->id,
'uploaded_at' => $article->updated_at ?? $article->published_at ?? now(),
'created_at' => $article->created_at ?? now(),
'updated_at' => $article->updated_at ?? $article->created_at ?? now(),
]);
}
private function buildMarkdownFromArticle(Article $article): string
{
$sections = [
'# '.$article->title,
'',
'- Source article slug: `'.$article->slug.'`',
'- Source article id: `'.$article->id.'`',
'- Imported at: `'.now()->toIso8601String().'`',
'',
];
if ($article->summary) {
$sections[] = '## Summary';
$sections[] = '';
$sections[] = trim($article->summary);
$sections[] = '';
}
$sections[] = '## Content';
$sections[] = '';
$sections[] = trim($article->content);
$sections[] = '';
return implode("\n", $sections);
}
private function sanitizeFilename(string $name): string
{
$sanitized = preg_replace('/[^A-Za-z0-9._-]+/', '-', $name) ?: 'file';
return trim($sanitized, '-');
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace Tests\Feature\Cms;
use App\Models\Article;
use App\Models\ArticleAttachment;
use App\Models\Document;
use App\Models\DocumentCategory;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class ImportArticleDocumentsCommandTest extends TestCase
{
use RefreshDatabase;
public function test_it_imports_article_documents_and_is_idempotent(): void
{
Storage::fake('public');
Storage::fake('private');
$user = User::factory()->create();
DocumentCategory::factory()->create([
'slug' => 'organization-public-disclosure',
'name' => '組織公開資訊',
'default_access_level' => 'public',
]);
$articleWithAttachment = Article::factory()->create([
'title' => '舊版章程 PDF',
'slug' => 'legacy-charter',
'content_type' => Article::CONTENT_TYPE_DOCUMENT,
'status' => Article::STATUS_PUBLISHED,
'access_level' => Article::ACCESS_LEVEL_PUBLIC,
'summary' => '舊版章程摘要',
'content' => '舊版章程內容',
'created_by_user_id' => $user->id,
'last_updated_by_user_id' => $user->id,
]);
Storage::disk('public')->put('articles/attachments/legacy-charter.pdf', '%PDF-1.4 test');
ArticleAttachment::create([
'article_id' => $articleWithAttachment->id,
'file_path' => 'articles/attachments/legacy-charter.pdf',
'original_filename' => 'legacy-charter.pdf',
'mime_type' => 'application/pdf',
'file_size' => 13,
'description' => '舊版附件',
]);
$articleWithoutAttachment = Article::factory()->create([
'title' => '純文字福利資源',
'slug' => 'legacy-welfare-links',
'content_type' => Article::CONTENT_TYPE_DOCUMENT,
'status' => Article::STATUS_PUBLISHED,
'access_level' => Article::ACCESS_LEVEL_PUBLIC,
'summary' => '福利連結摘要',
'content' => "第一行\n第二行",
'created_by_user_id' => $user->id,
'last_updated_by_user_id' => $user->id,
]);
$this->artisan('articles:import-documents', [
'--fallback-user-id' => $user->id,
])->assertExitCode(0);
$this->assertDatabaseCount('documents', 2);
$importedFromAttachment = Document::where('title', $articleWithAttachment->title)->firstOrFail();
$this->assertSame('public', $importedFromAttachment->access_level);
$this->assertSame('active', $importedFromAttachment->status);
$this->assertSame(1, $importedFromAttachment->version_count);
$this->assertNotNull($importedFromAttachment->current_version_id);
$this->assertTrue($importedFromAttachment->currentVersion()->exists());
$this->assertSame('legacy-charter.pdf', $importedFromAttachment->currentVersion->original_filename);
$this->assertTrue(Storage::disk('private')->exists($importedFromAttachment->currentVersion->file_path));
$importedFromMarkdown = Document::where('title', $articleWithoutAttachment->title)->firstOrFail();
$this->assertSame(1, $importedFromMarkdown->version_count);
$this->assertNotNull($importedFromMarkdown->current_version_id);
$this->assertSame('text/markdown', $importedFromMarkdown->currentVersion->mime_type);
$this->assertSame('legacy-welfare-links.md', $importedFromMarkdown->currentVersion->original_filename);
$this->assertTrue(Storage::disk('private')->exists($importedFromMarkdown->currentVersion->file_path));
$markdown = Storage::disk('private')->get($importedFromMarkdown->currentVersion->file_path);
$this->assertStringContainsString('# 純文字福利資源', $markdown);
$this->assertStringContainsString('Source article slug: `legacy-welfare-links`', $markdown);
// Idempotency check
$this->artisan('articles:import-documents', [
'--fallback-user-id' => $user->id,
])->assertExitCode(0);
$this->assertDatabaseCount('documents', 2);
}
public function test_it_can_archive_source_articles_after_import(): void
{
Storage::fake('public');
Storage::fake('private');
$user = User::factory()->create();
DocumentCategory::factory()->create([
'slug' => 'organization-public-disclosure',
'name' => '組織公開資訊',
'default_access_level' => 'public',
]);
$article = Article::factory()->create([
'title' => '待封存來源文章',
'slug' => 'legacy-archive-me',
'content_type' => Article::CONTENT_TYPE_DOCUMENT,
'status' => Article::STATUS_PUBLISHED,
'access_level' => Article::ACCESS_LEVEL_PUBLIC,
'created_by_user_id' => $user->id,
'last_updated_by_user_id' => $user->id,
]);
$this->artisan('articles:import-documents', [
'--fallback-user-id' => $user->id,
'--mark-archived' => true,
])->assertExitCode(0);
$article->refresh();
$this->assertSame(Article::STATUS_ARCHIVED, $article->status);
$this->assertNotNull($article->archived_at);
$this->assertDatabaseCount('documents', 1);
}
}