From 93555b3044a3d9aae3a5459bfeb9fa29b17c4976 Mon Sep 17 00:00:00 2001 From: interlope Date: Mon, 1 Jun 2026 16:46:58 +0200 Subject: [PATCH] Add initial plugin implementation for Bludit GitHub export --- metadata.json | 10 ++ plugin.php | 376 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 386 insertions(+) create mode 100644 metadata.json create mode 100644 plugin.php diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..991e6b5 --- /dev/null +++ b/metadata.json @@ -0,0 +1,10 @@ +{ + "author": "interlope", + "email": "", + "website": "https://github.com/interlope/bludit-github", + "version": "1.0.0", + "releaseDate": "2026-06-01", + "license": "MIT", + "compatible": "3.20", + "notes": "Export Bludit articles to a GitHub repository as Markdown files with YAML frontmatter." +} diff --git a/plugin.php b/plugin.php new file mode 100644 index 0000000..fcc84d1 --- /dev/null +++ b/plugin.php @@ -0,0 +1,376 @@ +dbFields = [ + 'token' => '', + 'owner' => '', + 'repo' => '', + 'branch' => 'main', + 'path' => 'posts', + 'exportDrafts' => false, + ]; + } + + public function form() + { + $token = $this->getValue('token'); + $owner = $this->getValue('owner'); + $repo = $this->getValue('repo'); + $branch = $this->getValue('branch'); + $path = $this->getValue('path'); + $exportDrafts = $this->getValue('exportDrafts') ? 'checked' : ''; + $status = $this->getStatus(); + + $html = '
'; + $html .= ''; + $html .= ''; + $html .= 'Requires repo scope. Generate token'; + $html .= '
'; + + $html .= '
'; + $html .= ''; + $html .= ''; + $html .= '
'; + + $html .= '
'; + $html .= ''; + $html .= ''; + $html .= '
'; + + $html .= '
'; + $html .= ''; + $html .= ''; + $html .= '
'; + + $html .= '
'; + $html .= ''; + $html .= ''; + $html .= 'Directory in the repo where articles are written (e.g. posts). Leave empty for root.'; + $html .= '
'; + + $html .= '
'; + $html .= ''; + $html .= '
'; + + $html .= '
'; + $html .= ''; + $html .= '

Push all ' . ($exportDrafts ? '' : 'published ') . 'articles to GitHub at once.

'; + $html .= ''; + $html .= '
'; + + if ($status) { + $isError = stripos($status, 'error') !== false; + $alertClass = $isError ? 'alert-danger' : 'alert-success'; + $html .= ''; + } + + return $html; + } + + public function post() + { + parent::post(); + + if (!empty($_POST['bulkExport'])) { + $this->bulkExport(); + } + } + + public function afterPageCreate() + { + $key = $this->getAffectedPageKey(); + if (!$key) { + return; + } + try { + $page = new Page($key); + if ($this->shouldExport($page)) { + $this->exportPage($page); + } + } catch (Exception $e) { + // page not found + } + } + + public function afterPageModify() + { + $key = $this->getAffectedPageKey(); + if (!$key) { + return; + } + try { + $page = new Page($key); + if ($this->shouldExport($page)) { + $this->exportPage($page); + } + } catch (Exception $e) { + // page not found + } + } + + public function afterPageDelete() + { + if (!$this->isConfigured()) { + return; + } + + $currentKeys = $this->getAllPageKeys(); + $exportPath = trim($this->getValue('path'), '/'); + $endpoint = '/contents/' . ($exportPath ?: ''); + + $files = $this->githubRequest('GET', $endpoint); + if (!is_array($files)) { + return; + } + + foreach ($files as $file) { + if (!isset($file['type']) || $file['type'] !== 'file') { + continue; + } + if (substr($file['name'], -3) !== '.md') { + continue; + } + $key = substr($file['name'], 0, -3); + if (!in_array($key, $currentKeys, true)) { + $this->githubRequest('DELETE', '/contents/' . $file['path'], [ + 'message' => 'Delete: ' . $key, + 'sha' => $file['sha'], + 'branch' => $this->getValue('branch'), + ]); + } + } + } + + // --- Private --- + + private function shouldExport($page) + { + return $page->status() !== 'draft' || $this->getValue('exportDrafts'); + } + + private function exportPage($page, $saveStatus = true) + { + $content = $this->buildMarkdown($page); + $filePath = $this->getFilePath($page->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 bulkExport() + { + if (!$this->isConfigured()) { + $this->setStatus('Error: GitHub settings not configured.'); + return; + } + + global $pages; + + $includeDrafts = (bool)$this->getValue('exportDrafts'); + $list = $pages->getList(1, 9999, true, true, true, $includeDrafts, false); + $count = 0; + + foreach ($list as $key) { + try { + $page = new Page($key); + if ($this->shouldExport($page)) { + $this->exportPage($page, false); + $count++; + } + } catch (Exception $e) { + continue; + } + } + + $this->setStatus(date('Y-m-d H:i:s') . " — Bulk export: {$count} articles pushed."); + } + + private function buildMarkdown($page) + { + // Prefer raw content (Markdown as typed); fall back to rendered HTML + $content = $page->getValue('content'); + if (empty($content)) { + $content = $page->content(); + } + + // Tags + $tags = $page->tags(); + $tagLines = ''; + if (!empty($tags)) { + foreach ($tags as $tag) { + $tagName = is_object($tag) ? $tag->name() : (string)$tag; + $tagLines .= "\n - " . $tagName; + } + } + + $fm = "---\n"; + $fm .= 'title: "' . addslashes($page->title()) . "\"\n"; + $fm .= 'date: ' . date('Y-m-d', strtotime($page->getValue('dateRaw'))) . "\n"; + $fm .= 'slug: ' . $page->key() . "\n"; + $fm .= 'status: ' . $page->status() . "\n"; + $fm .= 'tags:' . ($tagLines ?: ' []') . "\n"; + + $description = $page->description(); + if (!empty($description)) { + $fm .= 'description: "' . addslashes($description) . "\"\n"; + } + + $category = $page->category(); + if (!empty($category)) { + $catKey = is_object($category) ? $category->key() : (string)$category; + if ($catKey && $catKey !== 'uncategorized') { + $fm .= 'category: ' . $catKey . "\n"; + } + } + + $cover = $page->coverImage(); + if (!empty($cover)) { + $fm .= 'cover: ' . $cover . "\n"; + } + + $fm .= "---\n\n"; + + return $fm . $content; + } + + private function getFilePath($key) + { + $exportPath = trim($this->getValue('path'), '/'); + return ($exportPath ? $exportPath . '/' : '') . $key . '.md'; + } + + private function getFileSha($filePath) + { + $result = $this->githubRequest('GET', '/contents/' . $filePath); + return (is_array($result) && isset($result['sha'])) ? $result['sha'] : null; + } + + private function githubRequest($method, $endpoint, $data = null) + { + $token = $this->getValue('token'); + $owner = $this->getValue('owner'); + $repo = $this->getValue('repo'); + + if (!$token || !$owner || !$repo) { + return false; + } + + $url = 'https://api.github.com/repos/' . $owner . '/' . $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 means the file 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 getAffectedPageKey() + { + // Bludit puts the slug in POST on page save + foreach (['slug', 'key', 'friendlyURL'] as $field) { + if (!empty($_POST[$field])) { + return Sanitize::slug($_POST[$field]); + } + } + + // Fallback: most recently modified page directory + if (!defined('PATH_PAGES') || !is_dir(PATH_PAGES)) { + return null; + } + + $newest = null; + $newestTime = 0; + + foreach (glob(PATH_PAGES . '*', GLOB_ONLYDIR) as $dir) { + $indexFile = $dir . DS . 'index.php'; + if (file_exists($indexFile)) { + $mtime = filemtime($indexFile); + if ($mtime > $newestTime) { + $newestTime = $mtime; + $newest = basename($dir); + } + } + } + + return $newest; + } + + private function getAllPageKeys() + { + global $pages; + return $pages->getList(1, 9999, true, true, true, true, false); + } + + 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) : ''; + } +}