Files
usher-manage-stack/app/Console/Commands/ImportArticleDocuments.php

405 lines
15 KiB
PHP

<?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 App\Services\SiteRevalidationService;
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) {
if ($markArchived && $article->status !== Article::STATUS_ARCHIVED) {
$article->update([
'status' => Article::STATUS_ARCHIVED,
'archived_at' => now(),
'last_updated_by_user_id' => ($article->creator ?: $fallbackUser)->id,
]);
SiteRevalidationService::revalidateArticle($article->slug);
$this->info("{$article->slug}: already imported, source article archived.");
return 'archived_source';
}
$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,
]);
SiteRevalidationService::revalidateArticle($article->slug);
}
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, '-');
}
}