Files
soleprint/soleprint/artery/index.html
buenosairesam c4e702eae3 refactor: unified google vein, prefixed module loading, cfg separation
- Unified google vein with OAuth + Sheets API
- Prefixed vein module loading (vein_google) to avoid pip package shadowing
- Preload pip packages before vein loading
- Added common/auth framework
- Rebranded sbwrapper from Pawprint to Soleprint
- Removed cfg/ from history (now separate repo)
- Keep cfg/standalone/ as sample configuration
- gitignore cfg/amar/ and cfg/dlt/ (private configs)
2026-01-27 09:24:05 -03:00

1814 lines
68 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Artery · Soleprint</title>
<link
rel="icon"
type="image/svg+xml"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48' fill='none' stroke='%23b91c1c' stroke-width='2.5'%3E%3Cpath d='M24 4 L24 20 M24 20 L8 40 M24 20 L40 40'/%3E%3Ccircle cx='24' cy='4' r='3' fill='%23b91c1c'/%3E%3Ccircle cx='8' cy='40' r='3' fill='%23b91c1c'/%3E%3Ccircle cx='40' cy='40' r='3' fill='%23b91c1c'/%3E%3Ccircle cx='24' cy='20' r='5' fill='none'/%3E%3Ccircle cx='24' cy='20' r='2' fill='%23b91c1c'/%3E%3C/svg%3E"
/>
<style>
* {
box-sizing: border-box;
}
html {
background: #0a0a0a;
}
body {
font-family:
system-ui,
-apple-system,
sans-serif;
max-width: 960px;
margin: 0 auto;
padding: 2rem 1rem;
line-height: 1.6;
color: #e5e5e5;
background: #b91c1c;
}
header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.logo {
width: 64px;
height: 64px;
color: white;
}
h1 {
font-size: 2.5rem;
margin: 0;
color: white;
}
.tagline {
color: rgba(255, 255, 255, 0.85);
margin-bottom: 2rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
padding-bottom: 2rem;
}
section {
background: #0a0a0a;
padding: 1.5rem;
margin: 1.5rem 0;
border-radius: 12px;
}
section h2 {
margin: 0 0 1rem 0;
font-size: 1.2rem;
color: #fca5a5;
}
.composition {
background: #1a1a1a;
border: 2px solid #b91c1c;
padding: 1rem;
border-radius: 12px;
}
.composition h3 {
margin: 0 0 0.75rem 0;
font-size: 1.1rem;
color: #fca5a5;
}
.composition > p {
margin: 0 0 1rem 0;
font-size: 0.9rem;
color: #a3a3a3;
}
.components {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
}
.component {
background: #0a0a0a;
border: 1px solid #3f3f3f;
padding: 0.75rem;
border-radius: 8px;
}
.component h4 {
margin: 0 0 0.25rem 0;
font-size: 0.95rem;
color: #fca5a5;
}
.component p {
margin: 0;
font-size: 0.85rem;
color: #a3a3a3;
}
.veins {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1rem;
}
.vein {
background: #1a1a1a;
border: 1px solid #3f3f3f;
padding: 1rem;
border-radius: 8px;
text-align: center;
cursor: pointer;
transition: all 0.15s;
}
.vein:hover {
background: #2a2a2a;
}
.vein.selected {
border-color: #b91c1c;
border-width: 2px;
background: #1a1a1a;
}
.vein.active {
background: #b91c1c;
border-color: #b91c1c;
}
.vein.active h3 {
color: white;
}
.vein.active:hover {
background: #991b1b;
}
.vein.active.selected {
background: #991b1b;
border-color: white;
}
.vein.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.vein.disabled:hover {
background: #1a1a1a;
}
.vein h3 {
margin: 0;
font-size: 1rem;
color: #e5e5e5;
}
.endpoints {
list-style: none;
padding: 0;
margin: 0;
}
.endpoints li {
padding: 0.75rem 0;
border-bottom: 1px solid #3f3f3f;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
}
.endpoints li:last-child {
border-bottom: none;
}
.endpoints code {
font-family: monospace;
background: #1a1a1a;
padding: 0.25rem 0.5rem;
border-radius: 4px;
color: #fca5a5;
}
.endpoints .desc {
color: #a3a3a3;
font-size: 0.9rem;
}
code {
background: #2a2a2a;
color: #fca5a5;
padding: 0.1rem 0.3rem;
border-radius: 3px;
font-size: 0.85rem;
}
footer {
margin-top: 3rem;
padding-top: 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.3);
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.7);
}
footer a {
color: white;
}
footer .disabled {
opacity: 0.5;
}
/* Tab content */
.tab-content {
display: none;
}
.tab-content.visible {
display: block;
}
/* Jira form */
.api-form {
margin-bottom: 1.5rem;
}
.api-form label {
display: block;
font-weight: 500;
margin-bottom: 0.5rem;
color: #e5e5e5;
}
.api-form input[type="text"] {
width: 100%;
padding: 0.75rem;
border: 1px solid #3f3f3f;
border-radius: 8px;
font-family: monospace;
font-size: 0.9rem;
background: #1a1a1a;
color: #e5e5e5;
}
.api-form input[type="text"]:focus,
.api-form select:focus {
outline: none;
border-color: #b91c1c;
}
.api-form select {
width: 100%;
padding: 0.75rem;
border: 1px solid #3f3f3f;
border-radius: 8px;
font-size: 0.9rem;
background: #1a1a1a;
color: #e5e5e5;
cursor: pointer;
}
.api-form select:disabled {
background: #0a0a0a;
color: #666;
cursor: not-allowed;
}
.api-form input:disabled {
background: #0a0a0a;
color: #666;
cursor: not-allowed;
}
.api-controls {
display: flex;
align-items: center;
gap: 1rem;
margin-top: 1rem;
}
.api-controls button {
background: #b91c1c;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 0.95rem;
cursor: pointer;
transition: background 0.15s;
}
.api-controls button:hover {
background: #991b1b;
}
.api-controls button:disabled {
background: #ccc;
cursor: not-allowed;
}
.tab-button {
background: #1a1a1a !important;
border: 1px solid #3f3f3f !important;
color: #e5e5e5 !important;
}
.tab-button:hover {
background: #2a2a2a !important;
}
.tab-button.active {
border-color: white !important;
border-width: 2px !important;
background: #b91c1c !important;
color: white !important;
}
.tab-button.active:hover {
background: #991b1b !important;
}
.epic-status {
font-size: 2rem;
font-weight: bold;
padding: 2rem;
color: #e5e5e5;
animation: pulse 2s ease-in-out infinite;
}
.epic-status.error {
color: #fca5a5;
}
@keyframes pulse {
0%,
100% {
color: #0a0a0a;
}
50% {
color: #b91c1c;
}
}
.api-controls label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
cursor: pointer;
color: #e5e5e5;
}
.output-container {
position: relative;
display: none;
}
.output-container.visible {
display: block;
margin-top: 1.5rem;
}
.output-area {
background: #1a1a1a;
color: #e5e5e5;
padding: 1rem;
padding-top: 2.5rem;
border-radius: 8px;
font-family: monospace;
font-size: 0.85rem;
white-space: pre-wrap;
word-break: break-word;
user-select: text;
position: relative;
}
.output-area.error {
color: #fca5a5;
}
.output-area.scrollable {
max-height: 1000px;
overflow-y: auto;
}
.copy-button {
display: none;
}
/* Media attachments */
.attachments-container {
margin-top: 1.5rem;
}
.attachments-container h3 {
color: #fca5a5;
margin: 0 0 1rem 0;
font-size: 1rem;
}
.attachment-item {
margin-bottom: 1rem;
}
.attachment-item img {
max-width: 100%;
border-radius: 8px;
}
.attachment-item video {
max-width: 100%;
border-radius: 8px;
}
.attachment-label {
font-size: 0.85rem;
color: #a3a3a3;
margin-bottom: 0.5rem;
}
</style>
</head>
<body>
<header style="position: relative">
<!-- Flux capacitor -->
<svg
class="logo"
viewBox="0 0 48 48"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<path d="M24 4 L24 20 M24 20 L8 40 M24 20 L40 40" />
<circle cx="24" cy="4" r="3" fill="currentColor" />
<circle cx="8" cy="40" r="3" fill="currentColor" />
<circle cx="40" cy="40" r="3" fill="currentColor" />
<circle cx="24" cy="20" r="5" fill="none" />
<circle cx="24" cy="20" r="2" fill="currentColor" />
</svg>
<h1>Artery</h1>
{% if pawprint_url %}<a
href="{{ pawprint_url }}"
style="
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
color: rgba(255, 255, 255, 0.7);
font-size: 0.85rem;
"
>← Soleprint</a
>{% endif %}
</header>
<p class="tagline">
conectores y flujo de datos<!-- All things vital -->
</p>
<section>
<div class="composition">
<h3>Pulse</h3>
<div class="components">
<div class="component"><h4>Vein</h4></div>
<div class="component"><h4>Room</h4></div>
<div class="component"><h4>Depot</h4></div>
</div>
</div>
</section>
<section>
<h2>Veins</h2>
<div class="veins">
{% for vein in veins %}
<div
class="vein{% if vein.status == 'live' or vein.status == 'building' %} active{% else %} disabled{% endif %}{% if loop.first %} selected{% endif %}"
data-tab="{{ vein.slug }}"
{% if vein.status == "planned" %}data-disabled="true"{% endif %}
>
<h3>{{ vein.title }}</h3>
</div>
{% endfor %}
</div>
</section>
<!-- Jira Tab -->
<section id="tab-jira" class="tab-content visible">
<h2>Jira</h2>
<div class="api-form">
<label for="jira-email">Jira Email</label>
<input
type="text"
id="jira-email"
placeholder="your@email.com"
/>
<label for="jira-token" style="margin-top: 0.75rem"
>Jira API Token</label
>
<input
type="text"
id="jira-token"
placeholder="Your Jira API token"
/>
<div class="api-controls" style="margin-top: 1rem">
<button id="btn-mine" class="tab-button">My Tickets</button>
<button id="btn-quick-ticket" class="tab-button">
Ticket VET-#
</button>
<button id="btn-epic" class="tab-button">EPIC #</button>
<label style="margin-left: auto">
<input type="checkbox" id="text-mode-list" checked />
Text output
</label>
</div>
<div
id="quick-ticket-form"
style="display: none; margin-top: 0.75rem"
>
<label for="quick-ticket-input">VET-</label>
<input
type="text"
id="quick-ticket-input"
placeholder="123"
style="
width: 100px;
display: inline-block;
margin-left: 0.5rem;
"
/>
</div>
<div id="epic-form" style="display: none; margin-top: 0.75rem">
<label for="epic-input">VET-</label>
<input
type="text"
id="epic-input"
placeholder="494"
style="
width: 100px;
display: inline-block;
margin-left: 0.5rem;
"
/>
</div>
<div
id="epic-progress"
style="display: none; margin-top: 1rem; text-align: center"
>
<div id="epic-status" class="epic-status">0/0</div>
</div>
</div>
<div id="output-list-container" class="output-container">
<button class="copy-button" data-target="output-list">
Copy
</button>
<div id="output-list" class="output-area scrollable"></div>
</div>
<div class="api-form" style="margin-top: 1.5rem">
<label for="ticket-select">Select Ticket</label>
<select id="ticket-select" disabled>
<option value="">-- Load tickets first --</option>
</select>
<label for="ticket-key" style="margin-top: 1rem"
>Or enter manually</label
>
<input type="text" id="ticket-key" placeholder="e.g. AM-123" />
<div class="api-controls">
<button id="btn-ticket">Get Ticket</button>
<label>
<input type="checkbox" id="text-mode-ticket" checked />
Text output
</label>
<label>
<input
type="checkbox"
id="include-attachments-ticket"
checked
/>
Include attachments
</label>
</div>
</div>
<div id="output-ticket-container" class="output-container">
<button class="copy-button" data-target="output-ticket">
Copy
</button>
<div id="output-ticket" class="output-area"></div>
</div>
<div id="attachments" class="attachments-container"></div>
<h2 style="margin-top: 2rem">Endpoints</h2>
<ul class="endpoints">
<li>
<code>/jira/health</code>
<span class="desc">Jira connection status</span>
</li>
<li>
<code>/jira/mine</code>
<span class="desc">My open tickets</span>
</li>
<li>
<code>/jira/backlog?project=X</code>
<span class="desc">Project backlog</span>
</li>
<li>
<code>/jira/sprint?project=X</code>
<span class="desc">Current sprint</span>
</li>
<li>
<code>/jira/ticket/{key}</code>
<span class="desc">Ticket details</span>
</li>
</ul>
<p style="margin-top: 1rem; font-size: 0.85rem; color: #666">
Add <code>?text=true</code> for LLM-friendly output. Send
<code>X-API-Key</code> header for programmatic access.
</p>
</section>
<!-- Slack Tab -->
<section id="tab-slack" class="tab-content">
<h2>Slack</h2>
<div class="api-form">
<label for="slack-token">Slack Token</label>
<input
type="text"
id="slack-token"
placeholder="xoxb-... or xoxp-..."
/>
<div class="api-controls">
<button id="btn-channels">List Channels</button>
<label>
<input
type="checkbox"
id="slack-text-mode-list"
checked
/>
Text output
</label>
</div>
</div>
<div id="slack-output-list-container" class="output-container">
<button class="copy-button" data-target="slack-output-list">
Copy
</button>
<div
id="slack-output-list"
class="output-area scrollable"
></div>
</div>
<div class="api-form" style="margin-top: 1.5rem">
<label for="channel-select">Select Channel</label>
<select id="channel-select" disabled>
<option value="">-- Load channels first --</option>
</select>
<div class="api-controls">
<button id="btn-messages">Get Messages</button>
<label>
<input
type="checkbox"
id="slack-text-mode-messages"
checked
/>
Text output
</label>
<label>
<input type="checkbox" id="slack-include-users" />
Resolve users
</label>
</div>
</div>
<div id="slack-output-messages-container" class="output-container">
<button class="copy-button" data-target="slack-output-messages">
Copy
</button>
<div
id="slack-output-messages"
class="output-area scrollable"
></div>
</div>
<h2 style="margin-top: 2rem">Endpoints</h2>
<ul class="endpoints">
<li>
<code>/slack/health</code>
<span class="desc">Slack connection status</span>
</li>
<li>
<code>/slack/channels</code>
<span class="desc">List channels</span>
</li>
<li>
<code>/slack/channels/{id}/messages</code>
<span class="desc">Channel history</span>
</li>
<li>
<code>/slack/channels/{id}/thread/{ts}</code>
<span class="desc">Thread replies</span>
</li>
<li>
<code>/slack/users</code>
<span class="desc">List users</span>
</li>
<li>
<code>/slack/post</code>
<span class="desc">Post message (POST)</span>
</li>
</ul>
<p style="margin-top: 1rem; font-size: 0.85rem; color: #666">
Add <code>?text=true</code> for LLM-friendly output. Send
<code>X-Slack-Token</code> header for programmatic access.
</p>
</section>
<section>
<div class="component" style="padding: 1rem">
<h4>Vein</h4>
<ul class="endpoints" style="margin: 0">
{% for vein in veins %}
<li>
<code>{{ vein.name }}</code
><span class="desc"
>{{ vein.status | capitalize }}</span
>
</li>
{% else %}
<li><code>--</code><span class="desc">Pending</span></li>
{% endfor %}
</ul>
</div>
</section>
<section>
<div class="component" style="padding: 1rem">
<h4>Room</h4>
<ul class="endpoints" style="margin: 0">
{% for room in rooms %}
<li>
<code>{{ room.name }}</code
><span class="desc"
>{{ room.status | capitalize }}</span
>
</li>
{% else %}
<li><code>--</code><span class="desc">Pending</span></li>
{% endfor %}
</ul>
</div>
</section>
<section>
<div class="component" style="padding: 1rem">
<h4>Depot</h4>
<ul class="endpoints" style="margin: 0">
{% for depot in depots %}
<li>
<code>{{ depot.name }}</code
><span class="desc"
>{{ depot.status | capitalize }}</span
>
</li>
{% else %}
<li><code>--</code><span class="desc">Pending</span></li>
{% endfor %}
</ul>
</div>
</section>
<section>
<h2>Shunts</h2>
<ul class="endpoints">
{% for shunt in shunts %}
<li>
<code>{{ shunt.name }}</code
><span class="desc">{{ shunt.status | capitalize }}</span>
</li>
{% else %}
<li><code>--</code><span class="desc">Pending</span></li>
{% endfor %}
</ul>
</section>
<section>
<h2>Pulses</h2>
<ul class="endpoints">
{% for pulse in pulses %}
<li>
<code>{{ pulse.name }}</code
><span class="desc">{{ pulse.status | capitalize }}</span>
</li>
{% else %}
<li><code>--</code><span class="desc">Pending</span></li>
{% endfor %}
</ul>
</section>
<section>
<h2>Plexuses</h2>
<ul class="endpoints">
{% for plexus in plexuses %}
<li>
<code>{{ plexus.name }}</code
><span class="desc">{{ plexus.status | capitalize }}</span>
</li>
{% else %}
<li><code>whatsapp</code><span class="desc">Planned</span></li>
{% endfor %}
</ul>
</section>
<!-- Google API Tab -->
<section id="tab-google_api" class="tab-content">
<h2>Google Sheets</h2>
<!-- Auth Status -->
<div id="google-auth-status" class="api-form">
<div id="google-not-connected">
<p style="color: #a3a3a3; margin: 0 0 1rem 0">
Connect your Google account to access Sheets.
</p>
<div class="api-controls" style="margin-top: 0">
<button id="btn-google-connect">
Connect Google Account
</button>
</div>
</div>
<div id="google-connected" style="display: none">
<p style="color: #4ade80; margin: 0 0 1rem 0">
✓ Connected to Google
</p>
<div class="api-controls" style="margin-top: 0">
<button id="btn-google-disconnect" class="tab-button">
Disconnect
</button>
</div>
</div>
</div>
<!-- Sheets Form (shown when connected) -->
<div id="google-sheets-form" style="display: none">
<div class="api-form">
<label for="spreadsheet-id">Spreadsheet ID</label>
<input
type="text"
id="spreadsheet-id"
placeholder="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms"
/>
<p style="color: #666; font-size: 0.8rem; margin: 0.5rem 0">
Find this in the spreadsheet URL:
docs.google.com/spreadsheets/d/<strong>SPREADSHEET_ID</strong>/edit
</p>
<div class="api-controls" style="margin-top: 1rem">
<button id="btn-list-sheets" class="tab-button">
List Sheets
</button>
<button id="btn-get-metadata" class="tab-button">
Get Metadata
</button>
<label style="margin-left: auto">
<input
type="checkbox"
id="google-text-mode"
checked
/>
Text output
</label>
</div>
</div>
<div class="api-form" style="margin-top: 1.5rem">
<label for="sheet-range">Range (A1 notation)</label>
<input
type="text"
id="sheet-range"
placeholder="Sheet1!A1:D10"
/>
<div class="api-controls">
<button id="btn-get-values">Get Values</button>
</div>
</div>
</div>
<!-- Output -->
<div id="google-output-container" class="output-container">
<div id="google-output" class="output-area scrollable"></div>
</div>
<h2 style="margin-top: 2rem">Endpoints</h2>
<ul class="endpoints">
<li>
<code>/artery/google_api/oauth/start</code>
<span class="desc">Start OAuth flow</span>
</li>
<li>
<code>/artery/google_api/oauth/status</code>
<span class="desc">Check connection status</span>
</li>
<li>
<code>/artery/google_api/spreadsheets/{id}</code>
<span class="desc">Spreadsheet metadata</span>
</li>
<li>
<code>/artery/google_api/spreadsheets/{id}/sheets</code>
<span class="desc">List sheets</span>
</li>
<li>
<code
>/artery/google_api/spreadsheets/{id}/values?range=...</code
>
<span class="desc">Get cell values</span>
</li>
</ul>
<p style="margin-top: 1rem; font-size: 0.85rem; color: #666">
Add <code>?text=true</code> for LLM-friendly output.
</p>
</section>
<!-- Placeholder tabs -->
<section id="tab-maps" class="tab-content">
<h2>Maps</h2>
<p>Maps connector. Planned.</p>
</section>
<section id="tab-cash" class="tab-content">
<h2>Cash</h2>
<p>Cash connector. Planned.</p>
</section>
<section id="tab-whatsapp" class="tab-content">
<h2>WhatsApp</h2>
<p>WhatsApp connector. In development.</p>
</section>
<section id="tab-vnc" class="tab-content">
<h2>VNC</h2>
<p>VNC connector. Planned.</p>
</section>
<section id="tab-ia" class="tab-content">
<h2>IA</h2>
<p>IA connector. Planned.</p>
</section>
<footer>
{% if pawprint_url %}<a href="{{ pawprint_url }}">← Soleprint</a>{%
else %}<span class="disabled">← Soleprint</span>{% endif %}
</footer>
<script>
// Tab switching
document.querySelectorAll(".vein[data-tab]").forEach((vein) => {
vein.addEventListener("click", () => {
// Don't switch to disabled veins
if (vein.dataset.disabled === "true") return;
document
.querySelectorAll(".vein")
.forEach((v) => v.classList.remove("selected"));
vein.classList.add("selected");
const tabId = vein.dataset.tab;
document
.querySelectorAll(".tab-content")
.forEach((t) => t.classList.remove("visible"));
document
.getElementById("tab-" + tabId)
?.classList.add("visible");
});
});
function showError(outputEl, msg) {
outputEl.textContent = "Error: " + msg;
outputEl.classList.add("visible", "error");
}
async function renderAttachments(data) {
const container = document.getElementById("attachments");
container.innerHTML = "";
if (!data.attachments || data.attachments.length === 0) return;
// List all attachment URLs (direct Jira links)
let html =
'<h3>Attachment Links (download from Jira)</h3><ul style="margin:0 0 1.5rem 0;padding-left:1.5rem;">';
data.attachments.forEach((att) => {
html += `<li style="margin:0.25rem 0;"><a href="${att.url}" target="_blank" style="color:#fca5a5;">${att.filename}</a> <span style="color:#666;">(${att.mimetype})</span></li>`;
});
html += "</ul>";
container.innerHTML = html;
const mediaAttachments = data.attachments.filter(
(a) =>
a.mimetype.startsWith("image/") ||
a.mimetype.startsWith("video/"),
);
if (mediaAttachments.length === 0) return;
container.innerHTML += "<h3>Preview</h3>";
for (const att of mediaAttachments) {
const div = document.createElement("div");
div.className = "attachment-item";
const label = document.createElement("div");
label.className = "attachment-label";
label.textContent = att.filename + " (loading...)";
div.appendChild(label);
container.appendChild(div);
// Fetch with header auth, convert to blob URL
try {
const res = await fetch(`/jira/attachment/${att.id}`, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error("Failed to load");
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
label.textContent = att.filename;
if (att.mimetype.startsWith("image/")) {
const img = document.createElement("img");
img.src = blobUrl;
img.alt = att.filename;
div.appendChild(img);
} else if (att.mimetype.startsWith("video/")) {
const video = document.createElement("video");
video.src = blobUrl;
video.controls = true;
div.appendChild(video);
}
} catch (e) {
label.textContent = att.filename + " (failed to load)";
}
}
}
// Mutual exclusivity: dropdown vs manual input
const ticketSelect = document.getElementById("ticket-select");
const ticketKey = document.getElementById("ticket-key");
ticketSelect.addEventListener("change", () => {
if (ticketSelect.value) {
ticketKey.disabled = true;
ticketKey.value = "";
} else {
ticketKey.disabled = false;
}
});
ticketKey.addEventListener("input", () => {
if (ticketKey.value.trim()) {
ticketSelect.disabled = true;
ticketSelect.value = "";
} else {
ticketSelect.disabled = ticketSelect.options.length <= 1;
}
});
// Get credentials helper
function getCredentials() {
const email = document
.getElementById("jira-email")
.value.trim();
const token = document
.getElementById("jira-token")
.value.trim();
return { email, token };
}
function getAuthHeaders() {
const { email, token } = getCredentials();
return {
"X-Jira-Email": email,
"X-Jira-Token": token,
};
}
// Output container helpers
function showOutput(outputId) {
const output = document.getElementById(outputId);
const container = output.parentElement;
container.classList.add("visible");
}
function hideOutput(outputId) {
const output = document.getElementById(outputId);
const container = output.parentElement;
container.classList.remove("visible");
}
function clearOutput(outputId) {
const output = document.getElementById(outputId);
output.textContent = "";
output.classList.remove("error");
}
// Copy button functionality
document.querySelectorAll(".copy-button").forEach((btn) => {
btn.addEventListener("click", async () => {
const targetId = btn.getAttribute("data-target");
const target = document.getElementById(targetId);
try {
await navigator.clipboard.writeText(target.textContent);
btn.textContent = "Copied!";
btn.classList.add("copied");
setTimeout(() => {
btn.textContent = "Copy";
btn.classList.remove("copied");
}, 2000);
} catch (e) {
btn.textContent = "Failed";
setTimeout(() => {
btn.textContent = "Copy";
}, 2000);
}
});
});
// Triple-click to select all in output areas
document.querySelectorAll(".output-area").forEach((area) => {
area.addEventListener("click", (e) => {
if (e.detail === 3) {
// Triple click
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(area);
selection.removeAllRanges();
selection.addRange(range);
}
});
});
// Tab system for buttons
const btnMine = document.getElementById("btn-mine");
const btnQuickTicket = document.getElementById("btn-quick-ticket");
const btnEpic = document.getElementById("btn-epic");
const quickTicketForm =
document.getElementById("quick-ticket-form");
const quickTicketInput =
document.getElementById("quick-ticket-input");
const epicForm = document.getElementById("epic-form");
const epicInput = document.getElementById("epic-input");
const epicProgress = document.getElementById("epic-progress");
const epicStatus = document.getElementById("epic-status");
function setActiveTab(button) {
// Remove active from all tab buttons
document
.querySelectorAll(".tab-button")
.forEach((btn) => btn.classList.remove("active"));
// Set active on clicked button
button.classList.add("active");
// Hide all tab-specific forms
quickTicketForm.style.display = "none";
epicForm.style.display = "none";
epicProgress.style.display = "none";
// Show form for this tab if needed
if (button === btnQuickTicket) {
quickTicketForm.style.display = "block";
quickTicketInput.focus();
} else if (button === btnEpic) {
epicForm.style.display = "block";
epicInput.focus();
}
}
// My Tickets
btnMine.addEventListener("click", async () => {
if (!btnMine.classList.contains("active")) {
// First click: activate tab
setActiveTab(btnMine);
return;
}
// Already active: execute action
const textMode =
document.getElementById("text-mode-list").checked;
const output = document.getElementById("output-list");
clearOutput("output-list");
output.textContent = "Loading...";
showOutput("output-list");
try {
// Always fetch JSON to populate dropdown
const res = await fetch("/jira/mine", {
headers: getAuthHeaders(),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || res.statusText);
}
const json = await res.json();
// Populate dropdown
ticketSelect.innerHTML =
'<option value="">-- Select a ticket --</option>';
json.tickets.forEach((t) => {
const opt = document.createElement("option");
opt.value = t.key;
opt.textContent = `${t.key}: ${t.summary.substring(0, 60)}${t.summary.length > 60 ? "..." : ""}`;
ticketSelect.appendChild(opt);
});
ticketSelect.disabled = false;
ticketKey.disabled = false;
// Display output
if (textMode) {
// Fetch text version for display
const textRes = await fetch("/jira/mine?text=true", {
headers: getAuthHeaders(),
});
output.textContent = await textRes.text();
} else {
output.textContent = JSON.stringify(json, null, 2);
}
output.classList.remove("error");
} catch (e) {
showError(output, e.message);
}
});
// Quick Ticket (VET-#)
btnQuickTicket.addEventListener("click", async () => {
if (!btnQuickTicket.classList.contains("active")) {
// First click: activate tab and show form
setActiveTab(btnQuickTicket);
return;
}
// Already active: execute action
const ticketNum = quickTicketInput.value.trim();
if (!ticketNum) {
alert("Please enter a ticket number");
return;
}
const ticketKey = "VET-" + ticketNum;
const textMode =
document.getElementById("text-mode-list").checked;
const output = document.getElementById("output-list");
clearOutput("output-list");
output.textContent = "Loading...";
showOutput("output-list");
try {
const params = new URLSearchParams();
if (textMode) params.set("text", "true");
const res = await fetch(
`/jira/ticket/${ticketKey}?${params}`,
{
headers: getAuthHeaders(),
},
);
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || res.statusText);
}
if (textMode) {
output.textContent = await res.text();
} else {
output.textContent = JSON.stringify(
await res.json(),
null,
2,
);
}
output.classList.remove("error");
} catch (e) {
showError(output, e.message);
}
});
// Allow Enter key to submit
quickTicketInput.addEventListener("keypress", (e) => {
if (e.key === "Enter") {
btnQuickTicket.click();
}
});
// EPIC Processing
btnEpic.addEventListener("click", async () => {
if (!btnEpic.classList.contains("active")) {
// First click: activate tab and show form
setActiveTab(btnEpic);
return;
}
// Already active: execute action
const epicNum = epicInput.value.trim();
if (!epicNum) {
alert("Please enter an epic number");
return;
}
const epicKey = "VET-" + epicNum;
const output = document.getElementById("output-list");
// Don't show progress until we get first response
clearOutput("output-list");
hideOutput("output-list");
console.log("Starting EPIC fetch for:", epicKey);
console.log("Auth headers:", getAuthHeaders());
try {
console.log("Making fetch request...");
const response = await fetch(
`/jira/epic/${epicKey}/process`,
{
method: "POST",
headers: getAuthHeaders(),
},
);
console.log(
"Fetch response:",
response.status,
response.statusText,
);
if (!response.ok) {
throw new Error("Failed to process epic");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
console.log("Starting to read stream...");
while (true) {
const { done, value } = await reader.read();
console.log(
"Stream read:",
done ? "DONE" : `${value.length} bytes`,
);
if (done) break;
const chunk = decoder.decode(value);
console.log("Decoded chunk:", chunk);
const lines = chunk.split("\n").filter((l) => l.trim());
for (const line of lines) {
try {
console.log("Parsing line:", line);
const data = JSON.parse(line);
console.log("Parsed data:", data);
// Show progress indicator on first data
if (epicProgress.style.display === "none") {
epicProgress.style.display = "block";
epicStatus.textContent = "0/0";
epicStatus.classList.remove("error");
}
if (
data.status === "processing" ||
data.status === "complete"
) {
epicStatus.textContent = `${data.completed}/${data.total}`;
epicStatus.classList.remove("error");
} else if (data.status === "error") {
epicStatus.textContent = "Error";
epicStatus.classList.add("error");
// Show raw error with all details
const errorDetails = JSON.stringify(
data,
null,
2,
);
output.textContent = `Error occurred:\n\n${errorDetails}`;
output.classList.add("error");
showOutput("output-list");
} else if (data.status === "fetching_epic") {
epicStatus.textContent = "Fetching...";
}
if (data.status === "complete") {
setTimeout(() => {
if (data.text) {
output.textContent = data.text;
} else {
output.textContent = `Epic processed successfully!\nFiles saved to: ${data.path}`;
}
showOutput("output-list");
}, 500);
}
} catch (e) {
console.error("Failed to parse line:", line, e);
}
}
}
} catch (e) {
epicStatus.textContent = "Error";
epicStatus.classList.add("error");
output.textContent = `Request failed:\n\nError: ${e.message}\nStack: ${e.stack || "N/A"}`;
output.classList.add("error");
showOutput("output-list");
}
});
epicInput.addEventListener("keypress", (e) => {
if (e.key === "Enter") {
btnEpic.click();
}
});
// Get Ticket
document
.getElementById("btn-ticket")
.addEventListener("click", async () => {
const selectedTicket =
ticketSelect.value ||
document.getElementById("ticket-key").value.trim();
const includeAttachments = document.getElementById(
"include-attachments-ticket",
).checked;
const textMode =
document.getElementById("text-mode-ticket").checked;
const output = document.getElementById("output-ticket");
const attachments = document.getElementById("attachments");
clearOutput("output-ticket");
attachments.innerHTML = "";
if (!selectedTicket) {
showError(output, "Select a ticket or enter a key");
return;
}
output.textContent = "Loading...";
showOutput("output-ticket");
try {
// Always fetch JSON when including attachments (to render media)
if (includeAttachments) {
const jsonRes = await fetch(
`/jira/ticket/${selectedTicket}?include_attachments=true`,
{
headers: getAuthHeaders(),
},
);
if (!jsonRes.ok) {
const err = await jsonRes.json();
throw new Error(
err.detail || jsonRes.statusText,
);
}
const json = await jsonRes.json();
// Display text or JSON based on mode
if (textMode) {
const textRes = await fetch(
`/jira/ticket/${selectedTicket}?text=true`,
{
headers: getAuthHeaders(),
},
);
output.textContent = await textRes.text();
} else {
output.textContent = JSON.stringify(
json,
null,
2,
);
}
// Render attachments
renderAttachments(json);
} else {
const params = new URLSearchParams();
if (textMode) params.set("text", "true");
const res = await fetch(
`/jira/ticket/${selectedTicket}?${params}`,
{
headers: getAuthHeaders(),
},
);
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || res.statusText);
}
if (textMode) {
output.textContent = await res.text();
} else {
output.textContent = JSON.stringify(
await res.json(),
null,
2,
);
}
}
output.classList.remove("error");
} catch (e) {
showError(output, e.message);
}
});
// ===== SLACK =====
const channelSelect = document.getElementById("channel-select");
function getSlackToken() {
return document.getElementById("slack-token").value.trim();
}
function getSlackAuthHeaders() {
return { "X-Slack-Token": getSlackToken() };
}
// List Channels
document
.getElementById("btn-channels")
.addEventListener("click", async () => {
const textMode = document.getElementById(
"slack-text-mode-list",
).checked;
const output = document.getElementById("slack-output-list");
clearOutput("slack-output-list");
output.textContent = "Loading...";
showOutput("slack-output-list");
try {
// Always fetch JSON to populate dropdown
const res = await fetch("/slack/channels", {
headers: getSlackAuthHeaders(),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || res.statusText);
}
const json = await res.json();
// Populate dropdown
channelSelect.innerHTML =
'<option value="">-- Select a channel --</option>';
json.channels.forEach((ch) => {
const opt = document.createElement("option");
opt.value = ch.id;
const prefix = ch.is_private ? "🔒 " : "#";
opt.textContent = `${prefix}${ch.name}`;
channelSelect.appendChild(opt);
});
channelSelect.disabled = false;
// Display output
if (textMode) {
const textRes = await fetch(
"/slack/channels?text=true",
{
headers: getSlackAuthHeaders(),
},
);
output.textContent = await textRes.text();
} else {
output.textContent = JSON.stringify(json, null, 2);
}
output.classList.remove("error");
} catch (e) {
showError(output, e.message);
}
});
// Get Messages
document
.getElementById("btn-messages")
.addEventListener("click", async () => {
const selectedChannel = channelSelect.value;
const textMode = document.getElementById(
"slack-text-mode-messages",
).checked;
const includeUsers = document.getElementById(
"slack-include-users",
).checked;
const output = document.getElementById(
"slack-output-messages",
);
clearOutput("slack-output-messages");
if (!selectedChannel) {
showError(output, "Select a channel first");
return;
}
output.textContent = "Loading...";
showOutput("slack-output-messages");
try {
const params = new URLSearchParams();
if (textMode) params.set("text", "true");
if (includeUsers) params.set("include_users", "true");
const res = await fetch(
`/slack/channels/${selectedChannel}/messages?${params}`,
{
headers: getSlackAuthHeaders(),
},
);
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || res.statusText);
}
if (textMode) {
output.textContent = await res.text();
} else {
output.textContent = JSON.stringify(
await res.json(),
null,
2,
);
}
output.classList.remove("error");
} catch (e) {
showError(output, e.message);
}
});
// =====================================================================
// Google Tab
// =====================================================================
const googleNotConnected = document.getElementById(
"google-not-connected",
);
const googleConnected = document.getElementById("google-connected");
const googleSheetsForm =
document.getElementById("google-sheets-form");
const googleOutput = document.getElementById("google-output");
const googleOutputContainer = document.getElementById(
"google-output-container",
);
async function checkGoogleAuth() {
try {
const res = await fetch("/artery/google_api/oauth/status");
const data = await res.json();
if (data.authenticated) {
googleNotConnected.style.display = "none";
googleConnected.style.display = "block";
googleSheetsForm.style.display = "block";
} else {
googleNotConnected.style.display = "block";
googleConnected.style.display = "none";
googleSheetsForm.style.display = "none";
}
} catch (e) {
console.error("Failed to check Google auth status:", e);
}
}
// Check auth on page load if Google tab elements exist
if (googleNotConnected) {
checkGoogleAuth();
}
// Also check when switching to Google API tab
document
.querySelectorAll('.vein[data-tab="google_api"]')
.forEach((vein) => {
vein.addEventListener("click", () => {
checkGoogleAuth();
});
});
// Connect button
document
.getElementById("btn-google-connect")
?.addEventListener("click", () => {
// Redirect to OAuth start, will come back to /artery after auth
window.location.href =
"/artery/google_api/oauth/start?redirect=/artery";
});
// Disconnect button
document
.getElementById("btn-google-disconnect")
?.addEventListener("click", async () => {
await fetch("/artery/google_api/oauth/logout");
checkGoogleAuth();
googleOutputContainer.classList.remove("visible");
});
// Google output helpers
function showGoogleOutput(text, isError = false) {
googleOutput.textContent = text;
googleOutput.classList.toggle("error", isError);
googleOutputContainer.classList.add("visible");
}
// List Sheets
document
.getElementById("btn-list-sheets")
?.addEventListener("click", async () => {
const spreadsheetId = document
.getElementById("spreadsheet-id")
.value.trim();
if (!spreadsheetId) {
showGoogleOutput(
"Error: Please enter a Spreadsheet ID",
true,
);
return;
}
const textMode =
document.getElementById("google-text-mode").checked;
showGoogleOutput("Loading...");
try {
const params = new URLSearchParams();
if (textMode) params.set("text", "true");
const res = await fetch(
`/artery/google_api/spreadsheets/${spreadsheetId}/sheets?${params}`,
);
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || res.statusText);
}
if (textMode) {
showGoogleOutput(await res.text());
} else {
showGoogleOutput(
JSON.stringify(await res.json(), null, 2),
);
}
} catch (e) {
showGoogleOutput("Error: " + e.message, true);
}
});
// Get Metadata
document
.getElementById("btn-get-metadata")
?.addEventListener("click", async () => {
const spreadsheetId = document
.getElementById("spreadsheet-id")
.value.trim();
if (!spreadsheetId) {
showGoogleOutput(
"Error: Please enter a Spreadsheet ID",
true,
);
return;
}
const textMode =
document.getElementById("google-text-mode").checked;
showGoogleOutput("Loading...");
try {
const params = new URLSearchParams();
if (textMode) params.set("text", "true");
const res = await fetch(
`/artery/google_api/spreadsheets/${spreadsheetId}?${params}`,
);
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || res.statusText);
}
if (textMode) {
showGoogleOutput(await res.text());
} else {
showGoogleOutput(
JSON.stringify(await res.json(), null, 2),
);
}
} catch (e) {
showGoogleOutput("Error: " + e.message, true);
}
});
// Get Values
document
.getElementById("btn-get-values")
?.addEventListener("click", async () => {
const spreadsheetId = document
.getElementById("spreadsheet-id")
.value.trim();
const range = document
.getElementById("sheet-range")
.value.trim();
if (!spreadsheetId) {
showGoogleOutput(
"Error: Please enter a Spreadsheet ID",
true,
);
return;
}
if (!range) {
showGoogleOutput(
"Error: Please enter a Range (e.g., Sheet1!A1:D10)",
true,
);
return;
}
const textMode =
document.getElementById("google-text-mode").checked;
showGoogleOutput("Loading...");
try {
const params = new URLSearchParams();
params.set("range", range);
if (textMode) params.set("text", "true");
const res = await fetch(
`/artery/google_api/spreadsheets/${spreadsheetId}/values?${params}`,
);
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || res.statusText);
}
if (textMode) {
showGoogleOutput(await res.text());
} else {
showGoogleOutput(
JSON.stringify(await res.json(), null, 2),
);
}
} catch (e) {
showGoogleOutput("Error: " + e.message, true);
}
});
</script>
</body>
</html>