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) . ' '
. ''
. '';
}
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 '';
page_end();
}
function not_found(string $msg): void
{
http_response_code(404);
page_start('Nicht gefunden');
echo '';
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 '';
if (!$official) {
echo 'Es wurden noch keine offiziellen Daten veröffentlicht.
';
} else {
echo 'Version Veröffentlicht Einträge Grösse Notiz Hash ';
foreach (array_slice($official, 0, 5) as $i => $o) {
echo ''
. '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'])) . ' ';
}
echo '
';
if (count($official) > 5) echo '' . (count($official) - 5) . ' ältere Versionen – in der Detailansicht aufgelistet.
';
}
echo ' ';
// Preislisten
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) . ''
. 'Kopieren
';
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]) => '' + esc(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 '‹ Zurück '
+ 'Seite ' + (page + 1) + ' / ' + pages + ' '
+ '= pages - 1 ? ' disabled' : '') + '>Weiter › ';
}
// ---- 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 ? 'Alle Firmen Ohne Firma ' + companies.map(c => '' + esc(c) + ' ').join('') + ' ' : '')
+ '
'
+ ''
+ COLS.map(([k, l, c]) => '' + l + ' ').join('')
+ '
';
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 += 'vorher (' + esc(P.prevLabel) + ') jetzt ';
for (const k of keys) {
const ch = changed && changed.includes(k);
body += '' + esc(FIELD_LABEL[k] || k) + ' '
+ (o ? '' + valHtml(k, o[k]) + ' ' : '') + '' + valHtml(k, e[k]) + ' ';
}
body += '
';
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 = ''
+ (factorChanges ? 'Zusätzlich ' + factorChanges + ' geänderte Faktoren – siehe Tab „Faktoren“.
' : '')
+ 'Änderung Item Bezeichnung Vorher Jetzt Δ
';
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 = 'Firma Faktor Aufschlag '
+ (pf ? 'Vorher (' + esc(P.prevLabel) + ') ' : '') + 'Einträge '
+ names.map(n => {
const f = factors[n], o = pf ? pf[n] : undefined;
const ch = pf && o !== f;
return '' + 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)) + ' %' : '–') + ' '
+ (pf ? '' + (isNum(o) ? esc(nf.format(o)) : '–') + ' ' : '')
+ '' + nf0.format(count(n)) + ' ';
}).join('') + '
';
}
// ---- JSON ----
function renderJson() {
const text = JSON.stringify(P.download, null, 2);
const LIMIT = 400000;
pane.innerHTML = '⬇ ' + esc(P.filename) + ' herunterladen '
+ 'In Zwischenablage kopieren '
+ '' + 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;
}