- Import
This commit is contained in:
@@ -42,6 +42,25 @@ php cli/blog.php comments:spam 2
|
|||||||
php cli/blog.php comments:delete 3
|
php cli/blog.php comments:delete 3
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Import Bludit
|
||||||
|
|
||||||
|
Import from a local directory on the server. The source can be the old Bludit root or the old `bl-content` folder.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php cli/import-bludit.php /var/www/old-bludit --dry-run
|
||||||
|
php cli/import-bludit.php /var/www/old-bludit
|
||||||
|
php cli/import-bludit.php /var/www/old-bludit/bl-content
|
||||||
|
php cli/import-bludit.php --source=/var/www/old-bludit --overwrite
|
||||||
|
```
|
||||||
|
|
||||||
|
The importer preserves original publish dates, modified dates, post/static-page type, draft/published state, categories, tags, authors, cover image metadata, allow-comments flags, page order, page-local files, and `bl-content/uploads`.
|
||||||
|
|
||||||
|
It also scans local comment export files under the old `bl-content/databases` and `bl-content/workspaces` directories. To point it at a specific local comment export:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php cli/import-bludit.php /var/www/old-bludit --comments=/var/www/old-bludit/comments.json
|
||||||
|
```
|
||||||
|
|
||||||
## Stats
|
## Stats
|
||||||
|
|
||||||
Visits are appended to `storage/stats/visits.csv` with a header row suitable for spreadsheet import.
|
Visits are appended to `storage/stats/visits.csv` with a header row suitable for spreadsheet import.
|
||||||
|
|||||||
@@ -87,6 +87,42 @@ final class CommentService
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $data */
|
||||||
|
public function import(string $slug, array $data): int
|
||||||
|
{
|
||||||
|
$createdAt = $this->normalizeDate((string) ($data['created_at'] ?? $data['date'] ?? ''));
|
||||||
|
$updatedAt = $this->normalizeDate((string) ($data['updated_at'] ?? $data['modified'] ?? $createdAt));
|
||||||
|
$status = (string) ($data['status'] ?? 'approved');
|
||||||
|
if (!in_array($status, ['pending', 'approved', 'spam'], true)) {
|
||||||
|
$status = match ($status) {
|
||||||
|
'published', 'published-comment', 'visible', '1', 'true' => 'approved',
|
||||||
|
'trash', 'deleted', 'spam-comment' => 'spam',
|
||||||
|
default => 'pending',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'INSERT INTO comments
|
||||||
|
(slug, author, email, website, body, status, ip_hash, user_agent, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(:slug, :author, :email, :website, :body, :status, :ip_hash, :user_agent, :created_at, :updated_at)'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'slug' => $slug,
|
||||||
|
'author' => trim((string) ($data['author'] ?? $data['name'] ?? 'Imported')),
|
||||||
|
'email' => trim((string) ($data['email'] ?? '')),
|
||||||
|
'website' => trim((string) ($data['website'] ?? $data['site_url'] ?? $data['url'] ?? '')),
|
||||||
|
'body' => trim((string) ($data['body'] ?? $data['content'] ?? $data['comment'] ?? '')),
|
||||||
|
'status' => $status,
|
||||||
|
'ip_hash' => trim((string) ($data['ip_hash'] ?? '')),
|
||||||
|
'user_agent' => substr((string) ($data['user_agent'] ?? $data['agent'] ?? 'import'), 0, 300),
|
||||||
|
'created_at' => $createdAt,
|
||||||
|
'updated_at' => $updatedAt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (int) $this->pdo->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
/** @return array<int, array<string, mixed>> */
|
/** @return array<int, array<string, mixed>> */
|
||||||
public function list(?string $status = null, ?string $slug = null): array
|
public function list(?string $status = null, ?string $slug = null): array
|
||||||
{
|
{
|
||||||
@@ -144,4 +180,10 @@ final class CommentService
|
|||||||
|
|
||||||
return hash_hmac('sha256', $ip, $salt);
|
return hash_hmac('sha256', $ip, $salt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function normalizeDate(string $value): string
|
||||||
|
{
|
||||||
|
$timestamp = strtotime($value);
|
||||||
|
return date('c', $timestamp ?: time());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -254,6 +254,50 @@ final class ContentRepository
|
|||||||
return $this->find($slug, true) ?? [];
|
return $this->find($slug, true) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $metadata */
|
||||||
|
public function saveImported(string $slug, array $metadata, string $markdown, bool $rebuildIndex = true): array
|
||||||
|
{
|
||||||
|
$slug = self::slugify($slug !== '' ? $slug : (string) ($metadata['title'] ?? 'untitled'));
|
||||||
|
if ($slug === '') {
|
||||||
|
throw new RuntimeException('A title or slug is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$directory = $this->pagesRoot . '/' . $slug;
|
||||||
|
if (!is_dir($directory)) {
|
||||||
|
mkdir($directory, 0775, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = (string) ($metadata['date'] ?? date('c'));
|
||||||
|
$metadata = array_replace([
|
||||||
|
'title' => self::titleFromSlug($slug),
|
||||||
|
'type' => 'post',
|
||||||
|
'status' => 'published',
|
||||||
|
'date' => $date,
|
||||||
|
'modified' => $metadata['modified'] ?? $date,
|
||||||
|
'category' => 'Notes',
|
||||||
|
'tags' => [],
|
||||||
|
'summary' => '',
|
||||||
|
'author' => 'Editor',
|
||||||
|
'cover' => '',
|
||||||
|
'allow_comments' => true,
|
||||||
|
'menu_order' => 0,
|
||||||
|
], $metadata);
|
||||||
|
|
||||||
|
$metadata['slug'] = $slug;
|
||||||
|
$metadata['tags'] = self::normalizeList($metadata['tags'] ?? []);
|
||||||
|
$metadata['allow_comments'] = self::toBool($metadata['allow_comments'] ?? true);
|
||||||
|
|
||||||
|
file_put_contents($directory . '/index.txt', $this->serialize($metadata, $markdown));
|
||||||
|
$timestamp = strtotime((string) ($metadata['modified'] ?? $metadata['date'])) ?: time();
|
||||||
|
touch($directory . '/index.txt', $timestamp);
|
||||||
|
|
||||||
|
if ($rebuildIndex) {
|
||||||
|
$this->rebuildIndex();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->find($slug, true) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
public function delete(string $slug): bool
|
public function delete(string $slug): bool
|
||||||
{
|
{
|
||||||
$slug = self::slugify($slug);
|
$slug = self::slugify($slug);
|
||||||
|
|||||||
Executable
+646
@@ -0,0 +1,646 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use NeonBlog\ContentRepository;
|
||||||
|
|
||||||
|
$app = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
$repo = $app->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 <<<TEXT
|
||||||
|
Import Bludit content from a local directory on this server.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
php cli/import-bludit.php /var/www/old-bludit
|
||||||
|
php cli/import-bludit.php /var/www/old-bludit/bl-content
|
||||||
|
php cli/import-bludit.php --source=/var/www/old-bludit --dry-run
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--source=/path Local Bludit root or bl-content directory.
|
||||||
|
--dry-run Show what would import without writing files.
|
||||||
|
--overwrite Reuse matching slugs instead of adding numeric suffixes.
|
||||||
|
--skip-uploads Do not copy bl-content/uploads.
|
||||||
|
--skip-comments Do not scan/import local comment data.
|
||||||
|
--comments=/path/file Extra local JSON/PHP/CSV comment export to import.
|
||||||
|
--source-timezone=Zone Timezone for Bludit database dates without offsets.
|
||||||
|
|
||||||
|
Preserves:
|
||||||
|
Original publish dates, modified dates, static pages, post status, categories,
|
||||||
|
tags, authors, cover image metadata, allow-comments flags, page order, uploads,
|
||||||
|
page-local files, and best-effort local comments.
|
||||||
|
|
||||||
|
TEXT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{0: array<int, string>, 1: array<string, mixed>} */
|
||||||
|
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<string, mixed> */
|
||||||
|
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<int, array{key: string, path: string, db: array<string, mixed>}> */
|
||||||
|
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<string, mixed> $row @param array<string, mixed> $categories @param array<string, mixed> $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<string, bool> $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<int, string> */
|
||||||
|
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<int, array<string, mixed>> */
|
||||||
|
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<int, array<string, mixed>> */
|
||||||
|
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<int, array<string, mixed>> */
|
||||||
|
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<int, array<string, mixed>> */
|
||||||
|
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<string, mixed> $record @return array<string, mixed> */
|
||||||
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user