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, '-'); } }