Files
deskhub/chrome-erweiterung/lib/core.js
T
2026-11-04 07:55:00 +00:00

216 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 <br>: Der Server nimmt Ticket-Nachrichten als HTML an und bereinigt sie zusätzlich. */
export function plainToHtml(text) {
return String(text ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\r\n|\r|\n/g, '<br>');
}
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' };