// Gemeinsamer Kern von Oberfläche und Hintergrunddienst: Serveradresse, Anmeldung, API-Aufrufe, Hilfsfunktionen.
// Das Anmelde-Token liegt nur in chrome.storage.local dieser Erweiterung (nie in sync, nie im Seitencode).
// Der Zugriff auf den Server ist im Manifest erlaubt (host_permissions): Es gibt keine Rückfrage beim Anmelden.
export class ApiError extends Error {
constructor(status, code, message) { super(message); this.status = status; this.code = code; }
}
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
/** Bereinigt die Serveradresse wie das Windows-Programm: https ist Pflicht (http nur für lokale Tests), kein Benutzer/Suchtext/Anker. */
export function normalizeServerUrl(input) {
let s = String(input || '').trim();
if (!s) throw new Error('Bitte die Server-Adresse eingeben.');
if (!s.includes('://')) s = 'https://' + s;
let u;
try { u = new URL(s); } catch { throw new Error('Die Server-Adresse ist ungültig.'); }
const local = LOCAL_HOSTS.has(u.hostname);
if (u.protocol !== 'https:' && !(u.protocol === 'http:' && local)) throw new Error('Die Server-Adresse muss HTTPS verwenden.');
if (u.username || u.password || u.search || u.hash) throw new Error('Die Server-Adresse ist ungültig.');
return u.origin + u.pathname.replace(/\/+$/, '');
}
export async function getSession() {
const { session } = await chrome.storage.local.get('session');
return session && session.token && session.serverUrl ? session : null;
}
export const setSession = (session) => chrome.storage.local.set({ session });
export async function clearSession() {
const drafts = Object.keys(await chrome.storage.local.get(null)).filter((k) => k.startsWith('draft:')); // nicht gesendete Entwürfe gehören zur Anmeldung
await chrome.storage.local.remove(['session', 'lastSeen', 'badgeCount', 'meta', ...drafts]);
}
export const DEFAULT_SETTINGS = { notifications: true, notifyUnassigned: true, notifyMine: true, theme: 'auto', compact: false };
export async function getSettings() {
const { settings } = await chrome.storage.local.get('settings');
return { ...DEFAULT_SETTINGS, ...(settings || {}) };
}
export const setSettings = (settings) => chrome.storage.local.set({ settings });
/** Ein Aufruf der Mitarbeiter-API. Token wird wie bei den Apps in zwei Headern gesendet (viele Hoster entfernen „Authorization“). */
export async function apiCall(path, { method = 'GET', query, body, auth = true, serverUrl, signal } = {}) {
const session = auth || !serverUrl ? await getSession() : null;
const base = serverUrl || session?.serverUrl;
if (!base) throw new ApiError(401, 'UNAUTHENTICATED', 'Nicht angemeldet.');
const url = new URL(`${base}/api/staff/v1/${path}`);
for (const [k, v] of Object.entries(query || {})) if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v));
const headers = { Accept: 'application/json' };
if (auth) {
if (!session?.token) throw new ApiError(401, 'UNAUTHENTICATED', 'Nicht angemeldet.');
headers.Authorization = `Bearer ${session.token}`;
headers['X-DeskHub-Authorization'] = `Bearer ${session.token}`;
}
const init = { method, headers, redirect: 'error', credentials: 'omit', cache: 'no-store', signal: signal || AbortSignal.timeout(30000) };
if (body !== undefined && method !== 'GET') { headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(body); }
let res;
try { res = await fetch(url, init); } catch { throw new ApiError(0, 'NETWORK', 'Server nicht erreichbar.'); }
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { /* kein JSON */ }
if (!res.ok && res.status !== 202) {
if (res.status === 401 && auth) await clearSession();
throw new ApiError(res.status, data?.error?.code || `HTTP_${res.status}`, data?.error?.message || `Serverfehler (${res.status}).`);
}
return { status: res.status, data };
}
/**
* Schickt eine POST-Anfrage los, ohne auf etwas zu warten (für „beim Verlassen aufräumen“). Läuft synchron mit keepalive und überlebt deshalb
* das Schließen des Popups, das sich bei jedem Klick daneben schließt. Die Sitzung wird übergeben, weil ein Speicherzugriff dafür zu spät käme.
*/
export function apiBeacon(session, path, body) {
if (!session?.token || !session?.serverUrl) return;
try {
fetch(`${session.serverUrl}/api/staff/v1/${path}`, {
method: 'POST', keepalive: true, redirect: 'error', credentials: 'omit', cache: 'no-store',
headers: { Accept: 'application/json', 'Content-Type': 'application/json', Authorization: `Bearer ${session.token}`, 'X-DeskHub-Authorization': `Bearer ${session.token}` },
body: JSON.stringify(body),
}).catch(() => {});
} catch { /* egal */ }
}
/** Meldet an. @returns {{requiresTotp:true}|{session:object}} */
export async function login(serverUrlInput, email, password, totpCode) {
const serverUrl = normalizeServerUrl(serverUrlInput);
const { status, data } = await apiCall('login.php', {
method: 'POST', auth: false, serverUrl,
body: { email, password, totpCode: totpCode || null, deviceName: 'Chrome-Erweiterung' },
});
if (status === 202 || data?.requiresTotp) return { requiresTotp: true };
const session = { serverUrl, token: data.token, expiresAt: data.expiresAt, staff: data.staff };
await setSession(session);
return { session };
}
/** Meldet ab: Token auf dem Server sperren und lokale Daten entfernen. */
export async function logout() {
const session = await getSession();
if (session) { try { await apiCall('logout.php', { method: 'POST', body: {} }); } catch { /* Token läuft ohnehin ab */ } }
await clearSession();
}
/** Stammdaten (Abteilungen, Prioritäten, Mitarbeiter, Status, Tags) – 10 Minuten zwischengespeichert. */
export async function getMeta(force = false) {
const { meta } = await chrome.storage.local.get('meta');
if (!force && meta && Date.now() - meta.at < 10 * 60 * 1000) return meta;
const { data } = await apiCall('metadata.php');
const fresh = {
at: Date.now(),
departments: (data.departments || []).filter((d) => !/\(deaktiviert\)$/.test(d.name)),
priorities: data.priorities || [], agents: data.agents || [], statuses: data.statuses || [], tags: data.tags || [],
};
await chrome.storage.local.set({ meta: fresh });
return fresh;
}
/** Pfad eines Tickets in der Weboberfläche. Nur Ticketnummern im bekannten Format. */
export function ticketPath(number) {
const n = String(number || '');
if (!/^[A-Za-z0-9._-]{1,40}$/.test(n)) throw new Error('Ungültige Ticketnummer.');
return `/staff/ticket_view.php?n=${n}`;
}
/** Öffnet eine Seite der Weboberfläche in einem neuen Tab. Der Server gibt dafür eine einmalige Anmelde-Adresse (und prüft die Rechte). */
export async function openWeb(path) {
const session = await getSession();
if (!session) throw new ApiError(401, 'UNAUTHENTICATED', 'Nicht angemeldet.');
const { data } = await apiCall('web_pages.php', { method: 'POST', body: { path } });
const u = new URL(String(data?.url || ''));
const base = new URL(session.serverUrl);
if ((u.protocol !== 'https:' && !LOCAL_HOSTS.has(u.hostname)) || u.origin !== base.origin || !u.pathname.endsWith('/staff/app_login.php')) {
throw new Error('Der Server hat eine unerwartete Adresse geliefert.');
}
return chrome.tabs.create({ url: u.href });
}
/** Vergleicht zwei Versionsnummern ("2.0.0" gegen "2.1"): negativ, null oder positiv. Nicht-Zahlen zählen als 0. */
export function compareVersions(a, b) {
const pa = String(a).split(/[.+-]/).map((x) => parseInt(x, 10) || 0);
const pb = String(b).split(/[.+-]/).map((x) => parseInt(x, 10) || 0);
for (let i = 0; i < Math.max(pa.length, pb.length, 3); i++) {
const d = (pa[i] || 0) - (pb[i] || 0);
if (d !== 0) return d;
}
return 0;
}
const UPDATE_CHECK_EVERY_MS = 6 * 3600 * 1000;
/**
* Fragt den Server, ob es eine neuere Version dieser Erweiterung gibt (die Plugin-Webseite liefert sie über DeskHub). Das Ergebnis steht
* in storage.local unter "update": { version, notes, size, checkedAt } oder null, wenn die Erweiterung aktuell ist. Höchstens alle 6 Stunden,
* außer mit force. Netzwerkfehler ändern nichts.
*/
export async function checkForExtensionUpdate({ force = false } = {}) {
const session = await getSession();
if (!session) return null;
const { update, updateCheckedAt = 0 } = await chrome.storage.local.get(['update', 'updateCheckedAt']);
if (!force && Date.now() - updateCheckedAt < UPDATE_CHECK_EVERY_MS) return update || null;
try {
const { data } = await apiCall('app_update.php', { query: { platform: 'chrome' } });
const current = chrome.runtime.getManifest().version;
const newer = data?.available && data.version && compareVersions(data.version, current) > 0;
const info = newer ? { version: String(data.version), notes: String(data.notes || ''), size: Number(data.size) || 0, checkedAt: Date.now() } : null;
await chrome.storage.local.set({ update: info, updateCheckedAt: Date.now() });
return info;
} catch {
return update || null; // Fehler: beim nächsten Mal erneut
}
}
/** Maskiert Text und macht Zeilenumbrüche zu
: Der Server nimmt Ticket-Nachrichten als HTML an und bereinigt sie zusätzlich. */
export function plainToHtml(text) {
return String(text ?? '').replace(/&/g, '&').replace(//g, '>').replace(/\r\n|\r|\n/g, '
');
}
export const truncate = (s, n) => (String(s).length > n ? String(s).slice(0, n - 1) + '…' : String(s));
/** „2026-10-31 04:03:00“ (Serverzeit) → Date. Der Server liefert Zeiten ohne Zeitzone; sie werden wie lokale Zeit behandelt. */
export function parseServerTime(s) {
const m = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?/.exec(String(s || ''));
return m ? new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +(m[6] || 0)) : null;
}
/** Kurzform wie „vor 5 Min“, „gestern 14:02“, „31.10. 04:03“. */
export function timeAgo(s, now = new Date()) {
const d = parseServerTime(s);
if (!d) return '';
const diff = Math.round((now - d) / 1000);
const hm = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
if (diff < 0) { // Zukunft (z. B. Wiedervorlage)
const tomorrow = new Date(now); tomorrow.setDate(now.getDate() + 1);
if (d.toDateString() === now.toDateString()) return `heute ${hm}`;
if (d.toDateString() === tomorrow.toDateString()) return `morgen ${hm}`;
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}. ${hm}`;
}
if (diff < 60) return 'gerade eben';
if (diff < 3600) return `vor ${Math.floor(diff / 60)} Min`;
const sameDay = d.toDateString() === now.toDateString();
if (sameDay) return `heute ${hm}`;
const yesterday = new Date(now); yesterday.setDate(now.getDate() - 1);
if (d.toDateString() === yesterday.toDateString()) return `gestern ${hm}`;
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}. ${hm}`;
}
export const fmtBytes = (n) => (n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : n >= 1024 ? Math.round(n / 1024) + ' KB' : n + ' B');
export const initials = (name) => String(name || '?').trim().split(/\s+/).slice(0, 2).map((p) => p[0]?.toUpperCase() || '').join('') || '?';
export const safeColor = (c) => (typeof c === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : null);
export const RESOLUTION_REASONS = {
fixed: 'Fehler behoben', guidance: 'Anleitung oder Beratung', duplicate: 'Doppelt / zusammengeführt', not_reproducible: 'Nicht reproduzierbar',
no_action: 'Keine Maßnahme erforderlich', spam: 'Spam oder Sperre', other: 'Sonstiges',
};
export const STATUS_LABELS = { open: 'Offen', pending: 'Wartet auf Kunde', answered: 'Beantwortet', on_hold: 'Pausiert', resolved: 'Gelöst', closed: 'Geschlossen' };
export const AVAILABILITY = { available: 'Verfügbar', busy: 'Beschäftigt', break: 'Pause', offline: 'Offline' };