Init
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
// FORGE Snow Sync — service worker.
|
||||
//
|
||||
// Flow, on a "Sync now" request from the popup:
|
||||
// 1. read { serverUrl, token } from chrome.storage.local
|
||||
// 2. find (or open) a rbassist.service-now.com tab
|
||||
// 3. executeScript a collector in the PAGE context — it pages the ServiceNow
|
||||
// Table API same-origin (so the session cookie + g_ck CSRF token just work)
|
||||
// and maps sc_req_item rows into FORGE's normalized ticket shape
|
||||
// 4. POST the tickets to <serverUrl>/api/sync in chunks, Authorization: Bearer <token>
|
||||
// 5. push a { state, ... } status to storage so the popup can render it
|
||||
//
|
||||
// The token and g_ck are never logged.
|
||||
|
||||
const SNOW_ORIGIN = 'https://rbassist.service-now.com';
|
||||
const CHUNK = 100;
|
||||
|
||||
function setStatus(status) {
|
||||
chrome.storage.local.set({ syncStatus: { ...status, at: Date.now() } });
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
||||
if (msg?.type === 'SYNC') {
|
||||
runSync().then(sendResponse).catch(err => sendResponse({ ok: false, error: String(err?.message || err) }));
|
||||
return true; // async response
|
||||
}
|
||||
});
|
||||
|
||||
async function runSync() {
|
||||
const { serverUrl, token, jira } = await chrome.storage.local.get(['serverUrl', 'token', 'jira']);
|
||||
if (!serverUrl || !token) {
|
||||
const err = { ok: false, state: 'error', message: 'Set the server URL and token in options first.' };
|
||||
setStatus(err);
|
||||
return err;
|
||||
}
|
||||
|
||||
setStatus({ state: 'collecting', message: 'Reading ServiceNow…' });
|
||||
let tickets;
|
||||
try {
|
||||
tickets = await collectFromSnow();
|
||||
} catch (err) {
|
||||
const out = { ok: false, state: 'error', message: 'ServiceNow read failed: ' + (err?.message || err) };
|
||||
setStatus(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (!tickets.length) {
|
||||
const out = { ok: true, state: 'done', message: 'No active tickets found.', count: 0 };
|
||||
setStatus(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
setStatus({ state: 'pushing', message: `Pushing ${tickets.length} tickets…` });
|
||||
let upserted = 0;
|
||||
try {
|
||||
for (let i = 0; i < tickets.length; i += CHUNK) {
|
||||
const chunk = tickets.slice(i, i + CHUNK);
|
||||
const res = await postChunk(serverUrl, token, chunk);
|
||||
upserted += res.upserted ?? chunk.length;
|
||||
setStatus({ state: 'pushing', message: `Pushed ${Math.min(i + CHUNK, tickets.length)}/${tickets.length}…` });
|
||||
}
|
||||
} catch (err) {
|
||||
const out = { ok: false, state: 'error', message: 'Push failed: ' + (err?.message || err) };
|
||||
setStatus(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- Jira phase (optional) — runs AFTER SNOW so it attaches to fresh rows ---
|
||||
let jiraMsg = '';
|
||||
if (jira && jira.baseUrl && jira.apiToken && Array.isArray(jira.boardIds) && jira.boardIds.length) {
|
||||
setStatus({ state: 'collecting', message: 'Reading Jira…' });
|
||||
try {
|
||||
const items = await collectFromJira(jira);
|
||||
if (items.length) {
|
||||
setStatus({ state: 'pushing', message: `Pushing ${items.length} Jira links…` });
|
||||
let matched = 0;
|
||||
for (let i = 0; i < items.length; i += CHUNK) {
|
||||
const res = await postJiraChunk(serverUrl, token, items.slice(i, i + CHUNK));
|
||||
matched += res.matched ?? 0;
|
||||
}
|
||||
jiraMsg = ` · ${matched} Jira`;
|
||||
} else {
|
||||
jiraMsg = ' · 0 Jira';
|
||||
}
|
||||
} catch (err) {
|
||||
// Jira is best-effort: a Jira failure must not fail the whole (successful) SNOW sync.
|
||||
jiraMsg = ' · Jira failed: ' + (err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
const out = { ok: true, state: 'done', message: `Synced ${upserted} tickets${jiraMsg}.`, count: upserted };
|
||||
setStatus(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- Jira collector (direct REST from the service worker) ------------------
|
||||
// Basic auth (email + token) for Jira Cloud; Bearer PAT for Jira Server/DC.
|
||||
function jiraAuthHeader(jira) {
|
||||
if (jira.email) return 'Basic ' + btoa(`${jira.email}:${jira.apiToken}`);
|
||||
return 'Bearer ' + jira.apiToken;
|
||||
}
|
||||
// Resolve the RITM number for a Jira issue: the custom field first, then a
|
||||
// regex over summary/description. Returns null when no RITM is linkable.
|
||||
function ritmForIssue(issue) {
|
||||
const f = issue.fields || {};
|
||||
const cf = f.customfield_26001;
|
||||
const cfStr = typeof cf === 'string' ? cf : (cf && (cf.value || cf.name)) || '';
|
||||
const m = String(cfStr || `${f.summary || ''} ${f.description || ''}`).match(/RITM\d+/i);
|
||||
return m ? m[0].toUpperCase() : null;
|
||||
}
|
||||
// From an issue's changelog, reconstruct time spent per status (ms) and the list
|
||||
// of status movements. Powers chart #17 (durations) + the movement charts.
|
||||
function jiraFromChangelog(issue) {
|
||||
const f = issue.fields || {};
|
||||
const histories = (issue.changelog && issue.changelog.histories) || [];
|
||||
// status-change events, ascending by time
|
||||
const events = [];
|
||||
for (const h of histories) {
|
||||
for (const it of (h.items || [])) {
|
||||
if (it.field === 'status') {
|
||||
events.push({ at: h.created, who: (h.author && (h.author.displayName || h.author.name)) || null, from: it.fromString, to: it.toString });
|
||||
}
|
||||
}
|
||||
}
|
||||
events.sort((a, b) => new Date(a.at) - new Date(b.at));
|
||||
const movements = events.map(e => ({ at: e.at, who: e.who, from: e.from, to: e.to }));
|
||||
|
||||
const durations = {};
|
||||
const add = (status, ms) => { if (status && ms > 0) durations[status] = (durations[status] || 0) + ms; };
|
||||
const created = f.created ? new Date(f.created).getTime() : (events[0] ? new Date(events[0].at).getTime() : Date.now());
|
||||
if (events.length) {
|
||||
// status held before the first transition
|
||||
add(events[0].from, new Date(events[0].at).getTime() - created);
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const start = new Date(events[i].at).getTime();
|
||||
const end = i + 1 < events.length ? new Date(events[i + 1].at).getTime() : Date.now();
|
||||
add(events[i].to, end - start);
|
||||
}
|
||||
} else if (f.status && f.status.name) {
|
||||
add(f.status.name, Date.now() - created); // never moved
|
||||
}
|
||||
return { statusDurations: durations, movements };
|
||||
}
|
||||
|
||||
async function collectFromJira(jira) {
|
||||
const base = jira.baseUrl.replace(/\/+$/, '');
|
||||
const auth = jiraAuthHeader(jira);
|
||||
const fields = 'summary,status,assignee,updated,created,customfield_26001';
|
||||
const byRitm = new Map(); // RITM → jira info (last write wins; board order)
|
||||
for (const boardId of jira.boardIds) {
|
||||
let startAt = 0;
|
||||
for (let page = 0; page < 50; page++) { // hard cap
|
||||
const url = `${base}/rest/agile/1.0/board/${encodeURIComponent(boardId)}/issue`
|
||||
+ `?fields=${encodeURIComponent(fields)}&expand=changelog&maxResults=50&startAt=${startAt}`;
|
||||
const res = await fetch(url, { headers: { Accept: 'application/json', Authorization: auth } });
|
||||
if (res.status === 401 || res.status === 403) throw new Error('Jira auth rejected (' + res.status + ')');
|
||||
if (!res.ok) throw new Error('Jira board ' + boardId + ' HTTP ' + res.status);
|
||||
const body = await res.json();
|
||||
const issues = body.issues || [];
|
||||
for (const issue of issues) {
|
||||
const ritm = ritmForIssue(issue);
|
||||
if (!ritm) continue;
|
||||
const f = issue.fields || {};
|
||||
const cl = jiraFromChangelog(issue); // { statusDurations, movements }
|
||||
byRitm.set(ritm, {
|
||||
number: ritm,
|
||||
jira: {
|
||||
key: issue.key,
|
||||
status: (f.status && f.status.name) || null,
|
||||
statusChangedAt: f.updated || null,
|
||||
assignee: (f.assignee && (f.assignee.displayName || f.assignee.name)) || null,
|
||||
url: `${base}/browse/${issue.key}`,
|
||||
statusDurations: cl.statusDurations,
|
||||
movements: cl.movements,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (startAt + issues.length >= (body.total ?? 0) || issues.length === 0) break;
|
||||
startAt += issues.length;
|
||||
}
|
||||
}
|
||||
return [...byRitm.values()];
|
||||
}
|
||||
async function postJiraChunk(serverUrl, token, items) {
|
||||
const res = await fetch(`${serverUrl.replace(/\/+$/, '')}/api/sync/jira`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ items }),
|
||||
});
|
||||
if (res.status === 401) throw new Error('FORGE token rejected (401)');
|
||||
if (!res.ok) throw new Error('FORGE /api/sync/jira HTTP ' + res.status);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Find an existing SNOW tab or create a background one, then run the collector.
|
||||
async function collectFromSnow() {
|
||||
let tabs = await chrome.tabs.query({ url: `${SNOW_ORIGIN}/*` });
|
||||
let created = null;
|
||||
if (!tabs.length) {
|
||||
created = await chrome.tabs.create({ url: `${SNOW_ORIGIN}/now/nav/ui`, active: false });
|
||||
await waitForComplete(created.id);
|
||||
tabs = [created];
|
||||
}
|
||||
try {
|
||||
const [{ result }] = await chrome.scripting.executeScript({
|
||||
target: { tabId: tabs[0].id },
|
||||
world: 'MAIN',
|
||||
func: pageCollector,
|
||||
args: [SNOW_ORIGIN],
|
||||
});
|
||||
if (result?.error) throw new Error(result.error);
|
||||
return result?.tickets ?? [];
|
||||
} finally {
|
||||
if (created?.id) chrome.tabs.remove(created.id).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function waitForComplete(tabId) {
|
||||
return new Promise(resolve => {
|
||||
const listener = (id, info) => {
|
||||
if (id === tabId && info.status === 'complete') {
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
chrome.tabs.onUpdated.addListener(listener);
|
||||
setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); resolve(); }, 30000);
|
||||
});
|
||||
}
|
||||
|
||||
// Runs in the ServiceNow page context. Pages the Table API and maps rows.
|
||||
// Must be fully self-contained (serialized into the page).
|
||||
async function pageCollector(origin) {
|
||||
try {
|
||||
const gck = window.g_ck || (window.g_user && window.g_user.g_ck) || '';
|
||||
const query = 'active=true^assignment_groupLIKEMarketing Web Presence^ORDERBYDESCsys_updated_on';
|
||||
const fields = [
|
||||
'number', 'short_description', 'state', 'assigned_to', 'assignment_group',
|
||||
'opened_at', 'sys_updated_on', 'due_date', 'sys_updated_by',
|
||||
].join(',');
|
||||
|
||||
const tickets = [];
|
||||
const pageSize = 100;
|
||||
for (let offset = 0; offset < 2000; offset += pageSize) {
|
||||
const url = `${origin}/api/now/table/sc_req_item`
|
||||
+ `?sysparm_query=${encodeURIComponent(query)}`
|
||||
+ `&sysparm_display_value=true`
|
||||
+ `&sysparm_exclude_reference_link=true`
|
||||
+ `&sysparm_fields=${encodeURIComponent(fields)}`
|
||||
+ `&sysparm_limit=${pageSize}&sysparm_offset=${offset}`;
|
||||
const res = await fetch(url, {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json', ...(gck ? { 'X-UserToken': gck } : {}) },
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) return { error: 'Not signed in to ServiceNow.' };
|
||||
if (!res.ok) return { error: 'Table API HTTP ' + res.status };
|
||||
const body = await res.json();
|
||||
const rows = body.result || [];
|
||||
for (const r of rows) {
|
||||
tickets.push({
|
||||
number: r.number,
|
||||
status: 'active',
|
||||
state: r.state || '',
|
||||
shortDesc: r.short_description || '',
|
||||
assignedTo: r.assigned_to || null,
|
||||
assignmentGroup: r.assignment_group || null,
|
||||
openedAt: r.opened_at || null,
|
||||
dueDate: r.due_date || null,
|
||||
stateChangedBy: r.sys_updated_by || null,
|
||||
lastActivityAt: r.sys_updated_on || null,
|
||||
lastActivityBy: r.sys_updated_by || null,
|
||||
updatedAt: r.sys_updated_on || null,
|
||||
link: `${origin}/sc_req_item.do?sysparm_query=number=${encodeURIComponent(r.number)}`,
|
||||
activity: [],
|
||||
});
|
||||
}
|
||||
if (rows.length < pageSize) break;
|
||||
}
|
||||
return { tickets };
|
||||
} catch (e) {
|
||||
return { error: String(e && e.message ? e.message : e) };
|
||||
}
|
||||
}
|
||||
|
||||
async function postChunk(serverUrl, token, tickets) {
|
||||
const res = await fetch(`${serverUrl.replace(/\/+$/, '')}/api/sync`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ tickets }),
|
||||
});
|
||||
if (res.status === 401) throw new Error('token rejected (401)');
|
||||
if (res.status === 403) throw new Error('forbidden (403)');
|
||||
if (!res.ok) throw new Error('server HTTP ' + res.status);
|
||||
return res.json();
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 270 B |
Binary file not shown.
|
After Width: | Height: | Size: 602 B |
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "FORGE Sync",
|
||||
"description": "Sync ServiceNow RITM tickets (and optionally Jira status/links) into your FORGE portal.",
|
||||
"version": "1.0.0",
|
||||
"permissions": ["storage", "scripting"],
|
||||
"host_permissions": ["https://rbassist.service-now.com/*"],
|
||||
"optional_host_permissions": ["http://*/*", "https://*/*"],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_title": "FORGE Snow Sync",
|
||||
"default_icon": {
|
||||
"16": "icons/icon-16.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
},
|
||||
"options_page": "options.html",
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>FORGE Snow Sync — Settings</title>
|
||||
<style>
|
||||
:root { --p: #6366f1; --p-h: #4f46e5; --bg: #f8fafc; --sf: #fff; --bd: #e2e8f0; --tx: #0f172a; --mut: #64748b; }
|
||||
* { box-sizing: border-box; margin: 0; }
|
||||
body { font: 14px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--tx); padding: 40px 20px; }
|
||||
.card { max-width: 460px; margin: 0 auto; background: var(--sf); border: 1px solid var(--bd); border-radius: 12px; padding: 28px; box-shadow: 0 4px 12px rgba(15,23,42,.06); }
|
||||
h1 { font-size: 18px; margin-bottom: 4px; letter-spacing: .04em; }
|
||||
p.sub { color: var(--mut); font-size: 13px; margin-bottom: 22px; }
|
||||
label { display: block; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--mut); margin: 16px 0 6px; }
|
||||
input { width: 100%; height: 40px; padding: 0 12px; border: 1px solid var(--bd); border-radius: 8px; font-size: 14px; color: var(--tx); outline: none; }
|
||||
input:focus { border-color: var(--p); box-shadow: 0 0 0 3px rgba(99,102,241,.12); }
|
||||
.hint { font-size: 12px; color: var(--mut); margin-top: 6px; }
|
||||
.row { display: flex; gap: 10px; margin-top: 24px; }
|
||||
button { flex: 1; height: 40px; border: none; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; }
|
||||
.save { background: var(--p); color: #fff; }
|
||||
.save:hover { background: var(--p-h); }
|
||||
.test { background: var(--sf); color: var(--tx); border: 1px solid var(--bd); }
|
||||
.test:hover { background: var(--bg); }
|
||||
.feedback { margin-top: 16px; padding: 10px 12px; border-radius: 8px; font-size: 13px; display: none; }
|
||||
.feedback[data-state="ok"] { display: block; background: #f0fdf4; color: #16a34a; border: 1px solid #bbf7d0; }
|
||||
.feedback[data-state="error"] { display: block; background: #fef2f2; color: #dc2626; border: 1px solid #fecaca; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>❄ FORGE Snow Sync</h1>
|
||||
<p class="sub">Point the extension at your FORGE server and paste a sync token.</p>
|
||||
|
||||
<form id="form">
|
||||
<label for="server-url">Server URL</label>
|
||||
<input id="server-url" type="url" placeholder="https://forge.mycloud.dp.ua" autocomplete="off" />
|
||||
<div class="hint">Where FORGE is reachable. No trailing slash needed.</div>
|
||||
|
||||
<label for="api-token">Sync token</label>
|
||||
<input id="api-token" type="password" placeholder="fg_…" autocomplete="off" />
|
||||
<div class="hint">Mint one in FORGE → Admin → API Tokens (or <code>npx tsx server/mint-token.ts</code>).</div>
|
||||
|
||||
<hr style="border:none;border-top:1px solid var(--bd);margin:24px 0 4px" />
|
||||
<p class="sub" style="margin:12px 0 0">Jira (optional) — syncs status/links onto matching RITM tickets.</p>
|
||||
|
||||
<label for="jira-url">Jira base URL</label>
|
||||
<input id="jira-url" type="url" placeholder="https://support.dataart.com" autocomplete="off" />
|
||||
<div class="hint">Your Jira site (Cloud or self-hosted). Issue links use <code><baseUrl>/browse/<KEY></code>.</div>
|
||||
|
||||
<label for="jira-email">Jira email <span style="text-transform:none;font-weight:400">(Cloud only — leave blank for a PAT)</span></label>
|
||||
<input id="jira-email" type="email" placeholder="you@company.com" autocomplete="off" />
|
||||
|
||||
<label for="jira-token">Jira API token / PAT</label>
|
||||
<input id="jira-token" type="password" placeholder="ATATT… or a Personal Access Token" autocomplete="off" />
|
||||
<div class="hint">With an email → Basic auth (Jira Cloud). Without → Bearer PAT (Jira Server/DC).</div>
|
||||
|
||||
<label for="jira-boards">Board ID(s)</label>
|
||||
<input id="jira-boards" type="text" placeholder="13793" autocomplete="off" />
|
||||
<div class="hint">Comma-separated agile board ids to pull. Leave blank to skip Jira sync.</div>
|
||||
|
||||
<div class="row">
|
||||
<button type="button" class="test" id="test">Test connection</button>
|
||||
<button type="submit" class="save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="feedback" id="feedback"></div>
|
||||
</div>
|
||||
<script src="options.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,86 @@
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const urlInput = $('server-url');
|
||||
const tokenInput = $('api-token');
|
||||
const jUrl = $('jira-url');
|
||||
const jEmail = $('jira-email');
|
||||
const jToken = $('jira-token');
|
||||
const jBoards = $('jira-boards');
|
||||
const feedback = $('feedback');
|
||||
|
||||
const stripUrl = (v) => (v || '').trim().replace(/\/+$/, '');
|
||||
function originPattern(url) {
|
||||
try { return `${new URL(url).origin}/*`; } catch { return null; }
|
||||
}
|
||||
function show(state, text) {
|
||||
feedback.dataset.state = state;
|
||||
feedback.textContent = text;
|
||||
}
|
||||
function parseBoards(v) {
|
||||
return (v || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const { serverUrl, token, jira } = await chrome.storage.local.get(['serverUrl', 'token', 'jira']);
|
||||
if (serverUrl) urlInput.value = serverUrl;
|
||||
if (token) tokenInput.value = token;
|
||||
if (jira) {
|
||||
if (jira.baseUrl) jUrl.value = jira.baseUrl;
|
||||
if (jira.email) jEmail.value = jira.email;
|
||||
if (jira.apiToken) jToken.value = jira.apiToken;
|
||||
if (Array.isArray(jira.boardIds)) jBoards.value = jira.boardIds.join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
async function ensurePermission(pattern) {
|
||||
try { return await chrome.permissions.request({ origins: [pattern] }); }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
async function save(e) {
|
||||
e.preventDefault();
|
||||
const serverUrl = stripUrl(urlInput.value);
|
||||
const token = tokenInput.value.trim();
|
||||
const pattern = originPattern(serverUrl);
|
||||
if (!pattern) return show('error', 'Enter a valid server URL, e.g. https://forge.mycloud.dp.ua');
|
||||
if (!token) return show('error', 'Paste a sync token (starts with fg_).');
|
||||
if (!(await ensurePermission(pattern))) return show('error', 'Permission for the FORGE origin was denied.');
|
||||
|
||||
// Jira is optional; only persist + request permission when a base URL is given.
|
||||
const jiraBase = stripUrl(jUrl.value);
|
||||
let jira = null;
|
||||
if (jiraBase) {
|
||||
const jPattern = originPattern(jiraBase);
|
||||
if (!jPattern) return show('error', 'Enter a valid Jira base URL, e.g. https://support.dataart.com');
|
||||
if (!(await ensurePermission(jPattern))) return show('error', 'Permission for the Jira origin was denied.');
|
||||
jira = {
|
||||
baseUrl: jiraBase,
|
||||
email: jEmail.value.trim(),
|
||||
apiToken: jToken.value.trim(),
|
||||
boardIds: parseBoards(jBoards.value),
|
||||
jql: '',
|
||||
};
|
||||
}
|
||||
|
||||
await chrome.storage.local.set({ serverUrl, token, jira });
|
||||
show('ok', jira && jira.boardIds.length
|
||||
? 'Saved. Sync from the popup — SNOW then Jira.'
|
||||
: 'Saved. You can sync from the toolbar popup now.');
|
||||
}
|
||||
|
||||
async function test() {
|
||||
const serverUrl = stripUrl(urlInput.value);
|
||||
const pattern = originPattern(serverUrl);
|
||||
if (!pattern) return show('error', 'Enter a valid server URL first.');
|
||||
if (!(await ensurePermission(pattern))) return show('error', 'Permission for that origin was denied.');
|
||||
try {
|
||||
const res = await fetch(`${serverUrl}/healthz`);
|
||||
if (res.ok) show('ok', 'FORGE server reachable ✓');
|
||||
else show('error', `Server responded ${res.status}.`);
|
||||
} catch {
|
||||
show('error', 'Could not reach the server.');
|
||||
}
|
||||
}
|
||||
|
||||
$('form').addEventListener('submit', save);
|
||||
$('test').addEventListener('click', test);
|
||||
load();
|
||||
@@ -0,0 +1,35 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
:root { --p: #6366f1; --p-h: #4f46e5; --bg: #f8fafc; --sf: #fff; --bd: #e2e8f0; --tx: #0f172a; --mut: #64748b; }
|
||||
* { box-sizing: border-box; margin: 0; }
|
||||
body { width: 300px; font: 13px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--tx); padding: 16px; }
|
||||
.head { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
|
||||
.mark { font-size: 18px; }
|
||||
.name { font-weight: 700; letter-spacing: .12em; }
|
||||
button { width: 100%; height: 38px; border: none; border-radius: 8px; background: var(--p); color: #fff; font-size: 14px; font-weight: 600; cursor: pointer; }
|
||||
button:hover:not(:disabled) { background: var(--p-h); }
|
||||
button:disabled { opacity: .6; cursor: default; }
|
||||
.status { margin-top: 12px; padding: 10px; border-radius: 8px; background: var(--sf); border: 1px solid var(--bd); font-size: 12px; color: var(--mut); min-height: 38px; display: flex; align-items: center; gap: 8px; }
|
||||
.status[data-state="done"] { color: #16a34a; border-color: #bbf7d0; }
|
||||
.status[data-state="error"] { color: #dc2626; border-color: #fecaca; }
|
||||
.spin { width: 12px; height: 12px; border: 2px solid var(--bd); border-top-color: var(--p); border-radius: 50%; animation: s .7s linear infinite; }
|
||||
@keyframes s { to { transform: rotate(360deg); } }
|
||||
.opts { margin-top: 12px; text-align: center; }
|
||||
.opts a { color: var(--mut); font-size: 12px; text-decoration: none; }
|
||||
.opts a:hover { color: var(--p); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="head">
|
||||
<span class="mark">❄</span>
|
||||
<span class="name">FORGE SYNC</span>
|
||||
</div>
|
||||
<button id="sync">Sync now</button>
|
||||
<div class="status" id="status" data-state="idle">Ready.</div>
|
||||
<div class="opts"><a href="#" id="open-options">Server & token settings →</a></div>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,44 @@
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const syncBtn = $('sync');
|
||||
const statusEl = $('status');
|
||||
|
||||
function render(status) {
|
||||
if (!status) { statusEl.dataset.state = 'idle'; statusEl.textContent = 'Ready.'; return; }
|
||||
statusEl.dataset.state = status.state || 'idle';
|
||||
const busy = status.state === 'collecting' || status.state === 'pushing';
|
||||
statusEl.innerHTML = busy ? '<span class="spin"></span>' : '';
|
||||
statusEl.append(status.message || '');
|
||||
syncBtn.disabled = busy;
|
||||
syncBtn.textContent = busy ? 'Syncing…' : 'Sync now';
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const { serverUrl, token, syncStatus } = await chrome.storage.local.get(['serverUrl', 'token', 'syncStatus']);
|
||||
if (!serverUrl || !token) {
|
||||
render({ state: 'error', message: 'Set server URL and token in settings first.' });
|
||||
} else {
|
||||
render(syncStatus);
|
||||
}
|
||||
}
|
||||
|
||||
syncBtn.addEventListener('click', async () => {
|
||||
render({ state: 'collecting', message: 'Starting…' });
|
||||
try {
|
||||
const res = await chrome.runtime.sendMessage({ type: 'SYNC' });
|
||||
render(res);
|
||||
} catch (err) {
|
||||
render({ state: 'error', message: String(err?.message || err) });
|
||||
}
|
||||
});
|
||||
|
||||
$('open-options').addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
chrome.runtime.openOptionsPage();
|
||||
});
|
||||
|
||||
// Live-update while a sync runs in the worker.
|
||||
chrome.storage.onChanged.addListener((changes, area) => {
|
||||
if (area === 'local' && changes.syncStatus) render(changes.syncStatus.newValue);
|
||||
});
|
||||
|
||||
init();
|
||||
Reference in New Issue
Block a user