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 .= ''; $html .= ''; $html .= 'Requires public_repo scope (or repo for private repositories). Generate token'; $html .= '
'; $html .= '
'; $html .= ''; $html .= ''; $html .= '
'; $html .= '
'; $html .= ''; $html .= ''; $html .= '
'; $html .= '
'; $html .= ''; $html .= ''; $html .= '
'; $html .= '
'; $html .= ''; $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 .= ''; $html .= '
'; $html .= ''; $html .= '
'; $html .= '
'; $html .= ''; $html .= '
'; $html .= '
'; $html .= '
'; $html .= ''; $html .= ''; $html .= ''; $html .= '
'; $html .= $this->importForm(); if ($status) { $isError = stripos($status, 'error') !== false; $alertClass = $isError ? 'alert-danger' : 'alert-success'; $html .= ''; } 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) || strpos($key, 'autosave-') === 0 || !$this->isConfigured()) { return; } $page = new Page($key); if ($page->getValue('type') === 'autosave') { return; } $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) || strpos($key, 'autosave-') === 0 || !$this->isConfigured()) { return; } $page = new Page($key); if ($page->getValue('type') === 'autosave') { return; } $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) || strpos($key, 'autosave-') === 0 || !$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 .= ''; $html .= ''; $html .= ''; $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 .= ''; 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:

'; if ($plan['local'] !== null) { $html .= '
Current local content
'; } $html .= ''; $html .= ''; $html .= ''; $html .= '

A protected local backup is saved before replacing an existing page. Other page metadata is preserved.

'; $html .= ' '; $html .= ''; } 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->contentRaw(); // 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->contentRaw(); // 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) : ''; } }