option('path') ?: base_path('../usher-site/content/zh'); $imagesPath = $this->option('images') ?: base_path('../usher-site/static/images'); // Expand tilde $contentPath = str_replace('~', getenv('HOME'), $contentPath); $imagesPath = str_replace('~', getenv('HOME'), $imagesPath); if (! is_dir($contentPath)) { $this->error("Content directory not found: {$contentPath}"); return self::FAILURE; } $isDryRun = $this->option('dry-run'); if ($isDryRun) { $this->warn('DRY RUN — no changes will be written to database.'); } $this->info("Importing Hugo content from: {$contentPath}"); $this->newLine(); // Get or create a system user for attribution $systemUser = User::where('email', 'admin@test.com')->first() ?? User::first(); if (! $systemUser) { $this->error('No user found to attribute content to. Please create a user first.'); return self::FAILURE; } $this->info("Attributing content to: {$systemUser->name} ({$systemUser->email})"); $this->newLine(); DB::beginTransaction(); try { // Import articles by content type $contentTypes = [ 'blog' => Article::CONTENT_TYPE_BLOG, 'notice' => Article::CONTENT_TYPE_NOTICE, 'document' => Article::CONTENT_TYPE_DOCUMENT, 'related_news' => Article::CONTENT_TYPE_RELATED_NEWS, ]; foreach ($contentTypes as $dir => $contentType) { $dirPath = "{$contentPath}/{$dir}"; if (is_dir($dirPath)) { $this->importArticles($dirPath, $contentType, $systemUser, $isDryRun); } else { $this->warn("Directory not found, skipping: {$dir}/"); } } // Import static pages $pageDirectories = [ 'about' => '關於我們', 'message' => '理事長的話', 'structure' => '組織架構', 'mission' => '協會任務', 'contact' => '聯繫我們', 'logo_represent' => 'Logo象徵', ]; foreach ($pageDirectories as $dir => $fallbackTitle) { $dirPath = "{$contentPath}/{$dir}"; if (is_dir($dirPath)) { $this->importPage($dirPath, $dir, $fallbackTitle, $systemUser, $isDryRun); } else { $this->warn("Page directory not found, skipping: {$dir}/"); } } // Copy images if (is_dir($imagesPath)) { $this->copyImages($imagesPath, $isDryRun); } else { $this->warn("Images directory not found: {$imagesPath}"); } if ($isDryRun) { DB::rollBack(); $this->newLine(); $this->warn('DRY RUN complete — no changes were made.'); } else { DB::commit(); } } catch (\Exception $e) { DB::rollBack(); $this->error("Import failed: {$e->getMessage()}"); $this->error($e->getTraceAsString()); return self::FAILURE; } $this->newLine(); $this->info('=== Import Summary ==='); $this->table( ['Item', 'Count'], [ ['Articles created', $this->articlesCreated], ['Pages created', $this->pagesCreated], ['Categories created', $this->categoriesCreated], ['Tags created', $this->tagsCreated], ['Images copied', $this->imagesCopied], ['Skipped (_index.md etc.)', $this->skipped], ] ); return self::SUCCESS; } private function importArticles(string $dirPath, string $contentType, User $user, bool $isDryRun): void { $this->info("Importing {$contentType} articles from: {$dirPath}"); $files = File::glob("{$dirPath}/*.md"); foreach ($files as $filePath) { $filename = basename($filePath); // Skip _index.md files (Hugo section pages, not content) if ($filename === '_index.md') { $this->skipped++; continue; } $parsed = $this->parseMarkdownFile($filePath); if (! $parsed) { $this->warn(" Could not parse: {$filename}"); $this->skipped++; continue; } $frontmatter = $parsed['frontmatter']; $body = $parsed['body']; // Skip drafts if (! empty($frontmatter['draft']) && $frontmatter['draft'] === true) { $this->line(" Skipping draft: {$filename}"); $this->skipped++; continue; } $title = $frontmatter['title'] ?? pathinfo($filename, PATHINFO_FILENAME); // Prefer filename-based slug (Hugo filenames are usually descriptive ASCII) $filenameBase = pathinfo($filename, PATHINFO_FILENAME); $slug = Str::slug($filenameBase); if (empty($slug)) { $slug = Str::slug($title); } if (empty($slug)) { // For Chinese-only titles/filenames, use content_type + short hash $slug = $contentType.'-'.substr(md5($title), 0, 8); } // Ensure unique slug $originalSlug = $slug; $counter = 1; while (! $isDryRun && Article::withTrashed()->where('slug', $slug)->exists()) { $slug = "{$originalSlug}-{$counter}"; $counter++; } $publishedAt = $this->parseDate($frontmatter['date'] ?? null); $description = $frontmatter['description'] ?? null; $summary = $frontmatter['summary'] ?? null; $author = $frontmatter['author'] ?? null; $image = $this->resolveImagePath($frontmatter['image'] ?? null); $this->line(" [{$contentType}] {$title} → {$slug}"); if (! $isDryRun) { $article = Article::create([ 'title' => $title, 'slug' => $slug, 'summary' => $summary, 'content' => $body, 'content_type' => $contentType, 'status' => Article::STATUS_PUBLISHED, 'access_level' => Article::ACCESS_LEVEL_PUBLIC, 'featured_image_path' => $image, 'author_name' => $author, 'meta_description' => $description ?: null, 'published_at' => $publishedAt ?? now(), 'created_by_user_id' => $user->id, 'view_count' => 0, ]); // Sync categories $categoryNames = $this->extractList($frontmatter, 'categories', 'category'); foreach ($categoryNames as $catName) { $category = $this->findOrCreateCategory($catName); $article->categories()->attach($category->id); } // Sync tags $tagNames = $this->extractList($frontmatter, 'tags'); foreach ($tagNames as $tagName) { $tag = $this->findOrCreateTag($tagName); $article->tags()->attach($tag->id); } } $this->articlesCreated++; } } private function importPage(string $dirPath, string $slug, string $fallbackTitle, User $user, bool $isDryRun): void { $indexFile = "{$dirPath}/_index.md"; if (! file_exists($indexFile)) { $this->warn(" No _index.md in {$slug}/, skipping."); $this->skipped++; return; } $parsed = $this->parseMarkdownFile($indexFile); if (! $parsed) { $this->warn(" Could not parse: {$slug}/_index.md"); $this->skipped++; return; } $frontmatter = $parsed['frontmatter']; $body = $parsed['body']; $title = $frontmatter['title'] ?? $fallbackTitle; $description = $frontmatter['description'] ?? null; $this->info(" [page] {$title} → {$slug}"); $parentPage = null; if (! $isDryRun) { // Check for existing page with same slug if (Page::withTrashed()->where('slug', $slug)->exists()) { $this->warn(" Page with slug '{$slug}' already exists, skipping."); $this->skipped++; return; } $parentPage = Page::create([ 'title' => $title, 'slug' => $slug, 'content' => $body, 'status' => Page::STATUS_PUBLISHED, 'meta_description' => $description ?: null, 'published_at' => now(), 'created_by_user_id' => $user->id, 'sort_order' => 0, ]); } $this->pagesCreated++; // Import child pages (non-_index.md files in the same directory) $childFiles = File::glob("{$dirPath}/*.md"); $sortOrder = 1; foreach ($childFiles as $childFile) { $childFilename = basename($childFile); if ($childFilename === '_index.md') { continue; } $childParsed = $this->parseMarkdownFile($childFile); if (! $childParsed) { $this->skipped++; continue; } $childFm = $childParsed['frontmatter']; $childBody = $childParsed['body']; $childTitle = $childFm['title'] ?? pathinfo($childFilename, PATHINFO_FILENAME); $childSlug = Str::slug(pathinfo($childFilename, PATHINFO_FILENAME)); if (empty($childSlug)) { $childSlug = Str::slug($childTitle); } if (empty($childSlug)) { $childSlug = 'page-'.md5($childTitle); } $this->line(" [child] {$childTitle} → {$childSlug}"); if (! $isDryRun) { $originalChildSlug = $childSlug; $counter = 1; while (Page::withTrashed()->where('slug', $childSlug)->exists()) { $childSlug = "{$originalChildSlug}-{$counter}"; $counter++; } Page::create([ 'title' => $childTitle, 'slug' => $childSlug, 'content' => $childBody, 'status' => Page::STATUS_PUBLISHED, 'meta_description' => $childFm['description'] ?? null, 'parent_id' => $parentPage->id, 'sort_order' => $sortOrder, 'published_at' => now(), 'created_by_user_id' => $user->id, ]); } $this->pagesCreated++; $sortOrder++; } } private function copyImages(string $imagesPath, bool $isDryRun): void { $this->info("Copying images from: {$imagesPath}"); $nextPublicRoot = config('services.nextjs.public_path'); if (! is_string($nextPublicRoot) || $nextPublicRoot === '' || ! is_dir($nextPublicRoot)) { $nextPublicRoot = base_path('../usher-site/public'); } $nextPublicImages = rtrim($nextPublicRoot, '/').'/images'; $destPath = is_dir($nextPublicImages) ? $nextPublicImages : storage_path('app/public/migrated-images'); if (! $isDryRun && ! is_dir($destPath)) { File::makeDirectory($destPath, 0755, true); } $files = File::allFiles($imagesPath); foreach ($files as $file) { $relativePath = $file->getRelativePathname(); $destFile = "{$destPath}/{$relativePath}"; $this->line(" {$relativePath}"); if (! $isDryRun) { $destDir = dirname($destFile); if (! is_dir($destDir)) { File::makeDirectory($destDir, 0755, true); } File::copy($file->getPathname(), $destFile); } $this->imagesCopied++; } // If we copied into the Next.js repo, optionally auto-push the new assets. if (str_ends_with($destPath, '/images') && is_dir(dirname($destPath).'/.git')) { \App\Services\NextjsRepoSyncService::scheduleAssetsPush(); } } /** * Parse a markdown file into frontmatter array and body string. */ private function parseMarkdownFile(string $filePath): ?array { $content = file_get_contents($filePath); if ($content === false) { return null; } // Match YAML frontmatter between --- delimiters if (! preg_match('/\A---\s*\n(.*?)\n---\s*\n?(.*)\z/s', $content, $matches)) { // No frontmatter — treat entire content as body return [ 'frontmatter' => [], 'body' => trim($content), ]; } $yamlString = $matches[1]; $body = trim($matches[2]); try { // Strip YAML comments (lines starting with #) before parsing $yamlLines = array_filter( explode("\n", $yamlString), fn ($line) => ! preg_match('/^\s*#/', $line) ); $cleanYaml = implode("\n", $yamlLines); $frontmatter = Yaml::parse($cleanYaml) ?? []; } catch (\Exception $e) { $this->warn(" YAML parse error in {$filePath}: {$e->getMessage()}"); $frontmatter = []; } return [ 'frontmatter' => $frontmatter, 'body' => $body, ]; } /** * Parse a date value from Hugo frontmatter. * YAML parser may convert ISO 8601 dates to Unix timestamps (int). * * @param string|int|null $dateValue */ private function parseDate(mixed $dateValue): ?\Carbon\Carbon { if ($dateValue === null || $dateValue === '') { return null; } try { if (is_int($dateValue) || is_float($dateValue)) { return \Carbon\Carbon::createFromTimestamp((int) $dateValue); } return \Carbon\Carbon::parse((string) $dateValue); } catch (\Exception $e) { $this->warn(" Could not parse date: {$dateValue}"); return null; } } /** * Extract a list of values from frontmatter. * Hugo uses both plural array (categories: ["a", "b"]) and singular string (category: "a"). */ private function extractList(array $frontmatter, string $pluralKey, ?string $singularKey = null): array { $items = []; if (! empty($frontmatter[$pluralKey])) { $value = $frontmatter[$pluralKey]; $items = is_array($value) ? $value : [$value]; } elseif ($singularKey && ! empty($frontmatter[$singularKey])) { $value = $frontmatter[$singularKey]; $items = is_array($value) ? $value : [$value]; } // Clean up strings return array_filter(array_map('trim', $items)); } private function findOrCreateCategory(string $name): ArticleCategory { $slug = Str::slug($name) ?: 'category-'.mt_rand(100, 999); $category = ArticleCategory::where('slug', $slug)->first(); if (! $category) { $category = ArticleCategory::create([ 'name' => $name, 'slug' => $slug, 'sort_order' => 0, ]); $this->categoriesCreated++; $this->line(" Created category: {$name}"); } return $category; } private function findOrCreateTag(string $name): ArticleTag { $slug = Str::slug($name) ?: 'tag-'.mt_rand(100, 999); $tag = ArticleTag::where('slug', $slug)->first(); if (! $tag) { $tag = ArticleTag::create([ 'name' => $name, 'slug' => $slug, ]); $this->tagsCreated++; $this->line(" Created tag: {$name}"); } return $tag; } /** * Convert Hugo image paths to migrated storage paths. * Hugo: "images/blog/Update.jpg" → "images/blog/Update.jpg" */ private function resolveImagePath(?string $hugoPath): ?string { if (empty($hugoPath)) { return null; } $path = ltrim($hugoPath, '/'); // Normalize to "images/..." so it can be served from Next.js public/images/. if (! str_starts_with($path, 'images/')) { $path = 'images/'.ltrim($path, '/'); } return $path; } }