0, 'secure' => $https, 'httponly' => true, 'samesite' => 'Strict']); session_start(); // -------------------------------------------------------------------------------------------- // Hilfsfunktionen // -------------------------------------------------------------------------------------------- function h($v): string { return htmlspecialchars((string)$v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } function redirect(string $to): void { header('Location: ' . $to, true, 303); exit; } function fmt_time($ts): string { $ts = (int)$ts; return $ts > 0 ? date('d.m.Y H:i', $ts) : '–'; } function ago($ts): string { $d = time() - (int)$ts; if ($d < 60) return 'gerade eben'; if ($d < 3600) return 'vor ' . intdiv($d, 60) . ' Min.'; if ($d < 86400) return 'vor ' . intdiv($d, 3600) . ' Std.'; if ($d < 86400 * 60) return 'vor ' . intdiv($d, 86400) . ' Tagen'; return 'vor ' . intdiv($d, 86400 * 30) . ' Monaten'; } function fmt_num($n): string { return number_format((float)$n, 0, '.', "'"); } function fmt_bytes($b): string { $b = (int)$b; if ($b < 1024) return $b . ' B'; if ($b < 1048576) return number_format($b / 1024, 1, '.', "'") . ' KB'; return number_format($b / 1048576, 2, '.', "'") . ' MB'; } function short_hash($h): string { return substr((string)$h, 0, 10); } function space_title(array $s): string { return trim((string)$s['name']) !== '' ? (string)$s['name'] : 'Preisliste #' . $s['id']; } function client_ip(): string { return (string)($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'); } function viewer_password(): string { if (VIEWER_PASSWORD !== '') return VIEWER_PASSWORD; $src = @file_get_contents(__DIR__ . '/index.php'); if (is_string($src) && preg_match("/const\\s+ADMIN_KEY\\s*=\\s*'([^'\\\\]*)'\\s*;/", $src, $m)) return $m[1]; return ''; } function csrf(): string { if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(16)); return $_SESSION['csrf']; } function check_csrf(): bool { $t = $_POST['csrf'] ?? ''; return is_string($t) && !empty($_SESSION['csrf']) && hash_equals($_SESSION['csrf'], $t); } /** Basis-URL der API (index.php im selben Verzeichnis). */ function api_base(): string { $https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'); $host = (string)($_SERVER['HTTP_HOST'] ?? 'localhost'); $dir = rtrim(str_replace('\\', '/', dirname((string)($_SERVER['SCRIPT_NAME'] ?? '/'))), '/'); return ($https ? 'https' : 'http') . '://' . $host . $dir . '/'; } // -------------------------------------------------------------------------------------------- // Datenbank (nur lesend genutzt) // -------------------------------------------------------------------------------------------- function db(): PDO { static $pdo = null; if ($pdo !== null) return $pdo; $file = VIEW_DATA_DIR . '/gmsync.sqlite'; if (!extension_loaded('pdo_sqlite')) fatal('PDO-SQLite fehlt auf dem Server.'); if (!is_file($file)) { fatal('Noch keine Datenbank gefunden. Rufe die API einmal auf (z.B. ?action=status), ' . 'damit sie angelegt wird, und prüfe, ob VIEW_DATA_DIR auf dasselbe Verzeichnis zeigt wie DATA_DIR in der index.php.'); } $pdo = new PDO('sqlite:' . $file, null, null, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, ]); $pdo->exec('PRAGMA busy_timeout = 5000'); $ok = $pdo->query("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN ('spaces','history','official','ratelimit')")->fetchColumn(); if ((int)$ok < 4) fatal('Die Datenbank ist unvollständig. Rufe die API einmal auf, damit alle Tabellen angelegt werden.'); return $pdo; } function fail_bucket(): array { return ['viewfail:' . hash('sha256', client_ip() . '|gmsync'), intdiv(time(), 3600)]; } function login_fails(): int { [$b, $w] = fail_bucket(); $st = db()->prepare('SELECT hits FROM ratelimit WHERE bucket = ? AND window = ?'); $st->execute([$b, $w]); return (int)$st->fetchColumn(); } function add_login_fail(): void { [$b, $w] = fail_bucket(); db()->prepare('INSERT INTO ratelimit (bucket, window, hits) VALUES (?, ?, 1) ON CONFLICT(bucket, window) DO UPDATE SET hits = hits + 1')->execute([$b, $w]); } // -------------------------------------------------------------------------------------------- // Sitzung / Rechte // -------------------------------------------------------------------------------------------- function logged_in(): bool { return isset($_SESSION['role']); } function is_admin(): bool { return ($_SESSION['role'] ?? '') === 'admin'; } /** Für Spieler-Logins: eigene Preisliste neu nachschlagen (falls inzwischen gelöscht → abmelden). */ function own_space_id(): ?int { if (($_SESSION['role'] ?? '') !== 'space') return null; static $id = null; if ($id !== null) return $id; $st = db()->prepare('SELECT id FROM spaces WHERE token_hash = ?'); $st->execute([(string)($_SESSION['th'] ?? '')]); $found = $st->fetchColumn(); if ($found === false) { $_SESSION = []; session_destroy(); redirect('?view=login&gone=1'); } return $id = (int)$found; } function require_login(): void { if (!logged_in()) redirect('?view=login'); $last = (int)($_SESSION['t'] ?? 0); if ($last > 0 && time() - $last > SESSION_MINUTES * 60) { $_SESSION = []; session_destroy(); redirect('?view=login&expired=1'); } $_SESSION['t'] = time(); } // -------------------------------------------------------------------------------------------- // Layout // -------------------------------------------------------------------------------------------- function page_start(string $title, string $active = ''): void { global $NONCE; echo '' . '' . '' . '' . h($title) . ' · ' . h(VIEWER_TITLE) . '' . '' . '
' . ' ' . h(VIEWER_TITLE) . ''; if (logged_in()) { echo ''; } echo '
'; } function page_end(?array $payload = null): void { global $NONCE; echo '
'; echo '

'; if ($payload !== null) { $json = json_encode($payload, JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION | JSON_INVALID_UTF8_SUBSTITUTE); echo ''; } echo ''; exit; } function fatal(string $html): void { http_response_code(500); page_start('Fehler'); echo '

Fehler

' . $html . '

'; page_end(); } function not_found(string $msg): void { http_response_code(404); page_start('Nicht gefunden'); echo '

Nicht gefunden

' . h($msg) . '

Zur Übersicht

'; page_end(); } function forbidden(): void { http_response_code(403); page_start('Kein Zugriff'); echo '

Kein Zugriff

Mit deinem Schlüssel kannst du nur deine eigene Preisliste ansehen.

Zurück

'; page_end(); } // -------------------------------------------------------------------------------------------- // Routing // -------------------------------------------------------------------------------------------- set_exception_handler(function (Throwable $e) { error_log('[gmview] ' . get_class($e) . ': ' . $e->getMessage()); if (!headers_sent()) http_response_code(500); echo '

Interner Fehler. Details im Server-Log.

'; }); $view = (string)($_GET['view'] ?? 'home'); $method = (string)($_SERVER['REQUEST_METHOD'] ?? 'GET'); // ---------- Abmelden ---------- if ($view === 'logout') { if ($method === 'POST' && check_csrf()) { $_SESSION = []; session_destroy(); } redirect('?view=login'); } // ---------- Anmelden ---------- if ($view === 'login') { if (logged_in()) redirect('?'); $error = ''; if ($method === 'POST') { $key = trim((string)($_POST['key'] ?? '')); if (!check_csrf()) { $error = 'Sitzung abgelaufen – bitte erneut versuchen.'; } elseif (login_fails() >= LOGIN_FAILS_PER_HOUR) { $error = 'Zu viele Fehlversuche. Bitte später erneut versuchen.'; } else { $pw = viewer_password(); if (strlen($pw) >= 16 && $key !== '' && hash_equals($pw, $key)) { session_regenerate_id(true); $_SESSION = ['role' => 'admin', 't' => time()]; redirect('?'); } if (preg_match('/^gms_[a-f0-9]{48}$/', $key)) { $th = hash('sha256', $key); $st = db()->prepare('SELECT id FROM spaces WHERE token_hash = ?'); $st->execute([$th]); $id = $st->fetchColumn(); if ($id !== false) { session_regenerate_id(true); $_SESSION = ['role' => 'space', 'th' => $th, 't' => time()]; redirect('?view=space&id=' . (int)$id); } } add_login_fail(); $error = 'Schlüssel ist falsch.'; } } page_start('Anmelden'); echo '

Anmelden

'; if (isset($_GET['expired'])) echo '

Du wurdest wegen Inaktivität abgemeldet.

'; if (isset($_GET['gone'])) echo '

Diese Preisliste existiert nicht mehr.

'; if ($error !== '') echo '

' . h($error) . '

'; if (strlen(viewer_password()) < 16) { echo '

Admin-Anmeldung ist deaktiviert: Setze ADMIN_KEY in der index.php oder VIEWER_PASSWORD in der viewer.php (mind. 16 Zeichen). Anmeldung mit Schreib-Schlüssel funktioniert trotzdem.

'; } echo '
' . '' . '' . '' . '
' . '

Mit dem Schreib-Schlüssel aus der Mod siehst du deine eigene Preisliste und deren Verlauf.

'; page_end(); } require_login(); // ---------- Übersicht ---------- if ($view === 'home') { if (!is_admin()) redirect('?view=space&id=' . own_space_id()); $pdo = db(); $spaces = $pdo->query('SELECT s.id, s.name, s.revision, s.entries, s.device, s.created, s.updated, length(s.data) AS bytes, (SELECT COUNT(*) FROM history h WHERE h.space_id = s.id) AS hist FROM spaces s ORDER BY s.updated DESC')->fetchAll(); $official = $pdo->query('SELECT version, hash, entries, note, created, length(data) AS bytes FROM official ORDER BY version DESC')->fetchAll(); $histTotal = (int)$pdo->query('SELECT COUNT(*) FROM history')->fetchColumn(); $active30 = 0; $entriesTotal = 0; foreach ($spaces as $s) { if ((int)$s['updated'] > time() - 30 * 86400) $active30++; $entriesTotal += (int)$s['entries']; } page_start('Übersicht', 'home'); echo '

Übersicht

' . '
Preislisten' . fmt_num(count($spaces)) . '
' . '
Aktiv (30 Tage)' . fmt_num($active30) . '
' . '
Einträge gesamt' . fmt_num($entriesTotal) . '
' . '
Gespeicherte Versionen' . fmt_num($histTotal) . '
' . '
Offizielle Version' . ($official ? 'v' . (int)$official[0]['version'] : '–') . '
' . '
Datenbank' . fmt_bytes(@filesize(VIEW_DATA_DIR . '/gmsync.sqlite')) . '
' . '
'; // Offizielle Daten echo '

Offizielle Daten

' . ($official ? 'Aktuelle Version öffnen →' : '') . '
'; if (!$official) { echo '

Es wurden noch keine offiziellen Daten veröffentlicht.

'; } else { echo '
'; foreach (array_slice($official, 0, 5) as $i => $o) { echo '' . '' . '' . '' . ''; } echo '
VersionVeröffentlichtEinträgeGrösseNotizHash
v' . (int)$o['version'] . '' . ($i === 0 ? ' aktuell' : '') . '' . fmt_time($o['created']) . ' ' . h(ago($o['created'])) . '' . fmt_num($o['entries']) . '' . fmt_bytes($o['bytes']) . '' . h($o['note']) . '' . h(short_hash($o['hash'])) . '
'; if (count($official) > 5) echo '

' . (count($official) - 5) . ' ältere Versionen – in der Detailansicht aufgelistet.

'; } echo '
'; // Preislisten echo '

Preislisten

' . '
'; if (!$spaces) { echo '

Noch keine Preislisten angelegt.

'; } else { echo '
'; foreach ($spaces as $s) { $url = '?view=space&id=' . (int)$s['id']; $search = mb_strtolower(space_title($s) . ' ' . $s['device'] . ' #' . $s['id']); echo '' . '' . '' . '' . '' . '' . '' . '' . ''; } echo '
NameRevisionEinträgeVersionenZuletzt geändertGerätGrösseAngelegt
' . h(space_title($s)) . ' #' . (int)$s['id'] . 'r' . (int)$s['revision'] . '' . fmt_num($s['entries']) . '' . fmt_num((int)$s['hist'] + 1) . '' . fmt_time($s['updated']) . ' ' . h(ago($s['updated'])) . '' . h($s['device'] !== '' ? $s['device'] : '–') . '' . fmt_bytes($s['bytes']) . '' . fmt_time($s['created']) . '
'; } echo '
'; page_end(); } // ---------- Preisliste mit Versionen ---------- if ($view === 'space') { $id = (int)($_GET['id'] ?? 0); if (!is_admin() && $id !== own_space_id()) forbidden(); $pdo = db(); $st = $pdo->prepare('SELECT * FROM spaces WHERE id = ?'); $st->execute([$id]); $space = $st->fetch(); if (!$space) not_found('Diese Preisliste gibt es nicht (mehr).'); $st = $pdo->prepare('SELECT revision, hash, entries, device, updated, length(data) AS bytes FROM history WHERE space_id = ? ORDER BY revision DESC'); $st->execute([$id]); $versions = array_merge([[ 'revision' => $space['revision'], 'hash' => $space['hash'], 'entries' => $space['entries'], 'device' => $space['device'], 'updated' => $space['updated'], 'bytes' => strlen((string)$space['data']), 'current' => true, ]], $st->fetchAll()); $currentRev = (int)$space['revision']; $rev = isset($_GET['rev']) ? (int)$_GET['rev'] : $currentRev; if ($rev === $currentRev) { $sel = $space; } else { $st = $pdo->prepare('SELECT * FROM history WHERE space_id = ? AND revision = ?'); $st->execute([$id, $rev]); $sel = $st->fetch(); if (!$sel) not_found('Diese Version ist nicht mehr im Verlauf gespeichert.'); } $st = $pdo->prepare('SELECT revision, data FROM history WHERE space_id = ? AND revision < ? ORDER BY revision DESC LIMIT 1'); $st->execute([$id, $rev]); $prev = $st->fetch(); $title = space_title($space); page_start($title, 'space'); $listUrl = api_base() . '?action=list&read=' . $space['read_token']; echo '
' . (is_admin() ? 'Übersicht › ' : '') . 'Preisliste
' . '

' . h($title) . ' #' . (int)$space['id'] . '

' . '
' . 'Aktuell r' . $currentRev . '' . '' . fmt_num($space['entries']) . ' Einträge' . 'Geändert ' . fmt_time($space['updated']) . '' . ($space['device'] !== '' ? ' von ' . h($space['device']) . '' : '') . '' . 'Angelegt ' . fmt_time($space['created']) . '' . '
' . '
Community-Link (nur lesen):' . h($listUrl) . '' . '
'; echo '

Revision r' . $rev . ($rev === $currentRev ? ' aktuell' : '') . '

' . '' . fmt_time($sel['updated']) . ' · ' . h($sel['device'] !== '' ? $sel['device'] : 'unbekanntes Gerät') . ' · Hash ' . h(short_hash($sel['hash'])) . '
' . '
'; $entries = json_decode((string)$sel['data'], true); page_end([ 'kind' => 'space', 'entries' => is_array($entries) ? $entries : [], 'prev' => $prev ? (json_decode((string)$prev['data'], true) ?: []) : null, 'prevLabel' => $prev ? 'r' . (int)$prev['revision'] : null, 'factors' => null, 'download' => is_array($entries) ? $entries : [], 'filename' => 'preisliste-' . $id . '-r' . $rev . '.json', ]); } // ---------- Offizielle Daten ---------- if ($view === 'official') { $pdo = db(); $versions = $pdo->query('SELECT version, hash, entries, note, created, length(data) AS bytes FROM official ORDER BY version DESC')->fetchAll(); page_start('Offizielle Daten', 'official'); echo '
' . (is_admin() ? 'Übersicht › ' : '') . 'Offizielle Daten

Offizielle Daten

'; if (!$versions) { echo '

Es wurden noch keine offiziellen Daten veröffentlicht.

'; page_end(); } $latest = (int)$versions[0]['version']; $v = isset($_GET['v']) ? (int)$_GET['v'] : $latest; $st = $pdo->prepare('SELECT * FROM official WHERE version = ?'); $st->execute([$v]); $sel = $st->fetch(); if (!$sel) not_found('Diese offizielle Version ist nicht mehr gespeichert.'); $st = $pdo->prepare('SELECT version, data FROM official WHERE version < ? ORDER BY version DESC LIMIT 1'); $st->execute([$v]); $prev = $st->fetch(); echo '
Aktuell v' . $latest . '' . fmt_num($versions[0]['entries']) . ' Einträge' . 'Veröffentlicht ' . fmt_time($versions[0]['created']) . '
'; echo '

Version v' . $v . ($v === $latest ? ' aktuell' : '') . '

' . '' . fmt_time($sel['created']) . ($sel['note'] !== '' ? ' · ' . h($sel['note']) : '') . ' · Hash ' . h(short_hash($sel['hash'])) . '
' . '
'; $pkg = json_decode((string)$sel['data'], true) ?: []; $prevPkg = $prev ? (json_decode((string)$prev['data'], true) ?: []) : null; page_end([ 'kind' => 'official', 'entries' => $pkg['prices'] ?? [], 'prev' => $prevPkg ? ($prevPkg['prices'] ?? []) : null, 'prevLabel' => $prev ? 'v' . (int)$prev['version'] : null, 'factors' => (object)($pkg['factors'] ?? []), 'prevFactors' => $prevPkg ? (object)($prevPkg['factors'] ?? []) : null, 'download' => $pkg, 'filename' => 'offiziell-v' . $v . '.json', ]); } not_found('Unbekannte Seite.'); // ============================================================================================ // CSS // ============================================================================================ function css(): string { return <<<'CSS' :root{--bg:#0f1216;--card:#171c22;--card2:#1d232b;--line:#2a323c;--text:#e6e9ed;--muted:#8a95a3;--accent:#5fbf6b;--accent2:#3d8b47;--up:#5fbf6b;--down:#e0675a;--warn:#e3b341;--blue:#5aa2e0; --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace} *{box-sizing:border-box} html{-webkit-text-size-adjust:100%} body{margin:0;background:var(--bg);color:var(--text);font:14px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif} a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline} code{font-family:var(--mono);font-size:12.5px} h1{font-size:22px;margin:6px 0 10px}h2{font-size:17px;margin:0}h3{font-size:14px;margin:0 0 10px} .wrap{max-width:1400px;margin:0 auto;padding:0 18px} .top{background:#0b0e11;border-bottom:1px solid var(--line);position:sticky;top:0;z-index:5} .topin{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:52px;flex-wrap:wrap} .brand{color:var(--text);font-weight:600}.logo{color:var(--accent)} nav{display:flex;gap:4px;align-items:center;flex-wrap:wrap} nav a,button.link{color:var(--muted);padding:6px 10px;border-radius:6px;background:none;border:0;font:inherit;cursor:pointer} nav a:hover,button.link:hover{color:var(--text);background:var(--card2);text-decoration:none}nav a.on{color:var(--text);background:var(--card2)} form.inline{display:inline;margin:0} main{padding:18px 18px 60px} .card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:16px;margin:0 0 16px} .card.narrow{max-width:460px;margin:40px auto} .cardhead{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:12px} .login label{display:block;margin:12px 0 6px;color:var(--muted)} input[type=password],input[type=search],select{background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:7px;padding:8px 10px;font:inherit} input[type=password]{width:100%;font-family:var(--mono)} input:focus,select:focus{outline:2px solid var(--accent2);outline-offset:0} .btn{display:inline-block;background:var(--card2);color:var(--text);border:1px solid var(--line);border-radius:7px;padding:7px 12px;font:inherit;cursor:pointer} .btn:hover{border-color:var(--accent2);text-decoration:none}.btn.primary{background:var(--accent2);border-color:var(--accent2);margin-top:14px;width:100%} .btn.small{padding:4px 9px;font-size:12.5px}.btn.on{border-color:var(--accent);color:var(--accent)} .err{color:var(--down)}.note{color:var(--warn)}.muted{color:var(--muted)}.small{font-size:12px} .kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;margin-bottom:16px} .kpi{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px 14px} .kpi span{display:block;color:var(--muted);font-size:12px}.kpi b{font-size:20px} .kpi small{display:block;color:var(--muted);font-size:11.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .tablewrap{overflow-x:auto} table{width:100%;border-collapse:collapse} th,td{text-align:left;padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:top} th{color:var(--muted);font-weight:500;font-size:12px;white-space:nowrap;position:sticky;top:0;background:var(--card)} td.r,th.r{text-align:right} tbody tr[data-href],table.data tbody tr{cursor:pointer}tbody tr:hover{background:var(--card2)} th.sort{cursor:pointer;user-select:none}th.sort:hover{color:var(--text)}th .arr{color:var(--accent)} .filter{min-width:240px} .crumbs{color:var(--muted);font-size:12.5px} .meta{display:flex;flex-wrap:wrap;gap:6px 18px;color:var(--muted);margin-bottom:10px}.meta b{color:var(--text);font-weight:600} .share{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:16px} .share code{background:var(--card);border:1px solid var(--line);padding:4px 8px;border-radius:6px;word-break:break-all} .split{display:grid;grid-template-columns:280px minmax(0,1fr);gap:16px;align-items:start} @media(max-width:900px){.split{grid-template-columns:1fr}.versions ol{max-height:260px}} .versions{position:sticky;top:68px;padding:12px} .versions ol{list-style:none;margin:0;padding:0;max-height:calc(100vh - 140px);overflow:auto} .ver{display:block;padding:8px 10px;border-radius:7px;color:var(--text);border:1px solid transparent} .ver:hover{background:var(--card2);text-decoration:none}.ver.on{background:var(--card2);border-color:var(--accent2)} .vrow{display:flex;align-items:center;gap:6px}.vent{margin-left:auto;color:var(--muted);font-size:12px} .vsub{display:block;color:var(--muted);font-size:11.5px;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .tag{display:inline-block;font-size:11px;padding:1px 7px;border-radius:999px;background:var(--card2);border:1px solid var(--line);color:var(--muted);white-space:nowrap} .tag.ok{color:var(--accent);border-color:var(--accent2)}.tag.add{color:var(--up);border-color:#2f6b37}.tag.del{color:var(--down);border-color:#7a3530} .tag.chg{color:var(--warn);border-color:#7a6224}.tag.info{color:var(--blue);border-color:#2e5679} .up{color:var(--up)}.down{color:var(--down)} .tabs{display:flex;gap:6px;flex-wrap:wrap;margin:14px 0 12px;border-bottom:1px solid var(--line);padding-bottom:10px} .toolbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:10px} .toolbar input[type=search]{flex:1;min-width:200px} .count{color:var(--muted);font-size:12.5px;margin-left:auto} .ns{color:var(--muted)} .price{font-variant-numeric:tabular-nums;white-space:nowrap;font-weight:600} .crit{display:flex;gap:4px;flex-wrap:wrap} .pager{display:flex;align-items:center;justify-content:center;gap:10px;margin-top:12px;color:var(--muted)} pre.json{background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:12px;overflow:auto;max-height:70vh;font:12px/1.5 var(--mono);margin:0} .empty{padding:30px;text-align:center;color:var(--muted)} dialog{background:var(--card);color:var(--text);border:1px solid var(--line);border-radius:12px;padding:0;width:min(640px,94vw);max-height:86vh} dialog::backdrop{background:rgba(0,0,0,.6)} .dlg-head{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--line)} .dlg-head h3{margin:0;word-break:break-all}.x{background:none;border:0;color:var(--muted);font-size:18px;cursor:pointer} .dlg-body{padding:12px 16px;overflow:auto;max-height:calc(86vh - 56px)} .kv td:first-child{color:var(--muted);white-space:nowrap;width:1%}.kv td{word-break:break-word} .kv tr:hover{background:none}.kv .changed{background:rgba(227,179,65,.08)} .chips{display:flex;gap:6px;flex-wrap:wrap} CSS; } // ============================================================================================ // JavaScript // ============================================================================================ function js(): string { return <<<'JS' (() => { 'use strict'; const $ = (s, r = document) => r.querySelector(s); const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); // ---- Allgemein: Tabellenfilter, klickbare Zeilen, Kopieren, Dialog ---- document.querySelectorAll('[data-filter]').forEach(inp => { const tbl = document.getElementById(inp.dataset.filter); if (!tbl) return; inp.addEventListener('input', () => { const q = inp.value.trim().toLowerCase(); tbl.querySelectorAll('tbody tr').forEach(tr => { tr.hidden = q !== '' && !(tr.dataset.search || '').includes(q); }); }); }); document.querySelectorAll('tr[data-href]').forEach(tr => tr.addEventListener('click', e => { if (e.target.closest('a')) return; location.href = tr.dataset.href; })); document.querySelectorAll('[data-copy]').forEach(btn => btn.addEventListener('click', async () => { const text = document.getElementById(btn.dataset.copy)?.textContent || ''; try { await navigator.clipboard.writeText(text); btn.textContent = 'Kopiert ✓'; } catch { btn.textContent = 'Bitte manuell kopieren'; } setTimeout(() => { btn.textContent = 'Kopieren'; }, 1800); })); const dlg = $('#dlg'); $('#dlg-close')?.addEventListener('click', () => dlg.close()); dlg?.addEventListener('click', e => { if (e.target === dlg) dlg.close(); }); const node = $('#payload'); const app = $('#app'); if (!node || !app) return; const P = JSON.parse(node.textContent || 'null'); if (!P) return; // ---- Formatierung ---- const LOCALE = document.body.dataset.locale || 'de-CH'; const nf = new Intl.NumberFormat(LOCALE, {maximumFractionDigits: 2}); const nf0 = new Intl.NumberFormat(LOCALE, {maximumFractionDigits: 0}); const pct = new Intl.NumberFormat(LOCALE, {maximumFractionDigits: 1, signDisplay: 'always'}); const isNum = v => typeof v === 'number' && isFinite(v); const strip = s => String(s ?? '').replace(/§[0-9a-fk-or]/gi, ''); const priceHtml = v => !isNum(v) ? '–' : v < 0 ? '' + esc(nf.format(v)) + '' : '' + esc(nf.format(v)) + ''; const itemHtml = id => { const s = String(id ?? ''); const i = s.indexOf(':'); if (i < 0) return '' + esc(s) + ''; const ns = s.slice(0, i); return '' + (ns === 'minecraft' ? '' : '' + esc(ns) + ':') + esc(s.slice(i + 1)) + ''; }; const displayName = e => strip(e.label || e.name || ''); const KNOWN = ['item', 'price', 'label', 'name', 'nameRegex', 'lore', 'company']; const FIELD_LABEL = {item: 'Item', price: 'Preis', label: 'Bezeichnung', name: 'Name (exakt)', nameRegex: 'Name (Regex)', lore: 'Lore', company: 'Firma'}; const keyOf = e => [e.item, e.name ?? '', e.nameRegex ?? '', e.lore ?? ''].join('\u0001'); const fieldsOf = e => [...KNOWN.filter(k => k in e), ...Object.keys(e).filter(k => !KNOWN.includes(k)).sort()]; const valHtml = (k, v) => v === undefined ? '–' : k === 'price' ? priceHtml(v) : k === 'item' ? itemHtml(v) : v === null ? 'null' : typeof v === 'boolean' ? (v ? 'ja' : 'nein') : k === 'nameRegex' ? '' + esc(v) + '' : esc(typeof v === 'string' ? strip(v) : v) + (typeof v === 'string' && /§/.test(v) ? ' (formatiert)' : ''); const critHtml = e => { const out = []; if (e.name != null && e.name !== '') out.push('Name'); if (e.nameRegex != null && e.nameRegex !== '') out.push('Regex'); if (e.lore != null && e.lore !== '') out.push('Lore'); const extra = Object.keys(e).filter(k => !KNOWN.includes(k)); if (extra.length) out.push('+' + extra.length + ' Feld' + (extra.length > 1 ? 'er' : '') + ''); return out.length ? '' + out.join('') + '' : '–'; }; // ---- Daten vorbereiten ---- const entries = Array.isArray(P.entries) ? P.entries : []; const rows = entries.map((e, i) => ({e, i, s: [e.item, e.label, e.name, e.nameRegex, e.lore, e.company].map(v => strip(v ?? '')).join(' ').toLowerCase()})); const companies = [...new Set(entries.map(e => e.company).filter(c => c != null && c !== ''))].map(String).sort(); const factors = P.factors && typeof P.factors === 'object' ? P.factors : null; const hasFactors = factors && Object.keys(factors).length > 0; function computeDiff(prev, cur) { const index = list => { const m = new Map(), n = new Map(); for (const e of list) { const k = keyOf(e); const c = (n.get(k) || 0) + 1; n.set(k, c); m.set(c > 1 ? k + '#' + c : k, e); } return m; }; const a = index(prev), b = index(cur), out = []; for (const [k, e] of b) { const o = a.get(k); if (!o) { out.push({t: 'add', e}); continue; } const changed = [...new Set([...Object.keys(o), ...Object.keys(e)])].filter(f => JSON.stringify(o[f]) !== JSON.stringify(e[f])); if (!changed.length) continue; out.push({t: changed.includes('price') ? 'price' : 'meta', e, o, changed}); } for (const [k, o] of a) if (!b.has(k)) out.push({t: 'del', e: o, o}); const order = {price: 0, add: 1, del: 2, meta: 3}; out.forEach(d => { d.s = [d.e.item, d.e.label, d.e.name, d.e.company].map(v => strip(v ?? '')).join(' ').toLowerCase(); }); return out.sort((x, y) => order[x.t] - order[y.t] || String(x.e.item).localeCompare(String(y.e.item))); } const diff = Array.isArray(P.prev) ? computeDiff(P.prev, entries) : null; const factorChanges = hasFactors && P.prevFactors ? Object.keys(factors).filter(f => P.prevFactors[f] !== factors[f]).length + Object.keys(P.prevFactors).filter(f => !(f in factors)).length : 0; // ---- Kennzahlen ---- const valid = entries.map(e => e.price).filter(v => isNum(v) && v >= 0).sort((a, b) => a - b); const sum = valid.reduce((a, b) => a + b, 0); const median = valid.length ? (valid.length % 2 ? valid[(valid.length - 1) / 2] : (valid[valid.length / 2 - 1] + valid[valid.length / 2]) / 2) : null; const top = entries.reduce((m, e) => isNum(e.price) && (!m || e.price > m.price) ? e : m, null); const special = entries.filter(e => isNum(e.price) && e.price < 0).length; const kpi = (label, value, sub = '') => '
' + esc(label) + '' + value + '' + (sub ? '' + sub + '' : '') + '
'; let html = '
' + kpi('Einträge', esc(nf0.format(entries.length)), special ? esc(special + ' mit Sonderwert') : '') + kpi('Ø Preis', valid.length ? esc(nf.format(sum / valid.length)) : '–') + kpi('Median', median !== null ? esc(nf.format(median)) : '–') + kpi('Höchster Preis', top ? esc(nf.format(top.price)) : '–', top ? esc(displayName(top) || top.item) : '') + kpi('Firmen', esc(String(companies.length))) + (diff ? kpi('Änderungen ggü. ' + P.prevLabel, esc(nf0.format(diff.length + factorChanges))) : '') + '
'; // ---- Tabs ---- const tabs = [['table', 'Einträge']]; if (diff) tabs.push(['diff', 'Änderungen ggü. ' + P.prevLabel + ' (' + nf0.format(diff.length) + ')']); if (hasFactors) tabs.push(['factors', 'Faktoren (' + Object.keys(factors).length + ')']); tabs.push(['json', 'JSON']); html += '
' + tabs.map(([k, l]) => '').join('') + '
'; app.innerHTML = html; const pane = $('#pane'); const st = {tab: 'table', q: '', company: '', sort: 'i', dir: 1, page: 0, dq: '', dt: '', dpage: 0}; const PER = 100; const fromHash = location.hash.slice(1); if (tabs.some(([k]) => k === fromHash)) st.tab = fromHash; function setTab(t) { st.tab = t; history.replaceState(null, '', '#' + t); app.querySelectorAll('[data-tab]').forEach(b => b.classList.toggle('on', b.dataset.tab === t)); ({table: renderTable, diff: renderDiff, factors: renderFactors, json: renderJson})[t](); } app.querySelectorAll('[data-tab]').forEach(b => b.addEventListener('click', () => setTab(b.dataset.tab))); function pagerHtml(total, page) { const pages = Math.max(1, Math.ceil(total / PER)); if (pages <= 1) return ''; return '' + 'Seite ' + (page + 1) + ' / ' + pages + '' + ''; } // ---- Einträge ---- const COLS = [['i', '#', 'r'], ['item', 'Item', ''], ['name', 'Bezeichnung', ''], ['price', 'Preis', 'r'], ['company', 'Firma', ''], ['crit', 'Bedingungen', '']]; const sortVal = {i: r => r.i, item: r => String(r.e.item), name: r => displayName(r.e).toLowerCase(), price: r => isNum(r.e.price) ? r.e.price : -Infinity, company: r => String(r.e.company ?? '')}; function renderTable() { if (!entries.length) { pane.innerHTML = '
Diese Version enthält keine Einträge.
'; return; } pane.innerHTML = '
' + (companies.length ? '' : '') + '
' + '
' + COLS.map(([k, l, c]) => '').join('') + '
' + l + '
'; if (st.company) { const s = $('#co'); if (s) s.value = st.company; } $('#q').addEventListener('input', e => { st.q = e.target.value; st.page = 0; fillTable(); }); $('#co')?.addEventListener('change', e => { st.company = e.target.value; st.page = 0; fillTable(); }); pane.querySelectorAll('th.sort').forEach(th => th.addEventListener('click', () => { const k = th.dataset.sort; st.dir = st.sort === k ? -st.dir : (k === 'price' ? -1 : 1); st.sort = k; st.page = 0; fillTable(); })); $('#pg').addEventListener('click', e => { const b = e.target.closest('[data-pg]'); if (!b) return; st.page += +b.dataset.pg; fillTable(); pane.scrollIntoView({block: 'start'}); }); $('#tb').addEventListener('click', e => { const tr = e.target.closest('tr[data-i]'); if (tr) openEntry(entries[+tr.dataset.i], +tr.dataset.i); }); fillTable(); } function fillTable() { const q = st.q.trim().toLowerCase(); let list = rows.filter(r => (!q || r.s.includes(q)) && (!st.company || (st.company === '\u0000' ? (r.e.company == null || r.e.company === '') : String(r.e.company ?? '') === st.company))); const f = sortVal[st.sort]; list = list.slice().sort((a, b) => { const x = f(a), y = f(b); return (x < y ? -1 : x > y ? 1 : a.i - b.i) * st.dir; }); const pages = Math.max(1, Math.ceil(list.length / PER)); st.page = Math.min(Math.max(0, st.page), pages - 1); const slice = list.slice(st.page * PER, st.page * PER + PER); $('#tb').innerHTML = slice.length ? slice.map(r => '' + '' + (r.i + 1) + '' + itemHtml(r.e.item) + '' + '' + (displayName(r.e) ? esc(displayName(r.e)) : '–') + '' + '' + priceHtml(r.e.price) + '' + '' + (r.e.company ? esc(r.e.company) : '–') + '' + '' + critHtml(r.e) + '').join('') : 'Keine Treffer.'; $('#cnt').textContent = (list.length === entries.length ? '' : nf0.format(list.length) + ' von ') + nf0.format(entries.length) + ' Einträgen'; $('#pg').innerHTML = pagerHtml(list.length, st.page); pane.querySelectorAll('th[data-sort]').forEach(th => { th.querySelector('.arr').textContent = th.dataset.sort === st.sort ? (st.dir > 0 ? '▲' : '▼') : ''; }); } // ---- Detail-Dialog ---- function openEntry(e, i, o, changed) { $('#dlg-title').innerHTML = esc(displayName(e) || e.item) + (i !== undefined ? ' Eintrag ' + (i + 1) + '' : ''); const keys = o ? [...new Set([...fieldsOf(o), ...fieldsOf(e)])] : fieldsOf(e); let body = ''; if (o) body += ''; for (const k of keys) { const ch = changed && changed.includes(k); body += '' + (o ? '' : '') + ''; } body += '
vorher (' + esc(P.prevLabel) + ')jetzt
' + esc(FIELD_LABEL[k] || k) + '' + valHtml(k, o[k]) + '' + valHtml(k, e[k]) + '
'; if (factors && e.company && isNum(factors[e.company]) && isNum(e.price) && e.price >= 0) { body += '

Mit Faktor ' + esc(nf.format(factors[e.company])) + ' (' + esc(e.company) + '): ' + esc(nf.format(e.price * factors[e.company])) + '

'; } body += '

Rohdaten

' + esc(JSON.stringify(e, null, 2)) + '
'; $('#dlg-body').innerHTML = body; dlg.showModal(); } // ---- Änderungen ---- const DT = {add: ['add', 'Neu'], del: ['del', 'Entfernt'], price: ['chg', 'Preis'], meta: ['info', 'Sonstiges']}; function renderDiff() { const counts = {add: 0, del: 0, price: 0, meta: 0}; diff.forEach(d => counts[d.t]++); pane.innerHTML = '
' + '' + Object.keys(DT).map(t => '').join('') + '
' + (factorChanges ? '

Zusätzlich ' + factorChanges + ' geänderte Faktoren – siehe Tab „Faktoren“.

' : '') + '
ÄnderungItemBezeichnungVorherJetztΔ
'; pane.querySelectorAll('[data-dt]').forEach(b => b.addEventListener('click', () => { st.dt = b.dataset.dt; st.dpage = 0; fillDiff(); })); $('#dq').addEventListener('input', e => { st.dq = e.target.value; st.dpage = 0; fillDiff(); }); $('#dpg').addEventListener('click', e => { const b = e.target.closest('[data-pg]'); if (!b) return; st.dpage += +b.dataset.pg; fillDiff(); }); $('#dtb').addEventListener('click', e => { const tr = e.target.closest('tr[data-d]'); if (!tr) return; const d = cur[+tr.dataset.d]; if (d.t === 'add' || d.t === 'del') openEntry(d.e); else openEntry(d.e, undefined, d.o, d.changed); }); let cur = []; function fillDiff() { pane.querySelectorAll('[data-dt]').forEach(b => b.classList.toggle('on', b.dataset.dt === st.dt)); const q = st.dq.trim().toLowerCase(); cur = diff.filter(d => (!st.dt || d.t === st.dt) && (!q || d.s.includes(q))); const pages = Math.max(1, Math.ceil(cur.length / PER)); st.dpage = Math.min(Math.max(0, st.dpage), pages - 1); const start = st.dpage * PER; $('#dtb').innerHTML = cur.length ? cur.slice(start, start + PER).map((d, j) => { const before = d.t === 'add' ? undefined : d.o.price, after = d.t === 'del' ? undefined : d.e.price; let delta = '–'; if (d.t === 'price' && isNum(before) && isNum(after)) { const diffAbs = after - before, cls = diffAbs > 0 ? 'up' : 'down'; delta = '' + esc((diffAbs > 0 ? '+' : '') + nf.format(diffAbs)) + (before > 0 ? ' (' + esc(pct.format(diffAbs / before * 100)) + ' %)' : '') + ''; } else if (d.t === 'meta') { delta = '' + esc(d.changed.map(k => FIELD_LABEL[k] || k).join(', ')) + ''; } return '' + DT[d.t][1] + '' + '' + itemHtml(d.e.item) + '' + (displayName(d.e) ? esc(displayName(d.e)) : '–') + '' + '' + (before === undefined ? '–' : priceHtml(before)) + '' + '' + (after === undefined ? '–' : priceHtml(after)) + '' + '' + delta + ''; }).join('') : '' + (diff.length ? 'Keine Treffer.' : 'Keine Unterschiede bei den Einträgen.') + ''; $('#dcnt').textContent = nf0.format(cur.length) + ' Änderungen'; $('#dpg').innerHTML = pagerHtml(cur.length, st.dpage); } fillDiff(); } // ---- Faktoren ---- function renderFactors() { const pf = P.prevFactors || null; const names = [...new Set([...Object.keys(factors), ...(pf ? Object.keys(pf) : [])])].sort(); const count = c => entries.filter(e => e.company === c).length; pane.innerHTML = '
' + (pf ? '' : '') + '' + names.map(n => { const f = factors[n], o = pf ? pf[n] : undefined; const ch = pf && o !== f; return '' + '' + '' + (pf ? '' : '') + ''; }).join('') + '
FirmaFaktorAufschlagVorher (' + esc(P.prevLabel) + ')Einträge
' + esc(n) + '' + (f === undefined ? ' entfernt' : ch && o === undefined ? ' neu' : ch ? ' geändert' : '') + '' + (isNum(f) ? esc(nf.format(f)) : '–') + '' + (isNum(f) ? esc(pct.format((f - 1) * 100)) + ' %' : '–') + '' + (isNum(o) ? esc(nf.format(o)) : '–') + '' + nf0.format(count(n)) + '
'; } // ---- JSON ---- function renderJson() { const text = JSON.stringify(P.download, null, 2); const LIMIT = 400000; pane.innerHTML = '
' + '' + '' + esc(nf0.format(text.length / 1024)) + ' KB
' + (text.length > LIMIT ? '

Die Vorschau zeigt nur den Anfang. Für alles bitte herunterladen.

' : '') + '
' + esc(text.length > LIMIT ? text.slice(0, LIMIT) + '\n…' : text) + '
'; $('#dl').addEventListener('click', () => { const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([text], {type: 'application/json'})); a.download = P.filename; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 500); }); $('#cp').addEventListener('click', async e => { try { await navigator.clipboard.writeText(text); e.target.textContent = 'Kopiert ✓'; } catch { e.target.textContent = 'Kopieren nicht möglich'; } }); } setTab(st.tab); })(); JS; }