From 86508ffcca87b9db772663aae82fe38b36b8f237 Mon Sep 17 00:00:00 2001 From: th Date: Fri, 10 Apr 2026 11:46:15 +0200 Subject: [PATCH] gitea --- gitea-issue-client | 2 +- public/index.php | 2 +- public/js/hyprcrd.js | 2 + public/js/modules/caldav.js | 32 ++- public/js/modules/markdown.js | 5 + public/plugins/email/email.js | 65 +++-- public/plugins/gitea/GiteaClient.php | 145 +++++++++++ public/plugins/gitea/TODO.md | 99 ++++++++ public/plugins/gitea/backend.php | 171 +++++++++++++ public/plugins/gitea/gitea.css | 47 ++++ public/plugins/gitea/gitea.html | 47 ++++ public/plugins/gitea/gitea.js | 352 +++++++++++++++++++++++++++ public/plugins/gitea/plugin.json | 11 + 13 files changed, 945 insertions(+), 35 deletions(-) create mode 100644 public/plugins/gitea/GiteaClient.php create mode 100644 public/plugins/gitea/TODO.md create mode 100644 public/plugins/gitea/backend.php create mode 100644 public/plugins/gitea/gitea.css create mode 100644 public/plugins/gitea/gitea.html create mode 100644 public/plugins/gitea/gitea.js create mode 100644 public/plugins/gitea/plugin.json diff --git a/gitea-issue-client b/gitea-issue-client index 3d0c2f3..90353c9 160000 --- a/gitea-issue-client +++ b/gitea-issue-client @@ -1 +1 @@ -Subproject commit 3d0c2f3a82495df44e5697569694b4f21c4f6853 +Subproject commit 90353c93adb02b8d2e2362875ce6004fb7c3ec4e diff --git a/public/index.php b/public/index.php index cf52cfb..8017a84 100644 --- a/public/index.php +++ b/public/index.php @@ -1421,8 +1421,8 @@ if (str_starts_with($route, '/api/')) {
-
+ + @@ -233,6 +233,30 @@ function renderAccountsList() { }); } +function setAccountStatus(text, kind = 'info') { + const el = $('email-acc-status'); + if (!el) return; + if (!text) { + el.classList.add('hidden'); + el.textContent = ''; + el.style.borderColor = ''; + el.style.color = ''; + return; + } + el.classList.remove('hidden'); + el.textContent = text; + if (kind === 'error') { + el.style.borderColor = 'var(--t-danger)'; + el.style.color = 'var(--t-danger)'; + } else if (kind === 'success') { + el.style.borderColor = 'var(--t-accent)'; + el.style.color = 'var(--t-accent)'; + } else { + el.style.borderColor = 'var(--t-border)'; + el.style.color = 'var(--t-muted)'; + } +} + function resetAccountForm() { editingAccountId = null; const form = $('email-account-form'); @@ -246,7 +270,7 @@ function resetAccountForm() { $('email-acc-username').value = ''; $('email-acc-password').value = ''; $('email-acc-folder').value = 'INBOX'; - $('email-acc-status').textContent = ''; + setAccountStatus(''); $('email-pw-hint').textContent = ''; } @@ -264,7 +288,7 @@ function editAccount(id) { $('email-acc-password').value = ''; $('email-acc-folder').value = acc.folder; $('email-pw-hint').textContent = '(leave blank to keep existing)'; - $('email-acc-status').textContent = ''; + setAccountStatus(''); $('email-acc-name').focus(); } @@ -283,9 +307,7 @@ async function deleteAccount(id) { async function saveAccount(e) { e.preventDefault(); - const status = $('email-acc-status'); - status.textContent = 'saving...'; - status.style.color = 'var(--t-dim)'; + setAccountStatus('saving...'); const payload = { name: $('email-acc-name').value.trim(), @@ -297,8 +319,7 @@ async function saveAccount(e) { folder: $('email-acc-folder').value.trim() || 'INBOX', }; if (!payload.name || !payload.host || !payload.username || (!editingAccountId && !payload.password)) { - status.textContent = 'name, host, username, and password are required'; - status.style.color = 'var(--t-danger)'; + setAccountStatus('name, host, username, and password are required', 'error'); return; } if (editingAccountId && !payload.password) delete payload.password; @@ -311,45 +332,35 @@ async function saveAccount(e) { res = await hyprcrd.api('api/email/accounts', { method: 'POST', body: payload }); } if (res.error) { - status.textContent = 'error: ' + res.error; - status.style.color = 'var(--t-danger)'; + setAccountStatus('error: ' + res.error, 'error'); return; } - status.textContent = 'saved'; - status.style.color = 'var(--t-accent)'; + setAccountStatus('saved', 'success'); await loadAccounts(); renderAccountsList(); - resetAccountForm(); + setTimeout(() => { resetAccountForm(); }, 1500); } catch (err) { - status.textContent = 'error: ' + err.message; - status.style.color = 'var(--t-danger)'; + setAccountStatus('error: ' + err.message, 'error'); } } async function testExistingAccount(id) { - const status = $('email-acc-status'); - status.textContent = 'testing...'; - status.style.color = 'var(--t-dim)'; + setAccountStatus('testing...'); try { const res = await hyprcrd.api(`api/email/accounts/${id}/test`, { method: 'POST' }); if (res.ok) { - status.textContent = `ok — ${res.exists} messages in INBOX`; - status.style.color = 'var(--t-accent)'; + setAccountStatus(`ok — ${res.exists} messages in INBOX`, 'success'); } else { - status.textContent = 'failed: ' + (res.error || 'unknown'); - status.style.color = 'var(--t-danger)'; + setAccountStatus('failed: ' + (res.error || 'unknown'), 'error'); } } catch (err) { - status.textContent = 'error: ' + err.message; - status.style.color = 'var(--t-danger)'; + setAccountStatus('error: ' + err.message, 'error'); } } async function testCurrentForm() { if (!editingAccountId) { - const status = $('email-acc-status'); - status.textContent = 'save first, then test'; - status.style.color = 'var(--t-dim)'; + setAccountStatus('save first, then test'); return; } await testExistingAccount(editingAccountId); diff --git a/public/plugins/gitea/GiteaClient.php b/public/plugins/gitea/GiteaClient.php new file mode 100644 index 0000000..9035cc6 --- /dev/null +++ b/public/plugins/gitea/GiteaClient.php @@ -0,0 +1,145 @@ + + * + * Errors throw GiteaApiException with status code + body. + */ + +class GiteaApiException extends RuntimeException +{ + public int $statusCode; + public string $body; + + public function __construct(int $statusCode, string $body) + { + $this->statusCode = $statusCode; + $this->body = $body; + parent::__construct("Gitea API HTTP $statusCode: " . substr($body, 0, 200)); + } +} + +class GiteaClient +{ + private string $baseUrl; + private string $token; + private int $timeout; + + public function __construct(string $baseUrl, string $token, int $timeout = 20) + { + $this->baseUrl = rtrim($baseUrl, '/'); + $this->token = $token; + $this->timeout = $timeout; + } + + /** + * Verify the token works against the configured base URL by hitting + * /user. Returns the authenticated user dict on success. + * Throws GiteaApiException on failure. + */ + public function testConnection(): array + { + $resp = $this->request('GET', '/user'); + return is_array($resp) ? $resp : []; + } + + /** + * Cross-repo issue search. Mirrors Python search_issues(). + * @param array $opts Any of: state, labels, milestones, q, type, + * since, before, assigned, created, mentioned, + * review_requested, reviewed, owner, team, + * page, limit + */ + public function searchIssues(array $opts = []): array + { + $resp = $this->request('GET', '/repos/issues/search', $opts); + return is_array($resp) ? $resp : []; + } + + public function listRepoIssues(string $owner, string $repo, array $opts = []): array + { + $path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues'; + $resp = $this->request('GET', $path, $opts); + return is_array($resp) ? $resp : []; + } + + public function getIssue(string $owner, string $repo, int $index): array + { + $path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues/' . $index; + $resp = $this->request('GET', $path); + return is_array($resp) ? $resp : []; + } + + public function listIssueComments(string $owner, string $repo, int $index, array $opts = []): array + { + $path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues/' . $index . '/comments'; + $resp = $this->request('GET', $path, $opts); + return is_array($resp) ? $resp : []; + } + + // ── Low-level request ───────────────────────────────────────── + + private function request(string $method, string $path, array $query = [], ?array $payload = null) + { + $url = $this->baseUrl . '/api/v1' . $path; + $cleanQuery = []; + foreach ($query as $k => $v) { + if ($v === null || $v === '') continue; + if (is_bool($v)) $v = $v ? 'true' : 'false'; + $cleanQuery[$k] = $v; + } + if ($cleanQuery) { + $url .= '?' . http_build_query($cleanQuery); + } + + $ch = curl_init($url); + $headers = [ + 'Accept: application/json', + 'Authorization: token ' . $this->token, + 'User-Agent: hyprcrd-gitea-plugin/1.0', + ]; + $opts = [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + ]; + if ($payload !== null) { + $headers[] = 'Content-Type: application/json'; + $opts[CURLOPT_POSTFIELDS] = json_encode($payload); + } + $opts[CURLOPT_HTTPHEADER] = $headers; + curl_setopt_array($ch, $opts); + + $body = curl_exec($ch); + if ($body === false) { + $err = curl_error($ch); + curl_close($ch); + throw new RuntimeException("Could not reach Gitea: $err"); + } + $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?? ''; + curl_close($ch); + + if ($status >= 400) { + throw new GiteaApiException((int)$status, (string)$body); + } + + if ($body === '' || $body === null) return null; + if (stripos($contentType, 'application/json') !== false || preg_match('/^\s*[\[\{]/', (string)$body)) { + $decoded = json_decode((string)$body, true); + return $decoded; + } + return $body; + } +} diff --git a/public/plugins/gitea/TODO.md b/public/plugins/gitea/TODO.md new file mode 100644 index 0000000..fe96d64 --- /dev/null +++ b/public/plugins/gitea/TODO.md @@ -0,0 +1,99 @@ +# gitea plugin — roadmap + +> **Status:** scaffold. Phase 1 = accounts CRUD + connection test only. +> The `gitea-issue-client/` Python tool in the project root is the +> reference for the full feature surface that needs to be ported. + +## Phase 1 — done ✅ + +- [x] `GiteaClient.php` cURL wrapper with `testConnection()`, + `searchIssues()`, `listRepoIssues()`, `getIssue()`, `listIssueComments()` +- [x] `gitea_accounts` table with encrypted token (vault key, same as email) +- [x] Routes: `GET/POST/PATCH/DELETE /api/gitea/accounts` + `POST .../test` +- [x] Settings tab via `hyprcrd.settings.registerTab` +- [x] Header button "gitea" +- [x] Welcome view + accounts management form +- [x] Connection test against `/api/v1/user` + +## Phase 2 — issue reader (next) + +- [ ] **Issue list view** in the overlay: + - Search box (`q` parameter) + - State filter (open/closed/all) + - Owner/team filter (optional dropdown) + - Pagination (reuse pattern from email plugin) + - Each row: title, repo path, state badge, labels, author, age +- [ ] **Issue detail view** (right pane): + - Subject + state + labels + assignees + milestone + - Body (markdown rendered with the existing core renderer) + - Comments list (each with author + timestamp) + - "Open on Gitea" link button +- [ ] **Extract → card**: + - Card title = issue title + - Card body = markdown with metadata header + body + comments + `#gitea` `#issue` tags + - Optionally store backref `(account_id, owner, repo, index)` in a + `gitea_issue_links` table for later sync + +## Phase 3 — write operations + +- [ ] **Add comment** from the detail view +- [ ] **Close / reopen** issue +- [ ] **Edit title / body** (only for the issue creator) +- [ ] **Set labels** (need to fetch repo labels, multi-select UI) +- [ ] **Set assignees** (typeahead from repo collaborators) +- [ ] **Set milestone** +- [ ] **Set due date** + +## Phase 4 — create issues + +- [ ] **"new issue" button** in the overlay +- [ ] **Create from card**: a card → push as new gitea issue, store the + backref so future edits can sync both ways +- [ ] **Pre-fill template** from a repo's `.gitea/ISSUE_TEMPLATE.md` + if available + +## Phase 5 — bidirectional sync (the killer feature) + +- [ ] When a card has a gitea backref, an "issue" badge appears +- [ ] Edit the card body in hyprcrd → "push to gitea" button +- [ ] "pull from gitea" button refreshes the card from the live issue +- [ ] Conflict detection via `updated_at` timestamps +- [ ] This replaces the Python tool's `gir push` workflow + +## Phase 6 — multi-instance + ergonomics + +- [ ] Account selector remembers last-used account in localStorage +- [ ] Per-account default filters (e.g. always show `assigned=true`) +- [ ] Org/repo browsing tree on the left +- [ ] Label browser per repo +- [ ] Milestone view +- [ ] Search across multiple accounts + +## Hardening + +- [ ] Rate limiting on test endpoint +- [ ] Better error messages (currently raw HTTP body leaks through) +- [ ] Cert pinning option for self-hosted gitea instances +- [ ] Connection caching (currently every request opens a new cURL handle) +- [ ] Map common errors (401 → "token expired", 403 → "no access", etc.) + +## Mapping from the Python tool + +| Python feature | hyprcrd plan | +|----------------------------------------|-------------------------------------------| +| `gi search` | overlay issue list with `q` filter | +| `gi list --owner --repo` | overlay with owner/repo filter | +| `gi get` | overlay detail pane | +| `gi create` | Phase 4 "new issue" button | +| `gi edit` | Phase 3 detail-pane edit buttons | +| `gi comment` | Phase 3 comment box in detail pane | +| `gi comments` | Phase 2 comments rendering | +| `gir fetch` → markdown files | Phase 2 extract-as-card (no files needed) | +| `gir push` → sync local edits | Phase 5 bidirectional sync | +| `gir new --push` | Phase 4 create-from-card | +| `gir clean` | not needed (cards are managed via the UI) | +| `_valid-metadata.md` per repo | label/milestone dropdowns at edit time | +| `templates/gir-new-issue.md` | configurable issue template per account | + +Once Phase 2–4 are done, the entire `gitea-issue-client/` Python tool +can be removed from the project root. diff --git a/public/plugins/gitea/backend.php b/public/plugins/gitea/backend.php new file mode 100644 index 0000000..201a9e2 --- /dev/null +++ b/public/plugins/gitea/backend.php @@ -0,0 +1,171 @@ +exec(" + CREATE TABLE IF NOT EXISTS gitea_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + base_url TEXT NOT NULL, + token TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + "); +})(); + +// ── Encryption helpers (mirrors email plugin / CalDavSync) ──────── + +function giteaEncKey(): string { + $db = Database::connect(); + $vk = $db->query("SELECT value FROM settings WHERE key = 'vault_key'")->fetchColumn(); + if ($vk) return substr(hash('sha256', $vk), 0, 32); + $hash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn(); + return substr(hash('sha256', $hash ?: 'hyprcrd-default-key'), 0, 32); +} + +function giteaEncrypt(string $plain): string { + $key = giteaEncKey(); + $iv = random_bytes(16); + $cipher = openssl_encrypt($plain, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv); + return base64_encode($iv . $cipher); +} + +function giteaDecrypt(string $encoded): string { + $key = giteaEncKey(); + $raw = base64_decode($encoded); + if (!$raw || strlen($raw) < 17) return ''; + $iv = substr($raw, 0, 16); + $cipher = substr($raw, 16); + return openssl_decrypt($cipher, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv) ?: ''; +} + +function giteaJson($data, int $code = 200): void { + http_response_code($code); + header('Content-Type: application/json'); + echo json_encode($data); + exit; +} + +function giteaGetAccount(int $id): ?array { + $db = Database::connect(); + $stmt = $db->prepare('SELECT * FROM gitea_accounts WHERE id = :id'); + $stmt->execute(['id' => $id]); + return $stmt->fetch() ?: null; +} + +function giteaOpenClient(array $row): GiteaClient { + return new GiteaClient($row['base_url'], giteaDecrypt($row['token'])); +} + +// ── Route registration ──────────────────────────────────────────── + +PluginManager::instance()->onApiRoute(function (string $route, string $method) { + + // ── Accounts CRUD ── + + if ($route === '/api/gitea/accounts' && $method === 'GET') { + $db = Database::connect(); + $rows = $db->query('SELECT id, name, base_url, position, created_at FROM gitea_accounts ORDER BY position ASC, id ASC')->fetchAll(); + giteaJson($rows); + } + + if ($route === '/api/gitea/accounts' && $method === 'POST') { + $input = json_decode(file_get_contents('php://input'), true) ?: []; + $name = trim((string)($input['name'] ?? '')); + $baseUrl = trim((string)($input['base_url'] ?? '')); + $token = (string)($input['token'] ?? ''); + if ($name === '' || $baseUrl === '' || $token === '') { + giteaJson(['error' => 'name, base_url, and token are required'], 400); + } + // Normalize base URL: strip trailing slash, accept with/without scheme + if (!preg_match('#^https?://#i', $baseUrl)) { + $baseUrl = 'https://' . $baseUrl; + } + $baseUrl = rtrim($baseUrl, '/'); + $db = Database::connect(); + $stmt = $db->prepare(' + INSERT INTO gitea_accounts (name, base_url, token) + VALUES (:n, :u, :t) + '); + $stmt->execute([ + 'n' => $name, + 'u' => $baseUrl, + 't' => giteaEncrypt($token), + ]); + giteaJson(['ok' => true, 'id' => (int)$db->lastInsertId()]); + } + + if (preg_match('#^/api/gitea/accounts/(\d+)$#', $route, $m) && $method === 'PATCH') { + $id = (int)$m[1]; + $row = giteaGetAccount($id); + if (!$row) giteaJson(['error' => 'account not found'], 404); + $input = json_decode(file_get_contents('php://input'), true) ?: []; + $fields = []; $params = ['id' => $id]; + if (array_key_exists('name', $input)) { + $fields[] = 'name = :name'; $params['name'] = trim((string)$input['name']); + } + if (array_key_exists('base_url', $input)) { + $u = trim((string)$input['base_url']); + if (!preg_match('#^https?://#i', $u)) $u = 'https://' . $u; + $fields[] = 'base_url = :base_url'; $params['base_url'] = rtrim($u, '/'); + } + if (!empty($input['token'])) { + $fields[] = 'token = :token'; $params['token'] = giteaEncrypt($input['token']); + } + if (!$fields) giteaJson(['ok' => true]); + $db = Database::connect(); + $db->prepare('UPDATE gitea_accounts SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params); + giteaJson(['ok' => true]); + } + + if (preg_match('#^/api/gitea/accounts/(\d+)$#', $route, $m) && $method === 'DELETE') { + $id = (int)$m[1]; + $db = Database::connect(); + $db->prepare('DELETE FROM gitea_accounts WHERE id = :id')->execute(['id' => $id]); + giteaJson(['ok' => true]); + } + + if (preg_match('#^/api/gitea/accounts/(\d+)/test$#', $route, $m) && $method === 'POST') { + $id = (int)$m[1]; + $row = giteaGetAccount($id); + if (!$row) giteaJson(['error' => 'account not found'], 404); + try { + $client = giteaOpenClient($row); + $user = $client->testConnection(); + giteaJson([ + 'ok' => true, + 'user' => [ + 'login' => $user['login'] ?? null, + 'full_name' => $user['full_name'] ?? null, + 'email' => $user['email'] ?? null, + 'avatar_url' => $user['avatar_url'] ?? null, + ], + 'base_url' => $row['base_url'], + ]); + } catch (GiteaApiException $e) { + giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200); + } catch (\Throwable $e) { + giteaJson(['ok' => false, 'error' => $e->getMessage()], 200); + } + } +}); diff --git a/public/plugins/gitea/gitea.css b/public/plugins/gitea/gitea.css new file mode 100644 index 0000000..32bf6f2 --- /dev/null +++ b/public/plugins/gitea/gitea.css @@ -0,0 +1,47 @@ +/* Gitea Plugin styles */ + +#gitea-overlay { background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(2px); } + +/* Account list (in settings tab) */ +.gitea-account-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + border: 1px solid var(--t-border); + background: var(--t-surface); + border-radius: 2px; +} +.gitea-account-row .gitea-account-info { + flex: 1; + min-width: 0; +} +.gitea-account-row .gitea-account-name { + font-family: var(--t-font-pixel); + font-size: 11px; + color: var(--t-bright); +} +.gitea-account-row .gitea-account-meta { + font-size: 10px; + color: var(--t-muted); + font-family: monospace; + margin-top: 2px; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} +.gitea-account-actions { + display: flex; + gap: 4px; +} + +/* Override Chrome/Safari autofill on the gitea token field */ +#gitea-acc-token:-webkit-autofill, +#gitea-acc-token:-webkit-autofill:hover, +#gitea-acc-token:-webkit-autofill:focus { + -webkit-text-fill-color: var(--t-bright); + -webkit-box-shadow: 0 0 0 1000px var(--t-bg) inset; + box-shadow: 0 0 0 1000px var(--t-bg) inset; + caret-color: var(--t-bright); + transition: background-color 9999s ease-out, color 9999s ease-out; +} diff --git a/public/plugins/gitea/gitea.html b/public/plugins/gitea/gitea.html new file mode 100644 index 0000000..3f10400 --- /dev/null +++ b/public/plugins/gitea/gitea.html @@ -0,0 +1,47 @@ + +
+
+ + +
+ +

gitea

+ + +
+ + +
+ + + + + + +
+
diff --git a/public/plugins/gitea/gitea.js b/public/plugins/gitea/gitea.js new file mode 100644 index 0000000..8fbc627 --- /dev/null +++ b/public/plugins/gitea/gitea.js @@ -0,0 +1,352 @@ +// ── Gitea Plugin Frontend (scaffold) ── +// +// Phase 1 (this file): account management + connection test. +// Phase 2: issue list, detail view, extract-as-card, comments. +// +// Self-registers via window.hyprcrd: +// - Header button "gitea" +// - Settings tab "gitea" with account CRUD +// - Welcome view in the overlay until at least one account exists + +const hyprcrd = window.hyprcrd; +if (!hyprcrd) throw new Error('[gitea plugin] window.hyprcrd missing'); + +const $ = (id) => document.getElementById(id); + +// ── State ───────────────────────────────────────────────────────── + +let accounts = []; +let currentAccountId = null; +let editingAccountId = null; + +// ── Overlay open / close ────────────────────────────────────────── + +async function openGitea() { + $('gitea-overlay').classList.add('open'); + await loadAccounts(); + if (accounts.length === 0) { + showWelcomeView(); + } else { + if (!accounts.find(a => a.id === currentAccountId)) { + currentAccountId = accounts[0].id; + } + $('gitea-account-select').value = currentAccountId; + showIssuesView(); + } +} + +function closeGitea() { + $('gitea-overlay').classList.remove('open'); +} + +function showWelcomeView() { + $('gitea-view-welcome').classList.remove('hidden'); + $('gitea-view-issues').classList.add('hidden'); + $('gitea-view-issues').classList.remove('flex'); + $('gitea-status').textContent = ''; +} + +function showIssuesView() { + $('gitea-view-welcome').classList.add('hidden'); + $('gitea-view-issues').classList.remove('hidden'); + $('gitea-view-issues').classList.add('flex'); +} + +function gotoAccountsSettings() { + closeGitea(); + hyprcrd.settings.openTab('gitea'); +} + +// ── Accounts: data ──────────────────────────────────────────────── + +async function loadAccounts() { + const res = await fetch('api/gitea/accounts'); + accounts = await res.json(); + if (!Array.isArray(accounts)) accounts = []; + renderAccountSelect(); +} + +function renderAccountSelect() { + const sel = $('gitea-account-select'); + if (!sel) return; + sel.innerHTML = accounts.length + ? accounts.map(a => ``).join('') + : ''; +} + +// ── Settings tab: rendering ─────────────────────────────────────── + +function buildSettingsPanel(panelEl) { + panelEl.innerHTML = ` +
+
+
gitea accounts
+

+ Configure Gitea instances to read issues inside hyprcrd. Tokens are encrypted with the vault key. + Create a personal access token in your Gitea profile under Settings → Applications. +

+
+
+
+
add account
+
+ + + + +
+ + + +
+ +
+
+
+ `; + + panelEl.querySelector('#gitea-account-form').addEventListener('submit', saveAccount); + panelEl.querySelector('#gitea-acc-cancel').addEventListener('click', resetAccountForm); + panelEl.querySelector('#gitea-acc-test').addEventListener('click', testCurrentForm); +} + +async function refreshSettingsPanel(panelEl) { + if (!panelEl.querySelector('#gitea-account-form')) { + buildSettingsPanel(panelEl); + } + await loadAccounts(); + renderAccountsList(); + resetAccountForm(); +} + +function renderAccountsList() { + const list = $('gitea-accounts-list'); + if (!list) return; + if (!accounts.length) { + list.innerHTML = '

No accounts configured yet. Use the form below to add one.

'; + return; + } + list.innerHTML = accounts.map(a => ` +
+ + + +
+ `).join(''); + + list.querySelectorAll('.gitea-account-row').forEach(row => { + const id = parseInt(row.dataset.id); + row.querySelector('.gitea-edit-btn').addEventListener('click', () => editAccount(id)); + row.querySelector('.gitea-del-btn').addEventListener('click', () => deleteAccount(id)); + row.querySelector('.gitea-test-btn').addEventListener('click', () => testExistingAccount(id)); + }); +} + +function setAccountStatus(text, kind = 'info') { + const el = $('gitea-acc-status'); + if (!el) return; + if (!text) { + el.classList.add('hidden'); + el.textContent = ''; + el.style.borderColor = ''; + el.style.color = ''; + return; + } + el.classList.remove('hidden'); + el.textContent = text; + if (kind === 'error') { + el.style.borderColor = 'var(--t-danger)'; + el.style.color = 'var(--t-danger)'; + } else if (kind === 'success') { + el.style.borderColor = 'var(--t-accent)'; + el.style.color = 'var(--t-accent)'; + } else { + el.style.borderColor = 'var(--t-border)'; + el.style.color = 'var(--t-muted)'; + } +} + +function resetAccountForm() { + editingAccountId = null; + const form = $('gitea-account-form'); + if (!form) return; + $('gitea-form-title').textContent = 'add account'; + $('gitea-acc-id').value = ''; + $('gitea-acc-name').value = ''; + $('gitea-acc-baseurl').value = ''; + $('gitea-acc-token').value = ''; + setAccountStatus(''); + $('gitea-tok-hint').textContent = ''; +} + +function editAccount(id) { + const acc = accounts.find(a => a.id === id); + if (!acc) return; + editingAccountId = id; + $('gitea-form-title').textContent = 'edit account: ' + acc.name; + $('gitea-acc-id').value = id; + $('gitea-acc-name').value = acc.name; + $('gitea-acc-baseurl').value = acc.base_url; + $('gitea-acc-token').value = ''; + $('gitea-tok-hint').textContent = '(leave blank to keep existing)'; + setAccountStatus(''); + $('gitea-acc-name').focus(); +} + +async function deleteAccount(id) { + const acc = accounts.find(a => a.id === id); + if (!acc) return; + const ok = await hyprcrd.dialog.confirm( + `Delete gitea account "${acc.name}"? Stored token will be removed but no issues are deleted on the server.`, + { title: 'delete account', okLabel: 'delete', cancelLabel: 'cancel', danger: true } + ); + if (!ok) return; + await hyprcrd.api(`api/gitea/accounts/${id}`, { method: 'DELETE' }); + await loadAccounts(); + renderAccountsList(); +} + +async function saveAccount(e) { + e.preventDefault(); + setAccountStatus('saving...'); + + const payload = { + name: $('gitea-acc-name').value.trim(), + base_url: $('gitea-acc-baseurl').value.trim(), + token: $('gitea-acc-token').value, + }; + if (!payload.name || !payload.base_url || (!editingAccountId && !payload.token)) { + setAccountStatus('name, base url, and token are required', 'error'); + return; + } + if (editingAccountId && !payload.token) delete payload.token; + + try { + let res; + if (editingAccountId) { + res = await hyprcrd.api(`api/gitea/accounts/${editingAccountId}`, { method: 'PATCH', body: payload }); + } else { + res = await hyprcrd.api('api/gitea/accounts', { method: 'POST', body: payload }); + } + if (res.error) { + setAccountStatus('error: ' + res.error, 'error'); + return; + } + setAccountStatus('saved', 'success'); + await loadAccounts(); + renderAccountsList(); + // Don't fully reset — keep saved state visible briefly + setTimeout(() => { resetAccountForm(); }, 1500); + } catch (err) { + setAccountStatus('error: ' + err.message, 'error'); + } +} + +async function testExistingAccount(id) { + setAccountStatus('testing...'); + try { + const res = await hyprcrd.api(`api/gitea/accounts/${id}/test`, { method: 'POST' }); + if (res.ok) { + const u = res.user || {}; + const who = u.login || u.full_name || 'unknown'; + const extras = []; + if (u.full_name && u.full_name !== u.login) extras.push(u.full_name); + if (u.email) extras.push(u.email); + const detail = extras.length ? ` (${extras.join(', ')})` : ''; + setAccountStatus(`ok — connected as ${who}${detail}`, 'success'); + } else { + setAccountStatus('failed: ' + (res.error || 'unknown'), 'error'); + } + } catch (err) { + setAccountStatus('error: ' + err.message, 'error'); + } +} + +async function testCurrentForm() { + if (!editingAccountId) { + setAccountStatus('save first, then test'); + return; + } + await testExistingAccount(editingAccountId); +} + +// ── Helpers ──────────────────────────────────────────────────────── + +function escapeHtml(s) { + return String(s ?? '').replace(/[&<>"']/g, c => + ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +} + +// ── Header button injection ─────────────────────────────────────── + +function injectHeaderButton() { + const header = document.querySelector('#main-header .flex.items-center.gap-3:last-of-type'); + if (!header) return; + if (document.getElementById('gitea-btn')) return; + const btn = document.createElement('button'); + btn.id = 'gitea-btn'; + btn.className = 'flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors'; + btn.innerHTML = 'gitea'; + btn.addEventListener('click', openGitea); + const addBtn = document.getElementById('add-btn'); + if (addBtn) header.insertBefore(btn, addBtn); + else header.appendChild(btn); +} + +// ── Init ────────────────────────────────────────────────────────── + +function init() { + if (!$('gitea-overlay')) return; + + $('gitea-close').addEventListener('click', closeGitea); + $('gitea-overlay').addEventListener('click', e => { + if (e.target === $('gitea-overlay')) closeGitea(); + }); + $('gitea-overlay').addEventListener('keydown', e => { + if (e.key === 'Escape') { closeGitea(); e.stopPropagation(); } + }); + + $('gitea-account-select').addEventListener('change', e => { + currentAccountId = parseInt(e.target.value); + }); + $('gitea-show-settings').addEventListener('click', gotoAccountsSettings); + $('gitea-welcome-configure').addEventListener('click', gotoAccountsSettings); + + injectHeaderButton(); + + // Register the "gitea" tab inside the core settings overlay + hyprcrd.settings.registerTab({ + id: 'gitea', + label: 'gitea', + icon: 'ph-git-pull-request', + onShow: (panelEl) => refreshSettingsPanel(panelEl), + }); +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + init(); +} + +hyprcrd.registerPlugin({ + name: 'gitea', + version: '0.1.0', + description: 'Gitea issue browser (scaffold)', +}); diff --git a/public/plugins/gitea/plugin.json b/public/plugins/gitea/plugin.json new file mode 100644 index 0000000..4fad48f --- /dev/null +++ b/public/plugins/gitea/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "gitea", + "version": "0.1.0", + "description": "Gitea issue browser. Read issues and comments from one or more Gitea instances, extract them as cards. Multiple accounts supported, tokens encrypted with the vault key.", + "frontend": { + "js": ["gitea.js"], + "css": ["gitea.css"], + "html": ["gitea.html"] + }, + "backend": "backend.php" +}