dbFields = [
'token' => '',
'owner' => '',
'repo' => '',
'branch' => 'main',
'path' => '',
'exportDraftsAuto' => false,
'exportPublishedAuto' => true,
];
}
public function form()
{
$token = $this->getValue('token');
$owner = $this->getValue('owner');
$repo = $this->getValue('repo');
$branch = $this->getValue('branch');
$path = $this->getValue('path');
$exportDraftsAuto = $this->getValue('exportDraftsAuto') ? 'checked' : '';
$exportPublishedAuto = $this->getValue('exportPublishedAuto') ? 'checked' : '';
$status = $this->getStatus();
$html = '
';
$html .= '
GitHub Personal Access Token ';
$html .= '
';
$html .= '
Requires public_repo scope (or repo for private repositories). Generate token ';
$html .= '
';
$html .= '';
$html .= 'Repository Owner ';
$html .= ' ';
$html .= '
';
$html .= '';
$html .= 'Repository Name ';
$html .= ' ';
$html .= '
';
$html .= '';
$html .= 'Branch ';
$html .= ' ';
$html .= '
';
$html .= '';
$html .= 'Path Prefix (optional) ';
$html .= ' ';
$html .= 'Each article is exported as {key}/content/index.md. Set a prefix (e.g. articles) to nest under a subfolder. ';
$html .= '
';
$html .= '';
$html .= '
Auto-export settings ';
$html .= '
';
$html .= ' Auto-export draft articles on save ';
$html .= '
';
$html .= '
';
$html .= ' Auto-export published articles on save ';
$html .= '
';
$html .= '
';
$html .= '';
$html .= 'Bulk Export ';
$html .= ' ';
$html .= ' ';
$html .= '
';
$html .= $this->importForm();
if ($status) {
$isError = stripos($status, 'error') !== false;
$alertClass = $isError ? 'alert-danger' : 'alert-success';
$html .= '' . htmlspecialchars($status) . '
';
}
return $html;
}
public function post()
{
parent::post();
try {
if (!empty($_POST['previewImport'])) {
$this->previewImport($_POST['importSlug'] ?? '', !empty($_POST['importAsDraft']), $_POST['importAuthor'] ?? '');
} elseif (!empty($_POST['confirmImport'])) {
$this->confirmImport($_POST['importConfirmation'] ?? '');
} elseif (!empty($_POST['cancelImport'])) {
Session::remove('bluditGithubImport');
$this->setStatus('Import preview cancelled.');
} else {
Session::remove('bluditGithubImport');
if (!empty($_POST['bulkExportAll'])) {
$this->bulkExport(true);
} elseif (!empty($_POST['bulkExportPublished'])) {
$this->bulkExport(false);
}
}
} catch (Throwable $e) {
$this->setStatus('Error: ' . $e->getMessage());
}
}
public function afterPageCreate($key)
{
try {
if ($this->importing || empty($key) || !$this->isConfigured()) {
return;
}
$page = new Page($key);
$isDraft = $page->getValue('type') === 'draft';
$shouldAutoExport = $isDraft ? $this->getValue('exportDraftsAuto') : $this->getValue('exportPublishedAuto');
if ($shouldAutoExport) {
$this->exportPage($page);
}
} catch (Throwable $e) {
$this->setStatus('Error (afterPageCreate): ' . $e->getMessage());
}
}
public function afterPageModify($key)
{
try {
if ($this->importing || empty($key) || !$this->isConfigured()) {
return;
}
$page = new Page($key);
$isDraft = $page->getValue('type') === 'draft';
$shouldAutoExport = $isDraft ? $this->getValue('exportDraftsAuto') : $this->getValue('exportPublishedAuto');
if ($shouldAutoExport) {
$this->exportPage($page);
}
} catch (Throwable $e) {
$this->setStatus('Error (afterPageModify): ' . $e->getMessage());
}
}
public function afterPageDelete($key)
{
try {
if ($this->importing || empty($key) || !$this->isConfigured()) {
return;
}
$prefix = trim($this->getValue('path'), '/');
$base = ($prefix ? $prefix . '/' : '') . $key;
// Delete content/index.md
$mdPath = $base . '/content/index.md';
$sha = $this->getFileSha($mdPath);
if ($sha) {
$this->githubRequest('DELETE', '/contents/' . $mdPath, [
'message' => 'Delete: ' . $key,
'sha' => $sha,
'branch' => $this->getValue('branch'),
]);
}
// Delete article image and attachment files (one directory level, like the exporter).
foreach (['img', 'files'] as $directory) {
$items = $this->githubRequest('GET', '/contents/' . $base . '/' . $directory);
if (!is_array($items)) { continue; }
foreach ($items as $item) {
if (($item['type'] ?? '') === 'file') {
$this->githubRequest('DELETE', '/contents/' . $item['path'], [
'message' => 'Delete asset: ' . $key . '/' . $directory . '/' . $item['name'],
'sha' => $item['sha'],
'branch' => $this->getValue('branch'),
]);
}
}
}
} catch (Throwable $e) {
$this->setStatus('Error (afterPageDelete): ' . $e->getMessage());
}
}
// Import previews are bound to the current admin session and expire after 30 minutes.
private function importForm()
{
global $users;
$plan = Session::get('bluditGithubImport');
$e = function ($v) { return htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8'); };
$draftChecked = !is_array($plan) || !empty($plan['asDraft']) ? ' checked' : '';
$html = '';
$html .= '
Import one article from GitHub ';
$html .= '
';
$html .= '
Import as draft (including existing articles)';
$html .= '
Article author ';
$html .= 'Use repository author, otherwise preserve existing author (new pages: logged-in user) ';
foreach ($users->keys() as $username) {
$selected = ($plan['authorOverride'] ?? '') === $username ? ' selected' : '';
$html .= '' . $e($username) . ' ';
}
$html .= ' Choose an existing Bludit account to change the author. No user is created by importing.
';
$html .= '
Preview reads the configured branch without changing articles. Confirmation replaces the selected page only. No automatic pulling or deletion.
';
$html .= '
Preview import ';
if (is_array($plan)) {
$html .= '
Import preview: ' . $e($plan['slug']) . ' ';
$html .= '
' . ($plan['local'] === null ? 'Create a new page' : 'Replace the existing page') . ' from commit ' . $e($plan['commit']) . '.
';
$html .= '
';
foreach (['title', 'username', 'description', 'date', 'category', 'tags', 'type'] as $field) {
$value = $plan['args'][$field] ?? '(preserve existing value)';
if ($field === 'type') { $value = $plan['effectiveType']; }
$html .= '' . $e($field === 'type' ? 'Resulting status' : ($field === 'username' ? 'Author' : $field)) . ' ' . $e($value) . ' ';
}
$html .= ' Assets to download:
';
foreach ($plan['assets'] as $asset) {
$html .= '' . $e($asset['relative']) . ' (' . (int)$asset['size'] . ' bytes) ';
}
$html .= ' ';
if ($plan['local'] !== null) {
$html .= '
Current local content ';
}
$html .= '
Incoming content (escaped; not executed) ';
$html .= '
';
$html .= '
';
$html .= '
A protected local backup is saved before replacing an existing page. Other page metadata is preserved.
';
$html .= '
Confirm this import ';
$html .= '
Cancel preview ';
}
return $html . '
';
}
private function importConfigHash()
{
$values = [];
foreach (['owner', 'repo', 'branch', 'path', 'token'] as $key) { $values[] = $this->getValue($key); }
return hash('sha256', json_encode($values));
}
private function importLocalState($slug)
{
$current = new Pages();
if (!$current->exists($slug)) { return null; }
$content = file_get_contents(PATH_PAGES . $slug . DS . FILENAME);
if ($content === false) { throw new RuntimeException('Cannot read the existing page content.'); }
return ['row' => $current->getPageDB($slug), 'content' => $content];
}
private function previewImport($slug, $asDraft, $authorOverride = '')
{
global $pages, $categories, $users;
Session::remove('bluditGithubImport');
if (!$this->isConfigured()) { throw new RuntimeException('Configure the GitHub repository first.'); }
BluditGithubImport::slug($slug);
// Refuse Bludit slug normalization/collisions instead of importing into a different page.
if ($pages->generateKey($slug, false, false, $pages->exists($slug) ? $slug : '') !== $slug) {
throw new RuntimeException('Bludit would change this slug. Choose an exact, supported page key.');
}
$prefix = trim($this->getValue('path'), '/');
$base = ($prefix === '' ? '' : BluditGithubImport::path($prefix) . '/') . $slug . '/';
$commit = $this->githubRequest('GET', '/commits/' . rawurlencode($this->getValue('branch')));
if (!is_array($commit) || !preg_match('/\A[a-f0-9]{40}\z/', $commit['sha'] ?? '') || empty($commit['commit']['tree']['sha'])) {
throw new RuntimeException('Cannot resolve the configured GitHub branch. Check the repository, token and branch.');
}
$tree = $this->githubRequest('GET', '/git/trees/' . rawurlencode($commit['commit']['tree']['sha']) . '?recursive=1');
if (!is_array($tree) || !isset($tree['tree']) || !empty($tree['truncated'])) {
throw new RuntimeException('Cannot read a complete repository tree.');
}
$entries = [];
foreach ($tree['tree'] as $item) { $entries[$item['path']] = $item; }
$document = $entries[$base . 'content/index.md'] ?? null;
if (!$document) { throw new RuntimeException('Article content/index.md not found on the configured branch.'); }
$raw = $this->importBlob($document, 1024 * 1024);
$parsed = BluditGithubImport::document($raw, $slug);
$meta = $parsed['meta'];
$local = $this->importLocalState($slug);
$uuid = $local === null ? bin2hex(random_bytes(16)) : $local['row']['uuid'];
if (!is_string($uuid) || !preg_match('/\A[a-zA-Z0-9_-]+\z/', $uuid)) {
throw new RuntimeException('Invalid Bludit page upload identifier.');
}
$args = ['slug' => $slug, 'title' => Sanitize::htmlDecode($meta['title'])];
if (!is_string($authorOverride)) { throw new RuntimeException('Invalid author selection.'); }
$author = $authorOverride !== '' ? $authorOverride : (($meta['author'] ?? '') ?: ($local['row']['username'] ?? Session::get('username')));
if (!$users->exists($author)) {
throw new RuntimeException('The author does not exist in Bludit: ' . $author . '. Choose an existing author in the import form.');
}
$args['username'] = $author;
foreach (['description', 'category'] as $field) {
if (array_key_exists($field, $meta)) { $args[$field] = Sanitize::htmlDecode($meta[$field]); }
}
if (!empty($args['category']) && !$categories->exists($args['category'])) {
throw new RuntimeException('Create the category in Bludit before importing: ' . $args['category']);
}
if (isset($meta['tags'])) {
foreach ($meta['tags'] as $tag) {
if (strpos($tag, ',') !== false) { throw new RuntimeException('Tags cannot contain commas.'); }
}
$args['tags'] = implode(',', $meta['tags']);
}
if (!empty($meta['date'])) {
$oldDate = $local['row']['date'] ?? '';
$args['date'] = substr($oldDate, 0, 10) === $meta['date'] ? $oldDate : $meta['date'] . ' 00:00:00';
}
$args['type'] = $asDraft ? 'draft' : (($meta['status'] ?? '') ?: ($local['row']['type'] ?? 'draft'));
$effectiveType = $args['type'];
if ($effectiveType === 'published' && ($args['date'] ?? ($local['row']['date'] ?? '')) > date('Y-m-d H:i:s')) {
$effectiveType = 'scheduled';
}
$links = BluditGithubImport::links($parsed['content']);
if (!empty($meta['cover'])) {
if (strpos($meta['cover'], '../') === 0) {
$coverLinks = BluditGithubImport::links(' ');
$links += $coverLinks;
} elseif (!preg_match('~\Ahttps?://[^\s<>"\x27]+\z~', $meta['cover'])) {
throw new RuntimeException('Cover must be ../img/filename or an HTTP(S) URL.');
}
}
$assets = [];
$total = 0;
foreach ($links as $url => $relative) {
$entry = $entries[$base . $relative] ?? null;
if (!$entry || ($entry['type'] ?? '') !== 'blob' || !in_array($entry['mode'] ?? '', ['100644', '100755'], true)) {
throw new RuntimeException('Missing asset or unsupported symlink: ' . $relative);
}
$ext = BluditGithubImport::extension($relative);
$total += (int)($entry['size'] ?? 0);
if (($entry['size'] ?? 0) > 10 * 1024 * 1024 || $total > 32 * 1024 * 1024 || count($assets) >= 100) {
throw new RuntimeException('Import exceeds the asset limits (100 files, 10 MiB/file, 32 MiB total).');
}
$assets[$url] = ['relative' => $relative, 'sha' => $entry['sha'], 'mode' => $entry['mode'], 'type' => 'blob', 'size' => $entry['size'],
'filename' => 'gh-' . $entry['sha'] . '-' . substr(sha1($relative), 0, 12) . '.' . $ext];
}
$plan = ['slug' => $slug, 'commit' => $commit['sha'], 'config' => $this->importConfigHash(), 'created' => time(),
'confirmation' => bin2hex(random_bytes(24)), 'local' => $local, 'uuid' => $uuid, 'args' => $args,
'effectiveType' => $effectiveType, 'asDraft' => $asDraft, 'authorOverride' => $authorOverride, 'content' => $parsed['content'], 'cover' => $meta['cover'] ?? null, 'assets' => $assets];
Session::set('bluditGithubImport', $plan);
$this->setStatus('Preview ready. Review the incoming content and resulting status, then confirm to import.');
}
private function importBlob($entry, $limit)
{
if (($entry['type'] ?? '') !== 'blob' || !in_array($entry['mode'] ?? '', ['100644', '100755'], true) || !preg_match('/\A[a-f0-9]{40}\z/', $entry['sha'] ?? '') || ($entry['size'] ?? 0) > $limit) {
throw new RuntimeException('Invalid Git blob or file too large.');
}
return BluditGithubImport::blob($this->githubRequest('GET', '/git/blobs/' . $entry['sha']), $entry['sha'], $limit);
}
private function writeProtectedJson($file, $value)
{
$json = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$tmp = tempnam($this->workspace(), '.github-');
if ($tmp === false) { throw new RuntimeException('Cannot create a protected plugin workspace file.'); }
try {
if (!chmod($tmp, 0600) || file_put_contents($tmp, "\n" . $json) === false || !rename($tmp, $file)) {
throw new RuntimeException('Cannot save protected plugin workspace data.');
}
} finally { if (file_exists($tmp)) { unlink($tmp); } }
}
private function assetMap($slug)
{
$file = $this->workspace() . 'assets-' . hash('sha256', $slug) . '.php';
if (!is_file($file)) { return []; }
$raw = file_get_contents($file);
$data = json_decode(substr($raw, strpos($raw, "\n") + 1), true);
if (!is_array($data)) { throw new RuntimeException('Cannot read imported asset mapping.'); }
return $data;
}
private function confirmImport($confirmation)
{
global $pages, $users;
$plan = Session::get('bluditGithubImport');
if (!is_array($plan) || !is_string($confirmation) || !hash_equals($plan['confirmation'], $confirmation) || time() - $plan['created'] > 1800 || !hash_equals($plan['config'], $this->importConfigHash())) {
Session::remove('bluditGithubImport');
throw new RuntimeException('Import preview expired or settings changed. Preview again.');
}
if (!$users->exists($plan['args']['username'])) {
throw new RuntimeException('The selected author no longer exists. Preview again.');
}
$lock = fopen($this->workspace() . 'import.lock', 'c');
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
if ($lock) { fclose($lock); }
throw new RuntimeException('Another import is running. Retry later.');
}
$installed = [];
$attempted = false;
$backup = null;
try {
if ($this->importLocalState($plan['slug']) !== $plan['local']) {
throw new RuntimeException('The local page changed since preview. Preview again before replacing it.');
}
$files = [];
foreach ($plan['assets'] as $asset) {
$data = $this->importBlob($asset, 10 * 1024 * 1024);
$ext = BluditGithubImport::extension($asset['relative']);
if (in_array($ext, ['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'ico'], true)) {
$image = @getimagesizefromstring($data);
$types = ['png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'gif' => 'image/gif', 'webp' => 'image/webp', 'avif' => 'image/avif', 'ico' => 'image/vnd.microsoft.icon'];
if (!$image || ($image['mime'] ?? '') !== $types[$ext]) { throw new RuntimeException('Invalid image contents: ' . $asset['relative']); }
}
$files[$asset['filename']] = $data;
}
// Downloads may take time; catch edits made while waiting for GitHub.
if ($this->importLocalState($plan['slug']) !== $plan['local']) {
throw new RuntimeException('The local page changed during asset download. Preview again.');
}
if ($plan['local'] !== null) {
$backup = $this->workspace() . 'backup-' . date('Ymd-His') . '-' . bin2hex(random_bytes(8)) . '.php';
$this->writeProtectedJson($backup, ['slug' => $plan['slug'], 'commit' => $plan['commit'], 'page' => $plan['local'], 'assetMap' => $this->assetMap($plan['slug'])]);
}
$dir = PATH_UPLOADS_PAGES . $plan['uuid'] . DS;
if (is_link(rtrim($dir, DS)) || (!is_dir($dir) && !mkdir($dir, 0755, true))) { throw new RuntimeException('Cannot prepare the page upload directory.'); }
foreach ($files as $name => $data) {
$dest = $dir . $name;
if (is_link($dest)) { throw new RuntimeException('Refusing a symlink in page uploads.'); }
if (is_file($dest)) {
if (file_get_contents($dest) !== $data) { throw new RuntimeException('An imported asset filename already has different contents.'); }
continue;
}
$tmp = tempnam($dir, '.github-');
if ($tmp === false) { throw new RuntimeException('Cannot stage an imported asset.'); }
try {
if (file_put_contents($tmp, $data) === false || !chmod($tmp, 0644) || !rename($tmp, $dest)) { throw new RuntimeException('Cannot install an imported asset.'); }
} finally { if (is_file($tmp)) { unlink($tmp); } }
$installed[] = $dest;
}
$url = rtrim(DOMAIN_UPLOADS_PAGES, '/') . '/' . $plan['uuid'] . '/';
$args = $plan['args'];
$args['content'] = BluditGithubImport::links($plan['content'], function ($relative) use ($plan, $url) {
return $url . $plan['assets'][$relative]['filename'];
});
if ($plan['cover'] !== null) {
$args['coverImage'] = isset($plan['assets'][$plan['cover']]) ? $url . $plan['assets'][$plan['cover']]['filename'] : $plan['cover'];
}
if ($plan['local'] === null) { $args['uuid'] = $plan['uuid']; } else { $args['key'] = $plan['slug']; }
if ($this->importLocalState($plan['slug']) !== $plan['local']) {
throw new RuntimeException('The local page changed before save. Preview again.');
}
$pages = new Pages();
$this->importing = true;
$attempted = true;
$key = $plan['local'] === null ? createPage($args) : editPage($args);
// createPage() deliberately assigns the logged-in account, ignoring username.
// Apply an explicitly selected/repository author through a normal core edit,
// without impersonating another user or changing the administrator's session.
if ($key === $plan['slug'] && $plan['local'] === null && (new Page($key))->getValue('username') !== $args['username']) {
$key = editPage(['key' => $key, 'username' => $args['username']]);
}
if ($key !== $plan['slug'] || !$pages->save()) { throw new RuntimeException('Bludit could not persist the imported page.'); }
$map = $this->assetMap($plan['slug']);
foreach ($plan['assets'] as $asset) { $map[$asset['filename']] = $asset['relative']; }
$this->writeProtectedJson($this->workspace() . 'assets-' . hash('sha256', $plan['slug']) . '.php', $map);
Session::remove('bluditGithubImport');
$this->setStatus('Imported ' . $key . ' from ' . substr($plan['commit'], 0, 12) . ' as ' . (new Page($key))->getValue('type') . '. No export was triggered.' . ($backup ? ' Backup: ' . basename($backup) : ''));
} catch (Throwable $e) {
if ($attempted) {
try {
if ($plan['local'] === null) {
if ($pages->exists($plan['slug']) && !$pages->delete($plan['slug'])) { throw new RuntimeException('Cannot remove partial new page.'); }
} else {
if (file_put_contents(PATH_PAGES . $plan['slug'] . DS . FILENAME, $plan['local']['content']) === false) { throw new RuntimeException('Cannot restore page content.'); }
$pages->db[$plan['slug']] = $plan['local']['row'];
if (!$pages->save()) { throw new RuntimeException('Cannot restore page metadata.'); }
}
reindexCategories();
reindexTags();
} catch (Throwable $restore) {
throw new RuntimeException('Import failed and automatic restoration failed. Restore the protected backup ' . ($backup ? basename($backup) : '(new page)') . ': ' . $restore->getMessage(), 0, $e);
}
}
foreach ($installed as $file) { if (is_file($file)) { unlink($file); } }
throw $e;
} finally {
$this->importing = false;
flock($lock, LOCK_UN);
fclose($lock);
}
}
// --- Private ---
private function exportPage($page, $saveStatus = true)
{
$key = $page->getValue('key');
$rawContent = $page->content();
// Export images using URLs found in the content (before rewriting)
$this->exportImages($key, $rawContent . "\n" . $page->coverImage());
$content = $this->buildMarkdown($page, $key, $rawContent);
$filePath = $this->getFilePath($key);
$sha = $this->getFileSha($filePath);
$data = [
'message' => ($sha ? 'Update' : 'Add') . ': ' . $page->title(),
'content' => base64_encode($content),
'branch' => $this->getValue('branch'),
];
if ($sha) {
$data['sha'] = $sha;
}
$result = $this->githubRequest('PUT', '/contents/' . $filePath, $data);
if ($result !== false && $saveStatus) {
$this->setStatus(date('Y-m-d H:i:s') . ' — Exported: ' . $page->title());
}
}
private function defaultAssetPath($filename)
{
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
return (in_array($ext, ['txt', 'pdf', 'csv', 'json', 'zip'], true) ? 'files/' : 'img/') . $filename;
}
private function exportImages($key, $content)
{
preg_match_all(
'/https?:\/\/[^\/]+\/bl-content\/uploads\/pages\/([^\/]+)\/([^"\')\s<>]+)/',
$content,
$matches,
PREG_SET_ORDER
);
if (empty($matches)) {
return;
}
$prefix = trim($this->getValue('path'), '/');
$map = $this->assetMap($key);
$seen = [];
foreach ($matches as $match) {
$fullUrl = $match[0];
$uuid = $match[1];
$filename = $match[2];
if (isset($seen[$filename])) {
continue;
}
$seen[$filename] = true;
$imageData = $this->readImageData($key, $uuid, $filename, $fullUrl);
if ($imageData === null) {
continue;
}
$relative = $map[$filename] ?? $this->defaultAssetPath($filename);
BluditGithubImport::path($relative);
$repoPath = ($prefix ? $prefix . '/' : '') . $key . '/' . $relative;
$sha = $this->getFileSha($repoPath);
$data = [
'message' => ($sha ? 'Update' : 'Add') . ' asset: ' . $key . '/' . $relative,
'content' => base64_encode($imageData),
'branch' => $this->getValue('branch'),
];
if ($sha) {
$data['sha'] = $sha;
}
$this->githubRequest('PUT', '/contents/' . $repoPath, $data);
}
}
private function readImageData($key, $uuid, $filename, $fallbackUrl)
{
// Try filesystem first (UUID path, then page key symlink)
if (defined('PATH_UPLOADS_PAGES')) {
foreach ([PATH_UPLOADS_PAGES . $uuid . DS . $filename,
PATH_UPLOADS_PAGES . $key . DS . $filename] as $path) {
if (file_exists($path)) {
return file_get_contents($path);
}
}
}
// Fall back to fetching the image from the live URL
$ch = curl_init($fallbackUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_USERAGENT => 'Bludit-GitHub-Export',
]);
$data = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return ($data !== false && $httpCode === 200) ? $data : null;
}
private function bulkExport($includeDrafts = false)
{
if (!$this->isConfigured()) {
$this->setStatus('Error: GitHub settings not configured.');
return;
}
global $pages;
$list = $pages->getList(1, 9999, true, true, true, $includeDrafts, false);
$count = 0;
foreach ($list as $key) {
try {
$page = new Page($key);
$this->exportPage($page, false);
$count++;
} catch (Exception $e) {
continue;
}
}
$type = $includeDrafts ? 'everything' : 'published articles';
$this->setStatus(date('Y-m-d H:i:s') . " — Bulk export: {$count} {$type} pushed.");
}
private function buildMarkdown($page, $key, $rawContent = null)
{
$content = $rawContent ?? $page->content();
// Rewrite absolute Bludit upload URLs to relative img/ paths
// e.g. https://domain/bl-content/uploads/pages/UUID/image.png → ../img/image.png
$map = $this->assetMap($key);
$content = preg_replace_callback(
'/https?:\/\/[^\/]+\/bl-content\/uploads\/pages\/[^\/]+\/([^"\')\s<>]+)/',
function ($match) use ($map) {
return '../' . ($map[$match[1]] ?? $this->defaultAssetPath($match[1]));
},
$content
);
// Unwrap carousel code blocks: fenced blocks containing only image lines
// become plain image lists so GitHub renders them instead of showing raw text.
$content = preg_replace_callback(
'/^```[^\n]*\n(.*?)^```[ \t]*$/ms',
function ($m) {
$lines = explode("\n", $m[1]);
$imageLines = [];
foreach ($lines as $line) {
$trimmed = trim($line);
if ($trimmed === '') {
continue;
}
if (!preg_match('/^!\[[^\]]*\]\([^)]+\)$/', $trimmed)) {
return $m[0]; // contains non-image content — leave the block intact
}
$imageLines[] = $trimmed;
}
if (empty($imageLines)) {
return $m[0];
}
return implode("\n", $imageLines) . "\n";
},
$content
);
// Tags
$rawTags = $page->getValue('tags');
$tagLines = '';
if (!empty($rawTags)) {
$tagArray = is_array($rawTags) ? $rawTags : array_map('trim', explode(',', $rawTags));
foreach ($tagArray as $tag) {
$tag = trim((string)$tag);
if ($tag !== '') {
$tagLines .= "\n - " . json_encode($tag, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
}
}
$fm = "---\n";
$fm .= 'title: ' . json_encode(Sanitize::htmlDecode($page->title()), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
$fm .= 'date: ' . date('Y-m-d', strtotime($page->getValue('dateRaw'))) . "\n";
$fm .= 'slug: ' . $key . "\n";
$fm .= 'author: ' . json_encode($page->getValue('username'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
$fm .= 'status: ' . $page->getValue('type') . "\n";
$fm .= 'tags:' . ($tagLines ?: ' []') . "\n";
$description = Sanitize::htmlDecode($page->getValue('description'));
if (!empty($description)) {
$fm .= 'description: ' . json_encode($description, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
}
$category = Sanitize::htmlDecode($page->getValue('category'));
if (!empty($category) && $category !== 'uncategorized') {
$fm .= 'category: ' . json_encode($category, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
}
$cover = $page->coverImage();
if (!empty($cover)) {
$cover = preg_replace_callback(
'/https?:\/\/[^\/]+\/bl-content\/uploads\/pages\/[^\/]+\/([^"\')\s<>]+)/',
function ($match) use ($map) { return '../' . ($map[$match[1]] ?? $this->defaultAssetPath($match[1])); },
$cover
);
$fm .= 'cover: ' . json_encode($cover, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
}
$fm .= "---\n\n";
return $fm . $content;
}
private function getFilePath($key)
{
$prefix = trim($this->getValue('path'), '/');
return ($prefix ? $prefix . '/' : '') . $key . '/content/index.md';
}
private function getFileSha($filePath)
{
$result = $this->githubRequest('GET', '/contents/' . $filePath);
return (is_array($result) && isset($result['sha'])) ? $result['sha'] : null;
}
protected function githubRequest($method, $endpoint, $data = null)
{
$token = $this->getValue('token');
$owner = $this->getValue('owner');
$repo = $this->getValue('repo');
if (!$token || !$owner || !$repo) {
return false;
}
if ($method === 'GET' && strpos($endpoint, '/contents/') === 0 && strpos($endpoint, '?') === false) {
$endpoint .= '?ref=' . rawurlencode($this->getValue('branch'));
}
$url = 'https://api.github.com/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . $endpoint;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $token,
'Accept: application/vnd.github+json',
'X-GitHub-Api-Version: 2022-11-28',
'User-Agent: Bludit-GitHub-Export',
'Content-Type: application/json',
],
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_TIMEOUT => 15,
]);
if ($method === 'PUT') {
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
} elseif ($method === 'DELETE') {
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($response === false) {
$this->setStatus('Error: cURL — ' . $curlError);
return false;
}
// 404 on GET = file/dir doesn't exist yet, not an error
if ($httpCode === 404) {
return null;
}
$decoded = json_decode($response, true);
if ($httpCode >= 400) {
$message = isset($decoded['message']) ? $decoded['message'] : 'HTTP ' . $httpCode;
$this->setStatus('Error: GitHub API (' . $httpCode . ') — ' . $message);
return false;
}
return $decoded;
}
private function isConfigured()
{
return $this->getValue('token') && $this->getValue('owner') && $this->getValue('repo');
}
private function setStatus($message)
{
file_put_contents($this->workspace() . 'status.txt', $message);
}
private function getStatus()
{
$file = $this->workspace() . 'status.txt';
return file_exists($file) ? file_get_contents($file) : '';
}
}