#!/usr/bin/env php repository(); $comments = $app->comments(); [$args, $opts] = parseArguments(array_slice($_SERVER['argv'] ?? [], 1)); if (isset($opts['help']) || isset($opts['h']) || ($args === [] && !isset($opts['source']))) { help(); exit(isset($opts['help']) || isset($opts['h']) ? 0 : 1); } $source = (string) ($opts['source'] ?? $args[0]); $dryRun = isset($opts['dry-run']); $overwrite = isset($opts['overwrite']); $skipUploads = isset($opts['skip-uploads']); $skipComments = isset($opts['skip-comments']); try { $paths = resolveBluditSource($source); $siteDb = readDatabase($paths['databases'] . '/site.php') ?: readDatabase($paths['databases'] . '/site.json'); $timezone = (string) ($opts['source-timezone'] ?? $siteDb['timezone'] ?? date_default_timezone_get()); $pagesDb = readDatabase($paths['databases'] . '/pages.php') ?: readDatabase($paths['databases'] . '/pages.json'); $categoriesDb = readDatabase($paths['databases'] . '/categories.php') ?: readDatabase($paths['databases'] . '/categories.json'); $tagsDb = readDatabase($paths['databases'] . '/tags.php') ?: readDatabase($paths['databases'] . '/tags.json'); $entries = discoverPages($paths['pages'], $pagesDb); if ($entries === []) { fail('No Bludit pages were found in ' . $paths['pages']); } $summary = [ 'source' => $paths['content'], 'dry_run' => $dryRun, 'pages_found' => count($entries), 'content_imported' => 0, 'uploads_copied' => 0, 'page_assets_copied' => 0, 'comments_imported' => 0, 'skipped' => [], ]; $slugMap = []; $reservedSlugs = []; foreach ($entries as $entry) { $oldKey = (string) $entry['key']; $row = is_array($entry['db']) ? $entry['db'] : []; $contentPath = $entry['path']; $markdown = is_file($contentPath) ? (string) file_get_contents($contentPath) : ''; $baseSlug = slugFromBluditKey($oldKey); $slug = uniqueSlug($repo, $baseSlug, $reservedSlugs, $overwrite); $reservedSlugs[$slug] = true; $metadata = metadataFromBludit($oldKey, $row, $markdown, $categoriesDb, $tagsDb, $timezone); $slugMap[$oldKey] = $slug; if (!$dryRun) { $repo->saveImported($slug, $metadata, $markdown, false); } $summary['page_assets_copied'] += copyPageAssets(dirname($contentPath), BLOG_ROOT . '/bl-content/pages/' . $slug, $dryRun); $summary['content_imported']++; printf( "%s %-10s %-10s %s -> %s\n", $dryRun ? 'Would import' : 'Imported', $metadata['type'], $metadata['status'], $oldKey, $slug ); } if (!$skipUploads && is_dir($paths['uploads'])) { $summary['uploads_copied'] = copyTree($paths['uploads'], BLOG_ROOT . '/bl-content/uploads', $dryRun); line(($dryRun ? 'Would copy' : 'Copied') . ' uploads from ' . $paths['uploads']); } if (!$skipComments) { $commentFiles = []; if (isset($opts['comments'])) { $commentFiles[] = (string) $opts['comments']; } $commentFiles = array_merge($commentFiles, discoverCommentFiles($paths['content'])); $seenCommentFiles = []; foreach ($commentFiles as $commentFile) { $real = realpath($commentFile); if ($real === false || isset($seenCommentFiles[$real])) { continue; } $seenCommentFiles[$real] = true; $records = readComments($real); foreach ($records as $record) { $oldSlug = (string) ($record['slug'] ?? ''); $slug = $slugMap[$oldSlug] ?? $slugMap[str_replace('-', '/', $oldSlug)] ?? ContentRepository::slugify($oldSlug); $body = trim((string) ($record['body'] ?? $record['content'] ?? $record['comment'] ?? '')); if ($slug === '' || $body === '') { continue; } if (!$dryRun) { $comments->import($slug, [ 'author' => $record['author'] ?? $record['name'] ?? 'Imported', 'email' => $record['email'] ?? '', 'website' => $record['website'] ?? $record['url'] ?? '', 'body' => $body, 'status' => $record['status'] ?? 'approved', 'created_at' => normalizeDate((string) ($record['date'] ?? $record['created_at'] ?? ''), $timezone), 'updated_at' => normalizeDate((string) ($record['modified'] ?? $record['updated_at'] ?? $record['date'] ?? ''), $timezone), 'user_agent' => $record['user_agent'] ?? $record['agent'] ?? 'bludit-import', ]); } $summary['comments_imported']++; } } } if (!$dryRun) { $repo->rebuildIndex(); } line(''); line('Import summary:'); foreach ($summary as $key => $value) { if (is_array($value)) { continue; } line(' ' . $key . ': ' . (is_bool($value) ? ($value ? 'yes' : 'no') : (string) $value)); } } catch (Throwable $error) { fail($error->getMessage()); } function help(): void { echo <<, 1: array} */ function parseArguments(array $argv): array { $args = []; $opts = []; foreach ($argv as $arg) { if (str_starts_with($arg, '--')) { $option = substr($arg, 2); if (str_contains($option, '=')) { [$key, $value] = explode('=', $option, 2); $opts[$key] = $value; } else { $opts[$option] = true; } } elseif (str_starts_with($arg, '-')) { $opts[ltrim($arg, '-')] = true; } else { $args[] = $arg; } } return [$args, $opts]; } /** @return array{root: string, content: string, pages: string, databases: string, uploads: string} */ function resolveBluditSource(string $source): array { $path = realpath($source); if ($path === false || !is_dir($path)) { fail('Source directory not found or not readable: ' . $source); } if (is_dir($path . '/bl-content')) { $root = $path; $content = $path . '/bl-content'; } elseif (basename($path) === 'bl-content' || is_dir($path . '/pages')) { $content = $path; $root = dirname($path); } else { fail('Source must be a Bludit root or a bl-content directory: ' . $source); } $pages = $content . '/pages'; $databases = $content . '/databases'; if (!is_dir($pages)) { fail('Bludit pages directory not found: ' . $pages); } return [ 'root' => $root, 'content' => $content, 'pages' => $pages, 'databases' => is_dir($databases) ? $databases : $content, 'uploads' => $content . '/uploads', ]; } /** @return array */ function readDatabase(string $path): array { if (!is_file($path)) { return []; } $raw = (string) file_get_contents($path); $json = trim($raw); $firstObject = strpos($json, '{'); $firstArray = strpos($json, '['); $start = false; if ($firstObject !== false && $firstArray !== false) { $start = min($firstObject, $firstArray); } elseif ($firstObject !== false) { $start = $firstObject; } elseif ($firstArray !== false) { $start = $firstArray; } if ($start !== false) { $json = substr($json, $start); } $decoded = json_decode($json, true); return is_array($decoded) ? $decoded : []; } /** @return array}> */ function discoverPages(string $pagesRoot, array $pagesDb): array { $entries = []; if ($pagesDb !== []) { foreach ($pagesDb as $key => $row) { $file = $pagesRoot . '/' . $key . '/index.txt'; if (is_file($file)) { $entries[] = [ 'key' => (string) $key, 'path' => $file, 'db' => is_array($row) ? $row : [], ]; } } } if ($entries !== []) { return $entries; } $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($pagesRoot, FilesystemIterator::SKIP_DOTS) ); foreach ($iterator as $file) { if (!$file->isFile() || $file->getFilename() !== 'index.txt') { continue; } $directory = dirname($file->getPathname()); $key = trim(str_replace($pagesRoot, '', $directory), DIRECTORY_SEPARATOR); $entries[] = [ 'key' => str_replace(DIRECTORY_SEPARATOR, '/', $key), 'path' => $file->getPathname(), 'db' => [], ]; } usort($entries, static fn (array $a, array $b): int => strcmp($a['key'], $b['key'])); return $entries; } /** @param array $row @param array $categories @param array $tags */ function metadataFromBludit(string $key, array $row, string $markdown, array $categories, array $tags, string $timezone): array { $bluditType = (string) ($row['type'] ?? 'published'); $type = $bluditType === 'static' ? 'page' : 'post'; $status = in_array($bluditType, ['published', 'static', 'sticky'], true) ? 'published' : 'draft'; $categoryKey = (string) ($row['category'] ?? ''); $coverImage = (string) ($row['coverImage'] ?? ''); if ($coverImage !== '' && !filter_var($coverImage, FILTER_VALIDATE_URL)) { $uuid = (string) ($row['uuid'] ?? ''); $coverImage = $uuid !== '' ? '/bl-content/uploads/pages/' . $uuid . '/' . ltrim($coverImage, '/') : '/bl-content/uploads/' . ltrim($coverImage, '/'); } return [ 'title' => trim((string) ($row['title'] ?? '')) !== '' ? (string) $row['title'] : titleFromMarkdownOrKey($markdown, $key), 'type' => $type, 'status' => $status, 'date' => normalizeDate((string) ($row['date'] ?? ''), $timezone), 'modified' => normalizeDate((string) ($row['dateModified'] ?? $row['date'] ?? ''), $timezone), 'category' => categoryName($categoryKey, $categories), 'tags' => tagNames($row['tags'] ?? [], $tags), 'summary' => (string) ($row['description'] ?? ''), 'author' => (string) ($row['username'] ?? 'Imported'), 'cover' => $coverImage, 'allow_comments' => (bool) ($row['allowComments'] ?? true), 'menu_order' => (int) ($row['position'] ?? 0), ]; } function normalizeDate(string $value, string $timezone): string { $value = trim($value); if ($value === '') { return date('c'); } try { $zone = new DateTimeZone($timezone); } catch (Throwable) { $zone = new DateTimeZone(date_default_timezone_get()); } try { $date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $value, $zone) ?: DateTimeImmutable::createFromFormat(DateTimeInterface::ATOM, $value) ?: new DateTimeImmutable($value, $zone); } catch (Throwable) { $date = new DateTimeImmutable('now', $zone); } return $date->format(DateTimeInterface::ATOM); } function categoryName(string $key, array $categories): string { if ($key !== '' && isset($categories[$key]) && is_array($categories[$key])) { return (string) ($categories[$key]['name'] ?? $key); } return $key !== '' ? ucwords(str_replace('-', ' ', $key)) : 'Notes'; } function tagNames(mixed $rawTags, array $tagsDb): array { if (is_string($rawTags)) { return ContentRepository::normalizeList($rawTags); } if (!is_array($rawTags)) { return []; } $tags = []; foreach ($rawTags as $key => $value) { if (is_string($value) && $value !== '') { $tags[] = $value; } elseif (is_string($key) && isset($tagsDb[$key]) && is_array($tagsDb[$key])) { $tags[] = (string) ($tagsDb[$key]['name'] ?? $key); } elseif (is_string($key)) { $tags[] = ucwords(str_replace('-', ' ', $key)); } } return ContentRepository::normalizeList($tags); } function titleFromMarkdownOrKey(string $markdown, string $key): string { foreach (explode("\n", $markdown) as $line) { if (preg_match('/^#\s+(.+)$/', trim($line), $matches)) { return trim($matches[1]); } } return ucwords(str_replace(['-', '/'], ' ', $key)); } function slugFromBluditKey(string $key): string { return ContentRepository::slugify(str_replace('/', '-', $key)); } /** @param array $reserved */ function uniqueSlug(ContentRepository $repo, string $baseSlug, array $reserved, bool $overwrite): string { $baseSlug = $baseSlug !== '' ? $baseSlug : 'imported-page'; if ($overwrite) { return $baseSlug; } $slug = $baseSlug; $i = 2; while (isset($reserved[$slug]) || $repo->find($slug, true) !== null) { $slug = $baseSlug . '-' . $i; $i++; } return $slug; } function copyPageAssets(string $sourceDir, string $targetDir, bool $dryRun): int { $count = 0; foreach (glob($sourceDir . '/*') ?: [] as $path) { if (basename($path) === 'index.txt') { continue; } $count += copyTree($path, $targetDir . '/' . basename($path), $dryRun); } return $count; } function copyTree(string $source, string $target, bool $dryRun): int { if (!file_exists($source)) { return 0; } $sourceReal = realpath($source); $targetReal = realpath($target); if ($sourceReal !== false && $targetReal !== false && $sourceReal === $targetReal) { return 0; } if ($dryRun) { if (is_file($source) || is_link($source)) { return 1; } $count = 0; $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($source, FilesystemIterator::SKIP_DOTS) ); foreach ($iterator as $file) { if ($file->isFile() || $file->isLink()) { $count++; } } return $count; } if (is_link($source)) { $real = realpath($source); return $real === false ? 0 : copyTree($real, $target, false); } if (is_file($source)) { if (!is_dir(dirname($target))) { mkdir(dirname($target), 0775, true); } copy($source, $target); touch($target, filemtime($source) ?: time()); return 1; } if (!is_dir($target)) { mkdir($target, 0775, true); } $count = 0; $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($source, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST ); foreach ($iterator as $item) { $relative = substr($item->getPathname(), strlen($source) + 1); $destination = $target . '/' . $relative; if ($item->isDir() && !$item->isLink()) { if (!is_dir($destination)) { mkdir($destination, 0775, true); } } else { $count += copyTree($item->getPathname(), $destination, false); } } return $count; } /** @return array */ function discoverCommentFiles(string $contentRoot): array { $files = []; foreach ([$contentRoot . '/databases', $contentRoot . '/workspaces'] as $root) { if (!is_dir($root)) { continue; } $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS) ); foreach ($iterator as $file) { if (!$file->isFile()) { continue; } $name = strtolower($file->getFilename()); $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION)); if (str_contains($name, 'comment') && in_array($extension, ['php', 'json', 'csv', 'sqlite', 'db'], true)) { $files[] = $file->getPathname(); } } } return $files; } /** @return array> */ function readComments(string $path): array { $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); if ($extension === 'csv') { return readCommentCsv($path); } if (in_array($extension, ['sqlite', 'db'], true)) { return readCommentSqlite($path); } return collectComments(readDatabase($path)); } /** @return array> */ function readCommentCsv(string $path): array { $handle = fopen($path, 'rb'); if (!$handle) { return []; } $header = fgetcsv($handle, null, ',', '"', '') ?: []; $records = []; while (($row = fgetcsv($handle, null, ',', '"', '')) !== false) { $record = array_combine($header, $row); if (is_array($record)) { $records[] = normalizeCommentRecord($record); } } fclose($handle); return $records; } /** @return array> */ function readCommentSqlite(string $path): array { if (!class_exists(PDO::class)) { return []; } try { $pdo = new PDO('sqlite:' . $path); $tables = $pdo->query("SELECT name FROM sqlite_master WHERE type='table'")->fetchAll(PDO::FETCH_COLUMN); $records = []; foreach ($tables as $table) { if (!str_contains(strtolower((string) $table), 'comment')) { continue; } $rows = $pdo->query('SELECT * FROM "' . str_replace('"', '""', (string) $table) . '"')->fetchAll(PDO::FETCH_ASSOC); foreach ($rows as $row) { $records[] = normalizeCommentRecord($row); } } return $records; } catch (Throwable) { return []; } } /** @return array> */ function collectComments(mixed $value, string $slugHint = ''): array { if (!is_array($value)) { return []; } if (isCommentRecord($value)) { $record = normalizeCommentRecord($value); if (($record['slug'] ?? '') === '' && $slugHint !== '') { $record['slug'] = $slugHint; } return [$record]; } $records = []; foreach ($value as $key => $child) { $nextHint = is_string($key) && $key !== '' && !is_numeric($key) ? $key : $slugHint; foreach (collectComments($child, $nextHint) as $record) { $records[] = $record; } } return $records; } function isCommentRecord(array $value): bool { $bodyKeys = ['body', 'content', 'comment', 'message', 'text']; $identityKeys = ['author', 'name', 'email', 'username']; return array_intersect($bodyKeys, array_keys($value)) !== [] && array_intersect($identityKeys, array_keys($value)) !== []; } /** @param array $record @return array */ function normalizeCommentRecord(array $record): array { $slug = (string) ( $record['slug'] ?? $record['page'] ?? $record['pageKey'] ?? $record['page_key'] ?? $record['post'] ?? $record['post_slug'] ?? '' ); return [ 'slug' => $slug, 'author' => $record['author'] ?? $record['name'] ?? $record['username'] ?? 'Imported', 'email' => $record['email'] ?? '', 'website' => $record['website'] ?? $record['url'] ?? $record['site'] ?? '', 'body' => $record['body'] ?? $record['content'] ?? $record['comment'] ?? $record['message'] ?? $record['text'] ?? '', 'status' => $record['status'] ?? $record['state'] ?? 'approved', 'date' => $record['date'] ?? $record['created'] ?? $record['created_at'] ?? '', 'modified' => $record['modified'] ?? $record['updated'] ?? $record['updated_at'] ?? '', 'user_agent' => $record['user_agent'] ?? $record['agent'] ?? '', ]; } function line(string $message): void { echo $message . PHP_EOL; } function fail(string $message): never { fwrite(STDERR, $message . PHP_EOL); exit(1); }