gitea
This commit is contained in:
1 parent
cc97754ae6
commit
86508ffcca
13 files changed
+945
-35
No files matched your search
+1
-1
Submodule gitea-issue-client updated: 3d0c2f3a82...90353c93ad.
+1
-1
@@ -1421,8 +1421,8 @@ if (str_starts_with($route, '/api/')) {
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button id="caldav-test" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-plugs-connected text-sm"></i><span class="font-pixel text-[10px]">test</span></button>
|
||||
<span id="caldav-test-status" class="font-pixel text-[9px] text-t-dim"></span>
|
||||
</div>
|
||||
<div id="caldav-test-status" class="hidden mt-1 px-3 py-2 text-[10px] font-mono break-words border border-t-border bg-t-bg"></div>
|
||||
<div id="caldav-calendars" class="hidden border-t border-t-border pt-2">
|
||||
<label class="font-pixel text-[9px] text-t-dim uppercase block mb-2">calendars</label>
|
||||
<div id="caldav-cal-list" class="space-y-2"></div>
|
||||
|
||||
@@ -95,6 +95,8 @@
|
||||
getPostProcessors() {
|
||||
return postProcessors.slice();
|
||||
},
|
||||
// Filled in by core markdown.js after load
|
||||
render: null,
|
||||
};
|
||||
|
||||
// ── Util helpers (mirror of internal util.js) ───────────────────
|
||||
|
||||
@@ -120,9 +120,31 @@ export async function loadSettings() {
|
||||
}
|
||||
|
||||
|
||||
function setCaldavTestStatus(text, kind = 'info') {
|
||||
if (!testStatus) return;
|
||||
if (!text) {
|
||||
testStatus.classList.add('hidden');
|
||||
testStatus.textContent = '';
|
||||
testStatus.style.borderColor = '';
|
||||
testStatus.style.color = '';
|
||||
return;
|
||||
}
|
||||
testStatus.classList.remove('hidden');
|
||||
testStatus.textContent = text;
|
||||
if (kind === 'error') {
|
||||
testStatus.style.borderColor = 'var(--t-danger)';
|
||||
testStatus.style.color = 'var(--t-danger)';
|
||||
} else if (kind === 'success') {
|
||||
testStatus.style.borderColor = 'var(--t-accent)';
|
||||
testStatus.style.color = 'var(--t-accent)';
|
||||
} else {
|
||||
testStatus.style.borderColor = 'var(--t-border)';
|
||||
testStatus.style.color = 'var(--t-muted)';
|
||||
}
|
||||
}
|
||||
|
||||
$('caldav-test')?.addEventListener('click', async () => {
|
||||
testStatus.textContent = 'testing...';
|
||||
testStatus.style.color = 'var(--t-dim)';
|
||||
setCaldavTestStatus('testing...');
|
||||
const body = { url: urlInput.value, username: userInput.value };
|
||||
if (passInput.value) body.password = passInput.value;
|
||||
const res = await fetch('api/caldav/test', {
|
||||
@@ -132,12 +154,10 @@ $('caldav-test')?.addEventListener('click', async () => {
|
||||
|
||||
if (res.ok) {
|
||||
const names = (res.calendars || []).map(c => c.name).join(', ');
|
||||
testStatus.textContent = `connected (${names || 'no calendars'})`;
|
||||
testStatus.style.color = 'var(--t-accent)';
|
||||
setCaldavTestStatus(`connected (${names || 'no calendars'})`, 'success');
|
||||
if (res.calendars) renderCalList(res.calendars);
|
||||
} else {
|
||||
testStatus.textContent = res.error || 'failed';
|
||||
testStatus.style.color = 'var(--t-danger)';
|
||||
setCaldavTestStatus(res.error || 'failed', 'error');
|
||||
}
|
||||
showConnectionLog(res.log);
|
||||
});
|
||||
|
||||
@@ -168,6 +168,11 @@ export function renderMd(src) {
|
||||
return html;
|
||||
}
|
||||
|
||||
// Expose to plugins via the hyprcrd API
|
||||
if (typeof window !== 'undefined' && window.hyprcrd?.markdown) {
|
||||
window.hyprcrd.markdown.render = renderMd;
|
||||
}
|
||||
|
||||
export function addCodeCopyButtons(container) {
|
||||
if (!container) return;
|
||||
container.querySelectorAll('pre > code').forEach(code => {
|
||||
|
||||
@@ -168,8 +168,8 @@ function buildSettingsPanel(panelEl) {
|
||||
<button type="submit" id="email-acc-save" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">save</span></button>
|
||||
<button type="button" id="email-acc-test" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-plug text-sm"></i><span class="font-pixel text-[10px]">test</span></button>
|
||||
<button type="button" id="email-acc-cancel" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">cancel</span></button>
|
||||
<span id="email-acc-status" class="font-pixel text-[9px] text-t-dim"></span>
|
||||
</div>
|
||||
<div id="email-acc-status" class="hidden mt-1 px-3 py-2 text-[10px] font-mono break-words border border-t-border bg-t-bg"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Minimal Gitea v1 REST client for the hyprcrd gitea plugin.
|
||||
*
|
||||
* Mirrors the Python `gitea_client.py` shape but only implements the
|
||||
* subset needed for MVP: connection test, search/list/get issues, list
|
||||
* comments. Write operations (create issue, comment, edit) are deferred
|
||||
* to a later iteration — see TODO.md.
|
||||
*
|
||||
* Authentication is via the standard Gitea token header:
|
||||
* Authorization: token <TOKEN>
|
||||
*
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Gitea Plugin: backend.
|
||||
*
|
||||
* Replaces the standalone Python `gitea-issue-client` tool. MVP scope:
|
||||
* accounts CRUD + connection test. Issue browsing/extraction comes once
|
||||
* the connection layer is verified. See TODO.md for the deferred items.
|
||||
*
|
||||
* Routes:
|
||||
* GET /api/gitea/accounts → list accounts (no tokens)
|
||||
* POST /api/gitea/accounts → create account
|
||||
* PATCH /api/gitea/accounts/{id} → update (omit token to keep)
|
||||
* DELETE /api/gitea/accounts/{id} → remove
|
||||
* POST /api/gitea/accounts/{id}/test → verify token via /api/v1/user
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/GiteaClient.php';
|
||||
|
||||
// ── Schema migration ──────────────────────────────────────────────
|
||||
|
||||
(function () {
|
||||
$db = Database::connect();
|
||||
$db->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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<!-- Gitea Plugin: full-screen issue browser overlay -->
|
||||
<div class="overlay fixed inset-0" id="gitea-overlay" style="z-index:75;">
|
||||
<div class="gitea-modal bg-t-bg border border-t-border shadow-2xl flex flex-col" style="position:absolute;inset:24px;border-radius:4px;">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3 px-4 py-2 border-b border-t-border shrink-0 bg-t-surface">
|
||||
<i class="ph ph-git-pull-request text-t-accent text-base"></i>
|
||||
<h2 class="font-pixel text-xs text-t-accent">gitea</h2>
|
||||
<select id="gitea-account-select" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer min-w-32"></select>
|
||||
<span id="gitea-status" class="text-[10px] text-t-dim font-pixel"></span>
|
||||
<div class="flex-1"></div>
|
||||
<button id="gitea-show-settings" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="Manage accounts in settings"><i class="ph ph-gear text-sm"></i><span class="font-pixel text-[10px]">accounts</span></button>
|
||||
<button id="gitea-close" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">close</span></button>
|
||||
</div>
|
||||
|
||||
<!-- ── Welcome view (shown when no accounts) ── -->
|
||||
<div id="gitea-view-welcome" class="hidden flex-1 min-h-0 overflow-y-auto">
|
||||
<div class="flex items-center justify-center h-full px-6 py-12">
|
||||
<div class="text-center max-w-md">
|
||||
<i class="ph ph-git-pull-request text-t-accent text-6xl block mb-4"></i>
|
||||
<h3 class="font-pixel text-base text-t-accent tracking-wider mb-2">welcome to gitea</h3>
|
||||
<p class="text-xs text-t-muted leading-relaxed mb-6">
|
||||
Read issues from one or more Gitea instances inside hyprcrd. Issues stay
|
||||
on the server — only the ones you explicitly extract become cards.
|
||||
To get started, add at least one account in settings.
|
||||
</p>
|
||||
<button id="gitea-welcome-configure" class="inline-flex items-center gap-2 px-4 py-2 text-xs bg-t-accent text-t-bg border border-t-accent hover:brightness-110 transition-all">
|
||||
<i class="ph ph-gear text-sm"></i>
|
||||
<span class="font-pixel text-[11px]">configure accounts</span>
|
||||
</button>
|
||||
<p class="text-[10px] text-t-dim mt-4 font-pixel">settings → gitea</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Issues reader (placeholder for now — filled in next phase) ── -->
|
||||
<div id="gitea-view-issues" class="hidden flex-1 min-h-0 overflow-hidden">
|
||||
<div class="flex items-center justify-center h-full px-6 text-center">
|
||||
<div>
|
||||
<i class="ph ph-list-bullets text-t-dim text-4xl block mb-3"></i>
|
||||
<p class="font-pixel text-[10px] text-t-dim">issue reader coming next</p>
|
||||
<p class="text-[10px] text-t-dim mt-2">Connection works — UI is the next step.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 => `<option value="${a.id}">${escapeHtml(a.name)}</option>`).join('')
|
||||
: '<option value="">— no accounts —</option>';
|
||||
}
|
||||
|
||||
// ── Settings tab: rendering ───────────────────────────────────────
|
||||
|
||||
function buildSettingsPanel(panelEl) {
|
||||
panelEl.innerHTML = `
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-git-pull-request text-xs"></i> gitea accounts</div>
|
||||
<p class="text-[10px] text-t-dim leading-relaxed mb-3">
|
||||
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 <span class="font-mono">Settings → Applications</span>.
|
||||
</p>
|
||||
<div id="gitea-accounts-list" class="space-y-2"></div>
|
||||
</div>
|
||||
<div class="border-t border-t-border pt-4">
|
||||
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-3"><span id="gitea-form-title">add account</span></div>
|
||||
<form id="gitea-account-form" class="space-y-2">
|
||||
<input type="hidden" id="gitea-acc-id" value="">
|
||||
<label class="block">
|
||||
<span class="font-pixel text-[9px] text-t-dim uppercase">name</span>
|
||||
<input id="gitea-acc-name" type="text" placeholder="My Gitea" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="font-pixel text-[9px] text-t-dim uppercase">base url</span>
|
||||
<input id="gitea-acc-baseurl" type="text" placeholder="https://git.example.com" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="font-pixel text-[9px] text-t-dim uppercase">access token <span id="gitea-tok-hint" class="text-t-muted normal-case text-[8px]"></span></span>
|
||||
<input id="gitea-acc-token" type="password" autocomplete="off" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
||||
</label>
|
||||
<div class="flex items-center gap-2 pt-1">
|
||||
<button type="submit" id="gitea-acc-save" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">save</span></button>
|
||||
<button type="button" id="gitea-acc-test" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-plug text-sm"></i><span class="font-pixel text-[10px]">test</span></button>
|
||||
<button type="button" id="gitea-acc-cancel" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">cancel</span></button>
|
||||
</div>
|
||||
<div id="gitea-acc-status" class="hidden mt-1 px-3 py-2 text-[10px] font-mono break-words border border-t-border bg-t-bg"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = '<p class="text-xs text-t-dim italic">No accounts configured yet. Use the form below to add one.</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = accounts.map(a => `
|
||||
<div class="gitea-account-row" data-id="${a.id}">
|
||||
<i class="ph ph-git-pull-request text-t-accent text-base"></i>
|
||||
<div class="gitea-account-info">
|
||||
<div class="gitea-account-name">${escapeHtml(a.name)}</div>
|
||||
<div class="gitea-account-meta">${escapeHtml(a.base_url)}</div>
|
||||
</div>
|
||||
<div class="gitea-account-actions">
|
||||
<button class="toolbar-btn gitea-edit-btn" title="Edit"><i class="ph ph-pencil-simple text-sm"></i></button>
|
||||
<button class="toolbar-btn gitea-test-btn" title="Test"><i class="ph ph-plug text-sm"></i></button>
|
||||
<button class="toolbar-btn gitea-del-btn" title="Delete"><i class="ph ph-trash text-sm"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
`).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 = '<i class="ph ph-git-pull-request text-sm"></i><span class="font-pixel text-[10px]">gitea</span>';
|
||||
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)',
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
Reference in new issue
Block a user