// 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' };